diff --git a/.gitignore b/.gitignore index cd78a0c2..d236e4a0 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,9 @@ tmp*/ temp*/ .trash/ +# Local tool indexes (rebuildable, machine-local) +.codegraph/ + # ReMe runtime data .reme/ reme_workspace/ diff --git a/integrations/dsh/README.md b/integrations/dsh/README.md index faac78cc..0fe1be7b 100644 --- a/integrations/dsh/README.md +++ b/integrations/dsh/README.md @@ -4,7 +4,7 @@ This guide explains how to install, configure, and use `@agentscope-ai/reme-dsh-plugin` with DeepSeek Harness (DSH), including memory guidance injection, the `reme_search` tool, automatic memory, daily consolidation, and the ReMe Status page. -The screenshots record an earlier local integration test against DSH `0.1.5-rc.2`; the supported releases are `0.1.7-rc.2`, `0.2.0-rc.2`, `0.2.1-alpha.2`, `0.2.1-alpha.4`, and `0.2.1-rc.2`. Both the interface and ReMe guidance are set to English, and the isolated DSH and ReMe workspaces contain only fictional Project Aurora data. No `.env` values, API keys, access tokens, or personal memories appear in the screenshots. +The screenshots record an earlier local integration test against DSH `0.1.5-rc.2`; the supported releases are `0.1.7-rc.2`, `0.2.0-rc.2`, `0.2.1-alpha.2`, `0.2.1-alpha.4`, `0.2.1-rc.2`, and `0.2.2-alpha.2`. Both the interface and ReMe guidance are set to English, and the isolated DSH and ReMe workspaces contain only fictional Project Aurora data. No `.env` values, API keys, access tokens, or personal memories appear in the screenshots. ## 1. How the plugin works @@ -26,8 +26,8 @@ The DSH adapter injects **usage guidance**, not every historical memory. Relevan ## 2. Requirements - ReMe is installed and its configuration exposes the `search`, `auto_memory`, and `auto_dream` jobs. -- DeepSeek Harness `0.1.7-rc.2`, `0.2.0-rc.2`, `0.2.1-alpha.2`, `0.2.1-alpha.4`, or `0.2.1-rc.2`. -- Node.js `^22.19.0` or `>=24.0.0`, matching the current DSH engine range. +- DeepSeek Harness `0.1.7-rc.2`, `0.2.0-rc.2`, `0.2.1-alpha.2`, `0.2.1-alpha.4`, `0.2.1-rc.2`, or `0.2.2-alpha.2`. +- Node.js `^22.19.0` or `>=24.0.0`, matching the current DSH engine range (DSH `0.2.2` needs Node 24.9 or newer). - The browser running DSH can reach the configured ReMe HTTP endpoint. Cross-machine deployments must also allow the DSH browser origin. The default ReMe endpoint is `http://127.0.0.1:2333`. ReMe HTTP does not use API-key authentication, so do not expose it directly to an untrusted network. @@ -110,26 +110,43 @@ If an earlier upgrade already renamed `settings.yaml` to `settings.yaml.imported ![ReMe Memory plugin configuration](./figures/reme-memory-settings.png) -| UI meaning | Configuration key | Default | Description | -| ---------------------- | ---------------------- | ----------------------- | --------------------------------------------------------------- | -| Service URL | `endpoint` | `http://127.0.0.1:2333` | Absolute HTTP(S) URL without credentials, query, or fragment. | -| Guidance language | `language` | `en` | `en` or `zh`; controls guidance injected into new sessions. | -| Default search results | `searchLimit` | `5` | Default `reme_search` result limit, from 1 to 50. | -| Search timeout | `requestTimeoutMs` | `10000` | Search timeout in milliseconds, from 1,000 to 120,000. | -| Automatic memory | `autoMemoryEnabled` | `true` | Capture completed user/assistant turns for `auto_memory`. | -| Exclude subagents | `rootAgentsOnly` | `true` | Inject guidance and capture conversations only for root agents. | -| Submission interval | `autoMemoryInterval` | `5` | Submit after this many completed turns, from 1 to 1,000. | -| Memory consolidation | `autoDreamEnabled` | `true` | Run `auto_dream` on the daily schedule. | -| Consolidation schedule | `dreamCron` | `0 23 * * *` | Five-field cron expression interpreted in `timezone`. | -| Consolidation guidance | `dreamHint` | empty | Optional guidance passed to `auto_dream`. | -| Workspace timezone | `timezone` | `Asia/Shanghai` | IANA timezone used for batching and scheduling. | -| Background timeout | `backgroundTimeoutMs` | `3600000` | Timeout for `auto_memory` and `auto_dream`. | -| Shutdown flush timeout | `shutdownTimeoutMs` | `5000` | Budget for draining background work during shutdown. | -| Retry delay | `autoMemoryRetryMs` | `1000` | Initial retry delay; doubles after each failure. | -| Maximum retry delay | `autoMemoryRetryMaxMs` | `60000` | Upper bound for retry delay. | +| UI meaning | Configuration key | Default | Description | +| ---------------------- | ---------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| Service URL | `endpoint` | `http://127.0.0.1:2333` | Absolute HTTP(S) URL without credentials, query, or fragment. | +| Auth token (optional) | `authToken` | empty | Bearer token for a self-hosted gateway; set in the profile only (not shown in this form), stored in clear text, trusted networks only. | +| Guidance language | `language` | `en` | `en` or `zh`; controls guidance injected into new sessions. | +| Default search results | `searchLimit` | `5` | Default `reme_search` result limit, from 1 to 50. | +| Search timeout | `requestTimeoutMs` | `10000` | Search timeout in milliseconds, from 1,000 to 120,000. | +| Automatic memory | `autoMemoryEnabled` | `true` | Capture completed user/assistant turns for `auto_memory`. | +| Exclude subagents | `rootAgentsOnly` | `true` | Inject guidance and capture conversations only for root agents. | +| Submission interval | `autoMemoryInterval` | `5` | Submit after this many completed turns, from 1 to 1,000. | +| Memory consolidation | `autoDreamEnabled` | `true` | Run `auto_dream` on the daily schedule. | +| Consolidation schedule | `dreamCron` | `0 23 * * *` | Five-field cron expression interpreted in `timezone`. | +| Consolidation guidance | `dreamHint` | empty | Optional guidance passed to `auto_dream`. | +| Workspace timezone | `timezone` | `Asia/Shanghai` | IANA timezone used for batching and scheduling. | +| Background timeout | `backgroundTimeoutMs` | `3600000` | Timeout for `auto_memory` and `auto_dream`. | +| Shutdown flush timeout | `shutdownTimeoutMs` | `5000` | Budget for draining background work during shutdown. | +| Retry delay | `autoMemoryRetryMs` | `1000` | Initial retry delay; doubles after each failure. | +| Maximum retry delay | `autoMemoryRetryMaxMs` | `60000` | Upper bound for retry delay. | Deployment configuration also supports `REME_URL`, or `REME_HOST` together with `REME_PORT`. The timer-only test option `dreamIntervalMs` is intentionally excluded from user settings. +## 4.1 Environment management (Environment tab) + +The **Environment** tab (inside **Plugins → ReMe bundle → reme-memory → Configure**) covers the setup that +otherwise needs a separate terminal, on the machine that runs DSH: + +| Step | What it does | +| --------------- | ------------------------------------------------------------------------------------------------------------------------- | +| Local Python | Finds PEP 514, `py` launcher, `PATH` and conda interpreters, labels each origin and `reme-ai` state, stores `pythonPath` | +| ReMe dependency | Checks `reme-ai`, the `[core]` extras and the `reme` CLI; installs or upgrades `reme-ai[core]` with a live log and cancel | +| ReMe parameters | Edits the workspace `.env`; secrets stay in that file and are shown masked; provides an LLM connectivity test | +| Service control | Starts and stops the local ReMe service, showing PID, version and log | + +See the [Environment management guide](https://github.com/agentscope-ai/ReMe/blob/main/integrations/dsh/docs/environment.md) +for interpreter discovery and the recommendation rule, dependency installation, `.env` validation, connection +testing, service lifetime, platform support and troubleshooting. + ## 5. Memory context injection On `agent/created`, the plugin injects long-term-memory guidance as native plugin context. Expand **Context injection · reme-memory** in the message flow to inspect both the content and provenance. diff --git a/integrations/dsh/README_ZH.md b/integrations/dsh/README_ZH.md index e7d7cd77..67cf147b 100644 --- a/integrations/dsh/README_ZH.md +++ b/integrations/dsh/README_ZH.md @@ -26,8 +26,8 @@ DSH 启动新会话时,插件向根 Agent 注入一段“如何使用长期记 ## 2. 环境要求 - ReMe Python 服务已安装,且配置中提供 `search`、`auto_memory` 和 `auto_dream` Job。 -- DeepSeek Harness `0.1.7-rc.2`、`0.2.0-rc.2`、`0.2.1-alpha.2`、`0.2.1-alpha.4` 或 `0.2.1-rc.2`。 -- Node.js `^22.19.0` 或 `>=24.0.0`,与当前 DSH 的 engine 范围一致。 +- DeepSeek Harness `0.1.7-rc.2`、`0.2.0-rc.2`、`0.2.1-alpha.2`、`0.2.1-alpha.4`、`0.2.1-rc.2` 或 `0.2.2-alpha.2`。 +- Node.js `^22.19.0` 或 `>=24.0.0`,与当前 DSH 的 engine 范围一致(DSH `0.2.2` 需要 Node 24.9 以上)。 - DSH 页面能够访问 ReMe HTTP 地址;跨机器部署时还要允许 DSH 页面所在的浏览器 Origin。 ReMe HTTP 服务默认监听 `http://127.0.0.1:2333`,不使用 API Key 认证。因此不建议未经网络隔离直接暴露到公网。 @@ -115,26 +115,42 @@ dsh web --no-open --port 3090 截图中的测试配置使用 `http://127.0.0.1:3457`、English 指引、默认搜索数量 5、搜索超时 10 秒,并启用了自动记忆和“Exclude subagents”。完整字段如下: -| 界面含义 | 配置键 | 默认值 | 说明 | -| -------------------- | ---------------------- | ----------------------- | -------------------------------------------------------------------------- | -| 服务地址 | `endpoint` | `http://127.0.0.1:2333` | DSH 访问的绝对 HTTP(S) 地址,不支持 URL 内嵌凭据、查询参数或片段。 | -| 记忆指引语言 | `language` | `en` | `en` 或 `zh`;决定新会话中注入的指引语言。 | -| 默认搜索数量 | `searchLimit` | `5` | `reme_search` 未传 `limit` 时的默认结果上限,范围 1–50。 | -| 搜索超时(毫秒) | `requestTimeoutMs` | `10000` | `search` 请求的超时,范围 1,000–120,000。 | -| 自动记录已完成的对话 | `autoMemoryEnabled` | `true` | 是否捕获完成的用户/助手回合并提交给 `auto_memory`。 | -| 排除子 Agent | `rootAgentsOnly` | `true` | 开启时,只为根 Agent 注入指引并捕获对话。 | -| 自动记忆提交间隔 | `autoMemoryInterval` | `5` | 每完成多少轮对话提交一次,范围 1–1,000。退出时插件会在时间预算内尽力排空。 | -| 自动整理记忆 | `autoDreamEnabled` | `true` | 是否按照每日计划调用 `auto_dream`。 | -| 整理计划 | `dreamCron` | `0 23 * * *` | 五段 cron 表达式,按 `timezone` 解释;默认每天 23:00。 | -| 整理指引 | `dreamHint` | 空 | 可选的整理提示,原样交给 `auto_dream`。 | -| 工作区时区 | `timezone` | `Asia/Shanghai` | 有效的 IANA 时区,用于每日批次和 cron 计划。 | -| 后台任务超时(毫秒) | `backgroundTimeoutMs` | `3600000` | `auto_memory` 和 `auto_dream` 的最大等待时间。 | -| 退出等待(毫秒) | `shutdownTimeoutMs` | `5000` | DSH 退出时等待后台任务排空的时间预算。 | -| 失败重试间隔(毫秒) | `autoMemoryRetryMs` | `1000` | 首次失败后的等待时间,连续失败时翻倍。 | -| 最大重试间隔(毫秒) | `autoMemoryRetryMaxMs` | `60000` | 重试等待时间的上限。 | +| 界面含义 | 配置键 | 默认值 | 说明 | +| -------------------- | ---------------------- | ----------------------- | -------------------------------------------------------------------------------------------------- | +| 服务地址 | `endpoint` | `http://127.0.0.1:2333` | DSH 访问的绝对 HTTP(S) 地址,不支持 URL 内嵌凭据、查询参数或片段。 | +| 服务令牌(可选) | `authToken` | 空 | 自部署网关的 Bearer 令牌;仅通过 profile 配置(表单中不显示),明文保存,仅建议内网/受信环境使用。 | +| 记忆指引语言 | `language` | `en` | `en` 或 `zh`;决定新会话中注入的指引语言。 | +| 默认搜索数量 | `searchLimit` | `5` | `reme_search` 未传 `limit` 时的默认结果上限,范围 1–50。 | +| 搜索超时(毫秒) | `requestTimeoutMs` | `10000` | `search` 请求的超时,范围 1,000–120,000。 | +| 自动记录已完成的对话 | `autoMemoryEnabled` | `true` | 是否捕获完成的用户/助手回合并提交给 `auto_memory`。 | +| 排除子 Agent | `rootAgentsOnly` | `true` | 开启时,只为根 Agent 注入指引并捕获对话。 | +| 自动记忆提交间隔 | `autoMemoryInterval` | `5` | 每完成多少轮对话提交一次,范围 1–1,000。退出时插件会在时间预算内尽力排空。 | +| 自动整理记忆 | `autoDreamEnabled` | `true` | 是否按照每日计划调用 `auto_dream`。 | +| 整理计划 | `dreamCron` | `0 23 * * *` | 五段 cron 表达式,按 `timezone` 解释;默认每天 23:00。 | +| 整理指引 | `dreamHint` | 空 | 可选的整理提示,原样交给 `auto_dream`。 | +| 工作区时区 | `timezone` | `Asia/Shanghai` | 有效的 IANA 时区,用于每日批次和 cron 计划。 | +| 后台任务超时(毫秒) | `backgroundTimeoutMs` | `3600000` | `auto_memory` 和 `auto_dream` 的最大等待时间。 | +| 退出等待(毫秒) | `shutdownTimeoutMs` | `5000` | DSH 退出时等待后台任务排空的时间预算。 | +| 失败重试间隔(毫秒) | `autoMemoryRetryMs` | `1000` | 首次失败后的等待时间,连续失败时翻倍。 | +| 最大重试间隔(毫秒) | `autoMemoryRetryMaxMs` | `60000` | 重试等待时间的上限。 | 部署层还支持 `REME_URL`,或组合使用 `REME_HOST` 与 `REME_PORT`。仅供定时器测试的 `dreamIntervalMs` 不出现在用户设置中。 +## 4.1 环境管理(Environment 标签) + +**插件 → ReMe 插件包 → reme-memory → 配置 → 环境管理** 把「找 Python、装 ReMe、写凭据、启停服务」收进插件本体,无需另开终端; +这些操作都发生在运行 DSH 的那台机器上: + +| 步骤 | 做什么 | +| ---------------- | ------------------------------------------------------------------------------------------------------------ | +| 本地 Python 环境 | 发现 PEP 514 注册表、`py` 启动器、`PATH` 与 conda 解释器,标注来源与 `reme-ai` 状态,选中即写入 `pythonPath` | +| ReMe 依赖 | 检测 `reme-ai`、`[core]` 依赖与 `reme` CLI;可一键安装/升级 `reme-ai[core]`,日志实时可见、可取消 | +| ReMe 参数 | 编辑 workspace 的 `.env`:密钥只保存在该文件、界面仅显示掩码;提供 LLM 连通性测试 | +| 服务控制 | 启停本机 ReMe 服务,显示 PID、版本与日志 | + +解释器发现与推荐规则、依赖安装、`.env` 校验、连接测试、服务生命周期、平台支持与故障排查见 +[环境管理指南](https://github.com/agentscope-ai/ReMe/blob/main/integrations/dsh/docs/environment_ZH.md)。 + ## 5. 普通对话中的 memory 上下文注入 创建一个新会话后,插件监听 DSH 的 `agent/created`,把长期记忆使用规则作为一条原生 plugin context 注入。展开消息流中的 **上下文注入 · reme-memory** 可以直接检查内容与来源元数据。 diff --git a/integrations/dsh/docs/environment.md b/integrations/dsh/docs/environment.md new file mode 100644 index 00000000..fbbdb05d --- /dev/null +++ b/integrations/dsh/docs/environment.md @@ -0,0 +1,262 @@ +# Environment management + +The **Environment** tab is the setup surface for a local ReMe install. It finds a Python interpreter on the +machine that runs DSH, installs `reme-ai[core]` into it, writes the workspace `.env`, and starts or stops the +local ReMe service — the work that otherwise needs several terminals. + +Open it from **Plugins → ReMe bundle → reme-memory → Configure → Environment**. + +The tab manages a **loopback** endpoint at the origin root (`http://127.0.0.1:…` or `http://localhost:…`). Any +other service URL — a remote host, or a gateway mounted under a path prefix — is only reported as answering or +not, and hides the local steps. + +Everything the tab reports comes from the DSH **Host**, not from the browser: Python discovery, file access, +the LLM probe and the service health check all run on the Host machine. A browser on another machine therefore +sees the Host's environment, and the hero and the service card can never disagree about it. + +## What each step does + +| Section | Capability | +| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Local Python | Discovers PEP 514 registry entries, the `py` launcher, `PATH` and conda (base and named environments), labels their origin and `reme-ai` state, and stores the selection in `pythonPath` | +| ReMe dependency | Checks the `reme-ai` version, the `[core]` extras and the `reme` CLI for the selected interpreter; installs or upgrades `reme-ai[core]` with an optional package index, a live log and cancel | +| ReMe parameters | Edits the workspace `.env`; secrets stay in that file and are shown masked. Saving requires a non-empty `EMBEDDING_API_KEY` (use `EMPTY` when vector search is off). Provides an LLM connectivity test | +| Service control | Starts and stops the local ReMe service, showing PID, version and log; the launch injects UTF-8 and `NO_PROXY`, sets `enable_logo=false`, and runs inside the workspace so `.env` discovery works | + +The hero summarises the same state as three chips — service, Python, ReMe — plus the endpoint, the detection +time and the two refresh actions. + +## Python discovery + +ReMe needs **Python 3.11 or newer**. The tab probes these channels on the Host and keeps only interpreters that +answered its verification snippet (a `-c` probe reporting `sys.executable`, the version and the base prefix): + +- Windows: PEP 514 registry entries (`HKLM`/`HKCU`), the `py` launcher list, `PATH`, and `conda env list`. +- macOS and Linux: `PATH` (`which -a`), the well-known `/usr/bin`, `/usr/local`, Homebrew (`/opt/homebrew`), + `~/.pyenv/shims` and `~/.local/bin` locations, and `conda env list`. + +Microsoft Store placeholder links under `WindowsApps` are skipped, because they are shell aliases rather than +interpreters. When conda is present, named environments are listed both under `conda env list` and as +interpreters, and the conda root is reported separately. + +Detection is cached for 60 seconds; **Refresh** (in the hero) probes again. The recommended interpreter is +chosen in this order: + +1. A named conda environment (`base` excluded) that already has a supported `reme-ai`. +2. Another interpreter that already has a supported `reme-ai`. +3. Any named conda environment, which is ready for a ReMe install. +4. The first usable interpreter. + +Hover the **recommended** tag to see which rule applied. Picking another interpreter writes `pythonPath` and +nothing else; leaving it unset keeps following the recommendation. The list shows six rows and expands on +demand, with a filter over version, origin and path. + +## ReMe dependency + +The check asks the selected interpreter three questions and reports them as metrics: + +- `reme-ai` metadata version (`importlib.metadata.version("reme-ai")`), and whether it satisfies the minimum + this integration targets. +- `[core]` extras: whether `import agentscope` works. Without it, `auto_memory` and `auto_dream` are unavailable. +- `reme` CLI: the console script beside the interpreter, or the Python wrapper fallback used at start time. + +**Install reme-ai[core]** runs `python -m pip install reme-ai[core]`, optionally with a package index (PyPI, +Tsinghua, Aliyun). **Upgrade** adds `-U`; **Reinstall** adds `--force-reinstall`, because pip otherwise reports a +present-but-broken install as "already satisfied". Large wheels are downloaded on first use; expect 5–20 minutes +depending on the network. + +The installation is a Host-owned job: + +- It keeps running when the configuration page closes, and reopening the tab restores it for the same + interpreter. The Host looks the job up by interpreter, so two interpreters installing at the same time each + see their own progress. +- Progress is polled by job id, so a second installation for another interpreter cannot replace what you see. + The install answer is bound to the interpreter that asked for it, so a late response after an interpreter + switch is dropped instead of showing another interpreter's job. +- A successful install re-checks `reme-ai` for that interpreter, so the dependency card and **Start service** + follow the install without a manual **Check**. +- **Cancel** stops pip and its build children, and the interpreter stays locked until the whole managed process + group has exited. If pip exits while a build child keeps running, the group is force-killed and the job waits + for the group to empty. If even that is not confirmed, the job stays _cancelling_ and the interpreter stays + locked, because a second installation must not run while the first is still unaccounted for. The tab keeps + showing the job instead of reporting a completed cancellation. +- The log is written to `$DSH_HOME/reme-memory/install-.log` and its tail is shown in place. Each install + opens a new file and only the newest 20 are kept; the long-lived service log rotates to `service.log.1` once it + passes 5 MB. +- When the plugin unloads, running jobs are stopped through the same path: a graceful signal first, then a + force-kill, and the managed process group is watched until it is empty, so a pip that ignores `SIGTERM` — or a + build child that outlives pip — cannot outlive the Host. + +## Workspace `.env` + +ReMe reads credentials from the `.env` in the workspace directory at service start; the tab never stores them in +DSH settings. + +| Group | Keys | +| --------- | -------------------------------------------------------------------------------------- | +| LLM | `LLM_BACKEND`, `LLM_MODEL_NAME`, `LLM_API_KEY`, `LLM_BASE_URL` | +| Embedding | `EMBEDDING_BACKEND`, `EMBEDDING_MODEL_NAME`, `EMBEDDING_API_KEY`, `EMBEDDING_BASE_URL` | + +- The workspace directory defaults to `~/.reme` and must be absolute; changing it reloads the `.env` it points at. +- API keys are write-only: the Host returns a mask (last four characters) and a blank field means “keep the + stored key”. Leaving a key untouched never rewrites it. +- Emptying a **secret** field and saving keeps the stored key, even when a replacement was typed first and then + cleared: a credential cannot be deleted by emptying its control, so the blank field always means “keep what is + stored”. Emptying a **non-secret** field and saving **clears** it. Only fields you edited are written, so the rest + of the file keeps its comments, unknown keys and order. +- The two backend selections are the non-secret fields with a default: clearing one writes the default + (`LLM_BACKEND=openai`, `EMBEDDING_BACKEND=openai`) instead of an empty value. ReMe applies + `${LLM_BACKEND:-openai}` and `${EMBEDDING_BACKEND:-openai}` only when the variable is absent, so an empty + value would name no backend at all and the service would refuse to start. +- A save writes the edits that were submitted and leaves newer ones alone: typing another field while the save is in + flight keeps that text, and the card says that unsaved edits are still pending. +- Saving validates the whole merged file, not just the values you typed: ReMe 0.4.0.x instantiates the embedding + component unconditionally, so `EMBEDDING_API_KEY` must be non-empty even without vector search (`EMPTY` is the + documented placeholder). A duplicated key is rewritten in every position, and the file keeps its own line + ending. +- The write is atomic and owner-only (0600 on POSIX); the previous file is kept as `.env.bak`, so a failed save + cannot lose unrelated keys. +- **Test LLM connection** runs a one-token request from inside the workspace with the selected interpreter, and + adds a hint for `401`/`403` (key), `404` (model or base URL) and network errors. The request follows the + protocol of the `LLM_BACKEND` in the `.env`: `openai`, `dashscope`, `deepseek` and `moonshot` use the + OpenAI-compatible `/chat/completions` (Bearer auth), `anthropic` uses `/v1/messages` (`x-api-key`), `gemini` + uses `/v1beta/models/:generateContent` (`x-goog-api-key`), `ollama` uses `/api/chat` (a local server + needs no key) and `xai` uses the OpenAI-compatible `/chat/completions` at `api.x.ai`. +- The base URL follows ReMe's own credentials: `LLM_BASE_URL` is read for the OpenAI-compatible providers and for + Anthropic, while Gemini, Ollama and xAI carry their own endpoint and ignore it. A stale value left in the `.env` + therefore cannot send the probe to a host the service never calls, and a configuration ReMe accepts is never + reported as broken. The base URL is used as the provider's own SDK builds it, so a base that already carries the + version suffix fails in the probe exactly as it fails in the service. +- ReMe reaches xAI through its gRPC SDK, so the xAI probe is a REST-level approximation of that channel. +- The probe reads the `.env` on disk, so a configuration that is only picked in the form is not tested before the + file is saved; the button says so while it waits. +- A `.env` that sets `LLM_BACKEND` to an empty value is reported as an incomplete configuration + (`LLM config incomplete (LLM_BACKEND)`) and no request is sent: ReMe would start with no backend at all, so the + probe must not test a configuration the service cannot use. A `.env` without the key is probed with the default + backend, exactly as ReMe would start it. +- A known backend written in the wrong case (`LLM_BACKEND=OpenAI`) is reported as invalid, with the spelling to + use: ReMe's backend registry is case-sensitive and refuses that name. A backend name the plugin has never heard + of is still probed tolerantly — the list here is a snapshot, and ReMe may know more than the plugin does. That + holds however the name looks: only a backend the plugin actually knows can be reported as a wrong spelling. +- The draft, the save message and the LLM-test result belong to the workspace they were produced for: switching + the workspace disposes the section's state, so neither a key typed for one workspace nor a success reported for + it can be carried into another. + +## Connection testing + +**Test connection** in the hero asks the Host to `POST /health_check` on the configured endpoint. A healthy answer +must contain a ReMe health report — a version, a health flag and its components — so a generic HTTP server that +answers `200` with `{}` is not reported as a running ReMe service. The Host reuses the same ReMe client as the rest +of the plugin, so the request, the authentication and the three-second timeout are the ones the service itself is +called with. When the settings carry an `authToken`, the Host sends it as `Authorization: Bearer`, scoped to that +endpoint. + +## Service lifetime + +Starting the service here is equivalent to running `reme start` in a terminal — with one deliberate difference: +**the service outlives DSH**. Closing DSH does not stop it, and stopping it is an explicit action in the tab. + +That is why the plugin does not hand the service to DSH's local subprocess provider, which terminates the +processes it manages when the Host exits. The plugin owns a small service adapter instead: + +- The launch is recorded in `$DSH_HOME/reme-memory/service.json` (pid, endpoint, workspace, command, start time) + **after** the OS acknowledged the spawn, so a missing interpreter leaves no state behind. +- A service is reported as managed — and offered a **Stop** button — only when the record matches the endpoint on + screen, the pid is still alive, its image is still a Python or `reme` executable, **and** its command line still + carries the recorded `workspace_dir` and `service.port`. The command line rules out most recycled pids (a longer + workspace path or another port never matches), though it cannot tell a pid apart from a user who starts the exact + same command by hand. A record for port 2333 never claims a service on port 2444. +- When the evidence cannot be read (localized `tasklist`/`ps`, missing permissions), ownership stays unproven. Start + and Stop both leave the process and the record alone: Stop refuses, and Start reports an error instead of dropping + the record. Discarding it would orphan a live service that the tab could never stop again. The card then offers + **Forget this record**, which deletes only `service.json` and never signals the process; the user confirms what to do + with it themselves. The Host names the record that blocked the action, and **Forget** targets exactly that record — + which may belong to another port, not to the endpoint on screen — together with its pid. When the record changed in + the meantime, the Host refuses instead of dropping the new record silently. +- A service that occupies the port but fails the health check is reported as a conflict; if it is provably this + plugin's service, the tab still offers **Stop**. +- A conflict caused by a **recorded** process whose identity cannot be read is a different case: the process is + never signalled, so the tab offers **Forget this record** instead. The record travels with the status, so the way + out does not depend on a start that the conflict card disables. +- A record left by an older version under a path prefix of the same origin (for example + `127.0.0.1:2333/reme`) describes the **same** local service, because the launch configures only host + and port. When it can be proven to be this plugin's service, such a record is re-keyed to the endpoint on + screen and then managed like any other launch: **Stop** is offered, and **Start** replaces it while it is + not healthy. It is then not reported as a foreign process the tab cannot act on, which would leave the + user with a process to kill by hand. +- **Stop** tree-kills the recorded process (process group on POSIX, `taskkill /T /F` on Windows), waits for it to + exit, escalates (POSIX: `SIGTERM` then `SIGKILL`; Windows: force `taskkill` again), and clears the record only + after the pid is gone. A service started elsewhere stays untouched and is only observed. +- A recorded launch that is still alive but never became healthy is shown as **Last launch not ready** with both + **Stop** and **Start**: Stop clears the record, while Start makes the Host stop a provably owned previous launch or + discard a proven-foreign one. It is not shown as "Starting" unless a start from this tab is actually in flight. +- **Start** is serialized: two requests cannot spawn two services, and a second request observes the launch + already in flight. Before replacing a recorded launch that never became healthy, Start only kills it when + ownership is proven; an unproven record is kept and reported, never silently dropped. +- **Auto-start** (`autoStart`, off by default) pulls the service up when DSH starts, if it is not already running. + +Python probes and pip installs are bounded Host operations; the service is the only process that must outlive DSH. + +## Platform support + +| Area | Notes | +| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| DSH | `0.1.7-rc.2`, `0.2.0-rc.2`, `0.2.1-alpha.2`, `0.2.1-alpha.4`, `0.2.1-rc.2`, `0.2.2-alpha.2` | +| Node.js | `^22.19.0` or `>=24.0.0`; current DSH releases need Node 24.9 or newer, and the compatibility matrix is validated with Node 24.16 | +| Windows | Tested on Windows 11 with Miniconda: registry/PATH/conda discovery, `tasklist`/`taskkill` for identity and stop, `PYTHONUTF8`/`PYTHONIOENCODING` for pip output | +| macOS / Linux | Discovery, detached spawn, identity lookup and stop are covered by unit tests that run a real child process; still less exercised on real machines than Windows | + +## Troubleshooting + +**No usable Python found.** Install Miniconda or any Python 3.11+, then press **Refresh**. Store placeholders +are filtered by design, so install a real distribution instead of the Store alias. + +**The selected interpreter is not in the list.** The tab shows it with a “not detected” tag and still uses it. +Refresh, or pick a detected interpreter. + +**Installation fails.** Open the log tail: missing wheels, a stale proxy or a restricted network usually show +there. Try a mirror. In a conda environment `pip` works, but a dedicated environment avoids dependency conflicts +with other projects. + +**Saving `.env` is refused.** `LLM_API_KEY` and `EMBEDDING_API_KEY` must both be non-empty in the saved file; +use `EMPTY` for the embedding key when vector search is off. Emptying a secret field never reaches the file, so +the stored key is kept; a required key that is not stored at all still fails the save, and the file is left +unchanged. + +**The service reports “Port occupied”.** Something else listens on the endpoint. Change the port in the service +URL (Memory settings) or stop the other program. + +**The service does not become healthy.** The Start action polls for 120 seconds. If the port is answered but +`/health_check` never passes, the card switches to the conflict state so **Stop** stays available. Use **View log** +(the workspace `service.log`) for the startup trace — a missing `reme-ai`, an invalid `.env`, or a port conflict all +appear there. + +**The service shows “Last launch not ready”.** A launch recorded by the plugin is still alive but never answered +`/health_check` (for example DSH closed while it was booting). Use **Stop** to clear it and start again. If the pid +was recycled by another program, that record is provably foreign: **Start** discards it and launches a fresh service. +If the identity cannot be verified at all, use **Forget this record** — it deletes only the plugin's record and +leaves the process untouched. + +**The service shows as foreign.** It was started outside the plugin, so it is observed but not managed. Stop it +in its own terminal if you want the tab to manage the next launch. + +**Stop is disabled.** The running service does not belong to this endpoint, or its record is gone. Restart it +from the tab to make it managed. + +**LLM test fails.** The result distinguishes `401`/`403` (key), `404` (model name or base URL) and network +errors. The probe reads the workspace `.env`, so save before testing, and it follows the `LLM_BACKEND` there: a +native provider (Anthropic, Gemini, Ollama) is probed with its own protocol, not with an OpenAI-style request. + +## Where the code lives + +```text +integrations/dsh/src/environment/ +├── types.ts shared, browser-safe RPC types +├── env-fields.ts the managed `.env` keys and the rules for updating them +├── dependency.ts what one interpreter can host +├── endpoint-rules.ts the endpoints a local service can be managed on +├── host/ detection, install, service adapter, manager, gateway +└── client/ the tab, one module per step, copy and styles +``` + +The Host modules are the only place that touches processes or files; the client renders what the Host returns. diff --git a/integrations/dsh/docs/environment_ZH.md b/integrations/dsh/docs/environment_ZH.md new file mode 100644 index 00000000..49394855 --- /dev/null +++ b/integrations/dsh/docs/environment_ZH.md @@ -0,0 +1,211 @@ +# 环境管理 + +**Environment(环境管理)** 标签页用于在本机完成 ReMe 的准备工作:找到运行 DSH 那台机器上的 Python、把 +`reme-ai[core]` 装进该解释器、写入 workspace 的 `.env`、并启动或停止本地 ReMe 服务——这些原本需要在多个终端里完成。 + +入口:**插件 → ReMe Bundle → reme-memory → 配置 → 环境管理**。 + +该标签页只管理**根路径上的回环地址**(`http://127.0.0.1:…` 或 `http://localhost:…`)。其他服务地址——远程主机,或挂在 +路径前缀下的网关——只显示该地址是否可用,并隐藏本地步骤。 + +页面上的所有结果都来自 DSH **Host**,而不是浏览器:Python 探测、文件读写、LLM 探测、服务健康检查都在 Host 上执行。因此即使 +浏览器在另一台机器上,看到的也是 Host 的真实环境,顶部概览与服务卡片不会给出不一致的结论。 + +## 每一步做什么 + +| 分组 | 能力 | +| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| 本地 Python | 探测 PEP 514 注册表、`py` 启动器、`PATH` 与 conda(base 与命名环境),标注来源和 `reme-ai` 状态,选择结果写入 `pythonPath` | +| ReMe 依赖 | 针对所选解释器检查 `reme-ai` 版本、`[core]` 附加依赖与 `reme` CLI;可选用软件源安装或升级 `reme-ai[core]`,显示实时日志并支持取消 | +| ReMe 参数 | 编辑 workspace 的 `.env`;密钥只存在该文件中,界面仅显示掩码。保存要求 `EMBEDDING_API_KEY` 非空(关闭向量检索时用 `EMPTY`)。提供 LLM 连通性测试 | +| 服务控制 | 启动/停止本地 ReMe 服务,显示 PID、版本与日志;启动时注入 UTF-8 与 `NO_PROXY`、设置 `enable_logo=false`,并在 workspace 内运行以保证 `.env` 能被发现 | + +顶部的概览区用三个标签(服务、Python、ReMe)概括同一份状态,并显示服务地址、探测时间与两个刷新操作。 + +## Python 探测 + +ReMe 需要 **Python 3.11 或更新版本**。标签页在 Host 上探测下列来源,只保留能通过验证片段(上报 `sys.executable`、 +版本号与 base prefix 的 `-c` 探测)的解释器: + +- Windows:PEP 514 注册表项(`HKLM`/`HKCU`)、`py` 启动器列表、`PATH`、`conda env list`。 +- macOS / Linux:`PATH`(`which -a`)、常见的 `/usr/bin`、`/usr/local`、Homebrew(`/opt/homebrew`)、 + `~/.pyenv/shims` 与 `~/.local/bin` 路径,以及 `conda env list`。 + +`WindowsApps` 下的 Microsoft Store 占位符会被跳过,因为它们只是别名而不是解释器。装有 conda 时,命名环境会同时出现在 +`conda env list` 与环境列表中,conda 根目录单独上报。 + +探测结果缓存 60 秒,点击概览区的**重新探测**会重新执行。推荐解释器按以下顺序选出: + +1. 已安装受支持 `reme-ai` 的命名 conda 环境(排除 `base`)。 +2. 其他已安装受支持 `reme-ai` 的解释器。 +3. 任意命名 conda 环境(可直接安装 ReMe)。 +4. 第一个可用解释器。 + +把鼠标悬停在**推荐**标签上可以看命中的是哪条规则。选择其它解释器只会写入 `pythonPath`;保持未选择则一直跟随推荐。列表默认 +显示 6 行并可展开,支持按版本、来源、路径筛选。 + +## ReMe 依赖 + +检查会向所选解释器询问三件事,并以指标形式展示: + +- `reme-ai` 元数据版本(`importlib.metadata.version("reme-ai")`),以及是否达到本集成要求的最低版本。 +- `[core]` 附加依赖:`import agentscope` 是否可用。缺失时 `auto_memory` 与 `auto_dream` 不可用。 +- `reme` CLI:解释器旁的命令行入口,或启动时回退使用的 Python 包装。 + +**安装 reme-ai[core]** 执行 `python -m pip install reme-ai[core]`,可附加软件源(PyPI、清华、阿里云)。**升级**追加 +`-U`;**重装**追加 `--force-reinstall`,否则 pip 会把“存在但已损坏”的安装报成 “already satisfied”。首次使用需要下载较大的 +依赖,视网络情况通常 5–20 分钟。 + +安装是 Host 侧的任务: + +- 关闭配置页后仍在运行;重新打开标签页会为同一个解释器恢复进度。Host 按解释器查找正在运行的任务,所以两个解释器同时安装时, + 各自看到的是自己的进度。 +- 进度按任务 ID 轮询,为另一个解释器发起的安装不会顶替当前显示;安装的响应也绑定在发起它的解释器上,切换解释器后迟到的响应会被丢弃。 +- 安装成功后会自动重新检测 `reme-ai`(同一个解释器),依赖卡片与**启动服务**按钮随之更新,无需手动点“检测”。 +- **取消**会同时终止 pip 及其构建子进程,且在**整个受管进程组**真正退出前该解释器保持占用:pip 先退出而构建子进程 + 仍在时,会对进程组强制结束并等待组清空。若连这一步也无法确认,任务仍保持“正在取消”状态、该解释器依然被占用—— + 在第一个安装还没有交代清楚之前,不允许第二个安装开始。页面会继续显示该任务,而不是把它报告成一次已完成的取消。 +- 日志写入 `$DSH_HOME/reme-memory/install-.log`,页面直接显示尾部。每次安装都会新建文件,目录只保留最近 + 20 个;长期运行的服务日志超过 5 MB 后轮转为 `service.log.1`。 +- 插件卸载时,仍在运行的安装走同一条终止路径:先发优雅信号,再强制结束,并对受管进程组持续确认,因此忽略 + `SIGTERM` 的 pip 或比 pip 活得更久的构建子进程都不会活得比 Host 更久。 + +## Workspace `.env` + +ReMe 在服务启动时读取 workspace 目录下的 `.env`;标签页从不把密钥写进 DSH 设置。 + +| 分组 | 键 | +| --------- | -------------------------------------------------------------------------------------- | +| LLM | `LLM_BACKEND`、`LLM_MODEL_NAME`、`LLM_API_KEY`、`LLM_BASE_URL` | +| Embedding | `EMBEDDING_BACKEND`、`EMBEDDING_MODEL_NAME`、`EMBEDDING_API_KEY`、`EMBEDDING_BASE_URL` | + +- workspace 目录默认 `~/.reme`,必须是绝对路径;修改后会重新读取它指向的 `.env`。 +- API Key 是只写的:Host 只返回掩码(末四位),留空表示保留已存的值。没有编辑过的键不会被重写。 +- 清空**密钥**字段并保存会保留已存的密钥——即便先输入过替代值再清空也是如此:凭据无法通过清空输入框来删除, + 因此留空始终表示“保留已存的值”。清空**非密钥**字段并保存即表示**删除**该值。只有编辑过的字段会被写入, + 文件其余部分的注释、未知键与顺序保持不变。 +- 两个后端选择是带默认值的非密钥字段:清空后会写入默认值(`LLM_BACKEND=openai`、`EMBEDDING_BACKEND=openai`), + 而不是空值。ReMe 只在变量**不存在**时才应用 `${LLM_BACKEND:-openai}` 与 `${EMBEDDING_BACKEND:-openai}`, + 空值等于没有指定任何后端,服务将无法启动。 +- 保存只清除本次提交的那部分编辑,更晚的编辑会被保留:保存进行中再改动其它字段时,那份文本不会丢失,页面也会 + 提示仍有未保存的改动。 +- 保存时会校验合并后的整份文件,而不只是本次输入:ReMe 0.4.0.x 会无条件实例化 embedding 组件,因此即使不使用向量检索 + `EMBEDDING_API_KEY` 也必须非空(`EMPTY` 是文档化的占位值)。重复出现的键会在每一处被改写,文件原有的换行风格也会保留。 +- 写入是原子且仅属主可读写的(POSIX 上为 0600);旧文件会保留为 `.env.bak`,因此一次失败的保存不会丢掉其它键。 +- **测试 LLM 连接**在 workspace 内、用所选解释器发送一次只需 1 个 token 的请求,并对 `401`/`403` + (密钥)、`404`(模型名或 Base URL)与网络错误给出提示。请求按 `.env` 里的 `LLM_BACKEND` 选择协议: + `openai`、`dashscope`、`deepseek`、`moonshot` 走 OpenAI 兼容的 `/chat/completions`(Bearer 认证), + `anthropic` 走 `/v1/messages`(`x-api-key`),`gemini` 走 `/v1beta/models/:generateContent` + (`x-goog-api-key`),`ollama` 走 `/api/chat`(本地服务无需密钥),`xai` 走 `api.x.ai` 上 OpenAI 兼容的 + `/chat/completions`。 +- Base URL 的来源与 ReMe 自身的凭据保持一致:`LLM_BASE_URL` 只对 OpenAI 兼容族与 Anthropic 生效,Gemini、Ollama + 与 xAI 自带地址、并不读取该变量。因此 `.env` 里残留的旧值不会把探测引到服务根本不会访问的主机,ReMe 能接受的 + 配置也不会被报成故障。Base URL 的拼接方式与服务商 SDK 完全一致,因此已经带版本后缀的地址在探测与服务中都会 + 同样失败。 +- ReMe 通过 gRPC SDK 连接 xAI,因此 xAI 的探测只是该通道在 REST 层面的近似。 +- 探测读取的是磁盘上的 `.env`,所以只在表单里改过配置而未保存时不会发起测试;等待期间按钮会给出提示。 +- 若 `.env` 把 `LLM_BACKEND` 设为空值,探测会报告配置不完整(`LLM config incomplete (LLM_BACKEND)`)并且不发送 + 任何请求:此时 ReMe 启动时并没有任何后端,探测不能去测试服务根本无法使用的配置。若 `.env` 里没有这个键, + 探测会与 ReMe 一样使用默认后端。 +- 已知后端名写错了大小写(如 `LLM_BACKEND=OpenAI`)会被报告为无效,并给出正确写法:ReMe 的后端注册表区分大小写, + 会拒绝这个名字。而插件从未听说过的后端名仍被宽容地探测——这里的列表只是一份快照,ReMe 可能认识得更多。这条规则与名字 + 长什么样无关:只有插件确实认识的后端才会被判定为大小写写错。 +- 草稿、保存提示与 LLM 测试结果都属于产生它们的 workspace:切换 workspace 会随旧区块一并丢弃,因此为某个 workspace + 输入的密钥、或属于它的一次成功提示,都不会被带到另一个。 + +## 连接测试 + +概览区的**测试连接**让 Host 对配置的服务地址执行 `POST /health_check`。健康响应必须包含可解析的 ReMe 健康报告——版本、 +健康标记与各组件明细——所以只回 `200` 与 `{}` 的普通 HTTP 服务不会被当成运行中的 ReMe。Host 复用插件其余部分使用的 +同一个 ReMe 客户端,因此请求、鉴权与三秒超时都与服务本身被调用时一致。设置里填了 `authToken` 时,Host 会以 +`Authorization: Bearer` 发送,且只作用于该服务地址。 + +## 服务生命周期 + +在这里启动服务等价于在终端执行 `reme start`,但有一点是刻意的差异:**服务会比 DSH 活得更久**。关闭 DSH 不会停止它,停止 +它是在标签页里的一次显式操作。 + +因此本插件没有把服务交给 DSH 的本地子进程提供方——后者会在 Host 退出时终止它托管的进程。插件自带一个小型服务适配器: + +- 启动在操作系统确认 spawn 之后才记录到 `$DSH_HOME/reme-memory/service.json`(pid、地址、workspace、命令行、启动时间), + 所以解释器缺失时不会留下残留状态。 +- 只有记录与当前地址一致、pid 仍存活、进程映像仍是 Python 或 `reme`,**且命令行仍包含记录的 `workspace_dir` 与 + `service.port`** 时,服务才被判定为“由本插件托管”并给出**停止**按钮。命令行能排除大多数 pid 复用(更长的 + workspace 路径、另一个端口都不会命中),但无法区分“用户照抄同一命令行手工启动”的同一服务。2333 端口上的记录绝不会 + 声称管理 2444 端口上的服务。 +- 证据读不到时(`tasklist`/`ps` 输出本地化、权限不足等)归属保持“未证明”:启动与停止都不会碰进程、也不会丢弃记录—— + 停止会拒绝,启动会返回错误。丢弃记录会让一个仍在运行的进程变成标签页再也停不掉的孤儿;此时卡片会给出**忘记这条记录**, + 它只删除 `service.json`、绝不向进程发信号,由用户自行确认后续处理。Host 会在结果里指明挡住操作的是哪一条记录, + **忘记**针对的正是那条记录(可能是另一个端口上的记录,而不是当前显示的地址),并带上它的 pid:记录在此期间被换掉时, + Host 会拒绝而不是静默丢弃新记录。 +- 服务占用端口但健康检查失败时显示为“端口被占用”;若能证明是本插件启动的,标签页仍给出**停止**按钮。 +- 占用端口的是**已记录**但身份无法验证的进程时情况不同:该进程绝不会被发信号,因此标签页改为给出**忘记这条记录**。 + 这条记录随状态一起返回,所以出路不依赖被“端口被占用”状态禁用的**启动**按钮。 +- 旧版本遗留在同一源(origin)某个路径前缀下(例如 `127.0.0.1:2333/reme`)的记录,描述的是**同一个**本地服务—— + 启动只配置 host 与端口。只要能证明它是本插件启动的,这种记录就会被重新登记到当前显示的地址,随后与其他启动 + 一样被管理:给出**停止**按钮,**启动**在它尚未健康时替换它。它不会再被当成标签页无从处理的外部进程,否则用户 + 只能自己去杀进程。 +- **停止**会树杀记录的进程(POSIX 上按进程组、Windows 上为 `taskkill /T /F`),等待其退出,必要时升级(POSIX: + `SIGTERM` 后 `SIGKILL`;Windows:再次强制 `taskkill`),并**在 pid 真正消失之后**才清除记录。别处启动的服务不受影响, + 只会被观察。 +- 记录中的进程仍存活但从未就绪时,显示为**上次启动未就绪**,并同时给出**停止**与**启动**:停止负责清记录,启动则让 Host + 停掉可证明归属的旧启动、或丢弃已证明属于外部的记录。只有本标签页发起的启动仍在进行时才会显示“启动中”。 +- **启动**是串行化的:两个请求不会拉起两个服务,后到的请求会观察已在进行的启动。替换“一直没有就绪”的旧记录时,只有 + 归属被证明才会杀掉它;证明不了就保留记录并报错,不会静默丢弃。 +- **自动启动**(`autoStart`,默认关闭)会在 DSH 启动时把尚未运行的服务拉起。 + +Python 探测与 pip 安装都是有界的 Host 操作;服务是唯一需要比 DSH 活得更久的进程。 + +## 平台支持 + +| 方面 | 说明 | +| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| DSH | `0.1.7-rc.2`、`0.2.0-rc.2`、`0.2.1-alpha.2`、`0.2.1-alpha.4`、`0.2.1-rc.2`、`0.2.2-alpha.2` | +| Node.js | `^22.19.0` 或 `>=24.0.0`;当前 DSH 需要 Node 24.9 以上,兼容矩阵以 Node 24.16 验证 | +| Windows | 在 Windows 11 + Miniconda 上实测:注册表/PATH/conda 探测、`tasklist`/`taskkill` 身份校验与停止、`PYTHONUTF8`/`PYTHONIOENCODING` 保证 pip 输出不乱码 | +| macOS/Linux | 探测、分离启动、身份查询与停止均有运行真实子进程的单元测试覆盖;真机验证程度仍低于 Windows | + +## 常见问题 + +**未检测到可用的 Python。** 安装 Miniconda 或任意 Python 3.11+,再点**重新探测**。Store 占位符会被过滤,请安装真实的发行版 +而不是 Store 别名。 + +**所选解释器不在列表里。** 标签页会用“不在探测结果中”标注它,但仍然使用它。重新探测,或改选探测到的解释器。 + +**安装失败。** 先看日志尾部:缺少 wheel、代理配置过期或网络受限通常都能看出来,可以换一个软件源。conda 环境里 `pip` 也可 +用,但独立环境更能避免与其它项目的依赖冲突。 + +**保存 `.env` 被拒绝。** 保存后的文件中 `LLM_API_KEY` 与 `EMBEDDING_API_KEY` 都必须非空;关闭向量检索时 embedding 用 +`EMPTY`。清空密钥字段不会写入文件,已存的密钥会被保留;而一个根本没有存过的必需键仍会让保存失败,文件保持原样。 + +**服务显示“端口被占用”。** 该端口被其它程序占用。请在服务地址(记忆设置)中换一个端口,或停止占用它的程序。 + +**服务迟迟未就绪。** 启动会轮询 120 秒;如果端口有响应但 `/health_check` 一直不通过,卡片会切到“端口被占用”状态,**停止**按钮 +始终可用。用**查看日志**(workspace 下的 `service.log`)看启动过程——缺少 `reme-ai`、`.env` 不合法、端口冲突都会体现在那里。 + +**服务显示“上次启动未就绪”。** 本插件记录的进程仍存活,但一直没有通过 `/health_check`(例如启动途中 DSH 被关闭)。点**停止** +清掉它后重新启动。若 pid 已被其它程序复用,这条记录可证明属于外部:点**启动**会丢弃记录并拉起新服务。若完全无法验证身份, +点**忘记这条记录**——它只删除本插件的记录,不会碰进程。 + +**服务显示为外部启动。** 它不是由本插件启动的,因此只被观察而不被管理。若希望在标签页里管理,先在它自己的终端里停止,再从 +这里启动。 + +**停止按钮不可用。** 运行中的服务不属于当前服务地址,或记录已丢失。从标签页重新启动一次即可纳入管理。 + +**LLM 测试失败。** 结果会区分 `401`/`403`(密钥)、`404`(模型名或 Base URL)与网络错误。探测读取的是 workspace 的 +`.env`,所以请先保存再测试;探测协议也取自其中的 `LLM_BACKEND`:原生后端(Anthropic、Gemini、Ollama)用自己的协议 +探测,不会被当成 OpenAI 兼容接口。 + +## 代码位置 + +```text +integrations/dsh/src/environment/ +├── types.ts 浏览器安全的共享 RPC 类型 +├── env-fields.ts 受管的 `.env` 键及其更新规则 +├── dependency.ts 一个解释器能承载什么 +├── endpoint-rules.ts 哪些服务地址可以作为本地服务管理 +├── host/ 探测、安装、服务适配器、manager、gateway +└── client/ 标签页:每个步骤一个模块,以及文案与样式 +``` + +只有 Host 模块会接触进程与文件;客户端只负责渲染 Host 返回的结果。 diff --git a/integrations/dsh/package.json b/integrations/dsh/package.json index 0ec3eaa9..99bd5f00 100644 --- a/integrations/dsh/package.json +++ b/integrations/dsh/package.json @@ -20,6 +20,7 @@ "cordis.patch.yml", "README.md", "README_ZH.md", + "docs", "figures" ], "dsh": { @@ -51,12 +52,12 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "peerDependencies": { - "@deepseek-ai/cordis": "~4.0.4 || ^4.0.5-alpha.1", - "@deepseek-ai/dsh-client-ui-primitives": "^0.1.7-rc.2 || ^0.2.0-rc.2 || ^0.2.1-alpha.2", - "@deepseek-ai/dsh-llm": "^0.1.7-rc.2 || ^0.2.0-rc.2 || ^0.2.1-alpha.2", - "@deepseek-ai/dsh-tools": "^0.1.7-rc.2 || ^0.2.0-rc.2 || ^0.2.1-alpha.2", - "@deepseek-ai/dsh-typert-protocol": "^0.1.7-rc.2 || ^0.2.0-rc.2 || ^0.2.1-alpha.2", - "@deepseek-ai/schemastery": "~3.18.4 || ^3.18.5-alpha.1" + "@deepseek-ai/cordis": "~4.0.4 || ^4.0.5-alpha.1 || ~4.0.6-alpha.1", + "@deepseek-ai/dsh-client-ui-primitives": "^0.1.7-rc.2 || ^0.2.0-rc.2 || ^0.2.1-alpha.2 || ^0.2.2-alpha.2", + "@deepseek-ai/dsh-llm": "^0.1.7-rc.2 || ^0.2.0-rc.2 || ^0.2.1-alpha.2 || ^0.2.2-alpha.2", + "@deepseek-ai/dsh-tools": "^0.1.7-rc.2 || ^0.2.0-rc.2 || ^0.2.1-alpha.2 || ^0.2.2-alpha.2", + "@deepseek-ai/dsh-typert-protocol": "^0.1.7-rc.2 || ^0.2.0-rc.2 || ^0.2.1-alpha.2 || ^0.2.2-alpha.2", + "@deepseek-ai/schemastery": "~3.18.4 || ^3.18.5-alpha.1 || ~3.18.6-alpha.1" }, "peerDependenciesMeta": { "@deepseek-ai/cordis": { diff --git a/integrations/dsh/scripts/test-package.mjs b/integrations/dsh/scripts/test-package.mjs index 3e8f3f46..6775cdb4 100644 --- a/integrations/dsh/scripts/test-package.mjs +++ b/integrations/dsh/scripts/test-package.mjs @@ -38,24 +38,62 @@ const releases = [ schema: "3.18.5-alpha.2", }, { dsh: "0.2.1-rc.2", cordis: "4.0.5", loader: "1.0.6", schema: "3.18.5" }, + { + dsh: "0.2.2-alpha.2", + cordis: "4.0.6-alpha.1", + loader: "1.0.7-alpha.1", + schema: "3.18.6-alpha.1", + }, ]; -async function run(command, args, cwd) { +// `npm` is `npm.cmd` on Windows, which execFile cannot launch directly; the +// script runs the npm CLI through node instead, using the path npm exported to +// this process. +const npmCommand = process.env.npm_execpath + ? [process.execPath, [process.env.npm_execpath]] + : ["npm", []]; + +async function run(command, args, cwd, label = "command") { try { return await execFileAsync(command, args, { cwd, maxBuffer: 10 * 1024 * 1024, }); } catch (error) { - process.stderr.write(error.stdout || ""); - process.stderr.write(error.stderr || ""); - throw error; + const stdout = String(error.stdout ?? ""); + const stderr = String(error.stderr ?? ""); + const combined = `${stdout}\n${stderr}`; + // The raw TAP buffer is far too large for a CI log and hides which step + // failed. Report the failing cases and a short tail instead. + const failures = combined + .split("\n") + .map((line) => line.trim()) + .filter((line) => /^not ok \d+ - /.test(line) || line.startsWith("✖ ")) + .slice(0, 20); + const tail = combined.trim().split("\n").slice(-40).join("\n"); + process.stderr.write( + `\n[test-package] ${label} failed (exit ${error.code ?? "error"})\n`, + ); + if (failures.length > 0) + process.stderr.write( + `[test-package] failing tests:\n${failures.join("\n")}\n`, + ); + if (tail) process.stderr.write(`[test-package] last output:\n${tail}\n`); + // A short error keeps Node's unhandled-rejection dump readable; the large + // stdout/stderr buffers stay out of it. + const failure = new Error(`${label} failed: ${error.message}`); + failure.code = error.code; + throw failure; } } +/** Run the npm CLI on the platform this check runs on. */ +function runNpm(args, cwd, label = "npm") { + return run(npmCommand[0], [...npmCommand[1], ...args], cwd, label); +} + try { - const { stdout } = await run( - "npm", + const { stdout } = await runNpm( [ "pack", "--json", @@ -64,6 +102,7 @@ try { temporaryDirectory, ], root, + "npm pack", ); const [result] = JSON.parse(stdout); const files = new Set(result.files.map(({ path: file }) => file)); @@ -72,6 +111,7 @@ try { "dist/client.js", "cordis.patch.yml", "README.md", + "docs/environment.md", "figures/reme-status-overview.png", ]) { assert.ok(files.has(file), `missing ${file}`); @@ -112,8 +152,7 @@ try { JSON.stringify({ private: true, type: "module", dependencies }), ); // Install as a consumer with normal peer resolution, then check the shipped artifact. - await run( - "npm", + await runNpm( [ "install", "--ignore-scripts", @@ -123,6 +162,7 @@ try { "--fetch-timeout=30000", ], consumer, + `install DSH ${release.dsh}`, ); await run( process.execPath, @@ -136,6 +176,7 @@ try { `, ], consumer, + `import DSH ${release.dsh}`, ); const installed = path.join(consumer, "node_modules", manifest.name); @@ -156,8 +197,9 @@ try { process.execPath, ["--test", ...testFiles], installed, + `tests DSH ${release.dsh}`, ); - await run("npm", ["run", "build"], installed); + await runNpm(["run", "build"], installed, `build DSH ${release.dsh}`); const count = /# tests (\d+)/.exec(tested.stdout)?.[1]; process.stdout.write( `DSH ${release.dsh}: packed import, source build, and ${count} tests passed\n`, diff --git a/integrations/dsh/src/client/index.tsx b/integrations/dsh/src/client/index.tsx index d14a0151..abe6ad80 100644 --- a/integrations/dsh/src/client/index.tsx +++ b/integrations/dsh/src/client/index.tsx @@ -1,8 +1,19 @@ -import { useState, useSyncExternalStore } from "react"; +import { useEffect, useRef, useState, useSyncExternalStore } from "react"; +import { SettingsFormModel } from "@deepseek-ai/dsh-client-ui-primitives"; +import type { + SettingsFieldState, + SettingsFormShell, +} from "@deepseek-ai/dsh-client-ui-primitives"; import type { ConfigForm } from "@deepseek-ai/dsh-client-ui-settings/client"; import type { ReMeSettings } from "../types.js"; -import { validEndpoint } from "../endpoint.js"; +import { + ReMeEnvironmentTab, + envEn, + envZh, + type EnvTranslator, +} from "../environment/client/tab.js"; +import { environmentStyles } from "../environment/client/styles.js"; import { ReMeStatusPage, statusEn, @@ -10,38 +21,23 @@ import { type StatusRpc, type StatusTranslator, } from "./status-page.js"; +import { SETTINGS_SPECS } from "./settings-specs.js"; import { styles } from "./styles.js"; const NS = "reme.settings"; const STATUS_NS = "reme.status"; +const ENV_NS = "reme.environment"; const SETTINGS_NS = "reme-memory"; -const FIELDS = [ - "endpoint", - "requestTimeoutMs", - "backgroundTimeoutMs", - "shutdownTimeoutMs", - "autoMemoryRetryMs", - "autoMemoryRetryMaxMs", - "autoMemoryEnabled", - "autoMemoryInterval", - "autoDreamEnabled", - "dreamCron", - "dreamHint", - "rootAgentsOnly", - "language", - "searchLimit", - "timezone", -] as const satisfies readonly (keyof ReMeSettings)[]; -type Field = (typeof FIELDS)[number]; type Translator = (key: keyof typeof en) => string; interface ClientContext { get(name: string): { rpc: StatusRpc }; - effect(factory: () => (() => void) | void, label: string): void; + effect(factory: () => void | (() => void), label: string): void; locale: { bind(namespace: typeof NS): Translator; bind(namespace: typeof STATUS_NS): StatusTranslator; + bind(namespace: typeof ENV_NS): EnvTranslator; register( namespace: string, dictionaries: { @@ -63,27 +59,61 @@ interface ClientContext { }; } -interface Draft { - endpoint: string; - requestTimeoutMs: string; - backgroundTimeoutMs: string; - shutdownTimeoutMs: string; - autoMemoryRetryMs: string; - autoMemoryRetryMaxMs: string; - autoMemoryEnabled: boolean; - autoMemoryInterval: string; - autoDreamEnabled: boolean; - dreamTime: string; - dreamHint: string; - rootAgentsOnly: boolean; - language: "en" | "zh"; - searchLimit: string; - timezone: string; +/** One control as the model currently stages it. */ +type FieldState = SettingsFieldState; + +/** + * The slice of DSH's snapshot store this card reads. + * + * `@deepseek-ai/dsh-client-store` is not a dependency of this package, so the + * store's own type is not available here; the card declares the two members it + * uses instead of depending on the package. + */ +interface BoundStore { + getSnapshot(): T; + subscribe(listener: () => void): () => void; +} + +/** Everything the card renders, rebuilt from the model on every publish. */ +interface CardState { + /** Whether the Host still serves this namespace to this client. */ + status: "loading" | "ready" | "unavailable"; + /** What a save would do, and whether one is crossing the wire. */ + shell: SettingsFormShell; + /** Every control's staged state, keyed by field name. */ + fields: Record; +} + +/** + * Rebuild the card's view state from the model. + * + * The model publishes when the scope changes, when a draft is staged, when a + * save starts and when it settles, so this projection is the card's only + * source of truth: what it renders is what the model holds. + * + * The projection carries the model's own state and nothing the settings + * service owns: the store freezes what it is given, and the resolved section + * belongs to the service, which replaces it rather than editing it. + */ +function projectCard( + model: SettingsFormModel, + scope: ConfigForm, +): CardState { + const fields: Record = {}; + for (const spec of SETTINGS_SPECS) + fields[spec.field] = model.field(spec.field); + return { + status: scope.getSnapshot().status, + shell: model.shell(), + fields, + }; } interface ReMeCardProps { scope: ConfigForm; t: Translator; + rpc?: StatusRpc; + envT?: EnvTranslator; view?: "summary" | "page"; } @@ -121,6 +151,8 @@ const en = { retryDelay: "Retry delay (ms)", retryMaxDelay: "Maximum retry delay (ms)", diagnostics: "Service diagnostics", + tabMemory: "Memory settings", + tabEnvironment: "Environment", processMemory: "Process memory", componentMemory: "Component memory", components: "Health components", @@ -196,6 +228,8 @@ const zh: typeof en = { retryDelay: "失败重试间隔(毫秒)", retryMaxDelay: "最大重试间隔(毫秒)", diagnostics: "服务诊断", + tabMemory: "记忆设置", + tabEnvironment: "环境管理", processMemory: "进程内存", componentMemory: "组件内存", components: "健康组件", @@ -250,7 +284,12 @@ export function apply(ctx: ClientContext): void { () => ctx.locale.register(STATUS_NS, { en: statusEn, zh: statusZh }), "remeMemory.statusLocale()", ); + ctx.effect( + () => ctx.locale.register(ENV_NS, { en: envEn, zh: envZh }), + "remeMemory.envLocale()", + ); ctx.effect(() => installStyles(), "remeMemory.settingsStyles()"); + const envT = ctx.locale.bind(ENV_NS); const scope = ctx.configForms.get(SETTINGS_NS); ctx.effect( () => @@ -262,7 +301,12 @@ export function apply(ctx: ClientContext): void { key: "@agentscope-ai/reme-dsh-plugin#reme-memory", label: () => t("title"), locale: NS, - inject: () => ({ scope, t }), + inject: () => ({ + scope, + t, + rpc: ctx.get("connection").rpc, + envT, + }), }, ReMeSettingsCard, ), @@ -291,177 +335,217 @@ export function apply(ctx: ClientContext): void { function ReMeSettingsCard({ scope, t, + rpc, + envT, view, }: ReMeCardProps): JSX.Element | null { - const snapshot = useSyncExternalStore( - (listener) => scope.subscribe(listener), - () => scope.getSnapshot(), + // DSH's form model stages the drafts, marks the ones that are invalid and + // writes every staged edit as one revision-fenced mutation, so this page owns + // only its controls and the field specs behind them. + // + // The model, its actions and the store it publishes to are built once: the + // model owns one subscription to the scope, and `bind` registers one + // listener on the model, so a second build would publish every change twice. + const [form] = useState(() => { + const model = new SettingsFormModel(scope, SETTINGS_SPECS); + return { + model, + actions: model.actions(), + store: model.bind(() => + projectCard(model, scope), + ) as BoundStore, + }; + }); + // The model publishes on a scope change, on every staged edit, when a save + // starts and when it settles. The card reads those publications and nothing + // else, so a staged edit repaints the controls on its own. + const state = useSyncExternalStore( + form.store.subscribe, + form.store.getSnapshot, ); - const [draftOverride, setDraft] = useState<{ - value: Draft; - revision: number | undefined; - }>(); - const [resetAll, setResetAll] = useState(false); - const [saving, setSaving] = useState(false); - const [saveState, setSaveState] = useState<"" | "saved" | "failed">(""); - const value = snapshot.value; - const draft = - draftOverride?.value ?? - (value === undefined ? undefined : draftFrom(value)); + const disposeTimer = useRef | undefined>( + undefined, + ); + const [saved, setSaved] = useState(false); + const [tab, setTab] = useState<"memory" | "environment">("memory"); + useEffect(() => { + // React StrictMode mounts, cleans up and mounts again in development. + // Disposing the model immediately would hand the remounted card a dead + // one, so the cleanup is deferred and cancelled by the next mount. + if (disposeTimer.current !== undefined) { + clearTimeout(disposeTimer.current); + disposeTimer.current = undefined; + } + return () => { + disposeTimer.current = setTimeout(() => { + disposeTimer.current = undefined; + form.model.dispose(); + }, 0); + }; + }, [form.model]); if (view === "summary") return <>{t("description")}; - if (snapshot.status === "unavailable") return null; - const dirty = - resetAll || - (value !== undefined && - draft !== undefined && - !sameDraft(draft, draftFrom(value))); - const validation = draft === undefined ? undefined : parseDraft(draft); + if (state.status === "unavailable") return null; - const update = (field: K, next: Draft[K]) => { - if (value === undefined) return; - setDraft((current) => ({ - value: { ...(current?.value ?? draftFrom(value)), [field]: next }, - revision: current ? current.revision : snapshot.revision, - })); - setResetAll(false); - setSaveState(""); + const { shell, fields } = state; + // The model's own saving state: it is true from the moment the save starts + // until the write settles, so the card keeps no second flag for it. + const saving = shell.saving; + // "The maximum retry delay is at least the initial one" is a relation between + // two fields, which a per-field spec cannot state, so the page keeps it. + const retryMax = (fields["autoMemoryRetryMaxMs"]?.text ?? "").trim(); + const retryBase = (fields["autoMemoryRetryMs"]?.text ?? "").trim(); + const inconsistent = + retryMax !== "" && retryBase !== "" && Number(retryMax) < Number(retryBase); + const blocked = shell.invalid || inconsistent; + const edit = (name: string, text: string): void => { + setSaved(false); + form.actions.edit(name, text); }; - - const save = async () => { - if ( - value === undefined || - draft === undefined || - validation === undefined || - !snapshot.writable || - !dirty || - saving - ) + const save = async (): Promise => { + const current = form.store.getSnapshot().shell; + if (!current.writable || !current.dirty || blocked || current.saving) return; - setSaving(true); - setSaveState(""); - try { - const ops = resetAll - ? FIELDS.map((field) => ({ op: "unset" as const, path: [field] })) - : FIELDS.flatMap((field) => - Object.is(value[field], validation[field]) - ? [] - : [ - { - op: "set" as const, - path: [field], - value: validation[field], - }, - ], - ); - const success = await scope.mutate( - ops, - draftOverride?.revision ?? snapshot.revision, - ); - setSaveState(success ? "saved" : "failed"); - if (success) { - setDraft(undefined); - setResetAll(false); - } - } catch { - setSaveState("failed"); - } finally { - setSaving(false); - } + setSaved(false); + await form.model.save(); + // The model published the settled state before its promise resolved, so the + // store already carries the outcome of the write. + const after = form.store.getSnapshot().shell; + setSaved(!after.failed && !after.dirty); + }; + const reset = (): void => { + setSaved(false); + for (const spec of SETTINGS_SPECS) form.actions.resetField(spec.field); + }; + const discard = (): void => { + setSaved(false); + form.actions.discard(); }; return (
- {dirty ?

{t("unsaved")}

: null} - {snapshot.status === "loading" || - draft === undefined || - value === undefined ? ( -

{t("loading")}

- ) : ( - <> -
- -
- {!snapshot.writable ? ( -

- {t("readOnly")} -

- ) : null} - {validation === undefined ? ( -

- {t("invalid")} -

- ) : null} -
- + +
+
+ {tab === "environment" ? ( + rpc && envT ? ( + + ) : ( +

{t("loading")}

+ ) + ) : state.status === "ready" ? ( + <> + {shell.dirty ? ( +

{t("unsaved")}

+ ) : null} +
- {t("reset")} - - - {saveState === "failed" ? ( - {t("saveFailed")} - ) : null} - {saveState === "saved" ? ( - {t("saved")} - ) : null} - - - -
- - )} + + + {!shell.writable ? ( +

+ {t("readOnly")} +

+ ) : null} + {blocked ? ( +

+ {t("invalid")} +

+ ) : null} +
+ + + {shell.failed ? ( + + {t("saveFailed")} + + ) : null} + {saved && !shell.dirty ? ( + {t("saved")} + ) : null} + + + +
+ + ) : ( +

{t("loading")}

+ )} +
); } +/** The memory-settings controls, all reading and writing through the model. */ function SettingsForm({ - draft, - update, + fields, + edit, t, }: { - draft: Draft; - update: (field: K, value: Draft[K]) => void; + fields: Record; + edit: (name: string, text: string) => void; t: Translator; }): JSX.Element { + const text = (name: string): string => fields[name]?.text ?? ""; + const invalid = (name: string): boolean => fields[name]?.invalid ?? false; + const checked = (name: string): boolean => text(name) === "true"; return ( <>
@@ -469,17 +553,15 @@ function SettingsForm({
update("endpoint", event.target.value)} + value={text("endpoint")} + aria-invalid={invalid("endpoint")} + onChange={(event) => edit("endpoint", event.target.value)} /> - update("autoMemoryInterval", event.target.value) + edit("autoMemoryInterval", event.target.value) } /> update("timezone", event.target.value)} + value={text("timezone")} + aria-invalid={invalid("timezone")} + onChange={(event) => edit("timezone", event.target.value)} />
@@ -545,20 +627,22 @@ function SettingsForm({
update("autoDreamEnabled", value)} + checked={checked("autoDreamEnabled")} + onChange={(next) => + edit("autoDreamEnabled", next ? "true" : "false") + } /> update("dreamTime", event.target.value)} + value={text("dreamCron")} + onChange={(event) => edit("dreamCron", event.target.value)} />