feat(codex): merge connection checks into a tabbed status app

This commit is contained in:
huangsen 2026-09-30 17:16:04 +08:00
parent b9d44ca339
commit e5fb2628bb
18 changed files with 3428 additions and 45 deletions

View file

@ -99,11 +99,11 @@ For a first test, save these values:
| Capture batch size (`autoMemoryInterval`) | `1` |
| Memory guidance language (`language`) | `en` or `zh` |
Save before using an action. Click **Check connection**: the result appears beside the action as a
status or tooltip, including the ReMe version and `healthy`. This verifies service access; recording
and recall still need the conversation test below.
Save before using an action. Click **ReMe status** to open the status panel. Its Connection section checks the saved service
address and shows the ReMe version and `healthy` when reachable. This verifies service access;
recording and recall still need the conversation test below.
> **Screenshot placeholder — `mcp-settings.png`:** Actual native form with the address, capture switches, batch size, and successful Check connection tooltip.
> **Screenshot placeholder — `mcp-settings.png`:** Actual native form with the address, capture switches, batch size, and the single ReMe status entry.
<!--
![ReMe MCP settings and connection result](./figures/mcp-settings.png)
@ -119,7 +119,7 @@ at 16:45 UTC. The verification phrase is amber-lynx-7319.
Do not use tools or write files yourself; just acknowledge the facts.
```
Keep the conversation open while recording finishes. In MCP settings, **View status** should show
Keep the conversation open while recording finishes. In MCP settings, **ReMe status** should show
`memory_saved` and zero queued turns. In your ReMe workspace, confirm a `daily/` Markdown note
contains the fact. An acknowledgement by Codex alone is not evidence of a successful write.
@ -137,7 +137,7 @@ Use supplied ReMe memory and cite its source path. Do not call tools;
if no memory was supplied, say you do not know.
```
Expect the correct time, phrase, and a ReMe source path such as `daily/...md`. **View status** should
Expect the correct time, phrase, and a ReMe source path such as `daily/...md`. **ReMe status** should
include `recall_found`. This tests automatic recall independently of explicit search tools.
For everyday use, you can also ask Codex to search past decisions with `reme_search`.
@ -159,7 +159,7 @@ and optionally **Auto Dream hint**, then save. Defaults are `0 23 * * *` and `As
Only the daily form `minute hour * * *` is supported; for 02:30 use `30 2 * * *`.
The hint guides how ReMe consolidates existing daily notes into durable memory.
**View status** shows the active schedule, timezone, next run, and latest result. The schedule runs
**ReMe status** shows the active schedule, timezone, next run, and latest result. The schedule runs
while Codex keeps the ReMe MCP connection alive. Closing Codex stops the timer; missed occurrences
are not replayed on startup. Multiple sessions sharing one `CODEX_HOME` use one scheduler. Separate
profiles or machines need their own coordination: enable only one scheduler for the same ReMe workspace.
@ -171,12 +171,22 @@ already sent to ReMe; a timeout also does not prove that server-side processing
## 6. Inspect status and adjust settings
Click **View status**, or ask Codex to call `reme_status` for a larger result in the conversation.
It reports service health, queued turns, recent recall/write events, and scheduled/manual consolidation.
Click **ReMe status** to open a dedicated panel from MCP settings. Connection checks and memory
status share this one entry. Four tabs organize the panel: **Overview**, **Auto Memory**,
**Memory Consolidation**, and **Components**. They show service health and the memory flow;
capture settings, queued turns/sessions, recall and recent activity; Dream scheduling and results;
and component memory estimates plus process RSS. Click **Refresh** to check again;
service details are collapsed in Components until needed. The initial view uses the opening call's result without
a second request. The panel follows the host theme and the saved `language` setting.
You can also ask Codex to call `reme_status` in a conversation. Hosts without an App view receive
the same status as text. Opening or refreshing the panel does not record or consolidate memory.
Component health and indexed-document counts are not exposed by the current ReMe MCP response;
the panel shows the available memory estimates without substituting other counts.
Queue and activity information remain available when the service is unreachable. Hook trust is
checked separately in Codex's Hooks settings.
> **Screenshot placeholder — `plugin-status.png`:** Real View status result or `reme_status` output with queued turns, recent activity, and the next Dream run.
> **Screenshot placeholder — `plugin-status.png`:** Real ReMe status panel or `reme_status` output with connection health, queued turns, recent activity, and the next Dream run.
<!--
![ReMe delivery and consolidation status in Codex](./figures/plugin-status.png)

View file

@ -96,10 +96,10 @@ codex plugin add reme@reme-codex
| Capture batch size(`autoMemoryInterval`) | `1` |
| Memory guidance language(`language`) | `zh` 或 `en` |
先保存,再点击 **Check connection**。结果会出现在按钮旁的状态或浮层中,包含 ReMe 版本及
`healthy`。这说明服务可访问;记录和召回还需要通过下一步的对话验证。
先保存,再点击 **ReMe status** 打开状态面板。顶部的服务连接区域会检查已保存的地址,
服务可达时显示 ReMe 版本及 `healthy`。这说明服务可访问;记录和召回还需要通过下一步的对话验证。
> **截图槽位 — `mcp-settings.png`:** 真实原生表单,保留服务地址、自动开关、批次大小及连接成功浮层。
> **截图槽位 — `mcp-settings.png`:** 真实原生表单,保留服务地址、自动开关、批次大小及唯一的 ReMe status 入口。
<!--
![ReMe MCP 设置与连接结果](./figures/mcp-settings.png)
@ -115,7 +115,7 @@ codex plugin add reme@reme-codex
不要调用工具或自己写文件,只需确认这些事实。
```
保持会话打开,等待自动记录完成。在 MCP 设置中点击 **View status**,应看到 `memory_saved`,
保持会话打开,等待自动记录完成。在 MCP 设置中点击 **ReMe status**,应看到 `memory_saved`,
待提交轮数为零;同时检查 ReMe 工作区的 `daily/` Markdown 文件中出现了这条事实。
Codex 的确认回复本身不能证明记忆已写入。
@ -133,7 +133,7 @@ ReMe-Example-7319 的评审时间和校验词是什么?
如果没有提供相关记忆,就说不知道。
```
预期答复包含正确时间、校验词及 `daily/...md` 等来源路径;**View status** 中应有 `recall_found`。
预期答复包含正确时间、校验词及 `daily/...md` 等来源路径;**ReMe status** 中应有 `recall_found`。
这样可以单独确认自动召回生效。日常使用时,也可以让 Codex 通过 `reme_search` 主动检索过去的决策。
> **截图槽位 — `memory-recalled.png`:** 独立会话 B 中的正确事实和 ReMe 来源路径。
@ -153,7 +153,7 @@ ReMe-Example-7319 的评审时间和校验词是什么?
`Asia/Shanghai`,即每天上海时间 23:00。仅支持 `分钟 小时 * * *` 的每日计划,例如每天 02:30 为
`30 2 * * *`。Hint 用于指导 ReMe 将现有日常记录整理为长期记忆。
**View status** 会展示计划、时区、下次运行时间和最近结果。只有 Codex 保持 ReMe MCP 连接时,
**ReMe status** 会展示计划、时区、下次运行时间和最近结果。只有 Codex 保持 ReMe MCP 连接时,
定时任务才会触发;关闭 Codex 后停止计时,重启时不补跑错过的时点。同一 `CODEX_HOME` 下的多个会话
共用一个调度器;不同配置目录或机器需自行协调,同一个 ReMe 工作区仅启用一处定时整理。
@ -163,11 +163,17 @@ ReMe-Example-7319 的评审时间和校验词是什么?
## 6. 查看状态和调整配置
点击 **View status**,或让 Codex 调用 `reme_status`,在对话里查看较完整的结果:
服务健康、待提交轮数、最近召回/写入活动、定时和手动整理情况。服务不可达时仍可查看本地队列与活动。
点击 **ReMe status**,从 MCP 设置打开独立状态面板。连接检查和记忆状态共用这一个入口,
面板分为 **总览、自动记忆、记忆整理、组件** 四个页签,分别展示连接健康与记忆流程,
记录开关、批次和队列、召回及最近活动,Dream 计划和结果,以及组件内存估算与进程 RSS。
点击 **刷新** 可重新检查,组件页的服务详情默认折叠。首次展示直接使用打开时返回的结果,不重复请求;面板适配宿主主题及保存的 `language`。
也可以在对话中让 Codex 调用 `reme_status`;不展示 App 的宿主仍会收到文字结果。
打开或刷新面板不会记录或整理记忆。服务不可达时仍可查看本地队列与活动。
当前 ReMe MCP 响应不包含组件级健康和索引文档数量,面板仅展示可获取的内存估算,不用其他计数替代。
Hook 是否可信,需另到 Codex 的 Hooks 设置中检查。
> **截图槽位 — `plugin-status.png`:** 真实 View status 结果或 `reme_status` 输出,保留待提交轮数、最近活动和下次 Dream 时间。
> **截图槽位 — `plugin-status.png`:** 真实 ReMe status 面板或 `reme_status` 输出,保留连接健康、待提交轮数、最近活动和下次 Dream 时间。
<!--
![Codex 中的 ReMe 写入和整理状态](./figures/plugin-status.png)

View file

@ -7,10 +7,10 @@ Use real Codex screenshots only. The English and Chinese guides share these PNG
| --- | --- |
| `plugin-installed.png` | Installed ReMe plugin detail: name, enabled state, and `reme` MCP card. / 已安装的 ReMe 插件详情:名称、启用状态及 MCP 卡片。 |
| `hooks-trusted.png` | All seven ReMe handlers across five events enabled and trusted, in desktop settings or CLI `/hooks`. / 五类事件下的七个 ReMe Hook 条目均已启用、已信任,可使用桌面设置或 CLI。 |
| `mcp-settings.png` | Native MCP form: URL, auto switches, batch size, and successful Check connection tooltip. / 原生 MCP 表单:地址、自动开关、批次大小及连接健康浮层。 |
| `mcp-settings.png` | Native MCP form: URL, auto switches, batch size, and the single ReMe status entry. / 原生 MCP 表单:地址、自动开关、批次大小及唯一的 ReMe status 入口。 |
| `memory-recorded.png` | Conversation A: the test fact and final acknowledgement, without explicit tool calls. / 会话 A:测试事实及最终确认,不显式调用工具。 |
| `memory-recalled.png` | Separate conversation B: recall question, correct fact, and ReMe source path. / 独立会话 B:召回问题、正确事实和 ReMe 来源路径。 |
| `plugin-status.png` | View status result in native settings, or a real `reme_status` call in Codex: queued turns, recent activity, and the next Dream run. / 原生设置的状态结果或真实 Codex 对话中的 `reme_status` 调用,保留待提交轮数、最近活动和下次 Dream 时间。 |
| `plugin-status.png` | ReMe status panel opened from native settings, or a real `reme_status` call in Codex: queued turns, recent activity, and the next Dream run. / 从原生设置打开的 ReMe status 面板或真实 Codex 对话中的 `reme_status` 调用,保留待提交轮数、最近活动和下次 Dream 时间。 |
## Add the screenshots / 补图步骤

View file

@ -1,6 +1,6 @@
{
"name": "reme",
"version": "0.2.0",
"version": "0.2.1",
"description": "Automatic memory, focused recall, runtime status, and consolidation through ReMe MCP.",
"author": {
"name": "EconML team of Alibaba Tongyi Lab",

View file

@ -13,14 +13,12 @@ from fastmcp.tools import FunctionTool, ToolResult
from mcp.types import ToolAnnotations
from reme_config import DEFAULTS, load_config, settings_values, update_settings
from reme_mcp import call_async
from reme_runtime import ReMeRuntime
from reme_tools import LOCAL_TOOLS, RUN_DREAM, STATUS, register_tools
SETTINGS_READ = "reme_settings_read"
SETTINGS_UPDATE = "reme_settings_update"
CHECK_CONNECTION = "reme_check_connection"
RESERVED_TOOLS = LOCAL_TOOLS | {SETTINGS_READ, SETTINGS_UPDATE, CHECK_CONNECTION}
RESERVED_TOOLS = LOCAL_TOOLS | {SETTINGS_READ, SETTINGS_UPDATE}
# OpenAI MCP Extensions node-v0.1.0, docs/spec.md#structured-settings.
# Use its legacy capability advertisement with ReMe's existing FastMCP runtime.
@ -70,8 +68,12 @@ LAYOUT = [
"title": "Connection",
"items": [
{"kind": "property", "property": "mcpUrl"},
{"kind": "tool", "tool": CHECK_CONNECTION, "title": "Check connection"},
{"kind": "tool", "tool": STATUS, "title": "View status"},
{
"kind": "tool",
"tool": STATUS,
"title": "ReMe status",
"description": "Check the connection and inspect memory activity and the Dream schedule.",
},
],
},
{
@ -222,13 +224,6 @@ def create_server() -> FastMCP:
),
)
@server.tool(name=CHECK_CONNECTION, title="Check ReMe connection", annotations={"readOnlyHint": True})
async def check_connection() -> str:
"""Check the saved MCP endpoint without sending conversation content."""
config = load_config()
result = await call_async(config, "health_check", {}, config["requestTimeoutMs"] / 1000)
return str(result["answer"])
assert FIELDS.keys() == DEFAULTS.keys()
return server

View file

@ -4,10 +4,12 @@ from __future__ import annotations
import asyncio
import json
import time
from pathlib import Path
from typing import Annotated
from fastmcp import FastMCP
from fastmcp.resources import ResourceContent, ResourceResult
from fastmcp.tools import ToolResult
from pydantic import Field
@ -20,6 +22,11 @@ SEARCH = "reme_search"
STATUS = "reme_status"
RUN_DREAM = "reme_run_dream"
LOCAL_TOOLS = {SEARCH, STATUS, RUN_DREAM}
STATUS_URI = "ui://reme/status.html"
STATUS_META = {
"ui": {"csp": {"connectDomains": [], "resourceDomains": []}},
"openai/ui": {"preferredDisplayMode": "fullscreen", "availableDisplayModes": ["fullscreen"]},
}
def answer_text(answer) -> str:
@ -100,7 +107,18 @@ def register_tools(server: FastMCP) -> None:
empty = "没有找到相关记忆。" if config["language"] == "zh" else "No relevant memory found."
return answer_text(answer) if answer else empty
@server.tool(name=STATUS, title="ReMe status", annotations={"readOnlyHint": True})
@server.resource(STATUS_URI, title="ReMe status", mime_type="text/html;profile=mcp-app", meta=STATUS_META)
def status_view() -> ResourceResult:
"""Serve a self-contained read-only view without exposing a local HTTP server."""
html = (Path(__file__).parent / "ui/status.html").read_text(encoding="utf-8")
return ResourceResult([ResourceContent(html, mime_type="text/html;profile=mcp-app", meta=STATUS_META)])
@server.tool(
name=STATUS,
title="ReMe status",
annotations={"readOnlyHint": True},
app={"resourceUri": STATUS_URI, "visibility": ["app", "model"]},
)
async def status() -> ToolResult:
"""Inspect service health, queued turns, hook activity, and scheduled/manual consolidation."""
config = load_config()
@ -110,6 +128,8 @@ def register_tools(server: FastMCP) -> None:
)
manifest = Path(__file__).parent / ".codex-plugin/plugin.json"
snapshot = {
"checked_at": time.time(),
"language": config["language"],
"plugin_version": json.loads(manifest.read_text(encoding="utf-8"))["version"],
"mcpUrl": config["mcpUrl"],
"service": {"health_check": health, "status": components},

View file

@ -21,9 +21,10 @@ relevant is found, say so; do not present inference as recalled memory.
## Status and recording
Use `reme_check_connection` for a quick health check. Use `reme_status` for queued turns, recent
Use `reme_status` for connection health, queued turns, recent
recall/write activity, the daily schedule, next run, and last consolidation result. Local queue
information remains available when ReMe is offline. Health does not establish Hook trust or prove
information remains available when ReMe is offline. The native **ReMe status** action opens a
read-only panel with Refresh; the same tool also returns text for conversation/CLI use. Health does not establish Hook trust or prove
memory was saved. Direct the user to Codex's Hooks settings to review every ReMe entry.
Users configure the connection and memory options in the ReMe MCP server's native settings form.

View file

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View file

@ -0,0 +1,628 @@
<!doctype html>
<!-- Built from status-app/src. Includes @openai/mcp-extensions 0.1.0 app transport (OpenAI, Apache-2.0); see LICENSE-mcp-extensions.txt. -->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ReMe status</title>
<style>
:root { color-scheme: light dark; --bg: #f6f8fc; --surface: #fff; --panel: #f9faff; --ink: #20283b; --muted: #66718a; --line: #dfe5ee; --blue: #426ccd; --green: #23704b; --green-bg: #e6f2e9; --amber: #875811; --amber-bg: #fff2d9; --red: #a33036; --red-bg: #fbecee; }
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --bg: #0b0d12; --surface: #12151d; --panel: #181c26; --ink: #f3f5f7; --muted: #979dab; --line: #292e3b; --blue: #79a8ff; --green: #63d4a3; --green-bg: #25382f; --amber: #f2be5c; --amber-bg: #3c3526; --red: #ff7b86; --red-bg: #40262c; } }
:root[data-theme="dark"] { color-scheme: dark; --bg: #0b0d12; --surface: #12151d; --panel: #181c26; --ink: #f3f5f7; --muted: #979dab; --line: #292e3b; --blue: #79a8ff; --green: #63d4a3; --green-bg: #25382f; --amber: #f2be5c; --amber-bg: #3c3526; --red: #ff7b86; --red-bg: #40262c; }
:root[data-theme="light"] { color-scheme: light; }
* { box-sizing: border-box; }
body { margin: 0; padding: 28px; background: var(--color-background-primary, var(--bg)); color: var(--color-text-primary, var(--ink)); font: 14px/1.5 system-ui, -apple-system, sans-serif; }
main { max-width: 1100px; margin: auto; }
header { display: flex; align-items: start; justify-content: space-between; gap: 20px; margin-bottom: 22px; }
.brand { display: flex; gap: 12px; align-items: center; }
.logo { display: grid; place-items: center; width: 44px; height: 44px; flex: 0 0 44px; border-radius: 13px; background: linear-gradient(145deg, #678ff7, #725de3); color: #fff; font-size: 26px; }
.eyebrow { color: var(--blue); font-size: 10px; font-weight: 700; letter-spacing: .14em; text-transform: uppercase; }
h1 { font-size: 25px; letter-spacing: -.5px; margin: 0; font-weight: 650; }
h2 { font-size: 14px; margin: 0 0 12px; font-weight: 600; }
p { margin: 6px 0; }
.muted, small { color: var(--muted); }
.label { font-size: 12px; color: var(--muted); }
.endpoint { overflow-wrap: anywhere; font-size: 12px; font-family: ui-monospace, monospace; }
.actions { display: flex; gap: 10px; align-items: center; }
button { background: var(--surface); color: var(--ink); border: 1px solid var(--line); border-radius: 8px; padding: 8px 14px; font: inherit; cursor: pointer; white-space: nowrap; }
button:hover:not(:disabled) { border-color: var(--blue); }
button:focus-visible, summary:focus-visible { outline: 2px solid var(--blue); outline-offset: 3px; }
button:disabled { opacity: .55; cursor: wait; }
.tabs { display: flex; gap: 4px; overflow-x: auto; border-bottom: 1px solid var(--line); margin-bottom: 20px; }
.tabs button { color: var(--muted); background: transparent; border: 0; border-radius: 0; border-bottom: 2px solid transparent; padding: 11px 14px; font-weight: 600; }
.tabs button[aria-selected="true"] { color: var(--ink); border-bottom-color: var(--blue); }
.grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; }
.card { background: linear-gradient(150deg, var(--panel), var(--surface)); border: 1px solid var(--line); border-radius: 12px; padding: 18px; min-width: 0; }
.wide { grid-column: span 2; }
.full { grid-column: 1 / -1; }
.value { font-size: 25px; line-height: 1.3; font-weight: 700; margin-top: 8px; }
.compact { font-size: 16px; overflow-wrap: anywhere; }
.pill { display: inline-block; color: var(--green); background: var(--green-bg); border-radius: 99px; padding: 4px 10px; font-size: 12px; font-weight: 600; }
[data-tone="good"] { color: var(--green); }
[data-tone="bad"] { color: var(--red); }
.pill[data-tone="neutral"] { color: var(--muted); background: var(--bg); }
.pill[data-tone="warn"] { color: var(--amber); background: var(--amber-bg); }
.pill[data-tone="bad"] { color: var(--red); background: var(--red-bg); }
.flow { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.step { background: var(--surface); border: 1px solid var(--line); padding: 10px; border-radius: 8px; flex: 1 1 100px; }
.arrow { color: var(--muted); }
.rows { padding: 0; margin: 0; list-style: none; }
.rows li { display: flex; justify-content: space-between; align-items: baseline; gap: 16px; padding: 10px 0; border-bottom: 1px solid var(--line); }
.rows li:last-child { border: 0; padding-bottom: 0; }
.rows code { display: block; color: var(--muted); font-size: 11px; }
time { color: var(--muted); font-size: 12px; white-space: nowrap; }
.component-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.component { border: 1px solid var(--line); background: var(--surface); padding: 14px; border-radius: 9px; min-width: 0; }
.component strong { display: block; overflow-wrap: anywhere; font-size: 13px; }
details { margin-top: 18px; }
summary { cursor: pointer; color: var(--muted); }
pre { white-space: pre-wrap; overflow-wrap: anywhere; background: var(--surface); padding: 14px; border: 1px solid var(--line); border-radius: 8px; font-size: 12px; max-height: 280px; overflow: auto; }
.note { margin: 18px 0 0; font-size: 12px; color: var(--muted); }
#error { border: 1px solid var(--red); color: var(--red); padding: 12px; border-radius: 8px; margin-bottom: 16px; }
[hidden] { display: none !important; }
@media (max-width: 850px) { .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 540px) { body { padding: 18px; } .grid, .component-grid { grid-template-columns: 1fr; } .wide { grid-column: 1 / -1; } .card { padding: 16px; } header { flex-wrap: wrap; gap: 12px; } h1 { font-size: 23px; } .rows li { flex-direction: column; gap: 2px; } }
</style>
</head>
<body>
<main aria-busy="true">
<header>
<div class="brand"><div class="logo" aria-hidden="true">◈</div><div><div class="eyebrow">Codex memory</div><h1>ReMe status</h1><p id="checked" class="muted label" aria-live="polite">Waiting for status…</p></div></div>
<div class="actions"><span id="connection-state" class="pill" hidden></span><button id="refresh" type="button" disabled>Refresh</button></div>
</header>
<p id="error" role="alert" hidden></p>
<div id="loading" class="card muted">Opening the connection and memory overview…</div>
<div id="overview" hidden>
<nav class="tabs" role="tablist" aria-label="ReMe status">
<button id="tab-overview" role="tab" aria-controls="panel-overview" aria-selected="true" data-i18n="overview">Overview</button>
<button id="tab-memory" role="tab" aria-controls="panel-memory" aria-selected="false" tabindex="-1" data-i18n="autoMemory">Auto Memory</button>
<button id="tab-dream" role="tab" aria-controls="panel-dream" aria-selected="false" tabindex="-1" data-i18n="consolidation">Memory Consolidation</button>
<button id="tab-components" role="tab" aria-controls="panel-components" aria-selected="false" tabindex="-1" data-i18n="components">Components</button>
</nav>
<section id="panel-overview" role="tabpanel" aria-labelledby="tab-overview">
<div class="grid">
<article class="card"><div class="label" data-i18n="service">ReMe service</div><div id="service-health" class="value"></div></article>
<article class="card"><div class="label" data-i18n="runtime">Runtime phase</div><div id="runtime" class="value compact"></div></article>
<article class="card"><div class="label" data-i18n="queuedTurns">Queued turns</div><div id="overview-queue" class="value"></div></article>
<article class="card"><div class="label" data-i18n="queuedSessions">Queued sessions</div><div id="overview-sessions" class="value"></div></article>
<article class="card wide"><h2 data-i18n="automation">Memory automation</h2><div class="flow"><div class="step"><div class="label" data-i18n="prompt">1 · Prompt</div><span data-i18n="recall">Automatic recall</span></div><span class="arrow">→</span><div class="step"><div class="label" data-i18n="response">2 · Response</div><span data-i18n="capture">Turn capture</span></div><span class="arrow">→</span><div class="step"><div class="label" data-i18n="workspace">3 · Workspace</div><span data-i18n="files">Memory files</span></div></div></article>
<article class="card wide"><h2 data-i18n="connection">Connection</h2><p id="health"></p><p id="endpoint" class="endpoint muted"></p><small id="plugin-version"></small></article>
</div>
</section>
<section id="panel-memory" role="tabpanel" aria-labelledby="tab-memory" hidden>
<div class="grid">
<article class="card"><div class="label" data-i18n="memory">Automatic memory</div><p><span id="memory-state" class="pill"></span></p></article>
<article class="card"><div class="label" data-i18n="interval">Batch interval</div><div id="interval" class="value"></div></article>
<article class="card"><div class="label" data-i18n="queuedTurns">Queued turns</div><div id="queue" class="value"></div></article>
<article class="card"><div class="label" data-i18n="queuedSessions">Queued sessions</div><div id="sessions" class="value"></div></article>
<article class="card full"><h2 data-i18n="recall">Automatic recall</h2><span id="recall-state" class="pill"></span><p id="recall-result"></p><p id="recall-time" class="muted label"></p><p id="other-queue" class="muted label" hidden></p></article>
<article class="card full"><h2 data-i18n="activity">Recent activity</h2><ul id="activity" class="rows"></ul><p id="no-activity" class="muted" hidden></p></article>
</div>
</section>
<section id="panel-dream" role="tabpanel" aria-labelledby="tab-dream" hidden>
<div class="grid">
<article class="card"><div class="label">Auto Dream</div><p><span id="dream-state" class="pill"></span></p></article>
<article class="card"><div class="label" data-i18n="schedule">Schedule</div><div id="schedule" class="value compact"></div></article>
<article class="card"><div class="label" data-i18n="timezone">Timezone</div><div id="timezone" class="value compact"></div></article>
<article class="card"><div class="label" data-i18n="lastResult">Last result</div><div id="last-result" class="value compact"></div></article>
<article class="card wide"><h2 data-i18n="next">Next run</h2><div id="next" class="value compact"></div><p class="muted" data-i18n="dreamHelp">The daily schedule runs while the ReMe MCP connection is alive.</p></article>
<article class="card wide"><h2 data-i18n="last">Last consolidation</h2><p id="last"></p><p id="scheduler" class="muted"></p></article>
</div>
</section>
<section id="panel-components" role="tabpanel" aria-labelledby="tab-components" hidden>
<div class="grid">
<article class="card wide"><div class="label" data-i18n="processMemory">Process memory (RSS)</div><div id="rss" class="value"></div></article>
<article class="card wide"><div class="label" data-i18n="componentMemory">Estimated component memory</div><div id="component-total" class="value"></div></article>
<article class="card full"><h2 data-i18n="components">Components</h2><div id="components" class="component-grid"></div><p id="no-components" class="muted" hidden></p><p class="note" data-i18n="componentHelp">Memory estimates from ReMe status. Component-level health is not included in the MCP response.</p></article>
</div>
<details><summary data-i18n="details">Service details</summary><pre id="details"></pre></details>
</section>
<p class="note" data-i18n="trust">Connection health does not establish Hook trust. Review ReMe in Codex Hooks settings.</p>
<p class="note" data-i18n="saved">This view uses saved settings. Save changes in MCP settings, then refresh here.</p>
</div>
</main>
<script>(() => {
// node_modules/@openai/mcp-extensions/dist/app/transport.js
function createAppTransport() {
let sequence = 0;
const pending = /* @__PURE__ */ new Map();
const handlers = /* @__PURE__ */ new Map();
const listeners = /* @__PURE__ */ new Map();
const send = (message) => window.parent.postMessage({ jsonrpc: "2.0", ...message }, "*");
const receive = async (event) => {
if (event.source !== window.parent || event.data?.jsonrpc !== "2.0")
return;
const message = event.data;
if (!message.method && message.id != null) {
const request = pending.get(message.id);
if (!request)
return;
pending.delete(message.id);
clearTimeout(request.timer);
if (message.error)
request.reject(new Error(message.error.message));
else
request.resolve(message.result);
} else if (message.method && message.id != null) {
const handler = handlers.get(message.method);
if (!handler) {
send({
id: message.id,
error: {
code: -32601,
message: `Unsupported app method: ${message.method}`
}
});
return;
}
try {
send({
id: message.id,
result: await handler(message.params ?? {})
});
} catch (error) {
send({
id: message.id,
error: {
code: -32603,
message: error instanceof Error ? error.message : String(error)
}
});
}
} else if (message.method) {
for (const listener of listeners.get(message.method) ?? [])
listener(message.params ?? {});
}
};
window.addEventListener("message", receive);
return {
request(method, params = {}, timeoutMs = 15e3) {
if (window.parent === window)
return Promise.reject(new Error(`Open this app in an MCP host to use ${method}`));
const id = ++sequence;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(id);
reject(new Error(`${method} timed out`));
}, timeoutMs);
pending.set(id, {
resolve: (value) => resolve(value),
reject,
timer
});
send({ id, method, params });
});
},
notify(method, params = {}) {
send({ method, params });
},
handle(method, handler) {
handlers.set(method, handler);
},
on(method, listener) {
if (!listeners.has(method))
listeners.set(method, /* @__PURE__ */ new Set());
listeners.get(method).add(listener);
return () => {
listeners.get(method)?.delete(listener);
};
},
dispose() {
window.removeEventListener("message", receive);
for (const request of pending.values()) {
clearTimeout(request.timer);
request.reject(new Error("App disposed"));
}
pending.clear();
handlers.clear();
listeners.clear();
}
};
}
// src/status.js
var $ = (id) => document.getElementById(id);
var transport = createAppTransport();
var connected = false;
var disposed = false;
var language = "en";
var zone = "UTC";
var hasSnapshot = false;
var refreshing = false;
var words = {
en: {
overview: "Overview",
autoMemory: "Auto Memory",
consolidation: "Memory Consolidation",
components: "Components",
service: "ReMe service",
runtime: "Runtime phase",
queuedTurns: "Queued turns",
queuedSessions: "Queued sessions",
automation: "Memory automation",
prompt: "1 \xB7 Prompt",
response: "2 \xB7 Response",
workspace: "3 \xB7 Workspace",
capture: "Turn capture",
files: "Memory files",
interval: "Batch interval",
timezone: "Timezone",
lastResult: "Last result",
healthy: "Healthy",
unhealthy: "Unhealthy",
unknown: "Unknown",
processMemory: "Process memory (RSS)",
componentMemory: "Estimated component memory",
noComponents: "No component memory details returned.",
componentHelp: "Memory estimates from ReMe status. Component-level health is not included in the MCP response.",
dreamHelp: "The daily schedule runs while the ReMe MCP connection is alive.",
connection: "Connection",
memory: "Automatic memory",
recall: "Automatic recall",
schedule: "Schedule",
next: "Next run",
scheduler: "Scheduler",
last: "Last consolidation",
activity: "Recent activity",
details: "Service details",
on: "Enabled",
off: "Disabled",
connected: "Connected",
offline: "Unavailable",
refresh: "Refresh",
refreshing: "Refreshing\u2026",
checked: "Checked",
empty: "No activity recorded yet.",
waiting: "Waiting for the first status result\u2026",
unavailable: "Status could not be refreshed. Try again or reopen ReMe status.",
noHost: "Open ReMe status from Codex MCP settings to view live data.",
trust: "Connection health does not establish Hook trust. Review ReMe in Codex Hooks settings.",
saved: "This view uses saved settings. Save changes in MCP settings, then refresh here.",
pending: (n) => `${n} queued turns`,
batch: (n, s) => `Batch size: ${n} turns \xB7 ${s} queued sessions`,
other: (n) => `${n} turns are queued for other service addresses.`,
noRecall: "No recall activity yet",
noDream: "No consolidation recorded yet",
paused: "No run scheduled",
running: "Running",
starting: "Starting",
updating: "Applying settings",
stopped: "Stopped",
scheduled: "Scheduled",
manual: "Manual",
completed: "Completed",
failed: "Failed",
cancelled: "Cancelled",
interrupted: "Interrupted",
recall_found: "Relevant memory recalled",
recall_empty: "No matching memory",
recall_failed: "Recall failed",
recall_disabled: "Recall disabled",
memory_queued: "Turn queued",
memory_saved: "Memory saved",
memory_failed: "Memory delivery failed",
memory_disabled: "Recording disabled",
retry_failed: "Delivery retry failed",
dream_started: "Consolidation started",
dream_completed: "Consolidation completed",
dream_failed: "Consolidation failed",
dream_cancelled: "Consolidation cancelled",
scheduled_dream_failed: "Scheduled consolidation failed",
search_found: "Search found memory",
search_empty: "Search found no memory",
search_failed: "Search failed",
capture_skipped: "No completed turn captured",
SessionStart: "Session started",
SessionEnd: "Session ended",
Stop: "Turn completed",
SubagentStop: "Subagent turn completed",
UserPromptSubmit: "Prompt submitted"
},
zh: {
overview: "\u603B\u89C8",
autoMemory: "\u81EA\u52A8\u8BB0\u5FC6",
consolidation: "\u8BB0\u5FC6\u6574\u7406",
components: "\u7EC4\u4EF6",
service: "ReMe \u670D\u52A1",
runtime: "\u8FD0\u884C\u72B6\u6001",
queuedTurns: "\u5F85\u63D0\u4EA4\u8F6E\u6570",
queuedSessions: "\u5F85\u63D0\u4EA4\u4F1A\u8BDD",
automation: "\u8BB0\u5FC6\u6D41\u7A0B",
prompt: "1 \xB7 \u63D0\u95EE",
response: "2 \xB7 \u56DE\u590D",
workspace: "3 \xB7 \u5DE5\u4F5C\u533A",
capture: "\u8BB0\u5F55\u5B8C\u6210\u8F6E\u6B21",
files: "\u8BB0\u5FC6\u6587\u4EF6",
interval: "\u6279\u6B21\u8F6E\u6570",
timezone: "\u65F6\u533A",
lastResult: "\u6700\u8FD1\u7ED3\u679C",
healthy: "\u5065\u5EB7",
unhealthy: "\u5F02\u5E38",
unknown: "\u672A\u77E5",
processMemory: "\u8FDB\u7A0B\u5185\u5B58\uFF08RSS\uFF09",
componentMemory: "\u7EC4\u4EF6\u5185\u5B58\u4F30\u7B97",
noComponents: "\u6682\u65E0\u7EC4\u4EF6\u5185\u5B58\u4FE1\u606F\u3002",
componentHelp: "\u5185\u5B58\u4E3A ReMe status \u8FD4\u56DE\u7684\u4F30\u7B97\u503C\uFF1BMCP \u54CD\u5E94\u4E0D\u5305\u542B\u7EC4\u4EF6\u7EA7\u5065\u5EB7\u4FE1\u606F\u3002",
dreamHelp: "\u53EA\u6709 ReMe MCP \u8FDE\u63A5\u5B58\u6D3B\u65F6\uFF0C\u6BCF\u65E5\u8BA1\u5212\u624D\u4F1A\u89E6\u53D1\u3002",
connection: "\u670D\u52A1\u8FDE\u63A5",
memory: "\u81EA\u52A8\u8BB0\u5F55",
recall: "\u81EA\u52A8\u53EC\u56DE",
schedule: "\u6574\u7406\u8BA1\u5212",
next: "\u4E0B\u6B21\u8FD0\u884C",
scheduler: "\u8C03\u5EA6\u72B6\u6001",
last: "\u6700\u8FD1\u6574\u7406",
activity: "\u6700\u8FD1\u6D3B\u52A8",
details: "\u670D\u52A1\u8BE6\u60C5",
on: "\u5DF2\u5F00\u542F",
off: "\u5DF2\u5173\u95ED",
connected: "\u8FDE\u63A5\u6B63\u5E38",
offline: "\u65E0\u6CD5\u8FDE\u63A5",
refresh: "\u5237\u65B0",
refreshing: "\u5237\u65B0\u4E2D\u2026",
checked: "\u68C0\u67E5\u65F6\u95F4",
empty: "\u6682\u65E0\u6D3B\u52A8\u8BB0\u5F55\u3002",
waiting: "\u6B63\u5728\u7B49\u5F85\u9996\u6B21\u72B6\u6001\u7ED3\u679C\u2026",
unavailable: "\u672A\u80FD\u5237\u65B0\u72B6\u6001\uFF0C\u8BF7\u91CD\u8BD5\u6216\u91CD\u65B0\u6253\u5F00 ReMe status\u3002",
noHost: "\u8BF7\u4ECE Codex \u7684 MCP \u8BBE\u7F6E\u4E2D\u6253\u5F00 ReMe status \u67E5\u770B\u5B9E\u65F6\u6570\u636E\u3002",
trust: "\u670D\u52A1\u8FDE\u63A5\u6B63\u5E38\u4E0D\u4EE3\u8868 Hook \u5DF2\u83B7\u4FE1\u4EFB\uFF0C\u8BF7\u5728 Codex Hooks \u8BBE\u7F6E\u4E2D\u5BA1\u6838 ReMe \u6761\u76EE\u3002",
saved: "\u6B64\u9875\u9762\u4F7F\u7528\u5DF2\u4FDD\u5B58\u7684\u914D\u7F6E\u3002\u4FEE\u6539 MCP \u8BBE\u7F6E\u540E\uFF0C\u8BF7\u5148\u4FDD\u5B58\uFF0C\u518D\u5237\u65B0\u6B64\u9875\u9762\u3002",
pending: (n) => `${n} \u8F6E\u5F85\u63D0\u4EA4`,
batch: (n, s) => `\u6BCF\u6279 ${n} \u8F6E \xB7 ${s} \u4E2A\u4F1A\u8BDD\u5F85\u63D0\u4EA4`,
other: (n) => `\u53E6\u6709 ${n} \u8F6E\u7B49\u5F85\u63D0\u4EA4\u81F3\u5176\u4ED6\u670D\u52A1\u5730\u5740\u3002`,
noRecall: "\u6682\u65E0\u53EC\u56DE\u6D3B\u52A8",
noDream: "\u6682\u65E0\u6574\u7406\u8BB0\u5F55",
paused: "\u6682\u65E0\u8BA1\u5212",
running: "\u8FD0\u884C\u4E2D",
starting: "\u542F\u52A8\u4E2D",
updating: "\u6B63\u5728\u5E94\u7528\u914D\u7F6E",
stopped: "\u5DF2\u505C\u6B62",
scheduled: "\u5B9A\u65F6",
manual: "\u624B\u52A8",
completed: "\u5DF2\u5B8C\u6210",
failed: "\u5931\u8D25",
cancelled: "\u5DF2\u53D6\u6D88",
interrupted: "\u5DF2\u4E2D\u65AD",
recall_found: "\u5DF2\u53EC\u56DE\u76F8\u5173\u8BB0\u5FC6",
recall_empty: "\u672A\u627E\u5230\u76F8\u5173\u8BB0\u5FC6",
recall_failed: "\u53EC\u56DE\u5931\u8D25",
recall_disabled: "\u81EA\u52A8\u53EC\u56DE\u5DF2\u5173\u95ED",
memory_queued: "\u5DF2\u52A0\u5165\u8BB0\u5F55\u961F\u5217",
memory_saved: "\u8BB0\u5FC6\u5DF2\u4FDD\u5B58",
memory_failed: "\u8BB0\u5FC6\u63D0\u4EA4\u5931\u8D25",
memory_disabled: "\u81EA\u52A8\u8BB0\u5F55\u5DF2\u5173\u95ED",
retry_failed: "\u91CD\u8BD5\u63D0\u4EA4\u5931\u8D25",
dream_started: "\u5F00\u59CB\u6574\u7406",
dream_completed: "\u6574\u7406\u5DF2\u5B8C\u6210",
dream_failed: "\u6574\u7406\u5931\u8D25",
dream_cancelled: "\u6574\u7406\u5DF2\u53D6\u6D88",
scheduled_dream_failed: "\u5B9A\u65F6\u6574\u7406\u5931\u8D25",
search_found: "\u68C0\u7D22\u5230\u76F8\u5173\u8BB0\u5FC6",
search_empty: "\u672A\u68C0\u7D22\u5230\u76F8\u5173\u8BB0\u5FC6",
search_failed: "\u68C0\u7D22\u5931\u8D25",
capture_skipped: "\u672A\u6355\u83B7\u5230\u5B8C\u6210\u8F6E\u6B21",
SessionStart: "\u4F1A\u8BDD\u5F00\u59CB",
SessionEnd: "\u4F1A\u8BDD\u7ED3\u675F",
Stop: "\u8F6E\u6B21\u5B8C\u6210",
SubagentStop: "\u5B50\u4EE3\u7406\u8F6E\u6B21\u5B8C\u6210",
UserPromptSubmit: "\u5DF2\u63D0\u4EA4\u63D0\u95EE"
}
};
var t = (key) => words[language][key] ?? key;
var text = (id, value) => {
$(id).textContent = value;
};
function formatTime(value) {
if (value == null) return "\u2014";
const date = new Date(typeof value === "number" ? value * 1e3 : value);
if (!Number.isFinite(date.getTime())) return "\u2014";
return new Intl.DateTimeFormat(language === "zh" ? "zh-CN" : "en-GB", {
timeZone: zone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
timeZoneName: "short"
}).format(date);
}
function pill(id, value, tone = "good") {
text(id, value);
$(id).dataset.tone = tone;
}
function toggle(id, enabled) {
pill(id, t(enabled ? "on" : "off"), enabled ? "good" : "neutral");
}
function applyHost(context = {}) {
if (["dark", "light"].includes(context.theme)) document.documentElement.dataset.theme = context.theme;
for (const key of ["--color-background-primary", "--color-text-primary"]) {
const value = context.styles?.variables?.[key];
if (typeof value === "string") document.documentElement.style.setProperty(key, value);
}
}
function fail() {
if (disposed) return;
text("error", t("unavailable"));
$("error").hidden = false;
$("loading").hidden = true;
document.querySelector("main").setAttribute("aria-busy", "false");
}
function render(result) {
if (disposed) return;
const data = result?.structuredContent;
if (result?.isError || !data?.service?.health_check || !data?.auto_memory || !data?.auto_dream) {
fail();
return;
}
language = data.language === "zh" ? "zh" : "en";
zone = data.auto_dream.timezone;
document.documentElement.lang = language === "zh" ? "zh-CN" : "en";
document.querySelectorAll("[data-i18n]").forEach((node) => {
node.textContent = t(node.dataset.i18n);
});
text("refresh", t(refreshing ? "refreshing" : "refresh"));
const health = data.service.health_check;
pill("connection-state", t(health.reachable ? "connected" : "offline"), health.reachable ? "good" : "bad");
$("connection-state").hidden = false;
const summary = typeof health.answer === "string" ? health.answer.trim().match(/(?:^|-\s*)(unhealthy|healthy)$/) : null;
text("service-health", t(!health.reachable ? "offline" : summary?.[1] ?? "unknown"));
$("service-health").dataset.tone = !health.reachable || summary?.[1] === "unhealthy" ? "bad" : summary ? "good" : "neutral";
text("runtime", t(data.auto_dream.phase));
text("overview-queue", data.auto_memory.queued_turns);
text("overview-sessions", data.auto_memory.queued_sessions);
text("health", health.reachable ? typeof health.answer === "string" ? health.answer : JSON.stringify(health.answer) : health.error);
text("endpoint", data.mcpUrl);
text("plugin-version", `ReMe for Codex \xB7 ${data.plugin_version}`);
text("checked", `${t("checked")} ${formatTime(data.checked_at)}`);
toggle("memory-state", data.auto_memory.enabled);
text("queue", data.auto_memory.queued_turns);
text("interval", data.auto_memory.interval);
text("sessions", data.auto_memory.queued_sessions);
$("other-queue").hidden = !data.auto_memory.other_endpoint_queued_turns;
text("other-queue", t("other")(data.auto_memory.other_endpoint_queued_turns));
toggle("recall-state", data.auto_recall.enabled);
const activity = data.recent_activity ?? [];
const recall = activity.findLast((row) => row.event?.startsWith("recall_"));
text("recall-result", recall ? t(recall.event) : t("noRecall"));
text("recall-time", recall ? formatTime(recall.time) : "\u2014");
const dream = data.auto_dream;
toggle("dream-state", dream.enabled);
text("schedule", dream.cron);
text("timezone", dream.timezone);
text("next", dream.next_run_at ? formatTime(dream.next_run_at) : t("paused"));
text("scheduler", `${t("scheduler")}: ${t(dream.phase)}`);
const last = dream.last_run;
text("last-result", last ? t(last.status) : "\u2014");
text("last", last ? `${t(last.origin)} \xB7 ${t(last.status)} \xB7 ${formatTime(last.completed_at ?? last.started_at)}${last.error ? ` \xB7 ${last.error}` : ""}` : t("noDream"));
$("activity").replaceChildren();
for (const row of activity.slice(-20).reverse()) {
const item = document.createElement("li");
const description = document.createElement("span");
description.textContent = t(row.event);
const code = document.createElement("code");
code.textContent = `${row.event}${row.turns ? ` \xB7 ${row.turns}` : ""}${row.error ? ` \xB7 ${row.error}` : ""}`;
description.append(code);
const time = document.createElement("time");
time.textContent = formatTime(row.time);
item.append(description, time);
$("activity").append(item);
}
$("no-activity").hidden = activity.length > 0;
text("no-activity", t("empty"));
renderComponents(data.service.status);
text("details", JSON.stringify(data.service, null, 2));
$("error").hidden = true;
$("loading").hidden = true;
$("overview").hidden = false;
document.querySelector("main").setAttribute("aria-busy", "false");
hasSnapshot = true;
}
function renderComponents(status) {
const report = status?.reachable && typeof status.answer === "string" ? status.answer : "";
const amount = "([0-9]+(?:\\.[0-9]+)? (?:B|KiB|MiB|GiB|TiB))";
text("rss", report.match(new RegExp(`^ Process RSS +${amount}$`, "m"))?.[1] ?? "\u2014");
text("component-total", report.match(new RegExp(`^ Components total +${amount}$`, "m"))?.[1] ?? "\u2014");
$("components").replaceChildren();
for (const row of report.matchAll(new RegExp(`^ ([a-z_]+):([^\\s:]+) +${amount}$`, "gm"))) {
const card = document.createElement("div");
card.className = "component";
const title = document.createElement("strong");
title.textContent = `${row[1].replaceAll("_", " ")} \xB7 ${row[2]}`;
const size = document.createElement("div");
size.className = "value compact";
size.textContent = row[3];
card.append(title, size);
$("components").append(card);
}
$("no-components").hidden = $("components").childElementCount > 0;
text("no-components", status?.reachable === false ? status.error : t("noComponents"));
}
var tabs = [...document.querySelectorAll('[role="tab"]')];
function selectTab(selected) {
for (const tab of tabs) {
const active = tab === selected;
tab.setAttribute("aria-selected", String(active));
tab.tabIndex = active ? 0 : -1;
$(tab.getAttribute("aria-controls")).hidden = !active;
}
}
for (const tab of tabs) {
tab.addEventListener("click", () => selectTab(tab));
tab.addEventListener("keydown", (event) => {
const index = tabs.indexOf(tab);
const next = { ArrowRight: (index + 1) % tabs.length, ArrowLeft: (index + tabs.length - 1) % tabs.length, Home: 0, End: tabs.length - 1 }[event.key];
if (next == null) return;
event.preventDefault();
selectTab(tabs[next]);
tabs[next].focus();
});
}
var resizeFrame;
var resizeObserver = new ResizeObserver(() => {
cancelAnimationFrame(resizeFrame);
resizeFrame = requestAnimationFrame(() => {
if (connected && !disposed) transport.notify("ui/notifications/size-changed", { height: Math.ceil(document.body.getBoundingClientRect().height) });
});
});
resizeObserver.observe(document.body);
transport.on("ui/notifications/tool-result", (result) => {
try {
render(result);
} catch {
fail();
}
});
transport.on("ui/notifications/tool-cancelled", fail);
transport.on("ui/notifications/host-context-changed", applyHost);
transport.handle("ui/resource-teardown", () => {
disposed = true;
connected = false;
$("refresh").disabled = true;
resizeObserver.disconnect();
cancelAnimationFrame(resizeFrame);
setTimeout(() => transport.dispose(), 0);
return {};
});
$("refresh").addEventListener("click", async () => {
if (!connected || refreshing) return;
refreshing = true;
$("refresh").disabled = true;
text("refresh", t("refreshing"));
document.querySelector("main").setAttribute("aria-busy", "true");
try {
render(await transport.request("tools/call", { name: "reme_status", arguments: {} }, 125e3));
} catch {
fail();
} finally {
refreshing = false;
$("refresh").disabled = !connected;
text("refresh", t("refresh"));
}
});
async function connect() {
if (window.parent === window) {
text("loading", t("noHost"));
return;
}
const host = await transport.request("ui/initialize", {
protocolVersion: "2026-01-26",
appInfo: { name: "reme-status", version: "0.2.1" },
appCapabilities: { availableDisplayModes: ["fullscreen"] }
});
if (host.protocolVersion !== "2026-01-26") throw new Error("Unsupported UI protocol");
connected = true;
applyHost(host.hostContext);
$("refresh").disabled = false;
if (!hasSnapshot) text("loading", t("waiting"));
transport.notify("ui/notifications/initialized");
const context = host.hostContext ?? {};
if (context.displayMode !== "fullscreen" && context.availableDisplayModes?.includes("fullscreen")) {
await transport.request("ui/request-display-mode", { mode: "fullscreen" }).catch(() => {
});
}
}
connect().catch(fail);
})();
</script>
</body>
</html>

View file

@ -0,0 +1 @@
node_modules/

View file

@ -0,0 +1,28 @@
# ReMe status view maintenance
The settings action `reme_status` returns one read-only snapshot and opens the packaged MCP App.
The view consumes that initial result; only the Refresh button makes a further `reme_status` call.
It never calls memory-write tools, changes settings, or contacts the ReMe URL directly.
Edit `src/`, then build the self-contained installed asset:
```bash
npm ci
npm run build
```
Commit the generated `../plugins/reme/ui/status.html` with its source so installing from a checkout
requires only the existing Python runtime. Node is needed only to rebuild the view. The bundle uses
OpenAI MCP Extensions 0.1.0's app transport, includes its license, and loads no external scripts.
The resource advertises fullscreen; the host controls actual placement, including a settings modal.
Validation from the repository root:
```bash
pytest tests/unit/test_codex_plugin_mcp.py -q
REME_CODEX_STATUS_UI=1 pytest tests/integration/test_codex_plugin_status_ui.py -q
```
The opt-in browser test needs Playwright with Chromium and uses a synthetic host for protocol and
rendering checks. Its output is not a real Codex screenshot. Native Codex placement must be checked
in the app when access is available; user documentation keeps genuine screenshot placeholders.

View file

@ -0,0 +1,18 @@
import { build } from "esbuild";
import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
const result = await build({
entryPoints: [new URL("./src/status.js", import.meta.url).pathname],
bundle: true,
write: false,
format: "iife",
target: "es2022",
legalComments: "inline",
});
const template = await readFile(new URL("./src/status.html", import.meta.url), "utf8");
const script = result.outputFiles[0].text.replaceAll("</script", "<\\/script");
const output = new URL("../plugins/reme/ui/", import.meta.url);
await mkdir(output, { recursive: true });
await writeFile(new URL("status.html", output), template.replace("/* STATUS_APP */", () => script));
await copyFile(new URL("./node_modules/@openai/mcp-extensions/LICENSE", import.meta.url), new URL("LICENSE-mcp-extensions.txt", output));
console.log("Built plugins/reme/ui/status.html (self-contained; no runtime Node dependency)");

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,13 @@
{
"name": "reme-codex-status",
"private": true,
"type": "module",
"license": "Apache-2.0",
"scripts": {
"build": "node build.mjs"
},
"devDependencies": {
"@openai/mcp-extensions": "0.1.0",
"esbuild": "0.28.2"
}
}

View file

@ -0,0 +1,126 @@
<!doctype html>
<!-- Built from status-app/src. Includes @openai/mcp-extensions 0.1.0 app transport (OpenAI, Apache-2.0); see LICENSE-mcp-extensions.txt. -->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ReMe status</title>
<style>
:root { color-scheme: light dark; --bg: #f6f8fc; --surface: #fff; --panel: #f9faff; --ink: #20283b; --muted: #66718a; --line: #dfe5ee; --blue: #426ccd; --green: #23704b; --green-bg: #e6f2e9; --amber: #875811; --amber-bg: #fff2d9; --red: #a33036; --red-bg: #fbecee; }
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --bg: #0b0d12; --surface: #12151d; --panel: #181c26; --ink: #f3f5f7; --muted: #979dab; --line: #292e3b; --blue: #79a8ff; --green: #63d4a3; --green-bg: #25382f; --amber: #f2be5c; --amber-bg: #3c3526; --red: #ff7b86; --red-bg: #40262c; } }
:root[data-theme="dark"] { color-scheme: dark; --bg: #0b0d12; --surface: #12151d; --panel: #181c26; --ink: #f3f5f7; --muted: #979dab; --line: #292e3b; --blue: #79a8ff; --green: #63d4a3; --green-bg: #25382f; --amber: #f2be5c; --amber-bg: #3c3526; --red: #ff7b86; --red-bg: #40262c; }
:root[data-theme="light"] { color-scheme: light; }
* { box-sizing: border-box; }
body { margin: 0; padding: 28px; background: var(--color-background-primary, var(--bg)); color: var(--color-text-primary, var(--ink)); font: 14px/1.5 system-ui, -apple-system, sans-serif; }
main { max-width: 1100px; margin: auto; }
header { display: flex; align-items: start; justify-content: space-between; gap: 20px; margin-bottom: 22px; }
.brand { display: flex; gap: 12px; align-items: center; }
.logo { display: grid; place-items: center; width: 44px; height: 44px; flex: 0 0 44px; border-radius: 13px; background: linear-gradient(145deg, #678ff7, #725de3); color: #fff; font-size: 26px; }
.eyebrow { color: var(--blue); font-size: 10px; font-weight: 700; letter-spacing: .14em; text-transform: uppercase; }
h1 { font-size: 25px; letter-spacing: -.5px; margin: 0; font-weight: 650; }
h2 { font-size: 14px; margin: 0 0 12px; font-weight: 600; }
p { margin: 6px 0; }
.muted, small { color: var(--muted); }
.label { font-size: 12px; color: var(--muted); }
.endpoint { overflow-wrap: anywhere; font-size: 12px; font-family: ui-monospace, monospace; }
.actions { display: flex; gap: 10px; align-items: center; }
button { background: var(--surface); color: var(--ink); border: 1px solid var(--line); border-radius: 8px; padding: 8px 14px; font: inherit; cursor: pointer; white-space: nowrap; }
button:hover:not(:disabled) { border-color: var(--blue); }
button:focus-visible, summary:focus-visible { outline: 2px solid var(--blue); outline-offset: 3px; }
button:disabled { opacity: .55; cursor: wait; }
.tabs { display: flex; gap: 4px; overflow-x: auto; border-bottom: 1px solid var(--line); margin-bottom: 20px; }
.tabs button { color: var(--muted); background: transparent; border: 0; border-radius: 0; border-bottom: 2px solid transparent; padding: 11px 14px; font-weight: 600; }
.tabs button[aria-selected="true"] { color: var(--ink); border-bottom-color: var(--blue); }
.grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; }
.card { background: linear-gradient(150deg, var(--panel), var(--surface)); border: 1px solid var(--line); border-radius: 12px; padding: 18px; min-width: 0; }
.wide { grid-column: span 2; }
.full { grid-column: 1 / -1; }
.value { font-size: 25px; line-height: 1.3; font-weight: 700; margin-top: 8px; }
.compact { font-size: 16px; overflow-wrap: anywhere; }
.pill { display: inline-block; color: var(--green); background: var(--green-bg); border-radius: 99px; padding: 4px 10px; font-size: 12px; font-weight: 600; }
[data-tone="good"] { color: var(--green); }
[data-tone="bad"] { color: var(--red); }
.pill[data-tone="neutral"] { color: var(--muted); background: var(--bg); }
.pill[data-tone="warn"] { color: var(--amber); background: var(--amber-bg); }
.pill[data-tone="bad"] { color: var(--red); background: var(--red-bg); }
.flow { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.step { background: var(--surface); border: 1px solid var(--line); padding: 10px; border-radius: 8px; flex: 1 1 100px; }
.arrow { color: var(--muted); }
.rows { padding: 0; margin: 0; list-style: none; }
.rows li { display: flex; justify-content: space-between; align-items: baseline; gap: 16px; padding: 10px 0; border-bottom: 1px solid var(--line); }
.rows li:last-child { border: 0; padding-bottom: 0; }
.rows code { display: block; color: var(--muted); font-size: 11px; }
time { color: var(--muted); font-size: 12px; white-space: nowrap; }
.component-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.component { border: 1px solid var(--line); background: var(--surface); padding: 14px; border-radius: 9px; min-width: 0; }
.component strong { display: block; overflow-wrap: anywhere; font-size: 13px; }
details { margin-top: 18px; }
summary { cursor: pointer; color: var(--muted); }
pre { white-space: pre-wrap; overflow-wrap: anywhere; background: var(--surface); padding: 14px; border: 1px solid var(--line); border-radius: 8px; font-size: 12px; max-height: 280px; overflow: auto; }
.note { margin: 18px 0 0; font-size: 12px; color: var(--muted); }
#error { border: 1px solid var(--red); color: var(--red); padding: 12px; border-radius: 8px; margin-bottom: 16px; }
[hidden] { display: none !important; }
@media (max-width: 850px) { .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 540px) { body { padding: 18px; } .grid, .component-grid { grid-template-columns: 1fr; } .wide { grid-column: 1 / -1; } .card { padding: 16px; } header { flex-wrap: wrap; gap: 12px; } h1 { font-size: 23px; } .rows li { flex-direction: column; gap: 2px; } }
</style>
</head>
<body>
<main aria-busy="true">
<header>
<div class="brand"><div class="logo" aria-hidden="true">◈</div><div><div class="eyebrow">Codex memory</div><h1>ReMe status</h1><p id="checked" class="muted label" aria-live="polite">Waiting for status…</p></div></div>
<div class="actions"><span id="connection-state" class="pill" hidden></span><button id="refresh" type="button" disabled>Refresh</button></div>
</header>
<p id="error" role="alert" hidden></p>
<div id="loading" class="card muted">Opening the connection and memory overview…</div>
<div id="overview" hidden>
<nav class="tabs" role="tablist" aria-label="ReMe status">
<button id="tab-overview" role="tab" aria-controls="panel-overview" aria-selected="true" data-i18n="overview">Overview</button>
<button id="tab-memory" role="tab" aria-controls="panel-memory" aria-selected="false" tabindex="-1" data-i18n="autoMemory">Auto Memory</button>
<button id="tab-dream" role="tab" aria-controls="panel-dream" aria-selected="false" tabindex="-1" data-i18n="consolidation">Memory Consolidation</button>
<button id="tab-components" role="tab" aria-controls="panel-components" aria-selected="false" tabindex="-1" data-i18n="components">Components</button>
</nav>
<section id="panel-overview" role="tabpanel" aria-labelledby="tab-overview">
<div class="grid">
<article class="card"><div class="label" data-i18n="service">ReMe service</div><div id="service-health" class="value"></div></article>
<article class="card"><div class="label" data-i18n="runtime">Runtime phase</div><div id="runtime" class="value compact"></div></article>
<article class="card"><div class="label" data-i18n="queuedTurns">Queued turns</div><div id="overview-queue" class="value"></div></article>
<article class="card"><div class="label" data-i18n="queuedSessions">Queued sessions</div><div id="overview-sessions" class="value"></div></article>
<article class="card wide"><h2 data-i18n="automation">Memory automation</h2><div class="flow"><div class="step"><div class="label" data-i18n="prompt">1 · Prompt</div><span data-i18n="recall">Automatic recall</span></div><span class="arrow">→</span><div class="step"><div class="label" data-i18n="response">2 · Response</div><span data-i18n="capture">Turn capture</span></div><span class="arrow">→</span><div class="step"><div class="label" data-i18n="workspace">3 · Workspace</div><span data-i18n="files">Memory files</span></div></div></article>
<article class="card wide"><h2 data-i18n="connection">Connection</h2><p id="health"></p><p id="endpoint" class="endpoint muted"></p><small id="plugin-version"></small></article>
</div>
</section>
<section id="panel-memory" role="tabpanel" aria-labelledby="tab-memory" hidden>
<div class="grid">
<article class="card"><div class="label" data-i18n="memory">Automatic memory</div><p><span id="memory-state" class="pill"></span></p></article>
<article class="card"><div class="label" data-i18n="interval">Batch interval</div><div id="interval" class="value"></div></article>
<article class="card"><div class="label" data-i18n="queuedTurns">Queued turns</div><div id="queue" class="value"></div></article>
<article class="card"><div class="label" data-i18n="queuedSessions">Queued sessions</div><div id="sessions" class="value"></div></article>
<article class="card full"><h2 data-i18n="recall">Automatic recall</h2><span id="recall-state" class="pill"></span><p id="recall-result"></p><p id="recall-time" class="muted label"></p><p id="other-queue" class="muted label" hidden></p></article>
<article class="card full"><h2 data-i18n="activity">Recent activity</h2><ul id="activity" class="rows"></ul><p id="no-activity" class="muted" hidden></p></article>
</div>
</section>
<section id="panel-dream" role="tabpanel" aria-labelledby="tab-dream" hidden>
<div class="grid">
<article class="card"><div class="label">Auto Dream</div><p><span id="dream-state" class="pill"></span></p></article>
<article class="card"><div class="label" data-i18n="schedule">Schedule</div><div id="schedule" class="value compact"></div></article>
<article class="card"><div class="label" data-i18n="timezone">Timezone</div><div id="timezone" class="value compact"></div></article>
<article class="card"><div class="label" data-i18n="lastResult">Last result</div><div id="last-result" class="value compact"></div></article>
<article class="card wide"><h2 data-i18n="next">Next run</h2><div id="next" class="value compact"></div><p class="muted" data-i18n="dreamHelp">The daily schedule runs while the ReMe MCP connection is alive.</p></article>
<article class="card wide"><h2 data-i18n="last">Last consolidation</h2><p id="last"></p><p id="scheduler" class="muted"></p></article>
</div>
</section>
<section id="panel-components" role="tabpanel" aria-labelledby="tab-components" hidden>
<div class="grid">
<article class="card wide"><div class="label" data-i18n="processMemory">Process memory (RSS)</div><div id="rss" class="value"></div></article>
<article class="card wide"><div class="label" data-i18n="componentMemory">Estimated component memory</div><div id="component-total" class="value"></div></article>
<article class="card full"><h2 data-i18n="components">Components</h2><div id="components" class="component-grid"></div><p id="no-components" class="muted" hidden></p><p class="note" data-i18n="componentHelp">Memory estimates from ReMe status. Component-level health is not included in the MCP response.</p></article>
</div>
<details><summary data-i18n="details">Service details</summary><pre id="details"></pre></details>
</section>
<p class="note" data-i18n="trust">Connection health does not establish Hook trust. Review ReMe in Codex Hooks settings.</p>
<p class="note" data-i18n="saved">This view uses saved settings. Save changes in MCP settings, then refresh here.</p>
</div>
</main>
<script>/* STATUS_APP */</script>
</body>
</html>

View file

@ -0,0 +1,228 @@
import { createAppTransport } from "@openai/mcp-extensions/app/transport";
const $ = (id) => document.getElementById(id);
const transport = createAppTransport();
let connected = false;
let disposed = false;
let language = "en";
let zone = "UTC";
let hasSnapshot = false;
let refreshing = false;
const words = {
en: {
overview: "Overview", autoMemory: "Auto Memory", consolidation: "Memory Consolidation", components: "Components",
service: "ReMe service", runtime: "Runtime phase", queuedTurns: "Queued turns", queuedSessions: "Queued sessions",
automation: "Memory automation", prompt: "1 · Prompt", response: "2 · Response", workspace: "3 · Workspace",
capture: "Turn capture", files: "Memory files", interval: "Batch interval", timezone: "Timezone", lastResult: "Last result",
healthy: "Healthy", unhealthy: "Unhealthy", unknown: "Unknown", processMemory: "Process memory (RSS)",
componentMemory: "Estimated component memory", noComponents: "No component memory details returned.",
componentHelp: "Memory estimates from ReMe status. Component-level health is not included in the MCP response.",
dreamHelp: "The daily schedule runs while the ReMe MCP connection is alive.",
connection: "Connection", memory: "Automatic memory", recall: "Automatic recall", schedule: "Schedule",
next: "Next run", scheduler: "Scheduler", last: "Last consolidation", activity: "Recent activity",
details: "Service details", on: "Enabled", off: "Disabled", connected: "Connected", offline: "Unavailable",
refresh: "Refresh", refreshing: "Refreshing…", checked: "Checked", empty: "No activity recorded yet.",
waiting: "Waiting for the first status result…", unavailable: "Status could not be refreshed. Try again or reopen ReMe status.",
noHost: "Open ReMe status from Codex MCP settings to view live data.",
trust: "Connection health does not establish Hook trust. Review ReMe in Codex Hooks settings.",
saved: "This view uses saved settings. Save changes in MCP settings, then refresh here.",
pending: (n) => `${n} queued turns`, batch: (n, s) => `Batch size: ${n} turns · ${s} queued sessions`,
other: (n) => `${n} turns are queued for other service addresses.`,
noRecall: "No recall activity yet", noDream: "No consolidation recorded yet", paused: "No run scheduled",
running: "Running", starting: "Starting", updating: "Applying settings", stopped: "Stopped",
scheduled: "Scheduled", manual: "Manual", completed: "Completed", failed: "Failed", cancelled: "Cancelled", interrupted: "Interrupted",
recall_found: "Relevant memory recalled", recall_empty: "No matching memory", recall_failed: "Recall failed", recall_disabled: "Recall disabled",
memory_queued: "Turn queued", memory_saved: "Memory saved", memory_failed: "Memory delivery failed", memory_disabled: "Recording disabled",
retry_failed: "Delivery retry failed", dream_started: "Consolidation started", dream_completed: "Consolidation completed",
dream_failed: "Consolidation failed", dream_cancelled: "Consolidation cancelled", scheduled_dream_failed: "Scheduled consolidation failed",
search_found: "Search found memory", search_empty: "Search found no memory", search_failed: "Search failed",
capture_skipped: "No completed turn captured", SessionStart: "Session started", SessionEnd: "Session ended",
Stop: "Turn completed", SubagentStop: "Subagent turn completed", UserPromptSubmit: "Prompt submitted",
},
zh: {
overview: "总览", autoMemory: "自动记忆", consolidation: "记忆整理", components: "组件",
service: "ReMe 服务", runtime: "运行状态", queuedTurns: "待提交轮数", queuedSessions: "待提交会话",
automation: "记忆流程", prompt: "1 · 提问", response: "2 · 回复", workspace: "3 · 工作区",
capture: "记录完成轮次", files: "记忆文件", interval: "批次轮数", timezone: "时区", lastResult: "最近结果",
healthy: "健康", unhealthy: "异常", unknown: "未知", processMemory: "进程内存(RSS)",
componentMemory: "组件内存估算", noComponents: "暂无组件内存信息。",
componentHelp: "内存为 ReMe status 返回的估算值;MCP 响应不包含组件级健康信息。",
dreamHelp: "只有 ReMe MCP 连接存活时,每日计划才会触发。",
connection: "服务连接", memory: "自动记录", recall: "自动召回", schedule: "整理计划", next: "下次运行",
scheduler: "调度状态", last: "最近整理", activity: "最近活动", details: "服务详情", on: "已开启", off: "已关闭",
connected: "连接正常", offline: "无法连接", refresh: "刷新", refreshing: "刷新中…", checked: "检查时间",
empty: "暂无活动记录。", waiting: "正在等待首次状态结果…", unavailable: "未能刷新状态,请重试或重新打开 ReMe status。",
noHost: "请从 Codex 的 MCP 设置中打开 ReMe status 查看实时数据。",
trust: "服务连接正常不代表 Hook 已获信任,请在 Codex Hooks 设置中审核 ReMe 条目。",
saved: "此页面使用已保存的配置。修改 MCP 设置后,请先保存,再刷新此页面。",
pending: (n) => `${n} 轮待提交`, batch: (n, s) => `每批 ${n} 轮 · ${s} 个会话待提交`,
other: (n) => `另有 ${n} 轮等待提交至其他服务地址。`, noRecall: "暂无召回活动", noDream: "暂无整理记录", paused: "暂无计划",
running: "运行中", starting: "启动中", updating: "正在应用配置", stopped: "已停止",
scheduled: "定时", manual: "手动", completed: "已完成", failed: "失败", cancelled: "已取消", interrupted: "已中断",
recall_found: "已召回相关记忆", recall_empty: "未找到相关记忆", recall_failed: "召回失败", recall_disabled: "自动召回已关闭",
memory_queued: "已加入记录队列", memory_saved: "记忆已保存", memory_failed: "记忆提交失败", memory_disabled: "自动记录已关闭",
retry_failed: "重试提交失败", dream_started: "开始整理", dream_completed: "整理已完成", dream_failed: "整理失败",
dream_cancelled: "整理已取消", scheduled_dream_failed: "定时整理失败", search_found: "检索到相关记忆",
search_empty: "未检索到相关记忆", search_failed: "检索失败", capture_skipped: "未捕获到完成轮次",
SessionStart: "会话开始", SessionEnd: "会话结束", Stop: "轮次完成", SubagentStop: "子代理轮次完成", UserPromptSubmit: "已提交提问",
},
};
const t = (key) => words[language][key] ?? key;
const text = (id, value) => { $(id).textContent = value; };
function formatTime(value) {
if (value == null) return "—";
const date = new Date(typeof value === "number" ? value * 1000 : value);
if (!Number.isFinite(date.getTime())) return "—";
return new Intl.DateTimeFormat(language === "zh" ? "zh-CN" : "en-GB", {
timeZone: zone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", timeZoneName: "short",
}).format(date);
}
function pill(id, value, tone = "good") { text(id, value); $(id).dataset.tone = tone; }
function toggle(id, enabled) { pill(id, t(enabled ? "on" : "off"), enabled ? "good" : "neutral"); }
function applyHost(context = {}) {
if (["dark", "light"].includes(context.theme)) document.documentElement.dataset.theme = context.theme;
for (const key of ["--color-background-primary", "--color-text-primary"]) {
const value = context.styles?.variables?.[key];
if (typeof value === "string") document.documentElement.style.setProperty(key, value);
}
}
function fail() {
if (disposed) return;
text("error", t("unavailable")); $("error").hidden = false;
$("loading").hidden = true;
document.querySelector("main").setAttribute("aria-busy", "false");
}
function render(result) {
if (disposed) return;
const data = result?.structuredContent;
if (result?.isError || !data?.service?.health_check || !data?.auto_memory || !data?.auto_dream) { fail(); return; }
language = data.language === "zh" ? "zh" : "en";
zone = data.auto_dream.timezone;
document.documentElement.lang = language === "zh" ? "zh-CN" : "en";
document.querySelectorAll("[data-i18n]").forEach((node) => { node.textContent = t(node.dataset.i18n); });
text("refresh", t(refreshing ? "refreshing" : "refresh"));
const health = data.service.health_check;
pill("connection-state", t(health.reachable ? "connected" : "offline"), health.reachable ? "good" : "bad");
$("connection-state").hidden = false;
const summary = typeof health.answer === "string" ? health.answer.trim().match(/(?:^|-\s*)(unhealthy|healthy)$/) : null;
text("service-health", t(!health.reachable ? "offline" : summary?.[1] ?? "unknown"));
$("service-health").dataset.tone = !health.reachable || summary?.[1] === "unhealthy" ? "bad" : summary ? "good" : "neutral";
text("runtime", t(data.auto_dream.phase));
text("overview-queue", data.auto_memory.queued_turns);
text("overview-sessions", data.auto_memory.queued_sessions);
text("health", health.reachable ? (typeof health.answer === "string" ? health.answer : JSON.stringify(health.answer)) : health.error);
text("endpoint", data.mcpUrl);
text("plugin-version", `ReMe for Codex · ${data.plugin_version}`);
text("checked", `${t("checked")} ${formatTime(data.checked_at)}`);
toggle("memory-state", data.auto_memory.enabled);
text("queue", data.auto_memory.queued_turns);
text("interval", data.auto_memory.interval);
text("sessions", data.auto_memory.queued_sessions);
$("other-queue").hidden = !data.auto_memory.other_endpoint_queued_turns;
text("other-queue", t("other")(data.auto_memory.other_endpoint_queued_turns));
toggle("recall-state", data.auto_recall.enabled);
const activity = data.recent_activity ?? [];
const recall = activity.findLast((row) => row.event?.startsWith("recall_"));
text("recall-result", recall ? t(recall.event) : t("noRecall"));
text("recall-time", recall ? formatTime(recall.time) : "—");
const dream = data.auto_dream;
toggle("dream-state", dream.enabled);
text("schedule", dream.cron);
text("timezone", dream.timezone);
text("next", dream.next_run_at ? formatTime(dream.next_run_at) : t("paused"));
text("scheduler", `${t("scheduler")}: ${t(dream.phase)}`);
const last = dream.last_run;
text("last-result", last ? t(last.status) : "—");
text("last", last ? `${t(last.origin)} · ${t(last.status)} · ${formatTime(last.completed_at ?? last.started_at)}${last.error ? ` · ${last.error}` : ""}` : t("noDream"));
$("activity").replaceChildren();
for (const row of activity.slice(-20).reverse()) {
const item = document.createElement("li");
const description = document.createElement("span"); description.textContent = t(row.event);
const code = document.createElement("code"); code.textContent = `${row.event}${row.turns ? ` · ${row.turns}` : ""}${row.error ? ` · ${row.error}` : ""}`;
description.append(code);
const time = document.createElement("time"); time.textContent = formatTime(row.time);
item.append(description, time); $("activity").append(item);
}
$("no-activity").hidden = activity.length > 0; text("no-activity", t("empty"));
renderComponents(data.service.status);
text("details", JSON.stringify(data.service, null, 2));
$("error").hidden = true; $("loading").hidden = true; $("overview").hidden = false;
document.querySelector("main").setAttribute("aria-busy", "false"); hasSnapshot = true;
}
function renderComponents(status) {
// ReMe's MCP contract returns the human-readable answer, not HTTP response metadata.
const report = status?.reachable && typeof status.answer === "string" ? status.answer : "";
const amount = "([0-9]+(?:\\.[0-9]+)? (?:B|KiB|MiB|GiB|TiB))";
text("rss", report.match(new RegExp(`^ Process RSS +${amount}$`, "m"))?.[1] ?? "—");
text("component-total", report.match(new RegExp(`^ Components total +${amount}$`, "m"))?.[1] ?? "—");
$("components").replaceChildren();
for (const row of report.matchAll(new RegExp(`^ ([a-z_]+):([^\\s:]+) +${amount}$`, "gm"))) {
const card = document.createElement("div"); card.className = "component";
const title = document.createElement("strong"); title.textContent = `${row[1].replaceAll("_", " ")} · ${row[2]}`;
const size = document.createElement("div"); size.className = "value compact"; size.textContent = row[3];
card.append(title, size); $("components").append(card);
}
$("no-components").hidden = $("components").childElementCount > 0;
text("no-components", status?.reachable === false ? status.error : t("noComponents"));
}
const tabs = [...document.querySelectorAll('[role="tab"]')];
function selectTab(selected) {
for (const tab of tabs) {
const active = tab === selected;
tab.setAttribute("aria-selected", String(active)); tab.tabIndex = active ? 0 : -1;
$(tab.getAttribute("aria-controls")).hidden = !active;
}
}
for (const tab of tabs) {
tab.addEventListener("click", () => selectTab(tab));
tab.addEventListener("keydown", (event) => {
const index = tabs.indexOf(tab);
const next = { ArrowRight: (index + 1) % tabs.length, ArrowLeft: (index + tabs.length - 1) % tabs.length, Home: 0, End: tabs.length - 1 }[event.key];
if (next == null) return;
event.preventDefault(); selectTab(tabs[next]); tabs[next].focus();
});
}
let resizeFrame;
const resizeObserver = new ResizeObserver(() => {
cancelAnimationFrame(resizeFrame);
resizeFrame = requestAnimationFrame(() => {
if (connected && !disposed) transport.notify("ui/notifications/size-changed", { height: Math.ceil(document.body.getBoundingClientRect().height) });
});
});
resizeObserver.observe(document.body);
transport.on("ui/notifications/tool-result", (result) => { try { render(result); } catch { fail(); } });
transport.on("ui/notifications/tool-cancelled", fail);
transport.on("ui/notifications/host-context-changed", applyHost);
transport.handle("ui/resource-teardown", () => {
disposed = true; connected = false; $("refresh").disabled = true;
resizeObserver.disconnect(); cancelAnimationFrame(resizeFrame);
setTimeout(() => transport.dispose(), 0);
return {};
});
$("refresh").addEventListener("click", async () => {
if (!connected || refreshing) return;
refreshing = true; $("refresh").disabled = true; text("refresh", t("refreshing"));
document.querySelector("main").setAttribute("aria-busy", "true");
try { render(await transport.request("tools/call", { name: "reme_status", arguments: {} }, 125000)); }
catch { fail(); }
finally { refreshing = false; $("refresh").disabled = !connected; text("refresh", t("refresh")); }
});
async function connect() {
if (window.parent === window) { text("loading", t("noHost")); return; }
const host = await transport.request("ui/initialize", {
protocolVersion: "2026-01-26", appInfo: { name: "reme-status", version: "0.2.1" },
appCapabilities: { availableDisplayModes: ["fullscreen"] },
});
if (host.protocolVersion !== "2026-01-26") throw new Error("Unsupported UI protocol");
connected = true; applyHost(host.hostContext); $("refresh").disabled = false;
if (!hasSnapshot) text("loading", t("waiting"));
transport.notify("ui/notifications/initialized");
const context = host.hostContext ?? {};
if (context.displayMode !== "fullscreen" && context.availableDisplayModes?.includes("fullscreen")) {
// One supported request only; respect the host's final placement (including a settings modal).
await transport.request("ui/request-display-mode", { mode: "fullscreen" }).catch(() => {});
}
}
connect().catch(fail);

View file

@ -0,0 +1,162 @@
"""Opt-in browser verification of the bundled view using an MCP host harness, not Codex screenshots.
Run with REME_CODEX_STATUS_UI=1 pytest tests/integration/test_codex_plugin_status_ui.py.
Requires Playwright and its Chromium browser; no model, service, or external network is used.
"""
# pylint: disable=missing-function-docstring,redefined-outer-name
import os
from pathlib import Path
import pytest
pytestmark = pytest.mark.skipif(
os.environ.get("REME_CODEX_STATUS_UI") != "1",
reason="Opt-in status view browser check",
)
VIEW = Path(__file__).resolve().parents[2] / "integrations/codex/plugins/reme/ui/status.html"
def snapshot(language):
return {
"checked_at": 1790757300,
"language": language,
"plugin_version": "0.2.1",
"mcpUrl": "http://localhost:2333/mcp",
"service": {
"health_check": {"reachable": True, "answer": "ReMe v0.4.1.13 - healthy"},
"status": {
"reachable": True,
"answer": (
"Memory (estimated component object size)\n file_store:default 1.25 MiB\n"
" keyword_index:default 512 B\n Components total 1.25 MiB\n Process RSS 120.50 MiB"
),
},
},
"auto_memory": {
"enabled": True,
"interval": 5,
"queued_turns": 2,
"queued_sessions": 1,
"other_endpoint_queued_turns": 0,
},
"auto_recall": {"enabled": True},
"auto_dream": {
"enabled": True,
"cron": "0 23 * * *",
"timezone": "Asia/Shanghai",
"phase": "running",
"next_run_at": "2026-09-30T15:00:00+00:00",
"last_run": None,
},
"recent_activity": [
{"event": "memory_saved", "time": 1790757300, "turns": 1},
{"event": "recall_found", "time": 1790757301, "turns": 0},
],
}
@pytest.mark.parametrize(("language", "theme"), [("en", "light"), ("zh", "dark")])
def test_initial_result_refresh_offline_failure_theme_and_untrusted_text(language, theme, tmp_path):
playwright = pytest.importorskip("playwright.sync_api")
initial = snapshot(language)
with playwright.sync_playwright() as runtime:
browser = runtime.chromium.launch()
page = browser.new_page(viewport={"width": 980, "height": 1250})
page.set_default_timeout(5000)
errors, external = [], []
page.on("pageerror", lambda error: errors.append(str(error)))
page.route("**/*", lambda route: (external.append(route.request.url), route.abort()))
page.set_content('<iframe title="MCP status test host" style="border:0;width:940px;height:1200px"></iframe>')
page.evaluate(
"""({html, initial, theme}) => {
window.calls = [];
window.result = {structuredContent: initial};
window.failRefresh = false;
const iframe = document.querySelector('iframe');
function send(value) { iframe.contentWindow.postMessage({jsonrpc: '2.0', ...value}, '*'); }
window.sendStatus = () => send({method:'ui/notifications/tool-result', params:window.result});
window.addEventListener('message', event => {
if (event.source !== iframe.contentWindow) return;
const m = event.data;
window.calls.push(m);
if (m.method === 'ui/initialize') {
send({id:m.id, result:{protocolVersion:'2026-01-26', hostCapabilities:{serverTools:{}},
hostContext:{theme, displayMode:'fullscreen', availableDisplayModes:['fullscreen']}}});
} else if (m.method === 'ui/notifications/initialized') {
window.sendStatus();
} else if (m.method === 'tools/call') {
if (window.failRefresh) send({id:m.id, error:{code:-32603, message:'test failure'}});
else send({id:m.id, result:window.result});
}
});
iframe.srcdoc = html;
}""",
{"html": VIEW.read_text(), "initial": initial, "theme": theme},
)
frame = page.frame_locator("iframe")
try:
frame.locator("#overview").wait_for(state="visible")
except playwright.TimeoutError:
assert not errors, errors
raise
assert frame.locator("html").get_attribute("data-theme") == theme
assert "healthy" in frame.locator("#health").inner_text()
assert "23:00" in frame.locator("#next").inner_text()
assert frame.locator("#activity li").count() == 2
assert not page.evaluate(
"window.calls.some(m => m.method === 'tools/call')",
) # Opening reuses the supplied result.
page.screenshot(path=str(tmp_path / f"status-test-host-{theme}.png"), full_page=True)
for name in ("memory", "dream", "components"):
frame.locator(f"#tab-{name}").click()
assert frame.locator(f"#panel-{name}").is_visible()
assert frame.locator("#panel-overview").is_hidden()
page.screenshot(path=str(tmp_path / f"status-test-host-{theme}-{name}.png"), full_page=True)
assert frame.locator("#rss").inner_text() == "120.50 MiB"
assert frame.locator("#components .component").count() == 2
assert frame.locator("#component-total").inner_text() == "1.25 MiB"
frame.locator("#tab-components").press("Home")
assert frame.locator("#panel-overview").is_visible()
page.set_viewport_size({"width": 420, "height": 1250})
page.evaluate("document.querySelector('iframe').style.width = '100%'")
assert frame.locator("body").evaluate("node => node.scrollWidth <= window.innerWidth")
offline = snapshot(language)
offline["service"]["health_check"] = {"reachable": False, "error": "ConnectionError"}
offline["service"]["status"] = {"reachable": False, "error": "ConnectionError"}
offline["auto_memory"]["queued_turns"] = 3
offline["mcpUrl"] = "<img src=x onerror=window.injected=true>"
page.evaluate("value => window.result = {structuredContent:value}", offline)
frame.locator("#refresh").click()
playwright.expect(frame.locator("#connection-state")).to_have_attribute("data-tone", "bad")
assert frame.locator("#endpoint").inner_text() == offline["mcpUrl"]
assert frame.locator("#endpoint img").count() == 0
assert "3" in frame.locator("#queue").inner_text()
calls = page.evaluate("window.calls.filter(m => m.method === 'tools/call')")
assert len(calls) == 1 and calls[0]["params"] == {"name": "reme_status", "arguments": {}}
assert frame.locator("#error").is_hidden() # Offline is a useful status result, not a broken panel.
frame.locator("#tab-memory").click()
page.evaluate("window.failRefresh = true")
frame.locator("#refresh").click()
frame.locator("#error").wait_for(state="visible")
assert "3" in frame.locator("#queue").inner_text() # Failed refresh keeps the last snapshot.
assert frame.locator("#refresh").is_enabled()
assert frame.locator("#panel-memory").is_visible() # Refresh preserves navigation.
frame.locator("#tab-components").click()
assert frame.locator("#rss").inner_text() == "—"
assert frame.locator("#components .component").count() == 0
assert not frame.locator("details").get_attribute("open")
frame.locator("summary").click()
assert "ConnectionError" in frame.locator("#details").inner_text()
unhealthy = snapshot(language)
unhealthy["service"]["health_check"]["answer"] = "ReMe v0.4.1.13 - unhealthy"
page.evaluate("value => { window.failRefresh = false; window.result = {structuredContent:value}; }", unhealthy)
frame.locator("#tab-overview").click()
frame.locator("#refresh").click()
playwright.expect(frame.locator("#service-health")).to_have_attribute("data-tone", "bad")
assert frame.locator("#connection-state").get_attribute("data-tone") == "good" # Reachable is not healthy.
assert not errors and not external
browser.close()

View file

@ -62,12 +62,14 @@ async def test_settings_remain_editable_offline(plugin, monkeypatch):
capability = client.initialize_result.capabilities.experimental["openai/settings"]
assert capability == {"readTool": module.SETTINGS_READ, "updateTool": module.SETTINGS_UPDATE}
tools = {tool.name: tool for tool in await client.list_tools()}
assert {module.SETTINGS_READ, module.SETTINGS_UPDATE, module.CHECK_CONNECTION} <= tools.keys()
assert {module.SETTINGS_READ, module.SETTINGS_UPDATE, module.STATUS} <= tools.keys()
assert "reme_check_connection" not in tools
read = await client.call_tool(capability["readTool"], {})
settings = read.structured_content
assert settings["values"] == config.DEFAULTS
assert settings["schema"]["properties"].keys() == config.DEFAULTS.keys()
assert settings["layout"][0]["items"][1]["tool"] == module.CHECK_CONNECTION
assert settings["layout"][0]["items"][1]["tool"] == module.STATUS
assert len(settings["layout"][0]["items"]) == 2
assert tools[module.SETTINGS_READ].annotations.readOnlyHint
assert tools[module.SETTINGS_READ].outputSchema
assert not (config.data_dir() / "config.json").exists()
@ -171,23 +173,53 @@ async def test_hooks_call_mcp_and_preserve_structured_answers(plugin, monkeypatc
@pytest.mark.asyncio
async def test_connection_action_uses_current_endpoint_and_propagates_failure(plugin, upstreams, monkeypatch):
async def test_status_checks_current_connection_and_reports_failure_without_hiding_local_state(
plugin,
upstreams,
monkeypatch,
):
config, module, _ = plugin
call = AsyncMock(return_value={"success": True, "answer": "healthy"})
monkeypatch.setattr(module, "call_async", call)
monkeypatch.setattr(importlib.import_module("reme_tools"), "call_async", call)
async with Client(module.create_server()) as client:
await client.call_tool(
module.SETTINGS_UPDATE,
{"set": {"mcpUrl": "http://second.test/mcp", "requestTimeoutMs": 3000}},
)
assert (await client.call_tool(module.CHECK_CONNECTION, {})).data == "healthy"
assert call.call_args.args == (config.load_config(), "health_check", {}, 3)
result = await client.call_tool(module.STATUS, {})
assert result.structured_content["service"]["health_check"] == {"reachable": True, "answer": "healthy"}
assert {args.args[1] for args in call.call_args_list} == {"health_check", "status"}
assert all(args.args[0] == config.load_config() and args.args[2:] == ({}, 3) for args in call.call_args_list)
assert result.structured_content["checked_at"] > 0
assert result.structured_content["language"] == "en"
call.side_effect = ConnectionError("offline")
with pytest.raises(ToolError, match="offline"):
await client.call_tool(module.CHECK_CONNECTION, {})
offline = (await client.call_tool(module.STATUS, {})).structured_content
assert offline["service"]["health_check"] == {"reachable": False, "error": "ConnectionError"}
assert offline["auto_memory"]["queued_turns"] == 0
assert not upstreams
@pytest.mark.asyncio
async def test_status_opens_a_self_contained_fullscreen_app_resource(plugin):
_, module, _ = plugin
async with Client(module.create_server()) as client:
tools = {tool.name: tool for tool in await client.list_tools()}
ui = tools["reme_status"].meta["ui"]
assert ui["visibility"] == ["app", "model"]
content = (await client.read_resource(ui["resourceUri"]))[0]
assert content.mimeType == "text/html;profile=mcp-app"
assert content.meta["openai/ui"] == {
"preferredDisplayMode": "fullscreen",
"availableDisplayModes": ["fullscreen"],
}
assert content.meta["ui"]["csp"] == {"connectDomains": [], "resourceDomains": []}
assert 'id="refresh"' in content.text
assert "ui/initialize" in content.text
assert "ui/notifications/tool-result" in content.text
assert "/* STATUS_APP */" not in content.text
assert "<script src=" not in content.text
@pytest.mark.asyncio
async def test_focused_search_uses_live_defaults_validates_input_and_reports_empty(plugin, monkeypatch):
config, module, _ = plugin