memory-eternal 0.10.16 → 0.10.18

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.
@@ -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.16",
4
+ "version": "0.10.18",
5
5
  "description": "记忆核心(Memory Eternal):对话结束后自动沉淀知识卡到本地记忆库(~/.dsh/memory-vault),支持检索 / 召回 / 审核中心。",
6
6
  "author": {
7
7
  "name": "EternalNight996",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memory-eternal",
3
- "version": "0.10.16",
3
+ "version": "0.10.18",
4
4
  "description": "记忆核心(Memory Eternal):对话结束后自动沉淀知识卡到本地记忆库(~/.dsh/memory-vault),支持检索 / 召回 / 审核中心。",
5
5
  "author": {
6
6
  "name": "EternalNight996",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memory-eternal",
3
- "version": "0.10.16",
3
+ "version": "0.10.18",
4
4
  "description": "记忆核心(Memory Eternal):对话结束后自动沉淀知识卡到本地记忆库(~/.dsh/memory-vault),支持检索 / 召回 / 审核中心。",
5
5
  "author": {
6
6
  "name": "EternalNight996",
package/README.en.md CHANGED
@@ -361,14 +361,16 @@ 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.16** | 2026-10-09 | **`--reap` no longer says "nothing was reaped" when it did reap something** (reported by alario-tang, measured on macOS with 0.10.13). The scene: after `kill -9` on the watchdog leaves a `PPID=1` orphan web, `dsh-memory watchdog --reap --port 7999` really freed the port and really killed the web, yet the screen read "**scanned 1 watchdog process, cleaned 0 orphans**" — because 0.10.13 made `scanned` include webs while `webKilled` was never printed. Fix: a pure `formatReapSummary()` unifies the wording (scan counts split into `watchdog A / web B`, cleaned entries listed per kind with pids), `reapStaleWatchdogs` gains `scannedWatchdogs` / `scannedWebs` (`scanned` stays their sum for older callers), and the CLI uses it. Two checks added (never say "cleaned 0 orphans" when a web was reaped / scan counts must be split), and `tests/watchdog-orphan-web.test.mjs` now records the reproduction note: **such an orphan can only be created with `kill -9` — SIGTERM runs the watchdog's graceful exit, which also stops the web it spawned** (the same coin as #19's "on Windows SIGTERM is unconditional termination"). `npm test` now runs **308 checks** |
365
- | **v0.10.15** | 2026-10-09 | **Three loose ends left by the upgrades.** ① **Recommended plan A no longer reinstalls the call we just saved**: v0.10.12 raised the default distiller output cap to 4000 (removing the wasted "hit 2000, then re-run the same candidate at 4000"), but the panel one-click preset "A (light)" still hard-coded `captureMaxTokens: 2000` — one click brought the retry back; it now matches the default, guarded by a source check (`tests/csrf-origin.test.mjs` asserts planA cap === default). ② **The README "stall watchdog" description now matches the new rule** (both languages): the old wording said "if no turn-stopping event arrives for 15 minutes", while it is actually "**zero stops since this process started** plus turns still starting inside the last 20 minutes → record a `warn`", and it now states that an in-flight **long turn is never mistaken for a stall**. ③ While at it, the other log wording `轮次收尾事件未触发(agent/turn-stopping 没到)` was verified to have last fired on **2026-09-24** — a historical remnant of the old heuristic with no such code path left today (nothing to fix). `npm test` now runs **306 checks** |
364
+ | **v0.10.18** | 2026-10-12 | **"Save config" was being rejected unconditionally (issue #35)**. The scene (Dzifxazy): a fresh install, change nothing, hit save → **HTTP 400 "field must not be empty: secretHint"**, the whole submission rejected, so none of the numbers or toggles the user did change were stored; the wording also pointed troubleshooting at "some numeric box left blank". Root cause: the host-side pre-check treated **any empty string** as "numeric field not filled" and only exempted a hard-coded list (vaultDir / captureProvider / captureModel / recallEmbedding / activeVault) — while `secretHint`'s default **is** an empty string (`z.string().default('')`) and was not on that list; since the panel submits the whole form, every save hit it. Fix: new `emptyNumericFields(patch, Config.dict)` decides which keys were cleared by looking at the schema (`type === 'number'`), and the hard-coded list is gone (future nullable string fields need nobody to remember an exemption). Adds `tests/config-validate.test.mjs` (2 checks: empty strings only count for number fields — including a brand-new string field that appears on no list as a regression nail, plus a source guard against reintroducing the list). `npm test` now runs **312 checks** |
365
+ | **v0.10.17** | 2026-10-12 | **Root fix for the "resident is serving but nobody manages it" self-contradiction (issue #28)**. The scene: after upgrading to 0.10.16 the reporter had no resident instance at all, and the panel showed **neither a restart button nor any hint** (the banner only rendered when `vc.stale`, which is false when the host and the port report the same version), only an "auto-capture anomaly"; reproduced the other half locally: after `stop` the port was **still serving**, the lock was empty, and `status` said "no registered watchdog" — because that web had been spawned directly by the host's `ensureWeb()` (no `webPid` in the lock) and `stop` only killed the watchdog. Five fixes: ① **`/version-check` now reports `resident.managed`** (a live watchdog in the lock is what makes it managed) — "serving" is not "managed"; ② **panel**: both the **not-running** and the **unmanaged** resident show a restart button with dedicated wording ("not running" / "unmanaged") instead of only the version-drift case; ③ **`/restart-self`**: `alreadyCurrent` now requires a version match **and** a live manager, otherwise it replaces as usual (an orphan web does not get to stay just because its version is new); ④ **an explicit `dsh-memory stop` frees the port** (`forcePortOccupant: true`, same gate as ever: only processes `looksLikeOurWeb` recognises are terminated); ⑤ **the self-check now demands a fresh takeover**: `restartResident` no longer accepts "the port serves the expected version" (before and after an upgrade the version is often identical, so the **old** instance fooled the check — measuring "self-check passed (waited 0s)" immediately followed by `status` reporting no registered watchdog); the new `waitForTakeover()` requires a matching version **and** a live watchdog whose pid is not in the pre-replacement set, and failure reasons are split accordingly into "wrong version" vs "nobody took over". Measured on this machine in the same scenario: `41672/17432` before → `28136/31976` after, `restart` waited 13s and passed, and the **very next `status` showed the new watchdog registered** (no contradiction). `npm test` now runs **310 checks** |
366
366
 
367
367
  <details>
368
- <summary>Older release notes (v0.10.14 and earlier · 41 releases, click to expand)</summary>
368
+ <summary>Older release notes (v0.10.16 and earlier · 43 releases, click to expand)</summary>
369
369
 
370
370
  | Version | Date | Highlights |
371
371
  |---|---|---|
372
+ | **v0.10.16** | 2026-10-09 | **`--reap` no longer says "nothing was reaped" when it did reap something** (reported by alario-tang, measured on macOS with 0.10.13). The scene: after `kill -9` on the watchdog leaves a `PPID=1` orphan web, `dsh-memory watchdog --reap --port 7999` really freed the port and really killed the web, yet the screen read "**scanned 1 watchdog process, cleaned 0 orphans**" — because 0.10.13 made `scanned` include webs while `webKilled` was never printed. Fix: a pure `formatReapSummary()` unifies the wording (scan counts split into `watchdog A / web B`, cleaned entries listed per kind with pids), `reapStaleWatchdogs` gains `scannedWatchdogs` / `scannedWebs` (`scanned` stays their sum for older callers), and the CLI uses it. Two checks added (never say "cleaned 0 orphans" when a web was reaped / scan counts must be split), and `tests/watchdog-orphan-web.test.mjs` now records the reproduction note: **such an orphan can only be created with `kill -9` — SIGTERM runs the watchdog's graceful exit, which also stops the web it spawned** (the same coin as #19's "on Windows SIGTERM is unconditional termination"). `npm test` now runs **308 checks** |
373
+ | **v0.10.15** | 2026-10-09 | **Three loose ends left by the upgrades.** ① **Recommended plan A no longer reinstalls the call we just saved**: v0.10.12 raised the default distiller output cap to 4000 (removing the wasted "hit 2000, then re-run the same candidate at 4000"), but the panel one-click preset "A (light)" still hard-coded `captureMaxTokens: 2000` — one click brought the retry back; it now matches the default, guarded by a source check (`tests/csrf-origin.test.mjs` asserts planA cap === default). ② **The README "stall watchdog" description now matches the new rule** (both languages): the old wording said "if no turn-stopping event arrives for 15 minutes", while it is actually "**zero stops since this process started** plus turns still starting inside the last 20 minutes → record a `warn`", and it now states that an in-flight **long turn is never mistaken for a stall**. ③ While at it, the other log wording `轮次收尾事件未触发(agent/turn-stopping 没到)` was verified to have last fired on **2026-09-24** — a historical remnant of the old heuristic with no such code path left today (nothing to fix). `npm test` now runs **306 checks** |
372
374
  | **v0.10.14** | 2026-10-09 | **The "auto-capture anomaly: turn-stop event may be broken" false alarm is fixed at the root.** The scene: during a long turn lasting tens of minutes, `agent/inbox/claimed` fires **repeatedly inside one turn**, while `turn-stopping` only arrives when the turn really ends — so any 20-minute window necessarily shows "≥3 starts, 0 stops", and the check mistook an **in-flight** turn for a renamed event, pushing a red alert at the user (the "39/18" line measured in this session came from exactly that). Fix: one hard precondition — "the event was renamed/removed" is a **process-lifetime** property, not a window property: if this run has seen even one stop, the wiring works and 0 stops in the window merely means the current turn is still running (`evaluateStall` gains `everStopped` and reports `reason: wiring-proven | no-stop-ever`); only "zero stops since this process started **and** continuous starts inside the window" alerts. At the same time the alert is downgraded from `fail` to `warn` — an inference no longer paints capture health red nor masquerades as an auto-capture anomaly (real failures still turn it red on their own). Three checks in `tests/stall.test.mjs` lock the old and new behaviour (long turn stays quiet / a real rename alerts / `everStopped` injectable). `npm test` now runs **305 checks** |
373
375
  | **v0.10.13** | 2026-10-09 | **Orphan `web.js` processes are finally reachable (#11 item 1, the POSIX branch)**. The scene (alario-tang, macOS arm64): the watchdog died first and the lock file was gone, leaving a stale web on port 7999 with `PPID=1`; `stop`/`restart` read lock slots and found no target, `watchdog --reap` only matched `watchdog.js`, and the panel button rides the same path — so drift could be reported but never healed, and the user had to `kill` by hand. Two concrete gaps: ① **on POSIX the port occupant only came back as a process name** — `lsof -Fpcn` yields `node` for the `c` field rather than a command line, while the "should we reap it" test (`looksLikeOurWeb`) requires `memory-eternal` or this install web.js absolute path in the command line → always false → the fallback loops in `stopWatchdogs` / `clearResidentForTakeover` **never killed anything** (Windows uses `Get-CimInstance`, which returns the full command line, so this box never showed it); now a follow-up `ps -p <pid> -o args=` fills in the command line, falling back to the old behaviour when unavailable (better to miss than to misfire), with an injectable `platform` for tests; ② **`--reap` now covers our own webs**: a `listWebs` branch reaps "same port + our web + **its keeper is gone** (that slot watchdog is not alive) + outside the protected set (keepPid / current pid / parent / a `--reap` sibling)", while **a web whose keeper is alive is never touched**, and other ports or foreign `web.js` processes are never killed. Adds `tests/watchdog-orphan-web.test.mjs` (5 checks) and makes two watchdog-only cases state `listWebs: []` explicitly (otherwise reap enumerates the real process table). `npm test` now runs **302 checks** |
374
376
  | **v0.10.12** | 2026-10-09 | **Two quiet things that either burn money or leave a hole every day.** ① **Default distiller output cap: 2000 → 4000 (the hard ceiling)**: the logs kept showing "hit the maxTokens=2000 cap → retry the same candidate at 4000", and each occurrence **wasted one LLM call** (hit the cap, then re-run the same candidate at 4000). `maxTokens` is only a ceiling and billing follows what is actually generated, so the default now sits at the hard ceiling: those turns finish in one call; `maxTokenLadder` stays for users who **explicitly** lower `captureMaxTokens`. ② **A cross-site write gate on the plugin API**: `/memory-eternal/api/*` is unauthenticated, so any local web page could POST `/config`, `/audit/approve`, `/write`… New `crossOriginError()` — state-changing requests (non-GET/HEAD/OPTIONS) must be **same-origin with the Host of the request itself**: browsers always send `Origin` on cross-site requests (a mismatch returns 403 `CROSS_ORIGIN_BLOCKED`), while same-origin pages (DSH settings/memory page, the standalone page) and LAN access stay unaffected, curl / CLI / MCP calls without `Origin` keep working, and desktop-shell schemes like `app://` are allowed so nothing breaks. Both the intercepted host routes (`/config` POST, `/restart-self`, `/mcp/action`…) and the shared api entry carry the gate. Adds `tests/csrf-origin.test.mjs` (4 checks: decision matrix incl. LAN / no Origin / null / app://, a cross-site POST getting 403 without reaching business logic, both entry points present, and the default cap equal to the hard ceiling with no ladder retry). `npm test` now runs **297 checks** |
package/README.md CHANGED
@@ -393,14 +393,16 @@ curl http://127.0.0.1:7999/memory-eternal/api/web-info # 独立 web 是否活
393
393
 
394
394
  | 版本 | 日期 | 关键改动 |
395
395
  |---|---|---|
396
- | **v0.10.16** | 2026-10-09 | **`--reap` 的输出不再「明明收了却说没收」**(alario-tang 反馈,macOS + 0.10.13 实测)。现场:`kill -9` 掉 watchdog 造出 `PPID=1` 的孤儿 web 后跑 `dsh-memory watchdog --reap --port 7999`,端口确实被释放、web 确实被收掉,屏幕上却写着「**扫描到 1 个 watchdog 进程,清理孤儿 0 个**」—— 因为 0.10.13 让 `scanned` 含了 web、而 `webKilled` 一个字都没打印。修法:新增纯函数 `formatReapSummary()` 统一口径(扫描数拆成 `watchdog A / web B`、清理项按类分别列出 pid),`reapStaleWatchdogs` 返回值补 `scannedWatchdogs` / `scannedWebs`(`scanned` 仍是两者之和,兼容旧调用方),CLI 改用它。测试补 2 项(收了 web 时不许再说「清理孤儿 0 个」/ 扫描计数必须拆开),并在 `tests/watchdog-orphan-web.test.mjs` 头部记下复现要点:**这类孤儿只能用 `kill -9` 造 —— SIGTERM 会走 watchdog 的优雅退出、把它拉起的 web 一起停掉**(与 #19 里「Windows 上 SIGTERM 即无条件终止」是同一枚硬币的两面)。`npm test` 共 **308 项** |
397
- | **v0.10.15** | 2026-10-09 | **三处「升级留下的尾巴」**。① **推荐方案 A 不再把刚省掉的调用装回来**:v0.10.12 把「蒸馏输出上限」默认提到 4000(省掉「先撞 2000、再以 4000 重跑同一候选」那次白烧),但面板里「一键推荐配置 A(轻量)」仍写死 `captureMaxTokens: 2000` —— 用户点一下就又把重试装回来;现已与默认对齐,并加源码守卫(`tests/csrf-origin.test.mjs` 断言 planA 的上限 === 默认值)。② **README 的「停滞兜底」描述与新判据对齐**(中英两份):旧文案写「15 分钟收不到收尾事件就报」,现在是「**本次运行至今 0 收尾** + 最近 20 分钟内持续有轮次开始 → 记一条 `warn`」,并写明正在进行的**长回合不会被误判**。③ 顺带核实日志里另一种措辞 `轮次收尾事件未触发(agent/turn-stopping 没到)` 最后出现于 **2026-09-24**,属于旧判据的历史残留,现行代码里已无这条路径(无需修复)。`npm test` 共 **306 项** |
396
+ | **v0.10.18** | 2026-10-12 | **「保存配置」被无条件拒绝的修复(issue #35)**。现场(Dzifxazy):全新安装、什么都不动、直接点保存 → **HTTP 400「字段不能为空:secretHint」**,整单被拒,用户改的数字与开关**一个都存不进去**;文案还会把排查方向误导到「某个数字格子没填」。根因:宿主侧的前置校验把**任何空字符串**都当成「数字没填」,只豁免一份硬编码名单(vaultDir / captureProvider / captureModel / recallEmbedding / activeVault)—— 而 `secretHint` 的默认值**就是**空串(`z.string().default('')`)又不在名单里;面板提交的是整张表单,于是每次保存必然命中。修法:新增 `emptyNumericFields(patch, Config.dict)`,**按 schema 的 `type === 'number'` 判定**「哪些键被清空」,彻底删掉硬编码名单(以后新增可空字符串字段不需要谁记得去补)。新增 `tests/config-validate.test.mjs`(2 项:空串只对 number 字段报「没填」——里面放了一个「不在任何名单里」的新字符串字段做回归钉子;源码守卫不许退回名单)。`npm test` 共 **312 项** |
397
+ | **v0.10.17** | 2026-10-12 | **「常驻实例在服务、却没人托管」这类状态自相矛盾的根治(issue #28)**。现场:用户升级到 0.10.16 后常驻实例没起来,面板**既没有重启按钮也没有任何提示**(横幅只在 `vc.stale` 时渲染,而宿主与端口版本号一致时 `stale` 为假),只看到一条「自动沉淀异常」;本机复现出另一半:`stop` 之后端口上**还活着**、锁里却空了、`status` 说「没有登记的看门狗」—— 因为端口上那个 web 是宿主 `ensureWeb()` 直接拉起的(锁里没有 `webPid`),`stop` 只杀了看门狗。五处一起修:① **`/version-check` 报出 `resident.managed`**(锁里有活着的看门狗才算被托管)—— 「在服务」≠「被托管」;② **面板**:常驻实例**没在跑**或**没被托管**时都显示重启按钮与专门文案(「未运行」/「未托管」),不再只认「版本落后」这一种;③ **`/restart-self`**:`alreadyCurrent` 必须同时满足「版本一致 **且** 被托管」,否则照常替换(孤儿 web 版本再新也得重新托管);④ **显式 `dsh-memory stop` 把端口腾出来**(`forcePortOccupant: true`,闸门不变:只终止 `looksLikeOurWeb` 认得出是自家的进程);⑤ **自检改为「新实例接管」**:`restartResident` 不再只看「端口服着期望版本」(升级前后版本号常常相同、旧实例本就是这个版本 → 自检会被**旧实例**骗过,实测打印「自检通过(等待 0s)」后紧接着 `status` 说没有登记的看门狗),新增 `waitForTakeover()`:必须「版本一致 **且** 锁里出现一个不在替换前 pid 集合里的活看门狗」才算成功,失败原因也据此分离成「版本不对」与「没人接管」。本机同一现场实测:替换前 `41672/17432` → 替换后 `28136/31976`,`restart` 等待 13s 后自检通过,**紧接着的 `status` 立刻显示新看门狗已登记**(不再矛盾)。`npm test` 共 **310 项** |
398
398
 
399
399
  <details>
400
- <summary>更早的更新日志(v0.10.14 及以前 · 共 41 版,点开查看)</summary>
400
+ <summary>更早的更新日志(v0.10.16 及以前 · 共 43 版,点开查看)</summary>
401
401
 
402
402
  | 版本 | 日期 | 关键改动 |
403
403
  |---|---|---|
404
+ | **v0.10.16** | 2026-10-09 | **`--reap` 的输出不再「明明收了却说没收」**(alario-tang 反馈,macOS + 0.10.13 实测)。现场:`kill -9` 掉 watchdog 造出 `PPID=1` 的孤儿 web 后跑 `dsh-memory watchdog --reap --port 7999`,端口确实被释放、web 确实被收掉,屏幕上却写着「**扫描到 1 个 watchdog 进程,清理孤儿 0 个**」—— 因为 0.10.13 让 `scanned` 含了 web、而 `webKilled` 一个字都没打印。修法:新增纯函数 `formatReapSummary()` 统一口径(扫描数拆成 `watchdog A / web B`、清理项按类分别列出 pid),`reapStaleWatchdogs` 返回值补 `scannedWatchdogs` / `scannedWebs`(`scanned` 仍是两者之和,兼容旧调用方),CLI 改用它。测试补 2 项(收了 web 时不许再说「清理孤儿 0 个」/ 扫描计数必须拆开),并在 `tests/watchdog-orphan-web.test.mjs` 头部记下复现要点:**这类孤儿只能用 `kill -9` 造 —— SIGTERM 会走 watchdog 的优雅退出、把它拉起的 web 一起停掉**(与 #19 里「Windows 上 SIGTERM 即无条件终止」是同一枚硬币的两面)。`npm test` 共 **308 项** |
405
+ | **v0.10.15** | 2026-10-09 | **三处「升级留下的尾巴」**。① **推荐方案 A 不再把刚省掉的调用装回来**:v0.10.12 把「蒸馏输出上限」默认提到 4000(省掉「先撞 2000、再以 4000 重跑同一候选」那次白烧),但面板里「一键推荐配置 A(轻量)」仍写死 `captureMaxTokens: 2000` —— 用户点一下就又把重试装回来;现已与默认对齐,并加源码守卫(`tests/csrf-origin.test.mjs` 断言 planA 的上限 === 默认值)。② **README 的「停滞兜底」描述与新判据对齐**(中英两份):旧文案写「15 分钟收不到收尾事件就报」,现在是「**本次运行至今 0 收尾** + 最近 20 分钟内持续有轮次开始 → 记一条 `warn`」,并写明正在进行的**长回合不会被误判**。③ 顺带核实日志里另一种措辞 `轮次收尾事件未触发(agent/turn-stopping 没到)` 最后出现于 **2026-09-24**,属于旧判据的历史残留,现行代码里已无这条路径(无需修复)。`npm test` 共 **306 项** |
404
406
  | **v0.10.14** | 2026-10-09 | **「自动沉淀异常:轮次收尾事件疑似失效」误报根治**。现场:几十分钟的长回合里,`agent/inbox/claimed` 在**一轮对话中途**也会多次触发,而 `turn-stopping` 要等回合真的结束才来 —— 于是 20 分钟窗口必然「开始 ≥3、收尾 0」,判据把**还在进行中**的轮次当成「事件被改名」,直接把红警报推给用户(本会话实测那条「累计 39/18」就是这么来的)。修法:加一条硬前提 —— 「事件被改名/移除」是**进程生命周期**属性,不是窗口属性:本次运行里只要收到过哪怕一次收尾,通路就是好的,窗口内 0 收尾只说明当前轮次还没结束(`evaluateStall` 新增 `everStopped`,并在返回值里给出 `reason: wiring-proven | no-stop-ever`);只有「本次运行至今 **0** 收尾 + 窗口内持续开始」才报警。同时把这条**推断**从 `fail` 降级为 `warn` —— 不再由它把沉淀健康状态翻红、更不冒充「自动沉淀异常」(真故障会由写卡失败自己报红)。`tests/stall.test.mjs` 新增 3 项锁住新旧行为(长回合不报 / 真改名要报 / `everStopped` 可注入)。`npm test` 共 **305 项** |
405
407
  | **v0.10.13** | 2026-10-09 | **孤儿 `web.js` 终于收得掉了(#11「待做 1」的 POSIX 分支)**。现场(alario-tang,macOS arm64):watchdog 先死 + 锁文件丢失 → 端口 7999 上留着一个 `PPID=1` 的旧 web;`stop`/`restart` 读锁槽找不到目标、`watchdog --reap` 只认 `watchdog.js`、面板按钮又走同一条链路 → 漂移只能报不能自愈,用户被迫手动 `kill`。查出**两个具体缺口**:① **POSIX 侧端口占用者只拿到进程名** —— `lsof -Fpcn` 的 `c` 字段是 `node` 而不是命令行,而「要不要收」的判据(`looksLikeOurWeb`)要求命令行含 `memory-eternal` 或本机 `web.js` 绝对路径 → 判据恒假 → `stopWatchdogs` / `clearResidentForTakeover` 的兜底循环**一次都不杀**(Windows 走 `Get-CimInstance` 拿完整命令行,所以本机从未暴露);现在补一次 `ps -p <pid> -o args=`,拿不到才退回旧行为(宁可不杀、也不误杀),并让 `platform` 可注入以便单测。② **`--reap` 扩到自家 web**:新增 `listWebs` 分支,把「同端口 + 是自家 web + **托管者已死**(该槽 watchdog 不存活)+ 不在保护集合(keepPid / 当前 pid / 父进程 / 带 `--reap` 的同类)」的 web 一并回收;**托管者还活着的 web 一律不动**,别的端口、别人的 `web.js` 一个不杀。新增 `tests/watchdog-orphan-web.test.mjs`(5 项),并把两条只谈 watchdog 的旧用例显式声明 `listWebs: []`(否则 reap 会去列本机真实进程表)。`npm test` 共 **302 项** |
406
408
  | **v0.10.12** | 2026-10-09 | **两件「不显眼但每天都在扣钱 / 留洞」的事**。① **蒸馏输出上限默认 2000 → 4000(= 硬顶)**:日志实测高频出现「输出撞到 maxTokens=2000 上限 → 以 4000 重试同一候选」,每一次都是**白烧一次 LLM 调用**(先撞顶、再拿 4000 重跑同一个候选)。maxTokens 只是上限、按实际生成量计费,所以默认直接放到硬顶:同样的轮次一次调用就够;`maxTokenLadder` 仍保留给**显式**把 `captureMaxTokens` 调小的用户。② **插件 API 加跨站写入闸门**:`/memory-eternal/api/*` 无鉴权,本机任意网页都能 POST `/config`、`/audit/approve`、`/write`… 新增 `crossOriginError()` —— 状态变更请求(非 GET/HEAD/OPTIONS)必须与请求自身的 `Host` **同源**:浏览器跨站请求一定带 `Origin`(与 Host 不同源即 403 `CROSS_ORIGIN_BLOCKED`);同源页面(DSH 设置页/记忆页、独立页)与局域网访问都同源不受影响;没有 Origin 的 curl / CLI / MCP 照常放行;`app://` 这类桌面壳自定义 scheme 也放行以免误伤。宿主自己的拦截路由(`/config` POST、`/restart-self`、`/mcp/action`…)与共享 api 入口**都**装了这道闸门。新增 `tests/csrf-origin.test.mjs`(4 项:判定矩阵含局域网 / 无 Origin / null / app://、跨站 POST 被 403 且不进业务逻辑、两处入口都在、默认上限=硬顶且无 ladder 重试)。`npm test` 共 **297 项** |
@@ -167,7 +167,10 @@ async function main() {
167
167
  const { stopWatchdogs } = await import('../lib/watchdog.js')
168
168
  const port = argOf('--port')
169
169
  // checkPort:停完再问一次「端口上还有谁在服务」,把「停了却还占着端口」显式说出来(#23)
170
- const out = await stopWatchdogs({ port: port || undefined, checkPort: true })
170
+ // forcePortOccupant:**显式 stop 就该把端口腾出来** —— 端口上那个自家 web 很可能不是看门狗拉起的
171
+ // (宿主 ensureWeb 直接起、或看门狗被 kill -9 后残留的孤儿),锁里没有 webPid 就谁也收不到它。
172
+ // 闸门没松:只有 looksLikeOurWeb(命令行) 为真才会终止,认不出的照旧只告警。
173
+ const out = await stopWatchdogs({ port: port || undefined, checkPort: true, forcePortOccupant: true })
171
174
  if (has('--json')) { console.log(JSON.stringify({ ok: out.failed.length === 0, ...out }, null, 2)); return }
172
175
  if (!out.stopped.length && !out.failed.length && !out.webStopped.length) console.log('没有需要停止的常驻看门狗(锁里没有活着的实例)。')
173
176
  if (out.stopped.length) console.log(`已停止看门狗:pid ${out.stopped.join(', ')}`)
package/index.js CHANGED
@@ -32,7 +32,7 @@ import { evaluateStall, MAX_STAMPS } from './lib/stall.js'
32
32
  import { resolveVaultDir, currentWorkspace } from './lib/vault-resolve.js'
33
33
  import { createHub } from './lib/sse.js'
34
34
  import { summarizeTurn, summarizeTurnDetailed, routeCandidates, extractLastTurn, sliceNewEvents, sessionEvents, sessionEventApi, createCaptureHealth, resolveRoute, captureCard, captureUpdate, pickNeighbors, hasUsableContent, deriveTitle, looksTruncated, maxTokenLadder, DEFAULT_CAPTURE_MAX_TOKENS, MAX_CAPTURE_MAX_TOKENS } from './lib/capture.js'
35
- import { createApi, json, encodeBody, crossOriginError } from './lib/api.js'
35
+ import { createApi, json, encodeBody, crossOriginError, emptyNumericFields } from './lib/api.js'
36
36
  import { appendCaptureLog, readCaptureLog, rotateCaptureLog } from './lib/capture-log.js'
37
37
  import { missingWebAssets, missingAssetsReason } from './lib/web-assets.js'
38
38
  import { writeHostMarker, clearHostMarker } from './lib/host-heartbeat.js'
@@ -1183,9 +1183,12 @@ export function apply(ctx, config) {
1183
1183
  const clean = {}
1184
1184
  for (const k of Object.keys(patch)) { if (allowed.has(k)) clean[k] = patch[k] }
1185
1185
  if (Object.keys(clean).length === 0) return json(res, 400, { ok: false, error: '无可写入字段' })
1186
- // 数值字段前置校验:空串/NaN/非数字直接给「哪个字段不合法」,而不是让宿主 schema 抛错
1187
- const badNum = Object.entries(clean).filter(([k, v]) => typeof v === 'string' && v.trim() === '' && !/^(vaultDir|captureProvider|captureModel|recallEmbedding|activeVault)$/.test(k))
1188
- if (badNum.length) return json(res, 400, { ok: false, error: '字段不能为空:' + badNum.map(([k]) => k).join('、'), fields: badNum.map(([k]) => k) })
1186
+ // 数值字段前置校验:**只对 schema 里声明为 number 的键**判「空串」—— 面板提交的是整张表单,
1187
+ // 空串本身是很多字符串字段的合法值(secretHint 的默认值就是 ''),旧实现把任何空串都当
1188
+ // 「数字没填」并用一份硬编码豁免名单兜,于是「全新安装 + 什么都不动 + 点保存」必然 400
1189
+ // 「字段不能为空:secretHint」→ 整单被拒(issue #35)。判定改读 schema,不再维护名单。
1190
+ const badNum = emptyNumericFields(clean, Config.dict)
1191
+ if (badNum.length) return json(res, 400, { ok: false, error: '字段不能为空:' + badNum.join('、'), fields: badNum })
1189
1192
  if (typeof settings.update === 'function') {
1190
1193
  try {
1191
1194
  const writeResult = await settings.update(clean, expectedRevision)
@@ -1366,7 +1369,9 @@ export function apply(ctx, config) {
1366
1369
  // 自愈刚把实例挪到配置端口:立刻收敛 web-info,别再让面板指向被收掉的那个端口(issue #29)
1367
1370
  if (out.ok) refreshWebInfo({ url: `http://127.0.0.1:${wdPort}`, port: wdPort, spawned: false })
1368
1371
  console.error(`[memory-eternal] ${done}`)
1369
- logCapture('system', out.ok ? 'boot' : 'fail', done)
1372
+ // 用 warn 不用 fail:这是**服务管理**的失败(常驻实例没起来),不是沉淀管线坏了。
1373
+ // 过去这里记 fail 会把沉淀健康状态翻红,用户看到的是「自动沉淀异常」(实测 #28 的现场)。
1374
+ logCapture('system', out.ok ? 'boot' : 'warn', done)
1370
1375
  return
1371
1376
  }
1372
1377
 
package/lib/api.js CHANGED
@@ -20,7 +20,7 @@ import { buildDiagnostics } from './feedback.js'
20
20
  import { getSetupStatus, runSetup, mcpAgentAction } from './setup.js'
21
21
  import { backupDb } from './db.js'
22
22
  import { createHub } from './sse.js'
23
- import { probeServedVersion, restartResident, currentPkgVersion } from './watchdog.js'
23
+ import { probeServedVersion, restartResident, currentPkgVersion, readWatchdogLock, isPidAlive } from './watchdog.js'
24
24
 
25
25
  // 独立 web 的 SSE 中心:配置/数据变更即时推给已打开的页面(无需手动刷新)
26
26
  const hub = createHub()
@@ -102,6 +102,31 @@ export function crossOriginError(req) {
102
102
  return '跨站写入被拒(Origin ' + origin + ' 与请求的 Host ' + host + ' 不同源)'
103
103
  }
104
104
 
105
+ /**
106
+ * 找出「数字字段被清空」的键(值是空串 = 用户把数字格子清空了)。
107
+ *
108
+ * 为什么必须按 **schema 的 type** 判定,而不是「空字符串一律可疑 + 一份豁免名单」:
109
+ * 宿主面板提交的是**整张表单**,把任何空串都当成「数字没填」会误伤本来合法的空串字段 ——
110
+ * `secretHint` 的默认值就是 `''`,于是「全新安装 + 什么都不动 + 直接点保存」必然 400
111
+ * 「字段不能为空:secretHint」,**整单被拒**,用户改的数字与开关一个都存不进去(issue #35)。
112
+ * 旧实现正是靠一份硬编码豁免名单(vaultDir / captureProvider / captureModel / recallEmbedding /
113
+ * activeVault),每加一个可空的字符串字段就得记得补一次 —— 现在直接读 schema。
114
+ *
115
+ * @param {object} patch 待写入的字段
116
+ * @param {object} dict schemastery 的字段表(Config.dict)
117
+ * @returns {string[]} 被清空的数字字段名
118
+ */
119
+ export function emptyNumericFields(patch, dict = {}) {
120
+ const numeric = new Set(
121
+ Object.entries(dict || {})
122
+ .filter(([, schema]) => schema && schema.type === 'number')
123
+ .map(([k]) => k),
124
+ )
125
+ return Object.entries(patch || {})
126
+ .filter(([k, v]) => numeric.has(k) && typeof v === 'string' && v.trim() === '')
127
+ .map(([k]) => k)
128
+ }
129
+
105
130
  export function createApi(deps = {}) {
106
131
  const vaultDir = deps.vaultDir ?? (() => '')
107
132
  const vaultRoots = deps.vaultRoots ?? (() => [{ name: '', root: vaultDir() }])
@@ -251,13 +276,21 @@ export function createApi(deps = {}) {
251
276
  const residentPort = selfPort() || Number(cfgVc.webPort) || Number(process.env.MEMORY_WEB_PORT) || 7999
252
277
  let residentServed = ''
253
278
  try { residentServed = String((await probeResidentVersion(residentPort)) || '') } catch { residentServed = '' }
279
+ // 「在服务」≠「被托管」:web 可能是宿主 ensureWeb() 直接拉起的、或看门狗被 stop/kill 后残留的孤儿
280
+ // —— 此时锁里没有活着的看门狗,端口上的实例没人保活、也不会随版本升级被替换。
281
+ // #28 的现场就是这一种:host 与端口版本号一致 → 面板认为「无需重启」,而其实该重启(重新托管)。
282
+ let residentManaged = false
283
+ try {
284
+ residentManaged = readWatchdogLock(process.env).watchdogs
285
+ .some((w) => Number(w.port) === Number(residentPort) && isPidAlive(w.pid))
286
+ } catch { residentManaged = false }
254
287
  json(res, 200, {
255
288
  ok: true,
256
289
  loaded,
257
290
  onDisk,
258
291
  latest,
259
292
  checkError,
260
- resident: { port: residentPort, served: residentServed },
293
+ resident: { port: residentPort, served: residentServed, managed: residentManaged },
261
294
  stale: !!(loaded && onDisk && loaded !== onDisk),
262
295
  // 只在 **npm 上确实更新** 时提示升级:本地跑着未发布版本(磁盘 0.10.7 / npm 0.10.6)时
263
296
  // 旧的 `latest !== onDisk` 会反过来劝用户装回旧版。
@@ -280,10 +313,15 @@ export function createApi(deps = {}) {
280
313
  const port = Number(query.get('port')) || selfPort() || Number(cfgR.webPort) || Number(process.env.MEMORY_WEB_PORT) || 7999
281
314
  const expect = pkgVersion()
282
315
  const served = await probeResidentVersion(port).catch(() => '')
283
- if (served && expect && String(served) === String(expect)) {
316
+ // 「版本一致」还不够:还要**有活着的看门狗托管它**。孤儿 web(看门狗被 stop / 被 kill -9 后残留)
317
+ // 版本号可能正好也是新的,但没人保活、也不会被后续升级替换 —— 那时一键重启才是唯一出路(#28)。
318
+ const managed = (() => {
319
+ try { return readWatchdogLock(process.env).watchdogs.some((w) => Number(w.port) === Number(port) && isPidAlive(w.pid)) } catch { return false }
320
+ })()
321
+ if (served && expect && String(served) === String(expect) && managed) {
284
322
  return json(res, 200, {
285
- ok: true, alreadyCurrent: true, port, served: String(served), expect,
286
- note: `常驻实例(端口 ${port})已经在服务 v${served},无需重启。若「运行中」仍显示旧版本号,说明那一行来自另一个进程(例如 DSH 宿主),需重启桌面版 / DSH。`,
323
+ ok: true, alreadyCurrent: true, port, served: String(served), expect, managed,
324
+ note: `常驻实例(端口 ${port})已经在服务 v${served} 且有看门狗保活,无需重启。若「运行中」仍显示旧版本号,说明那一行来自另一个进程(例如 DSH 宿主),需重启桌面版 / DSH。`,
287
325
  })
288
326
  }
289
327
  if (residentRestarting) {
@@ -304,7 +342,8 @@ export function createApi(deps = {}) {
304
342
  try {
305
343
  const { appendCaptureLog } = await import('./capture-log.js')
306
344
  await appendCaptureLog({
307
- time: Date.now(), sessionId: 'system', action: out && out.ok ? 'boot' : 'fail',
345
+ // 同上:服务管理失败用 warn(别翻红沉淀健康状态、别冒充「自动沉淀异常」)
346
+ time: Date.now(), sessionId: 'system', action: out && out.ok ? 'boot' : 'warn',
308
347
  reason: out && out.ok
309
348
  ? `面板一键重启常驻实例成功:端口 ${port} 正在服务 v${out.served}`
310
349
  : `面板一键重启常驻实例未确认生效:${(out && out.error) || '未知原因'}`,