memory-eternal 0.10.7 → 0.10.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/README.en.md +1 -0
- package/README.md +1 -0
- package/index.js +46 -1
- package/lib/api.js +23 -9
- package/lib/client.js +6 -6
- package/lib/config-sync.js +75 -0
- package/lib/host-heartbeat.js +4 -0
- package/package.json +2 -2
- package/web/app.js +6 -6
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
|
3
3
|
"name": "memory-eternal",
|
|
4
|
-
"version": "0.10.
|
|
4
|
+
"version": "0.10.8",
|
|
5
5
|
"description": "记忆核心(Memory Eternal):对话结束后自动沉淀知识卡到本地记忆库(~/.dsh/memory-vault),支持检索 / 召回 / 审核中心。",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "EternalNight996",
|
package/README.en.md
CHANGED
|
@@ -361,6 +361,7 @@ curl http://127.0.0.1:7999/memory-eternal/api/web-info # is the standalone we
|
|
|
361
361
|
|
|
362
362
|
| Version | Date | Highlights |
|
|
363
363
|
|---|---|---|
|
|
364
|
+
| **v0.10.8** | 2026-10-08 | **Saving on the standalone web page now takes effect on click: the pending apply moves from a plugin callback into a host HTTP request (design A, measurement-driven)**. The scene: a standalone-page save ended in a `dropped` patch after five retries, always with `HMR transactions cannot be nested` (0.10.7 only stopped it from masquerading as an auto-capture anomaly and filtered upgrade-window keys — the sync itself was not fixed). **Measured on the same host with the same `settings.update`**: called from a plugin **callback** (`setInterval` / `fs.watch`) → guard error and **nothing was committed at all** (a patch with only host-known keys and a genuinely different value failed the same way); called from a plugin **HTTP request handler** → **it really committed** (`recallSummaryLen` 130→132→130, `revision` 4→6). So the fix is not another API but another **calling context**: ① the host gains `POST /memory-eternal/api/drain-pending`, applying the pending patch inside a **request handler** (two gates: loopback origin plus a per-process token from the heartbeat file, so no other local page can write); ② the host publishes its web server's **listening port + token** in the heartbeat (`webServer.port` is a public getter), and a standalone save now **wakes the host** and reports the real result (new `savedApplied` toast); ③ a failed wake-up (old host without the route → 404, port mismatch, timeout) is **not** an error — it falls back to the 0.10.5 waiting path and the "change it under DSH Settings" hint. Adds `tests/host-drain.test.mjs` (6 checks: loopback, the three gates, the end-to-end wake-up, failure reporting, 404 fallback, source guards) plus `tests/e2e-host-drain.mjs` (2 checks: a **real spawned process** end-to-end "standalone save → wake the host → host applies → reports applied", and the fallback to "queued" when the heartbeat comes from an old host with no port/token). `npm test` now runs **282 checks** |
|
|
364
365
|
| **v0.10.7** | 2026-10-08 | **"Still the old version after starting dsh" fixed at the root: version-drift self-heal + one-click resident restart (the positive answer to #19 / #23).** The scene: an npm upgrade only swaps files on disk, and the **resident web (port 7999 by default) outlives the DSH host** — the process on the port keeps running the code it loaded at startup, while activation policy was "there is already a live watchdog on this port → `delegated` (no spawn, no replacement)", so **no number of DSH restarts ever replaces it** (measured here: the host started at 10:19 on the new build and the same-origin settings panel showed 0.10.6, while the web on 7999 had started at 08:15 on 0.10.0 — `/config` and `/version-check` each reported a different version). ① **Startup self-heal (`autoRestartOnDrift`, default on)**: activation first runs `inspectResident()` — the version is always asked from **the port** (the lock's `pkgVersion` is only the watchdog's self-report, and an old instance wrote an empty string, so drift was invisible) — then `decideResidentAction()` decides: match → `delegate` (keeping #19's honest wording), drift → `restart`, own version unreadable → touch nothing, just replaced → debounce (a config change re-runs the effect). **"Updating the running program" is therefore defined as "restart that resident process on the new code", never as hot-swapping modules in memory**: ESM module cache plus closure state (settings / vault routing / SSE hub / `fs.watch` / the already-bound port) leaves no safe way to hot-swap, and a cache-busting re-import only produces a second module graph (double instances, double listeners, double writes). ② **One implementation for replacement**: new `restartResident()` spawns a detached `watchdog.js --replace` helper (reap the old watchdog / old web → **wait for the port to really free up** → take the lock → pull up the new web), then verifies "the version served on the port equals the local version" (#23 suggestion 2); if the port never frees up it **aborts the takeover** instead of falling back to `port+1` (fallback instances are exactly where orphan webs are born). `dsh-memory restart`, host startup self-heal and the panel button now share it. The helper is detached with `stdio:ignore` (nobody reads its stderr), so the takeover outcome **must** go into the capture log — otherwise "restarted but not in effect" becomes yet another untraceable silent failure. ③ **One-click fix in the panel**: new `POST /memory-eternal/api/restart-self` under three hard rules — this process may well **be** the stale web being replaced, so the response is sent **before** the replacement is scheduled; if the resident instance is already current it answers `alreadyCurrent` (the common case when clicking from the DSH panel — a healthy instance is never needlessly taken down); and only one replacement may run at a time. ④ **Panel semantics disambiguated**: "running" became "**running (process serving this page)**" (the resident web on the standalone page, the host inside DSH Settings — the very reason the two panels' numbers disagreed), plus a new "**resident instance vX**" chip so it is obvious which one to restart; the drift banner offers the button plus the equivalent command, and when the resident instance is already new but this page is not, it says plainly "restart the desktop app / DSH" instead of offering a button that would do nothing. ⑤ **`updateAvailable` now compares versions**: `latest !== onDisk` used to **advise downgrading** when running an unpublished local build (disk 0.10.7 / npm 0.10.6); it now uses `compareVersions` (with prerelease semantics, `rc.2 < rc.10`). ⑥ Also removed a duplicate `saving` key from the client dictionary (esbuild warned on every build). New `tests/watchdog-restart.test.mjs` (17 checks: decision matrix / inspection asking the port / replacement verification / non-lethal takeover cleanup / route responding before replacing + concurrency gate / version comparison). ⑦ **The README now ships as two separate files**: `README.md` is the **default** Simplified Chinese one (what npm and the DSH marketplace render) and `README.en.md` is the English mirror; both gained a language switch plus a "change one, change the other" note at the top, and the English self-diagnostics subsection that was missing; new `tests/readme-i18n.test.mjs` locks **1:1 structure** (heading-level sequence + code-block count + cross links + default-language markers) so future edits cannot land on one side only. ⑧ **`secretHint` credential hint + credential-read guard**: one line of your own convention gets injected into every session's systemPrompt so the agent looks up the **secret directory card** in memory first when it needs an API key / token (memory stores the directory only — names / locations / how to use — while **values stay in local credential stores**); `dsh-memory setup` now also registers the Claude Code `PreToolUse` guard (denies reads of `.credentials.yaml` / `.env` / `*-token` and points at the loader) and refreshes hook paths that still point at an older install. ⑨ **Three more live-site findings fixed before release**: ① the panel's **restart resident instance** button also showed when the *process serving this page* was the old one — clicking it must 404 (the old host has no such route) and the user only saw "unknown interface"; it now appears only when the resident instance on the port is behind the files on disk, and a 404/405 gets explicit "restart the desktop build / DSH, or run dsh-memory restart" guidance. ② When the standalone page (new) submits the whole form to an old host, the host drops the keys it does not know (e.g. `secretHint`) by whitelist → the read-back check can never match → five retries then `dropped`, with the user only seeing "HMR transactions cannot be nested"; the pending patch is now **filtered against the host schema** before it is applied (unknown keys take effect once the host upgrades), and "config sync failed" is kept strictly apart from "auto-capture anomaly" (`config-warn` / `config-fail`, no longer painting the capture health red). ③ `autoRestartOnDrift` now renders as **on** when the host snapshot has no such key (measured: 0.10.6 host + 0.10.7 panel → checkbox empty while the host actually treats it as on). Adds `tests/issue-fixes-4.test.mjs`. `npm test` now runs **274 checks** |
|
|
365
366
|
| **v0.10.6** | 2026-10-07 | **Orphan webs are now reclaimed + a failed shared-config sync is no longer silent.** ① **Orphan webs**: the web child is spawned detached, so when a watchdog is hard-killed (on Windows `process.kill(pid,'SIGTERM')` terminates unconditionally) its exit handler never runs; if that web had **fallen back to port+1…** because the target port was taken, the lock entry naming it disappears together with the watchdog — leaving an unowned web parked on 8001/8002 (two were found on this machine, one of them 20 hours old, and `status` only probes the configured port so it could never see them). Now `startWatchdog` on startup, and `stop`/`restart` (the `checkPort` path), sweep the **fallback window** `[port+1, port+9]` and reap them; three gates must all pass (the port is inside the window / the command line **really is this plugin's web** / the pid is not any watchdog's `pid` or `webPid` in the lock), so **someone else's `node web.js` is never killed**. In addition `spawnWeb` no longer spawns a duplicate when the target port already holds our own web (the main source of fallback instances) — liveness is left to the tick probe. ② **`syncConfigFile()` no longer swallows errors**: it writes the read source shared by the standalone page / hooks / MCP, so a failed write shows up as "config changed but they can't see it" — the old code was `catch { /* silent */ }` (observed once: the shared file's mtime stayed at pre-activation while the host's own value had changed). Failures now reach stderr and the capture log as a `warn` (same reason reported once, no spam); because `logCapture` is defined later inside `apply()`, the log line is deliberately deferred by one tick to avoid the `const` TDZ. New `tests/watchdog-orphans.test.mjs` (7 checks); `npm test` now runs **245 checks** |
|
|
366
367
|
| **v0.10.5** | 2026-10-07 | **Config saves from the standalone web page: no more empty promise, and no DSH install required.** ① **A host-independent write path (#21)**: if nothing consumes the pending file within 800 ms **and** no live DSH host is present (the heartbeat file `memory-eternal-config.host.json` is stale, or its pid is dead), the standalone side now **atomically merges** the change straight into the shared config, clears the pending file and answers `pendingOutcome: applied-direct` — so Codex / Claude Code / Cursor users, and anyone running only `dsh-memory serve`, get their save applied immediately (previously that path just wrote a "pending" file for a host that **does not exist**, while the UI promised "takes effect on next start" forever). When a host *is* alive the standalone side **never** writes directly: the shared file is the host's derived mirror, and a direct write would be overwritten by the next `syncConfigFile()` (a dedicated reverse test guards that boundary). ② **Failures now give directions, not just an error (#21)**: host-guard errors are recognised — `HMR transactions cannot be nested` (Cordis HMR hosts) and `root.events.emit is unavailable from a plugin activation` (dsh-tui and friends) are two phrasings of one defect family — and "this host does not let a plugin write config from its own callback context" plus where to change it instead (**DSH Settings → Memory**, or the profile patch layer) now lands in the give-up error, the diagnostics and the standalone page's hint; `POST /config` also returns `hostGuard` / `hint`. ③ **The UI no longer always says "Saved" (#21)**: the client consumes `pendingOutcome` — `queued` / `failed` use the non-success style and say "not confirmed applied yet", `applied-direct` states "no DSH host here, written straight into the shared config", and only a real apply shows "Saved". ④ Also fixed a cleanup race that could keep a host or test process **alive forever**: the heartbeat timer and the pending-file watcher are created after an async `import()`, so a dispose that happened before it resolved left them uncleaned (the heartbeat interval is additionally `unref()`ed) — this is exactly what hung `tests/settings-compat.test.mjs` when it ran `apply()` against a fake ctx. New `tests/issue-fixes-3.test.mjs` (9 checks); `npm test` now runs **238 checks** |
|
package/README.md
CHANGED
|
@@ -393,6 +393,7 @@ curl http://127.0.0.1:7999/memory-eternal/api/web-info # 独立 web 是否活
|
|
|
393
393
|
|
|
394
394
|
| 版本 | 日期 | 关键改动 |
|
|
395
395
|
|---|---|---|
|
|
396
|
+
| **v0.10.8** | 2026-10-08 | **独立 Web 端保存「点一下即生效」:把应用 pending 的上下文从插件回调搬到宿主 HTTP 请求(方案 A,实测驱动)**。现场:独立页保存 → 宿主连续重试 5 次后 `dropped`,报错恒为 `HMR transactions cannot be nested`(0.10.7 只修掉了它冒充「自动沉淀异常」与升级期未知键,同步本身没修)。**实测定位**(同一宿主、同一个 `settings.update`):从插件**回调**(`setInterval` / `fs.watch`)里调 → 抛守卫错且**一个字节都没提交**(只含宿主认识的键、值真的变了,同样失败);从插件 **HTTP 请求处理器**里调 → **真的提交**(`recallSummaryLen` 130→132→130,`revision` 4→6)。所以修法不是换 API 而是换**调用上下文**:① 宿主新增 `POST /memory-eternal/api/drain-pending`,在**请求处理器**里应用 pending(两道闸门:只接受本机来源 + 心跳里的一次性令牌,防本机任意页面乱写);② 宿主把自己 web server 的**监听端口 + 令牌**写进心跳(`webServer.port` 是服务公开的 getter),独立端保存后据此**主动叫醒宿主**,拿到真实结果再回给界面(新增 `savedApplied`「已保存并生效」);③ 叫醒失败(旧宿主没这条路由 404 / 端口对不上 / 超时)**不算错误** —— 回落到 0.10.5 的等待路径与「去 DSH 设置里改」的指路。新增 `tests/host-drain.test.mjs`(6 项:回环判定 / 守卫三件套 / 端到端叫醒 / 失败如实回报 / 404 回落 / 源码守卫)+ `tests/e2e-host-drain.mjs`(2 项:**真起进程**的端到端「独立页保存 → 叫醒宿主 → 宿主应用 → 回报已生效」,以及旧宿主心跳(无端口/令牌)时回落成「排队等待」)。`npm test` 共 **282 项** |
|
|
396
397
|
| **v0.10.7** | 2026-10-08 | **「启动 dsh 后运行中还是旧版本」根治:版本漂移自愈 + 一键重启常驻实例(#19 / #23 的正面解法)**。现场:升级 npm 包只换了磁盘文件,**常驻 web(默认 7999)比 DSH 宿主活得久** —— 端口上那个进程仍在跑「启动时加载进内存」的旧代码;而宿主激活时的策略是「同端口已有活着的 watchdog 就 `delegated`(不再 spawn、更不替换)」,于是**重启多少次 DSH 都不会换掉它**(实测本机:宿主 10:19 启动已是新版、设置页同源显示 0.10.6,而 7999 上的 web 是 08:15 启动的 0.10.0,`/config` 与 `/version-check` 各报一个版本)。① **启动自愈(`autoRestartOnDrift`,默认开)**:激活时先 `inspectResident()` 体检 —— 版本一律**问端口**(锁里的 `pkgVersion` 只是 watchdog 自述,旧实例写的还是空串,根本看不出版本漂移),再 `decideResidentAction()` 决策:一致 → `delegate`(保持 #19 的如实口径)、漂移 → `restart`、读不到本机版本 → 绝不乱动、刚替换过 → 防抖不重来(配置改动会让 effect 重跑)。**「更新运行中的程序」的正确形态由此定为「用新代码重启那个常驻进程」,而不是热替换内存里的模块**:ESM 模块缓存 + 闭包状态(settings / vault 路由 / SSE hub / `fs.watch` / 已监听端口)没有安全热换的余地,缓存失效重 import 只会得到第二份模块图(双实例、双监听、双写)。② **替换机制统一到一处**:新增 `restartResident()` = spawn 一个 detached 的 `watchdog.js --replace` 助手(收旧 watchdog / 旧 web → **等端口真的空出来** → 抢锁 → 拉起新 web),再自检「端口上真正服务的版本 = 本机版本」(#23 建议 2);端口没空出来就**中止接管**、不退让到 `port+1`(退让实例正是孤儿 web 的诞生地)。`dsh-memory restart`、宿主启动自愈、面板按钮现在共用这一份实现。助手是 detached + `stdio:ignore`(stderr 没人看),所以接管结论**必须**写进「自动沉淀日志」—— 否则「重启了却没生效」又会变成一条无迹可查的静默失败。③ **面板一键修复**:新增 `POST /memory-eternal/api/restart-self`,三条硬约束 —— 本进程很可能**就是**要被替换的旧 web,所以**先回响应再调度**替换;常驻实例已经是最新就回 `alreadyCurrent`(宿主面板点这个按钮时最常见,绝不为一个健康实例白折腾一次停机);同一时刻只允许一个替换在跑。④ **面板语义消歧**:「运行中」改为「**运行中(服务本页的进程)**」(独立 Web 页里它是常驻 web,DSH 设置页里是宿主 —— 这正是两个面板数字「打架」的根源),并新增「**常驻实例 vX**」徽标:两者不同时一眼看出该重启谁;漂移横幅给按钮 + 等价命令,若常驻实例已新而本页仍旧,则明说「需重启桌面版 / DSH」,不再给一个点了也没用的按钮。⑤ **`updateAvailable` 改按版本比较**:`latest !== onDisk` 在本地跑未发布版本时会**反过来劝你装回旧版**(磁盘 0.10.7 / npm 0.10.6),现在用 `compareVersions`(含预发布语义 `rc.2 < rc.10`)。⑥ 顺带清掉客户端词典里重复的 `saving` 键(esbuild 每次构建都在报警告)。新增 `tests/watchdog-restart.test.mjs`(17 项:决策矩阵 / 体检问端口 / 替换自检 / 接管前清场不误杀 / 路由先响应后替换 + 并发闸门 / 版本比较)。⑦ **README 改为中英两份独立文件**:`README.md` 是**默认**的简体中文(npm 与 DSH 插件市场展示这一份),`README.en.md` 是英文镜像;顶部加语言切换与「改一处要同步另一处」的说明,并补齐英文版缺失的「环境自检」小节;新增 `tests/readme-i18n.test.mjs` 锁住**结构 1:1**(标题层级序列 + 代码块数量 + 互链 + 默认语言标记),防止以后只改一份。⑧ **`secretHint` 凭证提示 + 凭据读取守卫**:填一句自己的约定就会被注入每个会话的 systemPrompt,让 agent 需要 API key / token 时**先查记忆里的「密钥目录」卡**(记忆库只存目录:名字/位置/取用方式,**值留在本机凭据库**);`dsh-memory setup` 同时注册 Claude Code 的 `PreToolUse` 守卫(拒读 `.credentials.yaml` / `.env` / `*-token` 并指路到加载器),并会刷新仍指向旧安装副本的 hook 路径。⑨ **发布前又抓到三个实测现场并修掉**:① 面板「重启常驻实例」按钮在**服务本页的进程**是旧版时也显示 —— 点了必然 404(旧宿主没有这条路由),用户只看到一句「未知接口」;现在按钮只在「端口上那个常驻实例落后于磁盘」时出现,404/405 还会给一条「重启桌面版 / DSH 或执行 dsh-memory restart」的指路。② 独立页(新版)提交整张表单、宿主还是旧版时:宿主按白名单丢掉它不认识的键(如 `secretHint`)→ 回读校验必然对不上 → 连试 5 次 `dropped`,用户只看到「HMR transactions cannot be nested」;现在应用前按宿主 schema **过滤未知键**(宿主升级后自然生效),并把「配置同步失败」与「自动沉淀异常」彻底分开(`config-warn` / `config-fail`,不再翻红沉淀健康状态)。③ `autoRestartOnDrift` 在宿主快照里没有这个键时按**默认开**渲染(实测现场:0.10.6 宿主 + 0.10.7 面板 → 复选框空着,而宿主实际按开处理,面板与真实行为不一致)。新增 `tests/issue-fixes-4.test.mjs`。`npm test` 共 **274 项** |
|
|
397
398
|
| **v0.10.6** | 2026-10-07 | **孤儿 web 有了回收机制 + 共享配置同步失败不再静默**。① **孤儿 web**:web 子进程是 detached 启动的,watchdog 被硬终止时(Windows 上 `process.kill(pid,'SIGTERM')` 即无条件终止)它的退出处理器不执行;若这个 web 当初因为目标端口被占而**退让到 port+1…**,登记它的锁条目又随 watchdog 一起消失 —— 于是 8001/8002 这类端口上会常驻没人认领的 web(实测本机就躺着两个,其中一个已跑了 20 小时,而 `status` 只探配置端口,永远发现不了)。现在 `startWatchdog` 启动时、以及 `stop`/`restart`(`checkPort` 路径)都会扫**退让窗口** `[port+1, port+9]` 并收掉它们;三道门缺一不可(端口在窗口内 / 命令行**确实是本插件的 web** / pid 不在锁里任何 watchdog 的 `pid` 或 `webPid` 上),**别人的 `node web.js` 一个都不杀**。另外 `spawnWeb` 发现目标端口上已有自家 web 时**不再重复拉起**(退让实例的主要来源),只交给 tick 探活。② **`syncConfigFile()` 不再静默吞错**:它写的是独立页 / hooks / MCP 共用的读源,写不进去就表现为「配置改了但他们看不到」——旧实现是 `catch { /* 静默 */ }`(实测到过一次:共享文件 mtime 停在激活前,而宿主里的值已经变了)。现在失败会进 stderr 与「自动沉淀日志」的 warn(同一原因只报一次,不刷屏);因为 `logCapture` 定义在 `apply()` 更靠后的位置,日志刻意**延迟一拍**再写,避开 `const` 的 TDZ。新增 `tests/watchdog-orphans.test.mjs`(7 项),`npm test` 共 **245 项** |
|
|
398
399
|
| **v0.10.5** | 2026-10-07 | **独立 Web 端的配置保存:不再空头承诺,也不再要求「必须装了 DSH」**。① **宿主无关的直写路径(#21)**:独立端保存后若 800ms 内没人消费 pending 文件、**且**本机没有活着的 DSH 宿主(心跳文件 `memory-eternal-config.host.json` 不新鲜、或里面的 pid 已死),就直接把改动**原子合并**写进共享配置、清掉 pending,响应回 `pendingOutcome: applied-direct` —— 于是 Codex / Claude Code / Cursor、以及只跑 `dsh-memory serve` 的用户,保存当场生效(此前那条路只会写「待应用」等一个**根本不存在**的宿主,界面上那句「下次启动生效」永远不会兑现)。宿主活着时**绝不**直写:共享配置是宿主的派生镜像,绕过它会被下一次 `syncConfigFile()` 盖回去(有专门的反向测试守着这条边界)。② **失败要指路,不只是重复报错(#21)**:识别「宿主守卫类」报错 —— `HMR transactions cannot be nested`(Cordis HMR 宿主)与 `root.events.emit is unavailable from a plugin activation`(dsh-tui 一类)是同一族缺陷的两种措辞 —— 并把「此宿主不允许插件从自己的回调上下文写配置」与「去哪改」(**DSH 设置 → 记忆**,或 profile 的配置补丁层)写进放弃时的报错、诊断信息与独立页提示;`POST /config` 同时回 `hostGuard` / `hint` 字段。③ **界面不再一律弹「已保存」(#21)**:客户端开始消费 `pendingOutcome` —— `queued` / `failed` 用**非成功样式**并说明「尚未确认生效」,`applied-direct` 明确写「本机没有 DSH 宿主,已直接写入共享配置」,只有真生效才显示「已保存」。④ 顺带修掉一个会让宿主/测试**无法退出**的清理竞态:心跳定时器与 pending 文件监听都是在异步 `import()` 之后才建立,若在它 resolve 之前就 dispose,回调就再没人清理(心跳定时器还额外 `unref()`)—— `tests/settings-compat.test.mjs` 用假 ctx 跑 `apply()` 时正是被它挂住的。新增 `tests/issue-fixes-3.test.mjs`(9 项),`npm test` 共 **238 项** |
|
package/index.js
CHANGED
|
@@ -19,6 +19,7 @@ import path from 'node:path'
|
|
|
19
19
|
import os from 'node:os'
|
|
20
20
|
import { spawn } from 'node:child_process'
|
|
21
21
|
import { fileURLToPath } from 'node:url'
|
|
22
|
+
import { randomBytes } from 'node:crypto'
|
|
22
23
|
const __filename = fileURLToPath(import.meta.url)
|
|
23
24
|
const PACKAGE_ROOT = path.resolve(path.dirname(__filename))
|
|
24
25
|
// 插件版本号(供「记忆配置」页面展示)
|
|
@@ -363,6 +364,19 @@ export function apply(ctx, config) {
|
|
|
363
364
|
// 否则 5 秒一轮的轮询会把日志刷爆)。
|
|
364
365
|
let lastDrainError = ''
|
|
365
366
|
let lastDrainErrorAt = 0
|
|
367
|
+
// 被本宿主 schema 过滤掉的键(升级期新独立页 + 旧宿主),随 drain 结果回报给独立端
|
|
368
|
+
let lastDrainIgnored = []
|
|
369
|
+
// ---- 方案 A:把「应用 pending」搬进宿主允许的上下文(2026-10-08 实测)---------------------------------
|
|
370
|
+
// 同一个 settings.update:从插件**回调**里调必然被 activation 守卫拒(HMR transactions cannot be
|
|
371
|
+
// nested,一个字节都不提交);从插件**HTTP 请求处理器**里调则真的提交(实测 revision 4→6)。
|
|
372
|
+
// 所以宿主把「自己的监听端口 + 一次性令牌」写进心跳,独立端保存后就地叫醒宿主:
|
|
373
|
+
// POST /memory-eternal/api/drain-pending(见下面的路由 + lib/config-sync.js 的 triggerHostDrain)。
|
|
374
|
+
const hostDrainToken = randomBytes(16).toString('hex')
|
|
375
|
+
let hostDrainPort = 0
|
|
376
|
+
const beatHostMarker = () => {
|
|
377
|
+
// 心跳失败不影响主流程:字段缺省时独立端会回落到「等宿主自己 drain」(旧行为)
|
|
378
|
+
try { writeHostMarker(process.env, { version: versionRef, port: hostDrainPort, token: hostDrainToken }) } catch { /* 心跳失败无妨 */ }
|
|
379
|
+
}
|
|
366
380
|
const reportDrainError = (error) => {
|
|
367
381
|
const dropped = !!(error && error.dropped)
|
|
368
382
|
const msg = String((error && error.message) || error || '未知错误')
|
|
@@ -397,6 +411,7 @@ export function apply(ctx, config) {
|
|
|
397
411
|
// 「HMR transactions cannot be nested」这种与配置内容无关的报错)。过滤后:旧宿主只应用它认识的
|
|
398
412
|
// 那部分,不认识的记一行日志等宿主升级后再生效。
|
|
399
413
|
const { known: filtered, ignored } = filterKnownKeys(patch, Object.keys(Config.dict || {}))
|
|
414
|
+
lastDrainIgnored = ignored
|
|
400
415
|
if (ignored.length) {
|
|
401
416
|
try { console.error('[memory-eternal] 待应用配置里有本宿主不认识的键(宿主升级后生效):' + ignored.join(',')) } catch { /* 日志失败无妨 */ }
|
|
402
417
|
}
|
|
@@ -412,7 +427,13 @@ export function apply(ctx, config) {
|
|
|
412
427
|
})
|
|
413
428
|
return { ...out, ignored }
|
|
414
429
|
}
|
|
430
|
+
/**
|
|
431
|
+
* 应用一次 pending,并把结果**返回**给调用方(不再只是写日志)。
|
|
432
|
+
* 返回结构同时服务两条路:5 秒轮询 / fs.watch(忽略返回值)与 drain 路由(要如实回报给独立端)。
|
|
433
|
+
* @returns {Promise<{applied:string[], ignored:string[], error:string, dropped:boolean}>}
|
|
434
|
+
*/
|
|
415
435
|
const drainPending = async () => {
|
|
436
|
+
lastDrainIgnored = []
|
|
416
437
|
try {
|
|
417
438
|
const { drainPendingConfig } = await import('./lib/config-sync.js')
|
|
418
439
|
const applied = await drainPendingConfig(process.env, applyPendingPatch)
|
|
@@ -421,10 +442,13 @@ export function apply(ctx, config) {
|
|
|
421
442
|
syncConfigFile()
|
|
422
443
|
syncAudit()
|
|
423
444
|
try { console.error('[memory-eternal] 已应用独立 Web 端的配置改动: ' + Object.keys(applied).join(',')) } catch { /* 日志失败无妨 */ }
|
|
445
|
+
return { applied: Object.keys(applied), ignored: lastDrainIgnored, error: '', dropped: false }
|
|
424
446
|
}
|
|
447
|
+
return { applied: [], ignored: lastDrainIgnored, error: '', dropped: false }
|
|
425
448
|
} catch (error) {
|
|
426
449
|
// 失败时保留文件由 config-sync.js 负责;这里只保证「失败可见」
|
|
427
450
|
reportDrainError(error)
|
|
451
|
+
return { applied: [], ignored: lastDrainIgnored, error: String((error && error.message) || error), dropped: Boolean(error && error.dropped) }
|
|
428
452
|
}
|
|
429
453
|
}
|
|
430
454
|
drainPending()
|
|
@@ -449,7 +473,7 @@ export function apply(ctx, config) {
|
|
|
449
473
|
// 直接写共享配置(绕过会与宿主的 volatile 配置分叉)。心跳必须定期刷新:进程被硬杀时
|
|
450
474
|
// 没有机会删文件,独立端靠「新鲜度 + pid 存活」两个条件判定,所以宁可多刷几次。
|
|
451
475
|
ctx.effect(() => {
|
|
452
|
-
const beat = () =>
|
|
476
|
+
const beat = () => beatHostMarker()
|
|
453
477
|
beat()
|
|
454
478
|
// unref:心跳只是「我在跑」的旁证,不该成为阻止进程退出的最后一根钉子
|
|
455
479
|
// (测试里以假 ctx 跑 apply() 时会因此挂住不退,实测过一次)。
|
|
@@ -990,6 +1014,10 @@ export function apply(ctx, config) {
|
|
|
990
1014
|
// 没有该服务的 profile(如 TUI)里子 fiber 保持 pending,主插件照常激活。
|
|
991
1015
|
// (DSH 自身的 dsh-client-modules 也是这么写的:ctx.inject(['webServer'], …))
|
|
992
1016
|
const registerApiRoutes = (webServer) => {
|
|
1017
|
+
// webServer 是晚到服务:它一就绪就把「监听端口」记下来并立刻刷新心跳 —— 独立端靠这个端口
|
|
1018
|
+
// 把 drain 请求发回来(port=0 时独立端自动回落到旧的「等宿主自己 drain」行为)。
|
|
1019
|
+
hostDrainPort = Number(webServer.port) || 0
|
|
1020
|
+
beatHostMarker()
|
|
993
1021
|
const handleApi = createApi({
|
|
994
1022
|
vaultDir, vaultRoots, getSettings: settings.get,
|
|
995
1023
|
// 自动沉淀运行轨迹 + 健康状态:供「用量/今日」页排查「为什么没写卡」,异常时页面顶部亮红。
|
|
@@ -1050,6 +1078,23 @@ export function apply(ctx, config) {
|
|
|
1050
1078
|
}
|
|
1051
1079
|
return
|
|
1052
1080
|
}
|
|
1081
|
+
if (pathname === API_PREFIX + '/drain-pending') {
|
|
1082
|
+
// 独立端保存后叫醒宿主(#方案 A):**HTTP 请求上下文**是宿主允许写配置的地方 ——
|
|
1083
|
+
// 实测同一个 settings.update 从回调里调必被 activation 守卫拒、从请求里调则真提交。
|
|
1084
|
+
// 两道闸门写在 lib/config-sync.js(有单测):只接受本机来源 + 心跳里的一次性令牌。
|
|
1085
|
+
const { drainRequestError } = await import('./lib/config-sync.js')
|
|
1086
|
+
const deny = drainRequestError(
|
|
1087
|
+
{ method: req.method, headers: req.headers, remoteAddress: req.socket && req.socket.remoteAddress },
|
|
1088
|
+
hostDrainToken,
|
|
1089
|
+
)
|
|
1090
|
+
if (deny) return json(res, deny === '需 POST' ? 405 : 403, { ok: false, error: deny })
|
|
1091
|
+
// 端口可能刚刚才就绪(webServer 是晚到服务):顺手把心跳里的端口刷新到最新
|
|
1092
|
+
if (Number(webServer.port) !== hostDrainPort) { hostDrainPort = Number(webServer.port) || 0; beatHostMarker() }
|
|
1093
|
+
const drained = await drainPending()
|
|
1094
|
+
if (drained.applied.length) hub.broadcast('config', { at: Date.now(), source: 'drain-request', applied: drained.applied })
|
|
1095
|
+
// 失败也回 200:调用方要读到具体原因,而不是一个没有 body 的 500
|
|
1096
|
+
return json(res, 200, { ok: !drained.error, ...drained })
|
|
1097
|
+
}
|
|
1053
1098
|
if (pathname === API_PREFIX + '/config') {
|
|
1054
1099
|
const method = req.method || 'GET'
|
|
1055
1100
|
if (method === 'GET') {
|
package/lib/api.js
CHANGED
|
@@ -600,16 +600,29 @@ export function createApi(deps = {}) {
|
|
|
600
600
|
}
|
|
601
601
|
if (bad.length) return json(res, 400, { ok: false, error: '字段值不合法:' + bad.join('、'), fields: bad })
|
|
602
602
|
if (!Object.keys(clean).length) return json(res, 400, { ok: false, error: '没有可写入的字段' })
|
|
603
|
-
const { writePendingConfig, readPendingConfig, waitPendingOutcome, clearPendingConfig, applySharedConfigDirect, describeApplyFailure } = await import('./config-sync.js')
|
|
603
|
+
const { writePendingConfig, readPendingConfig, waitPendingOutcome, clearPendingConfig, applySharedConfigDirect, describeApplyFailure, triggerHostDrain } = await import('./config-sync.js')
|
|
604
|
+
const { hostAlive, readHostMarker } = await import('./host-heartbeat.js')
|
|
604
605
|
// #16 建议 4:上一份改动是否已被判定失败?是的话这次不能再报「一切正常」——
|
|
605
606
|
// 否则用户第 N 次点击保存,看到的仍是成功,而问题一直在(其实是 settings.update 在报错)。
|
|
606
607
|
const before = readPendingConfig(process.env)
|
|
607
608
|
const out = writePendingConfig(process.env, clean)
|
|
608
609
|
hub.broadcast('config', { at: out.at, source: 'standalone', applied: Object.keys(clean) })
|
|
609
|
-
//
|
|
610
|
-
//
|
|
611
|
-
//
|
|
612
|
-
|
|
610
|
+
// 方案 A(首选):宿主活着、且心跳里带了端口 + 令牌时,**直接请宿主在 HTTP 请求上下文里
|
|
611
|
+
// 应用**。为什么不继续等宿主自己 drain:宿主那条 drain 挂在回调上下文(setInterval /
|
|
612
|
+
// fs.watch),在带 activation 守卫的宿主上必然被拒(实测与 patch 内容无关)—— 等不来结果。
|
|
613
|
+
// 叫醒失败(旧宿主没这条路由 / 端口对不上 / 超时)不是错误:退回 issue #21 的等待路径。
|
|
614
|
+
let outcome = 'queued'
|
|
615
|
+
let hostDrainError = ''
|
|
616
|
+
const aliveHost = hostAlive(process.env) ? readHostMarker(process.env) : null
|
|
617
|
+
if (aliveHost && aliveHost.port && aliveHost.token) {
|
|
618
|
+
const call = await triggerHostDrain({ port: aliveHost.port, token: aliveHost.token, timeoutMs: 2500 })
|
|
619
|
+
if (call.ok) outcome = 'applied'
|
|
620
|
+
else if (call.status === 404 || call.status === 405 || call.status === 0) hostDrainError = call.error || ''
|
|
621
|
+
else { outcome = 'failed'; hostDrainError = call.error || '' }
|
|
622
|
+
}
|
|
623
|
+
// 回落(issue #21 建议 3):别急着报「一切正常」。宿主对 pending 文件有 fs.watch(毫秒级),
|
|
624
|
+
// 稍等一下回读,就能分清「真的应用了」还是「只是排上队了」。
|
|
625
|
+
if (outcome === 'queued') outcome = await waitPendingOutcome(process.env, { timeoutMs: 800 })
|
|
613
626
|
// issue #21 的宿主无关路径:**本机没有活着的 DSH 宿主**时(Codex / Claude Code /
|
|
614
627
|
// 只跑 dsh-memory serve 的用户),没有任何进程会来 drain pending 文件 —— 那句
|
|
615
628
|
// 「DSH 未运行时下次启动生效」在那类环境下永远不会兑现。这时直接原子写共享配置
|
|
@@ -617,7 +630,6 @@ export function createApi(deps = {}) {
|
|
|
617
630
|
// 宿主活着时绝不走这条:共享文件是宿主的派生镜像,绕过它直写会被下一次 syncConfigFile 盖掉。
|
|
618
631
|
let appliedDirect = false
|
|
619
632
|
if (outcome === 'queued') {
|
|
620
|
-
const { hostAlive } = await import('./host-heartbeat.js')
|
|
621
633
|
if (!hostAlive(process.env)) {
|
|
622
634
|
try {
|
|
623
635
|
applySharedConfigDirect(process.env, clean)
|
|
@@ -634,18 +646,20 @@ export function createApi(deps = {}) {
|
|
|
634
646
|
// 「DSH 端会自动同步」:直说去哪儿改,否则用户会一直看到一个永远不会生效的「已排队」。
|
|
635
647
|
const knownGuard = Boolean(before && before.dropped && hostGuard)
|
|
636
648
|
const note = outcome === 'applied'
|
|
637
|
-
? '
|
|
649
|
+
? '宿主已在请求上下文里应用该改动(点保存即生效)。'
|
|
638
650
|
: outcome === 'applied-direct'
|
|
639
651
|
? '本机没有 DSH 宿主在运行,已直接写入共享配置(hooks / MCP / 独立页立即生效)。'
|
|
640
652
|
: outcome === 'failed'
|
|
641
|
-
? '改动已写入,但宿主侧应用失败:' + (hostGuard ? advice.hint : '详见「反馈异常」里的诊断信息。')
|
|
653
|
+
? '改动已写入,但宿主侧应用失败:' + (hostDrainError || (hostGuard ? advice.hint : '详见「反馈异常」里的诊断信息。'))
|
|
642
654
|
: knownGuard
|
|
643
655
|
? '这个宿主不允许插件从回调上下文写配置(与配置内容无关):请到「DSH 设置 → 记忆」里改同一项。本次改动已在独立端重新排队,宿主升级/重启后仍会自动应用。'
|
|
644
656
|
: '已写入待应用:DSH 端会自动同步(DSH 未运行时下次启动生效)'
|
|
645
657
|
return json(res, 200, {
|
|
646
|
-
ok: true, applied: Object.keys(clean), pending: appliedDirect ? [] : Object.keys(clean), retried: false, at: out.at,
|
|
658
|
+
ok: true, applied: Object.keys(clean), pending: (appliedDirect || outcome === 'applied') ? [] : Object.keys(clean), retried: false, at: out.at,
|
|
647
659
|
pendingOutcome: outcome,
|
|
648
660
|
appliedDirect,
|
|
661
|
+
// 方案 A 的现场:宿主叫醒失败的原因(旧宿主没路由 / 令牌不匹配 / 应用报错)如实回给界面
|
|
662
|
+
hostDrainError: hostDrainError || undefined,
|
|
649
663
|
hostGuard: hostGuard || undefined,
|
|
650
664
|
hint: hostGuard ? advice.hint : undefined,
|
|
651
665
|
warning: stale
|