memory-eternal 0.10.32 → 0.10.34

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.32",
4
+ "version": "0.10.34",
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.32",
3
+ "version": "0.10.34",
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.32",
3
+ "version": "0.10.34",
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.32** | 2026-10-15 | **"The resident instance cannot be replaced" becomes a long-lived state instead of a retry on every boot (issue #30, fifth retest round: the steady state on the A: drive machine)**. The reporter measured 44 "version drift" events over five days, with a mechanism where **every step works as designed yet the instance can never be replaced**: upgrade → the port still serves the previous generation → the host detects drift → it spawns a `--replace` helper → the helper pre-flights and finds `lock.writable=false` → it **correctly refuses** (the 0.10.29 design, zero damage) → the old instance survives → the next boot reports it again. Fixes: ① new `lib/resident-state.js` **records "cannot replace (environment limit)" as state** (cause / lock path / port / versions / count, valid for 7 days); ② the host now **probes lock writability before restarting** and, when unwritable, **stops retrying a doomed replacement**, recording just one line — `warn` the first time, then downgraded to a `boot`-level "known state" on later boots (the reporter's words: they do not want a warn on every boot forever); ③ the wording is explicitly split from "drift": **"the resident instance cannot be replaced (environment limit)"** plus the way out (fix permissions then restart, or run `dsh-memory watchdog --port N` in the foreground); ④ the panel, `/version-check` and `align` all surface the state (`resident.replaceBlocked`; in `align` it is a `warn` finding that clears itself once the environment is fixed). `npm test` now runs **359 checks** |
365
- | **v0.10.31** | 2026-10-15 | **Root fix for `restart` failing 100% of the time on machines with slow port enumeration (issue #30, third/fourth retest rounds — the reporter traced it to the line)**. Mechanism: the `--replace` helper ran "clear the scene → only then write the lock if the port is free → start the watchdog", while `Get-NetTCPConnection` inside the clear step measured **4–13.6 seconds** and was **not bounded by the 8-second budget** → the helper **never reached the lock write** → the lock stayed empty → `waitForTakeover` (which requires a fresh live watchdog in the lock) **was guaranteed to time out** → on such machines both the CLI and the panel button report "(no response)" **100% of the time, regardless of version**. Fixes: ① **a failed clear step still leaves a registration** (`acquireWatchdogLock({force:true})`), so "a web on the port with an empty lock" can no longer happen; ② **`acquireWatchdogLock` gains `force`** (the whole point of `--replace` is to replace that same-port instance, so it must not yield — otherwise the helper exits before the clear step finishes); ③ 0.10.30 already bounded the enumerator (`defaultExec` kills the child on timeout, `findPortListener({timeoutMs})` threads it through, the clear step uses ≤2.5s); ④ the CLI's `restart` result is now **actually written to the auto-capture log** (the hint always claimed so, but the CLI path never did). They also measured "two competing takeover helpers" (the host tick and the CLI each spawning one) — with registration plus force, that race is far less harmful (the later one takes over the registration instead of both leaving nothing). `npm test` now runs **355 checks** |
364
+ | **v0.10.34** | 2026-10-16 | **Root fix: when the lock is unwritable the HOST manages the resident instance itself (adopting the reporter's fourth point in issue #30)**. Their diagnosis: on that machine the "registration" happens to live in the only process that cannot write the lock, so automatic replacement is permanently refused. Fixes: ① the host's `autoWebMode=interval` keep-alive loop now probes **the version served on the port**, not just liveness — if it lags and the lock is writable the watchdog/drift path still handles it (the host does not compete); if it lags and the **lock is unwritable** the **host takes over**: `ensureWebServer({forceTakeover:true})` stops only **our own** web, starts a new instance on the **same port** and keeps it alive; ② a testable `takeoverOwnWeb()` with a safety gate that only acts on occupants where `looksLikeOurWeb` is true (foreign processes are never touched); ③ "host-managed" is recorded as state (`recordHostManaged`) so `/version-check` reports `resident.managedBy='host'` and the panel says "**host-managed**" instead of "unmanaged / orphan"; the state clears itself once a watchdog registers again. `npm test` now runs **366 checks** |
365
+ | **v0.10.33** | 2026-10-15 | **Distillation provider: dynamic dropdowns, best-effort follow-session, first-candidate-only and a test button (first slice of issue #42)**. ① **Dynamic dropdowns**: a new read-only `/llm-routes` endpoint (backed by the host's listProviders/listModels) turns the "distill provider / model" fields from hand-typed IDs into selects (falling back to text inputs when unavailable); ② **Follow the current session** (their top priority): the new `captureFollowSession` config makes host distillation **try** the session's provider/model first (`sessionRouteFrom()` sniffs `agent / session / session.route / session.options`), and **falls back to the explicit config with a log line** when nothing is exposed (DSH does not guarantee a route on the session object — that log is the basis for a future adapter rather than a guess); background/no-session tasks (CLI, watchdog, hooks) can only use the explicit config; ③ **First-candidate-only + test button**: `captureFirstOnly` makes routing take just the first candidate and stop on failure (no more "hit the official provider first, burn a call, then rotate"); `POST /llm-test` makes one small real call and echoes **provider / model / latency / candidate count / error code**, which also verifies ① and ②; ④ also fixes **probe jitter**: `probeServedVersion()` now retries with backoff three times (the reporter measured "version-check 200 at one moment, then status says no service 2s later"), so single-shot probes are no longer fooled by restart windows or transient refusals. `npm test` now runs **363 checks** |
366
366
 
367
367
  <details>
368
- <summary>Older release notes (v0.10.30 and earlier · 57 releases, click to expand)</summary>
368
+ <summary>Older release notes (v0.10.32 and earlier · 59 releases, click to expand)</summary>
369
369
 
370
370
  | Version | Date | Highlights |
371
371
  |---|---|---|
372
+ | **v0.10.32** | 2026-10-15 | **"The resident instance cannot be replaced" becomes a long-lived state instead of a retry on every boot (issue #30, fifth retest round: the steady state on the A: drive machine)**. The reporter measured 44 "version drift" events over five days, with a mechanism where **every step works as designed yet the instance can never be replaced**: upgrade → the port still serves the previous generation → the host detects drift → it spawns a `--replace` helper → the helper pre-flights and finds `lock.writable=false` → it **correctly refuses** (the 0.10.29 design, zero damage) → the old instance survives → the next boot reports it again. Fixes: ① new `lib/resident-state.js` **records "cannot replace (environment limit)" as state** (cause / lock path / port / versions / count, valid for 7 days); ② the host now **probes lock writability before restarting** and, when unwritable, **stops retrying a doomed replacement**, recording just one line — `warn` the first time, then downgraded to a `boot`-level "known state" on later boots (the reporter's words: they do not want a warn on every boot forever); ③ the wording is explicitly split from "drift": **"the resident instance cannot be replaced (environment limit)"** plus the way out (fix permissions then restart, or run `dsh-memory watchdog --port N` in the foreground); ④ the panel, `/version-check` and `align` all surface the state (`resident.replaceBlocked`; in `align` it is a `warn` finding that clears itself once the environment is fixed). `npm test` now runs **359 checks** |
373
+ | **v0.10.31** | 2026-10-15 | **Root fix for `restart` failing 100% of the time on machines with slow port enumeration (issue #30, third/fourth retest rounds — the reporter traced it to the line)**. Mechanism: the `--replace` helper ran "clear the scene → only then write the lock if the port is free → start the watchdog", while `Get-NetTCPConnection` inside the clear step measured **4–13.6 seconds** and was **not bounded by the 8-second budget** → the helper **never reached the lock write** → the lock stayed empty → `waitForTakeover` (which requires a fresh live watchdog in the lock) **was guaranteed to time out** → on such machines both the CLI and the panel button report "(no response)" **100% of the time, regardless of version**. Fixes: ① **a failed clear step still leaves a registration** (`acquireWatchdogLock({force:true})`), so "a web on the port with an empty lock" can no longer happen; ② **`acquireWatchdogLock` gains `force`** (the whole point of `--replace` is to replace that same-port instance, so it must not yield — otherwise the helper exits before the clear step finishes); ③ 0.10.30 already bounded the enumerator (`defaultExec` kills the child on timeout, `findPortListener({timeoutMs})` threads it through, the clear step uses ≤2.5s); ④ the CLI's `restart` result is now **actually written to the auto-capture log** (the hint always claimed so, but the CLI path never did). They also measured "two competing takeover helpers" (the host tick and the CLI each spawning one) — with registration plus force, that race is far less harmful (the later one takes over the registration instead of both leaving nothing). `npm test` now runs **355 checks** |
372
374
  | **v0.10.30** | 2026-10-15 | **Two fixes for "the whole page is unusable": malformed tags causing a black screen (#41) plus two real defects in the takeover path (issue #30, third/fourth retest rounds)**. ① **#41**: a string `tags` value sent to `/write` (e.g. `"[\"a\",\"b\"]"`) was passed straight into `writeCard`'s `JSON.stringify` → **double-encoded** in the database → read back as a string → the client's `card.tags.slice(...).map` threw → **the React root failed to mount and the whole page went black** (restarting DSH, the plugin or the resident instance changed nothing, because the bad data lives in the DB). `normalizeTags()` is now the **single entry point**: arrays, JSON strings, comma strings and **legacy double-encoded rows** all collapse to arrays, used by the write path **and every read path** (existing bad data needs no manual repair); the render layer also guards itself (bad tags cost a few chips, never the tree). ② **#30**: the reporter found two more issues inside my 0.10.29 — `restoreWatchdogLock`'s "live registration" test **missed `!w.endedAt`** (so with a reused pid the write-back was abandoned), and **Windows port enumeration had no timeout** (`Get-NetTCPConnection` measured at 4–7.5s, once 13.6s, while the takeover's 8s budget could not bound it → decisions were made on stale data). `defaultExec` now supports a timeout and actually kills the child, `findPortListener({timeoutMs})` threads it through, and the takeover passes a bounded value (≤2.5s). Adds `tests/tags-normalize.test.mjs` (4 checks) and 2 regressions in `watchdog-restart`. `npm test` now runs **354 checks** |
373
375
  | **v0.10.29** | 2026-10-14 | **A failed takeover no longer wrecks the crime scene (key finding from the issue #30 retest)**. The reporter measured on 0.10.26 that after a failed `restart` the **lock file was deleted** while the same web kept listening — the scene then looked as if a resident had never existed, leaving only an orphan. Four fixes: ① **releasing the lock no longer deletes the file** (`releaseWatchdogLock` leaves an "ended" record with `endedAt`; `status` shows "exited (stale record)", and such slots **never block** a new instance, even with pid reuse); ② **a failed restart writes the pre-replacement registration back** (`restoreWatchdogLock`, only when no live registration exists for that port); ③ **failing to enumerate the occupant no longer counts as "port free"** — `clearResidentForTakeover` now **double-checks with TCP** (in restricted environments the process query returns nothing, and the old code concluded the port was free, so the new web could not bind, the self-check reported "(no response)" and the lock was wiped); if something is still listening the takeover aborts and **touches no registration at all**; ④ **refuse to take over when the lock file is unwritable** (an unregisterable instance would only become another orphan — it now errors out and points at running the watchdog in the foreground). The restart failure message also names **who is on the port now** and whether the lock was written back, and `status` now **labels the port probe and the lock registration as separate sections** (the reporter pointed out the old output mixed the two and misled readers). `npm test` now runs **348 checks** |
374
376
  | **v0.10.28** | 2026-10-14 | **New "version alignment check": see at a glance what stays stale after a plugin upgrade**. Upgrading only replaces **files on disk**, and two kinds of "old" do not go away by themselves: ① **runtime** — the event subscriptions the DSH host registered in memory (`agent/turn-stopping`, `agent/inbox/claimed`, …) come from **the code it loaded at startup**, so without restarting DSH it keeps using the old interfaces (that "turn-stop event appears broken" alert is almost certainly this: measured here, host **v0.10.8** against disk v0.10.27 — and the alert text itself, with its cumulative "135/75", proves the running code predates 0.10.20); ② **persisted wiring** — the MCP and hook entries the plugin writes into `~/.claude.json`, `~/.claude/settings.json`, `~/.codex/config.toml` and `~/.cursor/mcp.json` carry **old install paths, old node paths and old arguments** that an upgrade never rewrites. Adds `lib/align.js`, `GET /align`, `dsh-memory align [--json] [--fix]` and a panel card (usage/today → 🔧 version alignment check) that lists each finding with an **actionable fix** (`fully quit and reopen DSH` / `dsh-memory restart --port N` / `dsh-memory setup` to rewrite the wiring / update the plugin); `--fix` rewrites only the plugin's own keys (your data and config are untouched). Adds `tests/align.test.mjs` (6 checks, including a wording guard for "same node but stale entry"). `npm test` now runs **345 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.32** | 2026-10-15 | **「常驻实例不可替换」变成长期状态,而不是每次开机重试(#30 第五轮复测:A: 盘的稳态)**。报告者实测 5 天 44 次「版本漂移」,机制是**每一步都按设计工作、合起来永远换不掉**:升级 → 端口上仍是上一代 → 宿主探测漂移 → 拉起 `--replace` 助手 → 助手前置探到 `lock.writable=false` → **正确地拒绝**(0.10.29 的设计,零破坏)→ 旧实例继续存活 → 下次开机再报一次。修法:① 新增 `lib/resident-state.js`:把「不可替换(环境限制)」**记成状态**(原因 / 锁路径 / 端口 / 版本 / 次数,7 天有效);② 宿主在重启前**先探锁可写性**,不可写就**不再重试注定失败的替换**,只记一条 —— **首次 `warn`**、之后每次开机降级为 `boot` 级「已知状态」(报告者原话:不想每次开机都看到一条 warn 永久持续);③ 措辞与「漂移」显式分开:**「常驻实例不可替换(环境限制)」** + 出路(修权限后 restart,或 `dsh-memory watchdog --port N` 前台观察);④ 面板 / `/version-check` / `align` 都能看到这个状态(`resident.replaceBlocked`;`align` 里是一条 `warn` 级结论,环境修好后自动清除)。`npm test` 共 **359 项** |
397
- | **v0.10.31** | 2026-10-15 | **`restart` 在「端口枚举很慢」的机器上 100% 失败的根因修复(#30 第三/四轮复测,报告者定位到行)**。机制:`--replace` 助手的顺序是「清场 → 端口空了才写锁 → 启动看门狗」,而清场里 `Get-NetTCPConnection` 实测要 **4–13.6 秒**且不受 8 秒预算约束 → 助手**永远走不到写锁那一步** → 锁里什么都没有 → `waitForTakeover`(要求「锁里出现新的活看门狗」)**必然超时** → CLI 与面板按钮在这类机器上 100% 报「(无响应)」,**与版本号无关**。修法:① **清场失败也必须留下登记**(`acquireWatchdogLock({force:true})`),现场不再出现「端口有 web、锁里空白」;② **`acquireWatchdogLock` 支持 `force`**(`--replace` 的语义就是替换同端口那个,不许让位 —— 否则助手在清场没跑完时先退出);③ 0.10.30 已给枚举执行器加超时(`defaultExec` 真掐断子进程、`findPortListener({timeoutMs})` 逐层传递、清场给 ≤2.5s 有界值);④ CLI 的 `restart` 结果**真的写进自动沉淀日志**(提示里一直这么说,但 CLI 路径过去不写)。他还实测到「两个接管助手互相赛跑」(宿主 tick + CLI 各拉一个)—— 登记 + force 之后这种赛跑的危害大幅降低(后到者接管登记,而不是双双留空)。`npm test` 共 **355 项** |
396
+ | **v0.10.34** | 2026-10-16 | **治本:锁不可写时由「宿主」自己托管常驻实例(采纳 #30 报告者第四点建议)**。他的诊断:那台机器上「登记」恰好落在**唯一写不了锁的那个进程**上 → 自动替换被永久拒绝。修法:① 宿主的 `autoWebMode=interval` 保活循环**不再只探存活**,还探**端口上服务的版本** —— 落后且锁可写 → 仍交给看门狗 / 漂移路径(宿主不抢);落后且**锁不可写** → **宿主自己接管**(`ensureWebServer({forceTakeover:true})`:只收掉**自家** web、在**同端口**起新实例、由宿主保活);② 新增可测试的 `takeoverOwnWeb()`(安全闸门:只对 `looksLikeOurWeb` 为真的占用者下手,别人的进程一个都不动);③ 「宿主托管」记成状态(`recordHostManaged`)→ `/version-check` 报 `resident.managedBy='host'`,面板显示「**宿主托管**」而不是「未托管 / 孤儿」;看门狗重新登记后自动清除。`npm test` 共 **366 项** |
397
+ | **v0.10.33** | 2026-10-15 | **蒸馏供应商:动态下拉 + 跟随当前会话(尽力)+ 仅试首选 + 测试按钮(issue #42 第一刀)**。① **动态下拉**:新增只读接口 `/llm-routes`(宿主侧的 listProviders/listModels),配置页的「蒸馏 provider / model」从**手填编号**变成**下拉选择**(取不到时自动回退成文本输入);② **跟随当前会话**(他的最高优先级):新增配置 `captureFollowSession`,开启后宿主蒸馏**先尝试**用这一轮会话的 provider/model(`sessionRouteFrom()` 按 `agent / session / session.route / session.options` 逐个嗅探),**读不到就回退显式配置并记一条日志**(DSH 未保证会话对象暴露 route —— 这条日志就是以后补适配的依据,不猜);后台 / 无会话任务(CLI、watchdog、hooks)天然只能回退配置;③ **仅试首选 + 测试按钮**:`captureFirstOnly` 让选路只取第一个候选、失败即停(避免「先打官方 → 白烧一次调用 → 再轮换」);`POST /llm-test` 用真实调用路径打一次小请求,回显 **provider / model / 耗时 / 候选数 / 错误码**,也是 ①② 的验证手段。④ 顺带修一处**探测抖动**:`probeServedVersion()` 现在退避重试 3 次(报告者实测「同一时刻 version-check 200、2 秒后 status 说没有服务」),单发探测不再被重启窗口/瞬时拒绝骗到。`npm test` 共 **363 项** |
398
398
 
399
399
  <details>
400
- <summary>更早的更新日志(v0.10.30 及以前 · 共 57 版,点开查看)</summary>
400
+ <summary>更早的更新日志(v0.10.32 及以前 · 共 59 版,点开查看)</summary>
401
401
 
402
402
  | 版本 | 日期 | 关键改动 |
403
403
  |---|---|---|
404
+ | **v0.10.32** | 2026-10-15 | **「常驻实例不可替换」变成长期状态,而不是每次开机重试(#30 第五轮复测:A: 盘的稳态)**。报告者实测 5 天 44 次「版本漂移」,机制是**每一步都按设计工作、合起来永远换不掉**:升级 → 端口上仍是上一代 → 宿主探测漂移 → 拉起 `--replace` 助手 → 助手前置探到 `lock.writable=false` → **正确地拒绝**(0.10.29 的设计,零破坏)→ 旧实例继续存活 → 下次开机再报一次。修法:① 新增 `lib/resident-state.js`:把「不可替换(环境限制)」**记成状态**(原因 / 锁路径 / 端口 / 版本 / 次数,7 天有效);② 宿主在重启前**先探锁可写性**,不可写就**不再重试注定失败的替换**,只记一条 —— **首次 `warn`**、之后每次开机降级为 `boot` 级「已知状态」(报告者原话:不想每次开机都看到一条 warn 永久持续);③ 措辞与「漂移」显式分开:**「常驻实例不可替换(环境限制)」** + 出路(修权限后 restart,或 `dsh-memory watchdog --port N` 前台观察);④ 面板 / `/version-check` / `align` 都能看到这个状态(`resident.replaceBlocked`;`align` 里是一条 `warn` 级结论,环境修好后自动清除)。`npm test` 共 **359 项** |
405
+ | **v0.10.31** | 2026-10-15 | **`restart` 在「端口枚举很慢」的机器上 100% 失败的根因修复(#30 第三/四轮复测,报告者定位到行)**。机制:`--replace` 助手的顺序是「清场 → 端口空了才写锁 → 启动看门狗」,而清场里 `Get-NetTCPConnection` 实测要 **4–13.6 秒**且不受 8 秒预算约束 → 助手**永远走不到写锁那一步** → 锁里什么都没有 → `waitForTakeover`(要求「锁里出现新的活看门狗」)**必然超时** → CLI 与面板按钮在这类机器上 100% 报「(无响应)」,**与版本号无关**。修法:① **清场失败也必须留下登记**(`acquireWatchdogLock({force:true})`),现场不再出现「端口有 web、锁里空白」;② **`acquireWatchdogLock` 支持 `force`**(`--replace` 的语义就是替换同端口那个,不许让位 —— 否则助手在清场没跑完时先退出);③ 0.10.30 已给枚举执行器加超时(`defaultExec` 真掐断子进程、`findPortListener({timeoutMs})` 逐层传递、清场给 ≤2.5s 有界值);④ CLI 的 `restart` 结果**真的写进自动沉淀日志**(提示里一直这么说,但 CLI 路径过去不写)。他还实测到「两个接管助手互相赛跑」(宿主 tick + CLI 各拉一个)—— 登记 + force 之后这种赛跑的危害大幅降低(后到者接管登记,而不是双双留空)。`npm test` 共 **355 项** |
404
406
  | **v0.10.30** | 2026-10-15 | **两处「让整页不可用」的修复:畸形 tags 黑屏(#41)+ 接管链路的两个真病灶(#30 第三/四轮复测)**。① **#41**:`/write` 传字符串 `tags`(如 `"[\"a\",\"b\"]"`)被 `writeCard` 直接 `JSON.stringify` → **双重编码**落库 → 读出来仍是字符串 → 前端 `card.tags.slice(...).map` 抛错 → **React 根组件挂载失败、整页黑屏**(重启 DSH / 插件 / 常驻实例都无效,因为坏数据一直在库里)。现在 `normalizeTags()` 是**唯一入口**:数组 / JSON 字符串 / 逗号串 / **双重编码的旧数据**都收敛成数组,写路径与**所有读路径**都走它(既有坏数据不必用户手工修);渲染层再加兜底(坏 tags 最多少显示几个标签,不许把整棵树带走)。② **#30**:报告者在我 0.10.29 里又抓出两处 —— `restoreWatchdogLock` 的「活登记」判定**少了 `!w.endedAt`**(PID 复用/正在退出时,写回会被放弃);**Windows 端口枚举没有超时**(实测 `Get-NetTCPConnection` 无条件要 4–7.5 秒、一次 13.6 秒,而接管清场的 8 秒预算根本管不住它 → 决策基于过期数据)。现在 `defaultExec` 支持超时并真的掐断子进程、`findPortListener({timeoutMs})` 逐层传下去、接管清场给有界值(≤2.5s)。新增 `tests/tags-normalize.test.mjs`(4 项)+ `watchdog-restart` 2 项回归。`npm test` 共 **354 项** |
405
407
  | **v0.10.29** | 2026-10-14 | **失败的接管不再破坏现场(issue #30 复测的关键发现)**。报告者在 0.10.26 上实测:`restart` 失败后**锁文件被删除**、端口上还是同一个 web —— 现场从此看起来像「从来没有过常驻实例」,只剩一个孤儿。四处修复:① **释放锁不再删文件**(`releaseWatchdogLock` 留一条带 `endedAt` 的「已结束」记录,`status` 会显示「已退出(陈旧记录)」;这种槽**永不阻挡**新实例,pid 复用也不怕);② **失败的 restart 把替换前的登记写回去**(`restoreWatchdogLock`,只在本端口没有活着的登记时才写);③ **枚举不到占用者时不再误判「端口已空」**——`clearResidentForTakeover` 用 **TCP 复核**(受限环境里进程查询被拒会返回空,旧实现据此认为端口空出 → 新 web 绑不上 → 自检「(无响应)」+ 锁被清空),还在监听就中止接管且**一个登记都不动**;④ **锁写不进去时前置拒绝接管**(写不进锁 → 新实例无法登记 → 只会留孤儿;直接报错并给前台观察的出路)。另外 `restart` 失败信息补上「**端口上现在是谁**」+「锁有没有写回」,`status` 把「端口探测」与「锁登记」**分段标注**(报告者指出旧输出两者并列会误导)。`npm test` 共 **348 项** |
406
408
  | **v0.10.28** | 2026-10-14 | **新增「版本对齐检查」:把「插件迭代后旧接口 / 旧接线还在」一次查清**。插件升级只换**磁盘文件**,两类「旧」不会自己消失:① **运行时** —— DSH 宿主进程内存里注册的事件订阅(`agent/turn-stopping`、`agent/inbox/claimed`…)来自它**启动时加载的那份代码**,不重启 DSH 就会一直用旧接口(那条「轮次收尾事件疑似失效」多半就是它:本机实测宿主 **v0.10.8** / 磁盘 v0.10.27,而且该告警文案里的「累计 135/75」本身就证明跑的是 ≥0.10.20 之前的旧判据);② **持久接线** —— 插件写进 `~/.claude.json`、`~/.claude/settings.json`、`~/.codex/config.toml`、`~/.cursor/mcp.json` 的 MCP 与 hooks 条目含**旧安装路径 / 旧 node 路径 / 旧参数**,升级后不会自动改写。新增 `lib/align.js` + `GET /align` + `dsh-memory align [--json] [--fix]` + 面板「用量/今日 → 🔧 版本对齐检查」:逐条给结论与**可执行动作**(`完全退出并重开 DSH` / `dsh-memory restart --port N` / `dsh-memory setup` 重写接线 / 更新插件),`--fix` 只重写插件自己的键(不动数据与配置)。新增 `tests/align.test.mjs`(6 项,含「同一 node 但条目是旧写法」的措辞准确性守卫)。`npm test` 共 **345 项** |
package/index.js CHANGED
@@ -31,7 +31,7 @@ import { migrateFromMarkdown, setAuditConfig, backupDb } from './lib/db.js'
31
31
  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
- 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'
34
+ import { summarizeTurn, summarizeTurnDetailed, routeCandidates, sessionRouteFrom, 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
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'
@@ -67,6 +67,12 @@ export const Config = z.object({
67
67
  // 多 provider 环境下务必显式指定,否则可能撞上没配 key 的官方 provider(issue #3)
68
68
  captureProvider: z.string().default(''),
69
69
  captureModel: z.string().default(''),
70
+ // issue #42 需求 2:「跟随当前会话」——宿主蒸馏优先用**这一轮会话**的 provider/model;
71
+ // 后台 / 无会话任务(CLI、watchdog、hooks)拿不到会话上下文,回退到上面的显式配置。
72
+ captureFollowSession: z.boolean().default(false),
73
+ // issue #42 需求 3:「仅试首选」——只尝试第一个候选、失败即停,不轮换兜底
74
+ //(避免「先打官方 → 白烧一次调用 → 再轮换」)。
75
+ captureFirstOnly: z.boolean().default(false),
70
76
  // 蒸馏单次输出上限(token),越高越准越贵
71
77
  // 默认值 900 → 2000(issue #18):prompt 要求 300-800 字正文,900 会让超长会话常态化撞上限,
72
78
  // 输出被截断后旧代码还会把它误报成「解析失败」,最终落一张 raw 噪声卡。
@@ -588,6 +594,8 @@ export function apply(ctx, config) {
588
594
  }
589
595
  }
590
596
  const lastCaptureAt = new Map() // sessionId -> 上次实际发起蒸馏的时间戳
597
+ // 「跟随当前会话」读不到路由时的告警:每进程只提示一次(否则每轮都刷一条)
598
+ let followSessionWarned = false
591
599
 
592
600
  const scheduleCapture = (agent, events) => {
593
601
  const cfg = settings.get() ?? {}
@@ -689,7 +697,22 @@ export function apply(ctx, config) {
689
697
  return
690
698
  }
691
699
  // 候选路由:显式配置优先,其余按注册顺序兜底(多 provider 下 providers[0] 可能是没配 key 的官方 provider → issue #3)。
692
- const routes = await routeCandidates(llm, { provider: cfg.captureProvider, model: cfg.captureModel })
700
+ // issue #42 ②:跟随当前会话(best-effort)。DSH 不保证会话对象暴露 route,读不到就回退显式配置,
701
+ // 并把「嗅探到哪一步」记进日志 —— 以后要补适配时有据可查,而不是靠猜。
702
+ let routeOpts = { provider: cfg.captureProvider, model: cfg.captureModel, firstOnly: cfg.captureFirstOnly === true }
703
+ if (cfg.captureFollowSession === true) {
704
+ try {
705
+ const sr = sessionRouteFrom(agent)
706
+ if (sr && sr.provider) {
707
+ routeOpts = { ...routeOpts, provider: sr.provider, model: sr.model || '' }
708
+ logCapture(sessionId, 'boot', `蒸馏路由跟随当前会话:${sr.provider}${sr.model ? '/' + sr.model : ''}(来源 ${sr.from})`)
709
+ } else if (!followSessionWarned) {
710
+ followSessionWarned = true
711
+ logCapture(sessionId, 'warn', '「跟随当前会话」已开启,但这一轮的会话对象里读不到 provider/model(DSH 未暴露)→ 本次回退到显式配置;若长期如此请反馈,我按实际字段补适配')
712
+ }
713
+ } catch { /* 探测失败一律回退配置 */ }
714
+ }
715
+ const routes = await routeCandidates(llm, routeOpts)
693
716
  if (!routes.length) { logCapture(sessionId, 'fail', '取不到模型路由(llm.listProviders 为空)'); return }
694
717
  // 语义去重近邻:把已有卡片索引喂给模型,让模型决定新建 vs 追加。
695
718
  // 成本控制:dedupByLLM=false 时跳过喂 LLM 的近邻采样(纯词法去重兜底)。
@@ -1154,6 +1177,8 @@ export function apply(ctx, config) {
1154
1177
  enabled: cfg.enabled !== false,
1155
1178
  vaultDir: cfg.vaultDir || '',
1156
1179
  captureProvider: cfg.captureProvider || '',
1180
+ captureFollowSession: cfg.captureFollowSession === true,
1181
+ captureFirstOnly: cfg.captureFirstOnly === true,
1157
1182
  captureModel: cfg.captureModel || '',
1158
1183
  recallEmbedding: cfg.recallEmbedding || '',
1159
1184
  sessionBudgetChars: cfg.sessionBudgetChars ?? 80000,
@@ -1284,8 +1309,9 @@ export function apply(ctx, config) {
1284
1309
  const mode = cfg0.autoWebMode || 'init'
1285
1310
  const port = Number(cfg0.webPort) || 7999
1286
1311
  const ensureOpts = { port, vaultRoot: vaultDir() }
1287
- const ensureWeb = () => import('./lib/web.js')
1288
- .then((m) => m.ensureWebServer(ensureOpts))
1312
+ let hostTakeoverAt = 0 // 宿主接管的防抖(issue #30 ④)
1313
+ const ensureWeb = (extra = {}) => import('./lib/web.js')
1314
+ .then((m) => m.ensureWebServer({ ...ensureOpts, ...extra }))
1289
1315
  .then((info) => { refreshWebInfo(info); return info })
1290
1316
  .catch((error) => console.error('[memory-eternal] web server failed:', error?.message || error))
1291
1317
 
@@ -1320,6 +1346,30 @@ export function apply(ctx, config) {
1320
1346
  restartCount = 0
1321
1347
  // 活着也要回写记录:首轮 ensure 可能漂到过别的端口,此后没有任何校正点 → web-info 悬空(issue #29)
1322
1348
  refreshWebInfo({ url: `http://127.0.0.1:${port}`, port, spawned: false })
1349
+ // issue #30 ④(治本):只探「活着」不够 —— 端口上可能是**升级前的旧实例**,而看门狗路径在
1350
+ // 锁不可写时被永久拒绝。锁可写 → 交给看门狗/漂移路径(宿主不抢);锁不可写 → 宿主自己接管。
1351
+ try {
1352
+ const wd = await import('./lib/watchdog.js')
1353
+ const served = await wd.probeServedVersion(port)
1354
+ const cur = wd.currentPkgVersion()
1355
+ const lockOk = Boolean(wd.probeLockWritable().writable)
1356
+ const locked = wd.readWatchdogLock(process.env).watchdogs.some((w) => Number(w.port) === port && !w.endedAt && wd.isPidAlive(w.pid))
1357
+ if (locked) { try { const st = await import('./lib/resident-state.js'); st.clearHostManaged(process.env) } catch { /* 无妨 */ } }
1358
+ if (!locked && served && cur && served !== cur && !lockOk) {
1359
+ if (!hostTakeoverAt || Date.now() - hostTakeoverAt > 60000) {
1360
+ hostTakeoverAt = Date.now()
1361
+ logCapture('system', 'warn', `常驻实例落后(端口 ${port} 服务 v${served} ≠ 本机 v${cur})且看门狗锁不可写 → 宿主自己接管:停掉旧实例并在同端口拉起新实例`)
1362
+ await ensureWeb({ forceTakeover: true })
1363
+ const after = await wd.probeServedVersion(port)
1364
+ if (after && after === cur) {
1365
+ try { const st = await import('./lib/resident-state.js'); st.recordHostManaged(process.env, { port, version: cur, served: after, reason: 'lock-unwritable' }) } catch { /* 无妨 */ }
1366
+ logCapture('system', 'boot', `宿主接管完成:端口 ${port} 现在服务 v${after}(锁不可写,故由宿主保活)`)
1367
+ } else {
1368
+ logCapture('system', 'warn', `宿主接管后端口 ${port} 仍服务 v${after || '(无响应)'}(本机 v${cur})`)
1369
+ }
1370
+ }
1371
+ }
1372
+ } catch { /* 探测失败不影响保活 */ }
1323
1373
  }
1324
1374
  }
1325
1375
  // 立即跑一次(首启 + 间隔循环)
package/lib/api.js CHANGED
@@ -328,6 +328,11 @@ export function createApi(deps = {}) {
328
328
  // 用户会以为实例坏了、反复重启。这里把「判不出来」单独标出来,并解释原因。
329
329
  const lockInfo = probeLockWritable(process.env)
330
330
  const managedKnown = residentManaged || lockInfo.writable
331
+ // 「宿主托管」(issue #30 ④):锁不可写的机器上,宿主自己停旧实例、起新实例并保活 ——
332
+ // 锁里当然没有登记,但不等于「未托管 / 孤儿」。
333
+ let hostManaged = null
334
+ try { const st = await import('./resident-state.js'); hostManaged = st.hostManagedInfo(process.env) } catch { hostManaged = null }
335
+ const managedBy = residentManaged ? 'watchdog' : ((hostManaged && hostManaged.fresh) ? 'host' : '')
331
336
  // 「不可替换(环境限制)」的长期状态(issue #30 第五轮复测):面板据此显示成**环境限制**,
332
337
  // 而不是每次开机都当成「漂移 → 替换失败」。
333
338
  let replaceBlocked = null
@@ -343,6 +348,8 @@ export function createApi(deps = {}) {
343
348
  served: residentServed,
344
349
  managed: residentManaged,
345
350
  managedKnown,
351
+ managedBy,
352
+ hostManaged,
346
353
  pid: process.pid,
347
354
  lock: lockInfo,
348
355
  replaceBlocked,
@@ -668,6 +675,49 @@ export function createApi(deps = {}) {
668
675
  json(res, out.ok ? 200 : 400, out)
669
676
  return
670
677
  }
678
+ case '/llm-routes': {
679
+ // issue #42 需求 1:把可选的 provider / model 动态列出来(面板用它渲染下拉,不再手填编号)。
680
+ const llm = getLlm()
681
+ if (!llm) { json(res, 200, { ok: false, error: '当前进程拿不到 llm 服务(独立页请在 DSH 面板里操作)' }); return }
682
+ const providers = []
683
+ try {
684
+ for (const p of (llm.listProviders() || [])) {
685
+ if (!p || !p.id) continue
686
+ let models = []
687
+ try { models = (await llm.listModels(p.id)) || [] } catch { models = [] }
688
+ providers.push({ id: p.id, label: p.label || p.name || p.id, models: models.map((m) => ({ id: m && m.id, label: (m && (m.label || m.name)) || (m && m.id) })).filter((m) => m.id) })
689
+ }
690
+ } catch { /* 列不出来就返回空的,面板显示「取不到」 */ }
691
+ const cfgL = getSettings() || {}
692
+ json(res, 200, { ok: true, providers, current: { provider: cfgL.captureProvider || '', model: cfgL.captureModel || '', followSession: cfgL.captureFollowSession === true, firstOnly: cfgL.captureFirstOnly === true } })
693
+ return
694
+ }
695
+ case '/llm-test': {
696
+ // issue #42 需求 3:用当前配置真打一次,把 provider/model/耗时/错误码原样回显(也是 ①② 的验证手段)。
697
+ if (req.method !== 'POST') { json(res, 405, { ok: false, error: '需 POST' }); return }
698
+ const llm = getLlm()
699
+ if (!llm) { json(res, 200, { ok: false, error: '当前进程拿不到 llm 服务' }); return }
700
+ let opts = {}
701
+ try { let raw = ''; for await (const chunk of req) raw += chunk; opts = JSON.parse(raw || '{}') } catch { opts = {} }
702
+ const cfgT = getSettings() || {}
703
+ const { routeCandidates: rc, callLlmJson: call } = await import('./capture.js')
704
+ const routes = await rc(llm, { provider: opts.provider || cfgT.captureProvider, model: opts.model || cfgT.captureModel, firstOnly: opts.firstOnly === true || cfgT.captureFirstOnly === true })
705
+ if (!routes.length) { json(res, 200, { ok: false, error: '取不到候选路由(llm.listProviders 为空)' }); return }
706
+ const route = routes[0]
707
+ const t0 = Date.now()
708
+ let out = null
709
+ try { out = await call(llm, route, { system: '你是连通性测试。只输出 JSON。', user: '输出 {"ok":true}', maxTokens: 64, purpose: 'memory-llm-test' }) } catch (e) { out = { failure: { code: 'EXCEPTION', message: String((e && e.message) || e) } } }
710
+ json(res, 200, {
711
+ ok: !out.failure,
712
+ route: { provider: route.provider, model: route.model },
713
+ candidates: routes.length,
714
+ firstOnly: opts.firstOnly === true || cfgT.captureFirstOnly === true,
715
+ ms: Date.now() - t0,
716
+ text: out.text ? String(out.text).slice(0, 200) : '',
717
+ failure: out.failure || null,
718
+ })
719
+ return
720
+ }
671
721
  case '/align': {
672
722
  // 版本对齐检查:把「宿主/常驻/npm 的版本差」与「持久接线里的旧路径/旧事件键」一次查清,
673
723
  // 每条给可执行动作(重启 DSH / 重启常驻 / 重写接线 / 更新插件)。只读,不改任何东西。
@@ -785,7 +835,7 @@ export function createApi(deps = {}) {
785
835
  autoWeb: cfg.autoWeb ?? env.MEMORY_AUTO_WEB !== '0', autoWebMode: cfg.autoWebMode || 'init', webPort: Number(cfg.webPort) || Number(env.MEMORY_WEB_PORT) || 7999, webCheckIntervalMs: Number(cfg.webCheckIntervalMs) || 5000, webMaxRestart: Number(cfg.webMaxRestart) || 10, watchdogAutoSpawn: cfg.watchdogAutoSpawn ?? env.MEMORY_WATCHDOG === '1', autoRestartOnDrift: cfg.autoRestartOnDrift ?? true, autoMcpSetup: cfg.autoMcpSetup ?? false,
786
836
  auditMode: cfg.auditMode ?? 'all', auditExemptAgents: cfg.auditExemptAgents ?? [], auditExemptKinds: cfg.auditExemptKinds ?? [], recycleRetentionDays: cfg.recycleRetentionDays ?? 30,
787
837
  vaultProfiles: Array.isArray(cfg.vaultProfiles) ? cfg.vaultProfiles : [], activeVault: cfg.activeVault || '',
788
- enabled: cfg.enabled !== false, vaultDir: cfg.vaultDir || '', captureProvider: cfg.captureProvider || '',
838
+ enabled: cfg.enabled !== false, vaultDir: cfg.vaultDir || '', captureProvider: cfg.captureProvider || '', captureFollowSession: cfg.captureFollowSession === true, captureFirstOnly: cfg.captureFirstOnly === true,
789
839
  captureModel: cfg.captureModel || '', recallEmbedding: cfg.recallEmbedding || '', sessionBudgetChars: cfg.sessionBudgetChars ?? 80000,
790
840
  webPort: cfg.webPort ?? 7999, webCheckIntervalMs: cfg.webCheckIntervalMs ?? 5000, webMaxRestart: cfg.webMaxRestart ?? 10,
791
841
  }
package/lib/capture.js CHANGED
@@ -828,11 +828,46 @@ export function compressExcerpt(text, maxChars = 2400) {
828
828
  * @param {{provider?:string, model?:string, limit?:number}} [opts]
829
829
  * @returns {Promise<Array<{provider:string, model:string}>>}
830
830
  */
831
+ /**
832
+ * 尽力从宿主给的 agent / session 上读出「这一轮用的是哪条路由」(issue #42 需求 2:跟随当前会话)。
833
+ *
834
+ * 为什么是「尽力」:DSH 的会话对象不保证暴露 route —— 所以这里按**已知可能的字段名**逐个嗅探,
835
+ * 读不到就返回 null(调用方回退到显式配置),并把「看过哪些字段」回报给日志,便于以后补适配。
836
+ * 只读、不改任何状态。
837
+ *
838
+ * @returns {{provider:string, model:string, from:string}|null}
839
+ */
840
+ export function sessionRouteFrom(agent) {
841
+ if (!agent || typeof agent !== 'object') return null
842
+ const cands = []
843
+ const s = agent.session
844
+ const push = (obj, from, pKeys, mKeys) => {
845
+ if (!obj || typeof obj !== 'object') return
846
+ for (const pk of pKeys) {
847
+ const p = obj[pk]
848
+ if (typeof p === 'string' && p.trim()) {
849
+ let model = ''
850
+ for (const mk of mKeys) { const m = obj[mk]; if (typeof m === 'string' && m.trim()) { model = m.trim(); break } }
851
+ cands.push({ provider: p.trim(), model, from })
852
+ return
853
+ }
854
+ }
855
+ }
856
+ push(agent, 'agent', ['providerId', 'provider'], ['modelId', 'model'])
857
+ push(s, 'session', ['providerId', 'provider'], ['modelId', 'model'])
858
+ push(s && s.route, 'session.route', ['providerId', 'provider', 'id'], ['modelId', 'model', 'name'])
859
+ push(s && s.options, 'session.options', ['providerId', 'provider'], ['modelId', 'model'])
860
+ push(agent && agent.route, 'agent.route', ['providerId', 'provider', 'id'], ['modelId', 'model', 'name'])
861
+ return cands.find((c) => c.provider) || null
862
+ }
863
+
831
864
  export async function routeCandidates(llm, opts = {}) {
832
865
  const out = []
833
866
  const want = String(opts.provider || '').trim()
834
867
  const wantModel = String(opts.model || '').trim()
835
- const limit = Number(opts.limit) > 0 ? Number(opts.limit) : 4
868
+ // 「仅试首选」(issue #42 需求 3):只返回第一个候选,失败即停 —— 不轮换兜底。
869
+ // 用途:用户明确知道自己只有某家额度时,避免「先打官方 → 白烧一次调用 → 再轮换」。
870
+ const limit = opts.firstOnly ? 1 : (Number(opts.limit) > 0 ? Number(opts.limit) : 4)
836
871
  try {
837
872
  const providers = llm.listProviders() || []
838
873
  if (!providers.length) return out