memory-eternal 0.10.3 → 0.10.5
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 +8 -1
- package/README.md +11 -1
- package/bin/dsh-memory.mjs +53 -5
- package/index.js +51 -8
- package/lib/api.js +62 -8
- package/lib/capture.js +128 -20
- package/lib/client.js +11 -11
- package/lib/config-sync.js +137 -1
- package/lib/host-heartbeat.js +72 -0
- package/lib/watchdog.js +165 -3
- package/package.json +2 -2
- package/scripts/release.mjs +14 -0
- package/web/app.js +12 -12
|
@@ -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.5",
|
|
5
5
|
"description": "记忆核心(Memory Eternal):对话结束后自动沉淀知识卡到本地记忆库(~/.dsh/memory-vault),支持检索 / 召回 / 审核中心。",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "EternalNight996",
|
package/README.en.md
CHANGED
|
@@ -111,8 +111,11 @@ dsh-memory serve [--port 7999] # run web in foreground
|
|
|
111
111
|
dsh-memory open # ensure web alive + open browser
|
|
112
112
|
dsh-memory watchdog [--port 7799] # watchdog keep-alive (standalone process)
|
|
113
113
|
dsh-memory status [--json] # resident watchdog: pid / port / version / alive
|
|
114
|
+
# plus the version **actually served on the port**
|
|
114
115
|
dsh-memory stop [--port 7799] # stop the resident watchdog (+ the web it spawned)
|
|
116
|
+
# then re-checks who still holds the port and warns
|
|
115
117
|
dsh-memory restart [--port 7799] # stop + respawn (apply a new version)
|
|
118
|
+
# also reaps port owners the lock never registered, then verifies the served version
|
|
116
119
|
dsh-memory audit list [--status pending|rejected|approved|deleted|all] [--limit N] [--json]
|
|
117
120
|
# list cards; the total is never rewritten by --limit (which caps output only)
|
|
118
121
|
# all = audit queue (pending + rejected), excluding approved / deleted
|
|
@@ -135,7 +138,9 @@ Three concepts, don't mix them:
|
|
|
135
138
|
**Change these**: Vault left rail "Memory Config" (or DSH Settings → Memory) → edit the table and save; `autoWebMode` / `watchdogAutoSpawn` need a DSH restart.
|
|
136
139
|
|
|
137
140
|
> ⚠️ **Same port is never auto-replaced (#19)**: the watchdog is a **standalone process we deliberately never kill** (several sessions share one). So turning `watchdogAutoSpawn` off does **not** stop the running one, and changing `webCheckIntervalMs` / `webMaxRestart` has no effect on it.
|
|
138
|
-
> Stop or upgrade it explicitly with `dsh-memory stop [--port N]` / `dsh-memory restart [--port N]` (`stop` also stops the **web it spawned**, so no orphan keeps holding the port); `dsh-memory status` shows the resident pid, port, start time
|
|
141
|
+
> Stop or upgrade it explicitly with `dsh-memory stop [--port N]` / `dsh-memory restart [--port N]` (`stop` also stops the **web it spawned**, so no orphan keeps holding the port); `dsh-memory status` shows the resident pid, port, start time, whether its **version lags the installed package**, and separately the version **actually served on the port** (the lock's version is only the watchdog's self-report).
|
|
142
|
+
>
|
|
143
|
+
> ⚠️ **After upgrading, trust the served version (#23)**: `restart` no longer trusts the lock's `webPid` alone — it reaps whoever holds the port (including web processes the lock never registered; non-memory-eternal processes are only reported, never killed), and after spawning it verifies that the **served version equals the installed version**, exiting non-zero instead of printing "started" and walking away.
|
|
139
144
|
|
|
140
145
|
**MCP is a protocol, not a resident service**: the agent spawns it per session and exits when done — no "auto-start on boot" concept.
|
|
141
146
|
|
|
@@ -322,6 +327,8 @@ Diagnostics:
|
|
|
322
327
|
|
|
323
328
|
| Version | Date | Highlights |
|
|
324
329
|
|---|---|---|
|
|
330
|
+
| **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** |
|
|
331
|
+
| **v0.10.4** | 2026-10-07 | **Four "looks fine, silently wrong" issues fixed at once (#21 / #22 / #23 / #24).** ① **A save misreported by a host guard is no longer treated as failed (#21)**: the capability guard in hosts such as dsh-tui throws `root.events.emit is unavailable from a plugin activation` from the `describe()` call **inside** `settings.update`, even though the write has already been committed — the old code only asked "did it throw", so an **already-applied** change was retried 5 times, marked `dropped` and logged as a failure. New `applyPatchVerified` **reads the config back** after a throw (predicate `patchApplied`, shallow compare) and logs one line while treating it as success; drain and the standalone web share that path, and the standalone web's `POST /config` no longer claims "all good" unconditionally: it waits up to 800 ms for the pending file to be consumed and returns `pendingOutcome: applied / failed / queued` with matching wording (`waitPendingOutcome`). ② **`/cards` status no longer silently misaligns (#22)**: `status=deleted` now really lists the **recycle bin** (the numbers match `/recycle/list`), and an unrecognized value returns `unknownStatus: true` plus `appliedStatus` / `requestedStatus` echoes (the fallback to approved stays for old callers, but it is no longer silent). ③ **After an upgrade, trust the version actually served on the port (#23)**: `/overview` gains the `version` loaded by the **serving process**; `dsh-memory status` reports the **port's actual served version** and says "upgrade not in effect" when it differs (the lock file's version is only the watchdog's self-report; when the port holds a **pre-0.10.4** web nothing can be probed because its `/overview` has no `version` field yet, and the tool then says exactly that — "this plugin's web, but it does not report a version, i.e. an old build" — instead of wrongly claiming no service is responding); `stop` re-checks the port occupant afterwards and warns if it is still taken; `restart` no longer trusts only the webPid recorded in the lock — it falls back to the **port occupant** to reap an old web the lock never registered (only after confirming from the command line that it really is **this plugin's** web — the `memory-eternal` package name, or the absolute path of this plugin's own `lib/web.js`; someone else's `node web.js` is merely warned about, since killing an unrelated process that happens to hold that port is worse than not killing at all), aborts if the port never frees up, and self-checks "port-served version = local version" after startup, exiting non-zero on a mismatch (no more "started" and then silence). ④ **Distillation no longer misreads bare control characters inside strings as unparseable (#24)**: when the model writes a markdown body with real U+000A newlines (braces balanced, `looksTruncatedJson` false), the old code always failed `JSON.parse` and dropped the whole card into a raw-text fallback. New `repairJsonControlChars` escapes bare control characters **only inside strings** (`\n` `\r` `\t` `\b` `\f`, otherwise `\u00XX`; newlines outside strings are legal JSON whitespace and stay untouched), and candidates are tried as "as-is → sliced `{ … }` → each one's repaired form"; parse failures no longer swallow the raw `JSON.parse` error, and the excerpt now keeps **both head and tail** (`describeOutputExcerpt`; the old `slice(0,160)` made output cut mid-`"body":` look like a broken stream); the max-tokens retry went from "double once" to `maxTokenLadder` **doubling all the way to the schema ceiling** (1200 → 2400 → 4000, no longer stopping at 2400). New `tests/issue-fixes-2.test.mjs` (21 checks); `npm test` now runs **229 checks** |
|
|
325
332
|
| **v0.10.3** | 2026-10-06 | **Fixed the counting semantics of `audit list` (#20).** ① The total is now **decoupled from `--limit`**: the reported count is the **matching total** (via `countCards`) while `--limit` caps output only, and truncation is stated explicitly (`显示前 M 张 … 还有 X 张未显示`). ② **No more silent truncation by default** (omit `--limit`, or `--limit 0`, to list everything). ③ `--json` now exposes `total` / `returned` / `count` (a backward-compatible alias of total; in 0.10.2 it wrongly equalled returned) / `limit` (null = all) / `hasMore`. ④ The meaning of `--status all` is documented in the usage text and README: the audit queue (pending + rejected), **excluding** dequeued approved and recycle-bin deleted; `approved` remains queryable explicitly. ⑤ **Same defect fixed in the MCP `memory_audit_list` tool**: it also silently capped at 20 and reported the truncated length as the pending count; it now reports the matching total plus how many are hidden, cap 100 → 500. |
|
|
326
333
|
| **v0.10.2** | 2026-10-06 | **Five long-standing issues fixed at once (#15 / #16 / #17 / #18 / #19).** ① **Per-project vaults finally work in the host (#15-1)**: the host matched `vaultProfiles[].match.workspace` against the process cwd (the directory dsh was launched from) — it now uses the **session's own workspace** from `session.header.cwd` (both capture and `memory_recall`), while CLI/MCP/hooks keep their existing cwd behaviour. ② **Distillation stops burning doomed candidates (#15-2)**: providers declaring `supportsReasoningEffort:false` no longer receive a `reasoningEffort` field (unknown = supported), and they move behind supporting candidates instead of being dropped. ③ **Truncated output is no longer misreported as a parse failure (#18)**: a dedicated `MAX_TOKENS` code (with the actual cap in the message), one automatic retry with a doubled cap, failure reporting that **always keeps the first candidate's error as the primary cause** plus a per-code summary of the rest, default `captureMaxTokens` 900 → **2000**, and a `distill-failed` tag on fallback raw cards. ④ **Standalone-web config saves are no longer lost silently (#16)**: drain errors are no longer swallowed (stderr + capture log, throttled), and the shared `memory-eternal-config.json` is now written **atomically (tmp + rename)** — the old non-atomic write could be read mid-truncation by the standalone web / MCP, fail JSON.parse and silently fall back to defaults, the pending file is **kept** with `dropped:true` + `lastError` after retries are exhausted, and the state shows up in `/diagnostics` and `/config`. ⑤ **Audit CLI (#17)**: `dsh-memory audit list/approve/reject` (multiple paths, `--json` for jq) reuses the same `setCardStatus` guard and `audit_log`; MCP gains only a **read-only** `memory_audit_list`. ⑥ **Watchdog lifecycle (#19)**: new `dsh-memory status/stop/restart`, `pkgVersion` recorded in the lock (status warns when the resident instance is outdated), truthful `spawned` vs `delegated to existing pid` host logs, a clear notice when `watchdogAutoSpawn` is off but an instance is still running, `dsh-memory stop` also stops the **web server the watchdog spawned** (on Windows `process.kill(pid,'SIGTERM')` terminates unconditionally, so the watchdog's own exit handler never runs — the old behaviour left an orphan web holding the port; the web pid is recorded in the lock and confirmed against the process command line before killing, avoiding pid-reuse mistakes), plus README fixes for the contradictory default and the "restart turns it off" promise. |
|
|
327
334
|
| **v0.10.1** | 2026-10-02 | **Fixes "export works, import brings in 0 cards".** Root cause: "Export JSON" wrote a **bare array**, while `/import` only read `payload.cards` — a bare array was silently treated as an empty backup (`ok:true` / `imported:0` / `skipped:0`), so the UI only ever said "Import complete: 0". ① `/import` now accepts both a **bare array** (backups from v0.10.0 and earlier) and an **envelope object** `{format, formatVersion, exportedAt, count, cards}`; an unrecognized shape returns **400 with a readable reason**, an empty file returns a `warning`. ② Export now writes the envelope (older builds only read `payload.cards`, so the envelope stays compatible with them) and keeps each card's `status` / `store`. ③ The import dedup baseline is now a **pre-import snapshot** (new `dedupAgainst` option on `writeCard`) — otherwise near-identical cards inside one backup dedup against each other and swallow part of the batch (a backup is the source of truth). ④ The import response adds `total` / `skipped` / `quarantined` / `failed[]` (with a per-card reason), and the toast reads "Import complete: N / of M · pending review K · skipped D"; a run that imports nothing is shown as a failure. ⑤ New `tests/import-roundtrip.test.mjs` (6 checks). ⑥ **A broken install now speaks plainly (issue #14)**: a copy installed from the plugin marketplace can be missing its `web/` assets, and the sidebar then showed a raw `{"ok":false,"error":"ENOENT …"}` page. Now a missing `index.html` with `app.js` still present is covered by a **built-in shell** that boots the UI anyway (self-healing), while a missing `app.js` returns a JS snippet that paints the diagnosis into `#root` plus a page stating which file is gone, its absolute path, the package version and how to reinstall. Both cases land in the auto-capture log (`app.js` → `fail`, health turns red; `index.html` only → `warn`), and the host self-checks the same way at startup (shared predicates and wording in the new `lib/web-assets.js`, with 6 checks in `tests/web-assets.test.mjs`). ⑦ `appendCaptureLog` no longer fails silently when the `DSH_HOME` directory does not exist. ⑧ **DSH compatibility widened to `>=0.1.5-alpha.2 <0.3.0-0`**: the old `<0.2.0` upper bound excludes DSH `0.2.0-rc.x` under plain semver (DSH's guard uses `includePrerelease:true`, so installs still worked, but the declaration did not match reality) — the range now covers the whole 0.1/0.2 line while `<0.3.0-0` still blocks 0.3.0 and its prereleases; `dsh.compatibility` gains `"0.2.0-rc.2": "compatible"` (verified on the official desktop build 0.2.0-rc.2). ⑨ **Fixed three third-party plugin manifests that were invalid JSON**: the closing quote of `description` in `.claude-plugin` / `.codex-plugin` / `.cursor-plugin` had been swallowed by a double-encoding accident (last touched in v0.9.23); DSH never reads those files, so nothing surfaced, but installing through Claude Code / Codex / Cursor **always failed to parse**. They are now valid UTF-8 JSON with `version` bumped to 0.10.1, plus a new **shipped-files health guard** `tests/manifests.test.mjs` (3 checks: every shipped JSON parses / the three manifests' `name`+`version` match package.json / no encoding-accident mojibake in shipped text). Measured on a real **661-card bare-array backup (3.9 MB): 0 cards before the fix → 661 after** (654 into the main store + 7 into quarantine). `npm test` now runs **187 checks** |
|
package/README.md
CHANGED
|
@@ -122,8 +122,11 @@ dsh-memory serve [--port 7999] # 前台跑 web
|
|
|
122
122
|
dsh-memory open # ensure web 存活 + 开浏览器
|
|
123
123
|
dsh-memory watchdog [--port 7799] # 看门狗保活 web(独立进程)
|
|
124
124
|
dsh-memory status [--json] # 看常驻 watchdog:pid / 端口 / 版本 / 是否存活
|
|
125
|
+
# 以及**端口上真正在服务的版本**(锁里的版本号只是 watchdog 自述)
|
|
125
126
|
dsh-memory stop [--port 7799] # 停止常驻 watchdog(配置改动不会自动停;连它拉起的 web 一起停)
|
|
127
|
+
# 停完会再问一次端口占用者,仍被占着就明确告警
|
|
126
128
|
dsh-memory restart [--port 7799] # 停止并重起 watchdog(升级后换新版)
|
|
129
|
+
# 按端口占用者兜底收掉锁里没登记的旧 web,起完自检端口上服务的版本
|
|
127
130
|
dsh-memory audit list [--status pending|rejected|approved|deleted|all] [--limit N] [--json]
|
|
128
131
|
# 列出卡片;总数不受 --limit 影响,--limit 只限制显示条数(默认全部)
|
|
129
132
|
# all = 审核队列(pending + rejected),不含 approved / deleted
|
|
@@ -148,7 +151,12 @@ dsh-memory audit reject <卡片路径...> --reason "..." # 人工批量驳回
|
|
|
148
151
|
> ⚠️ **同端口不会自动替换(#19)**:看门狗是**独立进程且故意不杀**(多会话共用一个),所以
|
|
149
152
|
> 关闭 `watchdogAutoSpawn` **不会**停掉已经在跑的那个,改 `webCheckIntervalMs`/`webMaxRestart` 也不会生效。
|
|
150
153
|
> 要停止 / 换新版请显式执行 `dsh-memory stop [--port N]` 与 `dsh-memory restart [--port N]`(`stop` 会**连它拉起的 web 一起停**,不留占端口的孤儿);
|
|
151
|
-
> `dsh-memory status` 会显示常驻实例的 pid
|
|
154
|
+
> `dsh-memory status` 会显示常驻实例的 pid、端口、启动时间与**版本是否落后于本机安装的包**,并单独报出**端口上真正服务的版本**
|
|
155
|
+
> (锁文件里的版本号只是 watchdog 自述,端口上跑旧代码时它不会变)。
|
|
156
|
+
>
|
|
157
|
+
> ⚠️ **升级后请以「端口实际服务版本」为准(#23)**:`restart` 现在不只信锁里登记的 web 子进程,
|
|
158
|
+
> 还会按**端口占用者**兜底清理(锁里没登记的旧 web 也会被识别并收掉,非本插件的进程只告警不动手),
|
|
159
|
+
> 并在起完之后自检「端口上服务的版本 = 本机版本」——对不上就以非 0 退出码报失败,不再打印「已启动」就完事。
|
|
152
160
|
|
|
153
161
|
**MCP 是协议不是常驻服务**:agent 开会话才 spawn,用完即退,没有「开机自启」一说。
|
|
154
162
|
|
|
@@ -358,6 +366,8 @@ curl http://127.0.0.1:7999/memory-eternal/api/web-info # 独立 web 是否活
|
|
|
358
366
|
|
|
359
367
|
| 版本 | 日期 | 关键改动 |
|
|
360
368
|
|---|---|---|
|
|
369
|
+
| **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 项** |
|
|
370
|
+
| **v0.10.4** | 2026-10-07 | **四个「看着正常、实际静默错位」的 issue 集中修复(#21 / #22 / #23 / #24)**。① **宿主守卫误报的保存不再被当成失败(#21)**:dsh-tui 一类宿主的能力守卫会在 `settings.update` **内部**的 `describe()` 上抛 `root.events.emit is unavailable from a plugin activation`,而写入其实已经提交 —— 旧实现只认「有没有抛错」,会把**已生效**的改动重试 5 次后标成 `dropped`、日志里还报失败。新增 `applyPatchVerified`(抛错后**回读确认**,判据 `patchApplied` 浅比较,命中则记一行日志按成功处理),drain 与独立 Web 端共用这条路径;独立 Web 的 `POST /config` 也不再无条件回「一切正常」:等最多 800ms 回读 pending 文件,回 `pendingOutcome: applied / failed / queued` 并给对应措辞(`waitPendingOutcome`)。② **`/cards` 的 status 不再静默错位(#22)**:`status=deleted` 现在真的列**回收站**(与 `/recycle/list` 的数字对得上),未知取值显式回 `unknownStatus: true` 并回显 `appliedStatus` / `requestedStatus`(回落 approved 的老行为保留,但不再沉默)。③ **升级后以「端口上真正服务的版本」为准(#23)**:`/overview` 新增**当前服务进程**加载的 `version`;`dsh-memory status` 单独报「端口实际服务版本」,对不上就直说「升级尚未生效」(锁里的版本号只是 watchdog 自述;端口上是 **v0.10.4 之前**的旧 web 时探不到版本 —— 它的 `/overview` 还没有 `version` 字段 —— 这时会明说「是本插件的 web 但不会自报版本,即旧版」,而不是误报「没有服务在响应」);`stop` 停完再问一次端口占用者、仍被占就告警;`restart` 不再只信锁里登记的 webPid —— 按**端口占用者**兜底收掉锁里没登记的旧 web(先用命令行确认**确实是本插件的 web**(包名 `memory-eternal`,或本插件自己的 `lib/web.js` 绝对路径)才动手,别人的 `node web.js` 只告警不动手 —— 收紧这条判据是刻意的:误杀别人占着该端口的进程,比不杀更糟),端口没空出来就中止重启,起完自检「端口服务版本 = 本机版本」,对不上以非 0 退出(不再打印「已启动」就完事)。④ **蒸馏解析不再把「字符串里的裸控制字符」误判成无法解析(#24)**:模型把 markdown 正文的换行写成真实 U+000A(括号闭合、`looksTruncatedJson` 为 false)时,旧实现必然 `JSON.parse` 失败 → 整卡退成原文卡;新增 `repairJsonControlChars` **只在字符串内部**把裸控制字符转成合法转义(`\n` `\r` `\t` `\b` `\f`,其余 `\u00XX`;字符串外的换行是合法 JSON 空白,一律不动),候选串顺序为「原样 → 截 `{ … }` → 各自的已修复版」;解析失败的消息不再吞掉 `JSON.parse` 原始报错,现场摘要改为**首尾各留一段**(`describeOutputExcerpt`,旧的 `slice(0,160)` 恰好把「被切在 `"body":` 半截处」显示得像输出断了);撞输出上限的重试从「只翻倍一次」改为 `maxTokenLadder` **一路翻倍到 schema 上限**(1200 → 2400 → 4000,不再止步 2400)。新增 `tests/issue-fixes-2.test.mjs`(21 项),`npm test` 共 **229 项** |
|
|
361
371
|
| **v0.10.3** | 2026-10-06 | **修复 `audit list` 的计数语义(#20)**。① 总数与 `--limit` **解耦**:`共 N 张` 取**匹配总数**(`countCards`),`--limit` 只限制显示条数,截断时明确写出 `显示前 M 张 … 还有 X 张未显示`。② **默认不再静默截断**(不给 `--limit`、或 `--limit 0` = 全部显示)。③ `--json` 拆分为 `total`(匹配总数)/ `returned`(本次返回)/ `count`(= total 的兼容别名;0.10.2 里它误等于 returned)/ `limit`(null = 全部)/ `hasMore`。④ `--status all` 的语义写进用法与 README:= 审核队列(pending + rejected),**不含**已出队的 approved 与回收中心的 deleted;`approved` 可显式查询。⑤ **同源修复 MCP 的 `memory_audit_list`**:它同样存在「默认 20 静默截断 + 把截断条数报成待处理数」,现改为报匹配总数 + 未显示提示,上限 100 → 500。 |
|
|
362
372
|
| **v0.10.2** | 2026-10-06 | **五个存量 issue 集中修复(#15 / #16 / #17 / #18 / #19)**。① **按项目选库在宿主侧终于生效(#15-1)**:宿主过去拿进程 cwd 去匹配 `vaultProfiles[].match.workspace`,而那是「启动 dsh 的目录」——现在从 `session.header.cwd` 取**会话自己的工作区**(capture 与 memory_recall 两条路径都传),CLI/MCP/hooks 行为不变。② **蒸馏不再选错 provider(#15-2)**:声明 `supportsReasoningEffort:false` 的候选不再被传 `reasoningEffort`(能力未知一律按支持处理),并把它们排到支持者之后但**不删除**,避免兜底全无。③ **输出截断不再伪装成解析失败(#18)**:新增独立错误码 `MAX_TOKENS`(含实际上限值),撞上限自动以双倍上限重试同一候选;兜底上报改为**始终以第一候选为主因** + 其余错误按码汇总(不再被最后一个候选覆盖);默认 `captureMaxTokens` 900 → **2000**;失败兜底的原文卡加 `distill-failed` 标签便于过滤。④ **独立 Web 端保存配置不再静默丢失(#16)**:drain 报错不再被 `catch {}` 吞掉(进 stderr + 自动沉淀日志,5 分钟去重节流),共享配置 `memory-eternal-config.json` 改为 **tmp + rename 原子写**(原先非原子写会在截断窗口内被独立 web / MCP 读到半截内容 → JSON.parse 失败 → 设置静默回落成默认值),重试耗尽后**保留 pending 文件**并标注 `dropped:true` + `lastError`(不再删除用户改动),失败状态进 `/diagnostics` 与 `/config` 响应。⑤ **审核中心有了 CLI(#17)**:`dsh-memory audit list/approve/reject`(多路径批量、`--json` 便于 jq 筛选、复用同一套 `setCardStatus` 守卫与 `audit_log`);MCP 侧只新增**只读**的 `memory_audit_list`,不暴露 approve/reject。⑥ **看门狗有了生命周期(#19)**:新增 `dsh-memory status/stop/restart`,锁文件写入 `pkgVersion`(status 会提示「常驻实例是旧版」),宿主日志按抢锁结果如实打印 `spawned` / `delegated to existing pid`(不再每次都谎报 spawned),关闭 `watchdogAutoSpawn` 时明确提示既有实例仍在跑;并且 `dsh-memory stop` 会**连同该 watchdog 拉起的 web 一起停**(Windows 上 `process.kill(pid,'SIGTERM')` 是无条件终止,watchdog 的退出处理器根本不会执行 —— 旧写法会在 stop 后留下一个占着端口的孤儿 web;web 子进程 pid 现在记在锁里,杀之前还会用进程命令行确认确实是 `web.js`,防 pid 复用误杀);README 修正「默认值自相矛盾」与「重启即可关闭」的错误承诺。 |
|
|
363
373
|
| **v0.10.1** | 2026-10-02 | **修复「导出 OK、导入 0 张」**。根因:客户端「导出JSON」写出的是**裸数组**,而 `/import` 只读 `payload.cards` —— 裸数组被静默当成空备份(`ok:true` / `imported:0` / `skipped:0`),UI 只显示「导入完成:0」,看不出是格式不匹配。① `/import` 现在同时接受**裸数组**(v0.10.0 及以前的备份)与**信封对象** `{format, formatVersion, exportedAt, count, cards}`;形状不认识 → **400 + 人话原因**,空文件 → `warning`,坏 JSON → 原有的解析失败提示;② 导出改为**带信封**(老版本只认 `payload.cards`,信封对老版本也互通)并保留每张卡的 `status` / `store`;③ 导入去重基线改为**导入前快照**(`writeCard` 新增 `dedupAgainst`)—— 否则同一份备份里互相近似的卡会在写入过程中互相判重、被自己人吞掉一批(备份是真相源);④ 导入应答补 `total` / `skipped` / `quarantined` / `failed[]`(含每条未导入原因),提示语改为「导入完成:N 张 / 文件共 M 张 · 待审核 K · 跳过 D」,一张都没进来时按**失败样式**提示;⑤ 新增 `tests/import-roundtrip.test.mjs`(6 项:裸数组 / 信封 / 导出→导入全量往返 / 重复导入如实报重复 / 坏形状 / 审核守卫不可绕过)。⑥ **装坏了也要说人话(issue #14)**:从插件市场装出来的副本可能缺 `web/` 静态资源,那时侧边栏「记忆」只会弹一页 `{"ok":false,"error":"ENOENT …"}` —— 现在缺 `index.html` 但 `app.js` 还在就用**内置外壳**把界面拉起来(自救),缺 `app.js` 则回一段把诊断画进 `#root` 的 JS + 一页含「缺哪个文件 / 绝对路径 / 包版本 / 怎么重装」的说明;两种缺失都写进自动沉淀日志(`app.js` → `fail` 健康态亮红、只缺 `index.html` → `warn`),宿主启动时也自检一次(判据与措辞由新增的 `lib/web-assets.js` 统一,附 `tests/web-assets.test.mjs` 6 项)。⑦ 顺带修 `appendCaptureLog` 在 `DSH_HOME` 目录不存在时静默失败(诊断信息连一条都留不下)。⑧ **DSH 兼容范围放宽到 `>=0.1.5-alpha.2 <0.3.0-0`**:旧上界 `<0.2.0` 按 semver 会把 DSH `0.2.0-rc.x` 排除在外(DSH 守卫用 `includePrerelease:true`,实际装得上,但声明与事实不符)——现在覆盖整个 0.1/0.2 线,`<0.3.0-0` 仍挡住 0.3.0 及其预发布;`dsh.compatibility` 同步补 `"0.2.0-rc.2": "compatible"`(本机在官方桌面版 0.2.0-rc.2 上实测运行)。⑨ **修三个第三方插件清单长期非法 JSON**:`.claude-plugin` / `.codex-plugin` / `.cursor-plugin` 的 `description` 收尾引号被双重编码吃掉(最后改动停在 v0.9.23),DSH 不走这三个文件所以一直没暴露,但 Claude Code / Codex / Cursor 侧安装**必然解析失败**;现已重写为合法 UTF-8 JSON、`version` 跟到 0.10.1,并新增**随包体检守卫** `tests/manifests.test.mjs`(3 项:随包 JSON 全部可解析 / 三清单 `name`+`version` 与 package.json 一致 / 随包文本无编码事故乱码)。实测用户 **661 张真实备份(裸数组、3.9MB):修复前导入 0 张 → 修复后 661 张**(654 进主库 + 7 进隔离区)。`npm test` 共 **187 项** |
|
package/bin/dsh-memory.mjs
CHANGED
|
@@ -122,34 +122,52 @@ async function main() {
|
|
|
122
122
|
return
|
|
123
123
|
}
|
|
124
124
|
case 'status': {
|
|
125
|
-
const { watchdogStatus } = await import('../lib/watchdog.js')
|
|
125
|
+
const { watchdogStatus, probeServedVersion, findPortListener, looksLikeOurWeb } = await import('../lib/watchdog.js')
|
|
126
126
|
const port = argOf('--port')
|
|
127
127
|
const st = watchdogStatus({ port: port || undefined })
|
|
128
|
-
|
|
128
|
+
// issue #23:锁里的 pkgVersion 是「看门狗自述」,与端口上真正跑着的代码无关 ——
|
|
129
|
+
// 升级后旧 web 仍占端口时它会骗人地报新版本。这里直接问端口上服务的那个进程。
|
|
130
|
+
const probePort = Number(port) || (st.watchdogs[0] && Number(st.watchdogs[0].port)) || 0
|
|
131
|
+
const served = probePort ? await probeServedVersion(probePort) : ''
|
|
132
|
+
const occupant = probePort && !served ? await findPortListener(probePort) : null
|
|
133
|
+
// 旧版(< v0.10.4)的 /overview 里**没有 version 字段**,所以探不到版本不等于「不是我们的服务」。
|
|
134
|
+
// 这正是 v0.10.3 → v0.10.4 升级时的常见现场,必须说清楚,否则用户会以为端口空着。
|
|
135
|
+
const oursNoVersion = Boolean(occupant && occupant.pid && looksLikeOurWeb(occupant.command))
|
|
136
|
+
if (has('--json')) { console.log(JSON.stringify({ ok: true, ...st, servedPort: probePort, servedVersion: served, portOccupant: occupant, portOccupantIsOurs: oursNoVersion }, null, 2)); return }
|
|
129
137
|
console.log(`锁文件:${st.lockPath}`)
|
|
130
138
|
console.log(`本机包版本:${st.pkgVersion || '?'}`)
|
|
139
|
+
if (probePort) {
|
|
140
|
+
if (served) console.log(`端口 ${probePort} 实际服务版本:${served}`)
|
|
141
|
+
else if (oursNoVersion) console.log(`端口 ${probePort} 上跑的是本插件的 web(pid ${occupant.pid}),但它不会自报版本 —— 说明是 v0.10.4 之前的旧版,升级尚未生效 → 执行 dsh-memory restart --port ${probePort}`)
|
|
142
|
+
else console.log(`端口 ${probePort} 上没有本插件的服务在响应${occupant && occupant.pid ? `(被 pid ${occupant.pid} 占用:${occupant.command || '命令行未知'})` : ''}`)
|
|
143
|
+
}
|
|
131
144
|
if (!st.watchdogs.length) { console.log('锁文件里没有登记的看门狗(没有常驻实例)。'); return }
|
|
132
145
|
for (const w of st.watchdogs) {
|
|
133
146
|
console.log(`- pid ${w.pid} · 端口 ${w.port} · ${w.alive ? '运行中' : '已退出(陈旧记录)'}${w.pkgVersion ? ' · v' + w.pkgVersion : ''}${w.startedAt ? ' · 启动于 ' + w.startedAt : ''}${w.vault ? ' · vault ' + w.vault : ''}`)
|
|
134
147
|
if (w.webPid) console.log(` web 子进程:pid ${w.webPid}(端口 ${w.webPort || w.port},${w.webAlive ? '运行中' : '已退出'})`)
|
|
135
148
|
if (w.versionMismatch) console.log(` ⚠ 常驻实例是旧版本(v${w.pkgVersion} ≠ 本机 v${st.pkgVersion})→ 执行 dsh-memory restart --port ${w.port} 替换`)
|
|
149
|
+
if (probePort && Number(w.port) === probePort && served && st.pkgVersion && served !== st.pkgVersion) {
|
|
150
|
+
console.log(` ⚠ 端口 ${probePort} 上真正服务的是 v${served}(本机 v${st.pkgVersion})—— 升级尚未生效,执行 dsh-memory restart --port ${probePort}`)
|
|
151
|
+
}
|
|
136
152
|
}
|
|
137
153
|
return
|
|
138
154
|
}
|
|
139
155
|
case 'stop': {
|
|
140
156
|
const { stopWatchdogs } = await import('../lib/watchdog.js')
|
|
141
157
|
const port = argOf('--port')
|
|
142
|
-
|
|
158
|
+
// checkPort:停完再问一次「端口上还有谁在服务」,把「停了却还占着端口」显式说出来(#23)
|
|
159
|
+
const out = await stopWatchdogs({ port: port || undefined, checkPort: true })
|
|
143
160
|
if (has('--json')) { console.log(JSON.stringify({ ok: out.failed.length === 0, ...out }, null, 2)); return }
|
|
144
161
|
if (!out.stopped.length && !out.failed.length && !out.webStopped.length) console.log('没有需要停止的常驻看门狗(锁里没有活着的实例)。')
|
|
145
162
|
if (out.stopped.length) console.log(`已停止看门狗:pid ${out.stopped.join(', ')}`)
|
|
146
163
|
if (out.webStopped.length) console.log(`已停止它拉起的 web:pid ${out.webStopped.join(', ')}`)
|
|
164
|
+
for (const w of out.warnings || []) console.error('⚠ ' + w)
|
|
147
165
|
if (out.failed.length) { console.error(`未能停止:pid ${out.failed.join(', ')}(可能无权限,请手动任务管理器 / kill 处理)`); process.exitCode = 1 }
|
|
148
166
|
return
|
|
149
167
|
}
|
|
150
168
|
case 'restart': {
|
|
151
169
|
// 显式替换常驻实例(#19):同端口已有实例时新进程只会让位,所以必须先停再起。
|
|
152
|
-
const { stopWatchdogs } = await import('../lib/watchdog.js')
|
|
170
|
+
const { stopWatchdogs, findPortListener, waitForServedVersion, probeServedVersion, looksLikeOurWeb, currentPkgVersion } = await import('../lib/watchdog.js')
|
|
153
171
|
const { nodeBinary, childEnv } = await import('../lib/node-bin.js')
|
|
154
172
|
const { defaultVaultDir } = await import('../lib/capture-run.js')
|
|
155
173
|
const { spawn } = await import('node:child_process')
|
|
@@ -158,10 +176,28 @@ async function main() {
|
|
|
158
176
|
const interval = Number(argOf('--interval')) || 5000
|
|
159
177
|
const maxRestart = Number(argOf('--max-restart')) || 10
|
|
160
178
|
const vaultRoot = argOf('--vault') ? path.resolve(argOf('--vault')) : defaultVaultDir()
|
|
161
|
-
|
|
179
|
+
// 停的时候不只信锁里登记的 webPid —— 锁可能没写 / 写了却枚举不到,正是
|
|
180
|
+
// 「restart 报成功、端口上还是旧代码」的漏网处(issue #23):直接按端口占用者兜底收掉。
|
|
181
|
+
const stopped = await stopWatchdogs({ port, checkPort: true, forcePortOccupant: true })
|
|
162
182
|
if (stopped.failed.length) { console.error(`旧实例未能停止:pid ${stopped.failed.join(', ')},放弃重启`); process.exitCode = 1; return }
|
|
163
183
|
if (stopped.stopped.length) console.log(`已停止旧实例:pid ${stopped.stopped.join(', ')}`)
|
|
164
184
|
if (stopped.webStopped && stopped.webStopped.length) console.log(`已停止旧实例拉起的 web:pid ${stopped.webStopped.join(', ')}`)
|
|
185
|
+
if (stopped.portStopped && stopped.portStopped.length) console.log(`已停止占着端口 ${port} 的旧 web(锁里没登记):pid ${stopped.portStopped.join(', ')}`)
|
|
186
|
+
for (const w of stopped.warnings || []) console.error('⚠ ' + w)
|
|
187
|
+
// 端口必须真的空出来:否则新 web 只能退到 port+1,旧代码继续在目标端口上服务
|
|
188
|
+
let left = await findPortListener(port)
|
|
189
|
+
if (left && left.pid && looksLikeOurWeb(left.command)) {
|
|
190
|
+
try { process.kill(left.pid, 'SIGTERM') } catch { /* 无权限只能报错 */ }
|
|
191
|
+
const releaseDeadline = Date.now() + 5000
|
|
192
|
+
while (Date.now() < releaseDeadline && (left = await findPortListener(port))) await new Promise((r) => setTimeout(r, 250))
|
|
193
|
+
}
|
|
194
|
+
left = await findPortListener(port)
|
|
195
|
+
if (left && left.pid) {
|
|
196
|
+
console.error(`端口 ${port} 仍被 pid ${left.pid} 占用(${left.command || '命令行未知'}),新实例无法接管该端口,已中止重启。`)
|
|
197
|
+
console.error(`请先结束该进程,或用 --port 换一个端口:dsh-memory restart --port <其它端口>`)
|
|
198
|
+
process.exitCode = 1
|
|
199
|
+
return
|
|
200
|
+
}
|
|
165
201
|
const bin = nodeBinary()
|
|
166
202
|
const script = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'lib', 'watchdog.js')
|
|
167
203
|
const wd = spawn(bin, [script, '--port', String(port), '--interval', String(interval), '--max-restart', String(maxRestart), '--vault', vaultRoot], {
|
|
@@ -169,6 +205,18 @@ async function main() {
|
|
|
169
205
|
})
|
|
170
206
|
wd.unref()
|
|
171
207
|
console.log(`已在后台启动新看门狗 pid=${wd.pid} port=${port} vault=${vaultRoot}`)
|
|
208
|
+
// 起完必须自检「端口上真正服务的版本」(issue #23 建议 2):只看 spawn 成功就报
|
|
209
|
+
// 「已启动」,正是升级静默不生效时骗人的那一步 —— 端口上服务的版本必须等于本机版本。
|
|
210
|
+
const expect = currentPkgVersion()
|
|
211
|
+
const check = await waitForServedVersion(port, expect, { timeoutMs: 20000 })
|
|
212
|
+
if (check.ok) {
|
|
213
|
+
console.log(`自检通过:端口 ${port} 正在服务 v${check.version}(等待 ${Math.round(check.waitedMs / 1000)}s)`)
|
|
214
|
+
} else {
|
|
215
|
+
const served = check.version || (await probeServedVersion(port)) || ''
|
|
216
|
+
console.error(`自检失败:端口 ${port} 上服务的版本是 ${served || '(无响应)'},本机是 v${expect || '?'} —— 升级可能未生效。`)
|
|
217
|
+
console.error(`排查:dsh-memory status --port ${port};若端口被别的进程占着,先结束它再 restart。`)
|
|
218
|
+
process.exitCode = 1
|
|
219
|
+
}
|
|
172
220
|
return
|
|
173
221
|
}
|
|
174
222
|
case 'audit': {
|
package/index.js
CHANGED
|
@@ -30,10 +30,11 @@ import { migrateFromMarkdown, setAuditConfig, backupDb } from './lib/db.js'
|
|
|
30
30
|
import { evaluateStall, MAX_STAMPS } from './lib/stall.js'
|
|
31
31
|
import { resolveVaultDir, currentWorkspace } from './lib/vault-resolve.js'
|
|
32
32
|
import { createHub } from './lib/sse.js'
|
|
33
|
-
import { summarizeTurn, summarizeTurnDetailed, routeCandidates, extractLastTurn, sliceNewEvents, sessionEvents, sessionEventApi, createCaptureHealth, resolveRoute, captureCard, captureUpdate, pickNeighbors, hasUsableContent, deriveTitle, looksTruncated, DEFAULT_CAPTURE_MAX_TOKENS, MAX_CAPTURE_MAX_TOKENS } from './lib/capture.js'
|
|
33
|
+
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
34
|
import { createApi, json, encodeBody } from './lib/api.js'
|
|
35
35
|
import { appendCaptureLog, readCaptureLog, rotateCaptureLog } from './lib/capture-log.js'
|
|
36
36
|
import { missingWebAssets, missingAssetsReason } from './lib/web-assets.js'
|
|
37
|
+
import { writeHostMarker, clearHostMarker } from './lib/host-heartbeat.js'
|
|
37
38
|
import { nodeBinary, childEnv } from './lib/node-bin.js'
|
|
38
39
|
|
|
39
40
|
export const name = 'memory-eternal'
|
|
@@ -362,10 +363,32 @@ export function apply(ctx, config) {
|
|
|
362
363
|
try { console.error('[memory-eternal] ' + why) } catch { /* 日志失败无妨 */ }
|
|
363
364
|
try { logCapture('system', dropped ? 'fail' : 'warn', why) } catch { /* 日志失败无妨 */ }
|
|
364
365
|
}
|
|
366
|
+
// issue #21:dsh-tui 一类宿主的能力守卫会在 settings.update **内部**的 describe() 上抛
|
|
367
|
+
// 「root.events.emit is unavailable from a plugin activation」(守卫只区分「第三方插件 vs
|
|
368
|
+
// host 内部代码」,识别不出「官方服务在插件调用链里执行的合法动作」,上游已开 issue)。
|
|
369
|
+
// 关键点:抛错 ≠ 没写进去 —— 只认有没有抛错,会把其实已经生效的改动重试 5 次后标成
|
|
370
|
+
// dropped,日志里还报失败。applyPatchVerified 在抛错后回读确认(带守卫的宿主上这是
|
|
371
|
+
// 唯一能自证的信号),判据与实现都在 lib/config-sync.js 里(有单测)。
|
|
372
|
+
//
|
|
373
|
+
// 注意:把 drain 推迟到 activation 之后**解决不了**这个问题 —— 上游 dsh-tui 用
|
|
374
|
+
// AsyncLocalStorage.run(token) 包住每次插件回调,并 patch 了 Fiber._execute,定时器 /
|
|
375
|
+
// fs.watch 的每一次回调都带着 activation token(见 ccch1mneyyy/dsh-TUI#1348 的定位)。
|
|
376
|
+
const applyPendingPatch = async (patch) => {
|
|
377
|
+
const { applyPatchVerified } = await import('./lib/config-sync.js')
|
|
378
|
+
return applyPatchVerified(patch, {
|
|
379
|
+
apply: (p) => settings.update(p, undefined),
|
|
380
|
+
read: () => settings.get() ?? {},
|
|
381
|
+
onRepaired: (error) => {
|
|
382
|
+
try {
|
|
383
|
+
console.error('[memory-eternal] settings.update 抛错但回读确认改动已生效(宿主守卫误报),按成功处理:' + String((error && error.message) || error))
|
|
384
|
+
} catch { /* 日志失败无妨 */ }
|
|
385
|
+
},
|
|
386
|
+
})
|
|
387
|
+
}
|
|
365
388
|
const drainPending = async () => {
|
|
366
389
|
try {
|
|
367
390
|
const { drainPendingConfig } = await import('./lib/config-sync.js')
|
|
368
|
-
const applied = await drainPendingConfig(process.env,
|
|
391
|
+
const applied = await drainPendingConfig(process.env, applyPendingPatch)
|
|
369
392
|
if (applied) {
|
|
370
393
|
lastDrainError = ''
|
|
371
394
|
syncConfigFile()
|
|
@@ -383,14 +406,31 @@ export function apply(ctx, config) {
|
|
|
383
406
|
ctx.effect(() => () => clearInterval(pendingTimer), 'memory-eternal: pending config sync')
|
|
384
407
|
ctx.effect(() => {
|
|
385
408
|
let stop = () => {}
|
|
409
|
+
// 动态 import 是异步的:若宿主在它 resolve 之前就 dispose(测试里的假 ctx 会立刻 dispose),
|
|
410
|
+
// 后到的监听器就再也没人清理 —— 会一直挂住事件循环。用一个 disposed 标记堵住这个窗口。
|
|
411
|
+
let disposed = false
|
|
386
412
|
import('./lib/config-sync.js').then(({ watchPendingConfig }) => {
|
|
413
|
+
if (disposed) return
|
|
387
414
|
stop = watchPendingConfig(process.env, () => {
|
|
388
415
|
drainPending().then(() => hub.broadcast('config', { at: Date.now(), source: 'pending' })).catch(() => {})
|
|
389
416
|
})
|
|
390
417
|
}).catch(() => {})
|
|
391
|
-
return () => { try { stop() } catch { /* 已停止 */ } }
|
|
418
|
+
return () => { disposed = true; try { stop() } catch { /* 已停止 */ } }
|
|
392
419
|
}, 'memory-eternal: pending config watch')
|
|
393
420
|
|
|
421
|
+
// 宿主心跳(issue #21):告诉独立 Web 端「本机有 DSH 宿主在跑」,这样它就不会绕过宿主
|
|
422
|
+
// 直接写共享配置(绕过会与宿主的 volatile 配置分叉)。心跳必须定期刷新:进程被硬杀时
|
|
423
|
+
// 没有机会删文件,独立端靠「新鲜度 + pid 存活」两个条件判定,所以宁可多刷几次。
|
|
424
|
+
ctx.effect(() => {
|
|
425
|
+
const beat = () => { try { writeHostMarker(process.env, { version: versionRef }) } catch { /* 心跳失败不影响主流程 */ } }
|
|
426
|
+
beat()
|
|
427
|
+
// unref:心跳只是「我在跑」的旁证,不该成为阻止进程退出的最后一根钉子
|
|
428
|
+
// (测试里以假 ctx 跑 apply() 时会因此挂住不退,实测过一次)。
|
|
429
|
+
const timer = setInterval(beat, 15000)
|
|
430
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
431
|
+
return () => { clearInterval(timer); try { clearHostMarker(process.env) } catch { /* 清理失败无妨 */ } }
|
|
432
|
+
}, 'memory-eternal: host heartbeat')
|
|
433
|
+
|
|
394
434
|
// 所有 profile 目录(当前激活 + 其余命名的),供跨库聚合。
|
|
395
435
|
const vaultRoots = (workspace) => {
|
|
396
436
|
const cfg = settings.get() ?? {}
|
|
@@ -591,12 +631,15 @@ export function apply(ctx, config) {
|
|
|
591
631
|
for (let i = 0; i < routes.length; i++) {
|
|
592
632
|
route = routes[i]
|
|
593
633
|
let detailed = await summarizeTurnDetailed(llm, route, text, { signal: AbortSignal.timeout(45000), existing: neighbors, maxTokens })
|
|
594
|
-
// #18 建议 3
|
|
595
|
-
//
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
634
|
+
// #18 建议 3 + #24:撞输出上限不是「模型坏了」,把上限一路翻倍到 schema 上限。
|
|
635
|
+
// 旧实现只翻倍**一次**:1200 → 2400 仍不够就照样退成原文卡,尽管 4000 本来装得下
|
|
636
|
+
// (issue #24 里 9 次失败中有一半正是被这一限制吃掉的)。
|
|
637
|
+
let prevBudget = maxTokens
|
|
638
|
+
for (const bigger of maxTokenLadder(maxTokens)) {
|
|
639
|
+
logCapture(sessionId, 'listen', `输出撞到 maxTokens=${prevBudget} 上限 → 以 ${bigger} 重试同一候选(${route.provider}/${route.model})`)
|
|
599
640
|
detailed = await summarizeTurnDetailed(llm, route, text, { signal: AbortSignal.timeout(45000), existing: neighbors, maxTokens: bigger })
|
|
641
|
+
prevBudget = bigger
|
|
642
|
+
if (!(detailed.failure && detailed.failure.code === 'MAX_TOKENS')) break
|
|
600
643
|
}
|
|
601
644
|
if (detailed.card !== undefined) { result = detailed.card; failure = null; break }
|
|
602
645
|
if (detailed.failure) {
|
package/lib/api.js
CHANGED
|
@@ -50,7 +50,15 @@ export function createApi(deps = {}) {
|
|
|
50
50
|
switch (route) {
|
|
51
51
|
case '/overview': {
|
|
52
52
|
await ensureVault(vaultRoot)
|
|
53
|
-
|
|
53
|
+
// version = **当前正在服务这个端口的进程**加载的包版本(issue #23):
|
|
54
|
+
// 磁盘上升级了、端口上跑的还是旧进程时,只有这个字段说得清真相(锁文件里的
|
|
55
|
+
// pkgVersion 是 watchdog 自己写的,与端口上真正服务的代码无关)。
|
|
56
|
+
json(res, 200, {
|
|
57
|
+
ok: true,
|
|
58
|
+
vaultDir: vaultRoot,
|
|
59
|
+
version: (getDshInfo() || {}).version || process.env.MEMORY_ETERNAL_VERSION || '',
|
|
60
|
+
...(await overview(vaultRoot)),
|
|
61
|
+
})
|
|
54
62
|
return
|
|
55
63
|
}
|
|
56
64
|
case '/cards': {
|
|
@@ -62,24 +70,36 @@ export function createApi(deps = {}) {
|
|
|
62
70
|
const offset = Math.max(Number(query.get('offset')) || 0, 0)
|
|
63
71
|
const sort = query.get('sort') === 'title' ? 'title' : 'recent'
|
|
64
72
|
const agent = query.get('agent') || '' // 服务端署名过滤(如 agent=deepseek-harness)
|
|
65
|
-
// all = 全部(含 pending/rejected,排除回收站);默认/approved =
|
|
66
|
-
//
|
|
73
|
+
// all = 全部(含 pending/rejected,排除回收站);默认/approved = 已审核;
|
|
74
|
+
// deleted = 回收站(隔离区里 status=deleted,与 /card、/recycle/list 保持一致,见 issue #22)。
|
|
75
|
+
// 白名单(P0 修复):除下面四种取值外一律按 approved 处理,未知 status 不再可能越权列未审核卡。
|
|
76
|
+
const KNOWN_STATUS = ['all', 'approved', 'pending', 'rejected', 'deleted']
|
|
67
77
|
const want = status === 'all' ? ['approved', 'pending', 'rejected']
|
|
68
78
|
: status === 'pending' ? ['pending']
|
|
69
79
|
: status === 'rejected' ? ['rejected']
|
|
80
|
+
: status === 'deleted' ? ['deleted']
|
|
70
81
|
: ['approved']
|
|
82
|
+
// issue #22:unknown status 以前被静默当成 approved,调用方拿到「看起来正常」的主库内容
|
|
83
|
+
// 却无从察觉(total 与 /recycle/list 打架就是这么来的)。这里回显**实际生效**的 status,
|
|
84
|
+
// 并对无法识别的取值显式置 unknownStatus:true —— 回落行为保留(不破坏老调用方),但不再沉默。
|
|
85
|
+
const appliedStatus = status === 'all' ? 'all'
|
|
86
|
+
: status === 'pending' ? 'pending'
|
|
87
|
+
: status === 'rejected' ? 'rejected'
|
|
88
|
+
: status === 'deleted' ? 'deleted'
|
|
89
|
+
: 'approved'
|
|
90
|
+
const unknownStatus = !KNOWN_STATUS.includes(status)
|
|
71
91
|
if (q.trim()) {
|
|
72
92
|
// 检索是相关性排序:先把命中集合取齐,再在内存里分页(保证 total 与顺序一致)
|
|
73
93
|
const hits = await search(vaultRoot, q, { limit: 500 })
|
|
74
94
|
const hitPaths = new Set(hits.map((h) => h.path))
|
|
75
95
|
let cards = (await listCards(vaultRoot, { status: want, kind, agent })).filter((c) => hitPaths.has(c.path))
|
|
76
96
|
const total = cards.length
|
|
77
|
-
json(res, 200, { ok: true, cards: cards.slice(offset, offset + limit), total, offset, limit })
|
|
97
|
+
json(res, 200, { ok: true, cards: cards.slice(offset, offset + limit), total, offset, limit, appliedStatus, requestedStatus: status, unknownStatus })
|
|
78
98
|
return
|
|
79
99
|
}
|
|
80
100
|
const total = await countCards(vaultRoot, { status: want, kind, agent })
|
|
81
101
|
const cards = await listCards(vaultRoot, { status: want, kind, agent, limit, offset, sort })
|
|
82
|
-
json(res, 200, { ok: true, cards, total, offset, limit })
|
|
102
|
+
json(res, 200, { ok: true, cards, total, offset, limit, appliedStatus, requestedStatus: status, unknownStatus })
|
|
83
103
|
return
|
|
84
104
|
}
|
|
85
105
|
case '/card': {
|
|
@@ -468,19 +488,53 @@ export function createApi(deps = {}) {
|
|
|
468
488
|
}
|
|
469
489
|
if (bad.length) return json(res, 400, { ok: false, error: '字段值不合法:' + bad.join('、'), fields: bad })
|
|
470
490
|
if (!Object.keys(clean).length) return json(res, 400, { ok: false, error: '没有可写入的字段' })
|
|
471
|
-
const { writePendingConfig, readPendingConfig } = await import('./config-sync.js')
|
|
491
|
+
const { writePendingConfig, readPendingConfig, waitPendingOutcome, clearPendingConfig, applySharedConfigDirect, describeApplyFailure } = await import('./config-sync.js')
|
|
472
492
|
// #16 建议 4:上一份改动是否已被判定失败?是的话这次不能再报「一切正常」——
|
|
473
493
|
// 否则用户第 N 次点击保存,看到的仍是成功,而问题一直在(其实是 settings.update 在报错)。
|
|
474
494
|
const before = readPendingConfig(process.env)
|
|
475
495
|
const out = writePendingConfig(process.env, clean)
|
|
476
496
|
hub.broadcast('config', { at: out.at, source: 'standalone', applied: Object.keys(clean) })
|
|
497
|
+
// issue #21 建议 3:别急着报「一切正常」。宿主在跑时对 pending 文件有 fs.watch
|
|
498
|
+
// (毫秒级),稍等一下回读,就能分清「真的应用了」还是「只是排上队了」——
|
|
499
|
+
// 以前无论哪种都回 ok:true + 一句泛泛的 note,用户只会在几秒后从日志里发现失败。
|
|
500
|
+
let outcome = await waitPendingOutcome(process.env, { timeoutMs: 800 })
|
|
501
|
+
// issue #21 的宿主无关路径:**本机没有活着的 DSH 宿主**时(Codex / Claude Code /
|
|
502
|
+
// 只跑 dsh-memory serve 的用户),没有任何进程会来 drain pending 文件 —— 那句
|
|
503
|
+
// 「DSH 未运行时下次启动生效」在那类环境下永远不会兑现。这时直接原子写共享配置
|
|
504
|
+
// (hooks / MCP / 独立页都读这个文件),让保存当场生效。
|
|
505
|
+
// 宿主活着时绝不走这条:共享文件是宿主的派生镜像,绕过它直写会被下一次 syncConfigFile 盖掉。
|
|
506
|
+
let appliedDirect = false
|
|
507
|
+
if (outcome === 'queued') {
|
|
508
|
+
const { hostAlive } = await import('./host-heartbeat.js')
|
|
509
|
+
if (!hostAlive(process.env)) {
|
|
510
|
+
try {
|
|
511
|
+
applySharedConfigDirect(process.env, clean)
|
|
512
|
+
clearPendingConfig(process.env)
|
|
513
|
+
outcome = 'applied-direct'
|
|
514
|
+
appliedDirect = true
|
|
515
|
+
} catch { /* 直写失败 → 保持 queued,如实回报,改动仍留在 pending 文件里 */ }
|
|
516
|
+
}
|
|
517
|
+
}
|
|
477
518
|
const stale = before && (before.dropped || before.lastError)
|
|
519
|
+
const advice = describeApplyFailure(before && before.lastError)
|
|
520
|
+
const hostGuard = advice.kind === 'host-guard'
|
|
521
|
+
const note = outcome === 'applied'
|
|
522
|
+
? '宿主已应用该改动。'
|
|
523
|
+
: outcome === 'applied-direct'
|
|
524
|
+
? '本机没有 DSH 宿主在运行,已直接写入共享配置(hooks / MCP / 独立页立即生效)。'
|
|
525
|
+
: outcome === 'failed'
|
|
526
|
+
? '改动已写入,但宿主侧应用失败:' + (hostGuard ? advice.hint : '详见「反馈异常」里的诊断信息。')
|
|
527
|
+
: '已写入待应用:DSH 端会自动同步(DSH 未运行时下次启动生效)'
|
|
478
528
|
return json(res, 200, {
|
|
479
|
-
ok: true, applied: Object.keys(clean), pending: Object.keys(clean), retried: false, at: out.at,
|
|
529
|
+
ok: true, applied: Object.keys(clean), pending: appliedDirect ? [] : Object.keys(clean), retried: false, at: out.at,
|
|
530
|
+
pendingOutcome: outcome,
|
|
531
|
+
appliedDirect,
|
|
532
|
+
hostGuard: hostGuard || undefined,
|
|
533
|
+
hint: hostGuard ? advice.hint : undefined,
|
|
480
534
|
warning: stale
|
|
481
535
|
? '上一次写入待应用时连续失败(' + (before.lastError || '原因未记录') + ');本次已重新写入并重试,如仍不生效请查看「反馈异常」里的诊断信息'
|
|
482
536
|
: undefined,
|
|
483
|
-
note
|
|
537
|
+
note,
|
|
484
538
|
})
|
|
485
539
|
}
|
|
486
540
|
let pendingD = null
|