memory-eternal 0.10.26 → 0.10.28
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 +5 -3
- package/README.md +5 -3
- package/bin/dsh-memory.mjs +63 -0
- package/index.js +2 -0
- package/lib/align.js +181 -0
- package/lib/api.js +15 -0
- package/lib/client.js +15 -15
- package/lib/db.js +107 -1
- package/package.json +2 -2
- package/scripts/release.mjs +14 -2
- package/web/app.js +13 -13
|
@@ -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.28",
|
|
5
5
|
"description": "记忆核心(Memory Eternal):对话结束后自动沉淀知识卡到本地记忆库(~/.dsh/memory-vault),支持检索 / 召回 / 审核中心。",
|
|
6
6
|
"author": {
|
|
7
7
|
"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.
|
|
365
|
-
| **v0.10.
|
|
364
|
+
| **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** |
|
|
365
|
+
| **v0.10.27** | 2026-10-13 | **"Rebuild from backup" goes from guidance to something you can run (the practical way out for issue #31)**. Once a WAL header is corrupt, code cannot repair the **data** — the previous version could only tell you which backups exist and in what order to swap files; now `dsh-memory doctor --restore latest\|<name>` does it: **without `--yes` it is a dry run** (not a single file touched, and it tells you how many cards the backup holds and whether it would overwrite a writable database). Execution passes four gates: ① **validate the backup** (open read-only + `PRAGMA quick_check` + count cards); ② require an explicit `--force` when the current database is healthy **right now** (decided by a live probe, not a cached health verdict); ③ **abort** if a resident watchdog is still alive (no swapping while writers are active, unless `--force`); ④ **re-verify** afterwards (reopen + write probe + card count). The original scene is **always renamed to `*.corrupt-<timestamp>` and never deleted** (the DB plus `-wal` and `-shm`). Adds `tests/db-restore.test.mjs` (4 checks: rebuild from a broken DB / healthy DB needs force and a dry run touches nothing / dangerous sources refused / health verdict refreshed). `npm test` now runs **339 checks** |
|
|
366
366
|
|
|
367
367
|
<details>
|
|
368
|
-
<summary>Older release notes (v0.10.
|
|
368
|
+
<summary>Older release notes (v0.10.26 and earlier · 53 releases, click to expand)</summary>
|
|
369
369
|
|
|
370
370
|
| Version | Date | Highlights |
|
|
371
371
|
|---|---|---|
|
|
372
|
+
| **v0.10.26** | 2026-10-13 | **Lock contention now retries asynchronously at the application level instead of using `busy_timeout` (the measured remedy for issues #31/#33)**. Local controlled experiment: while an external process held a write transaction we immediately got `database is locked`, and it **stayed locked even after that process was killed** — the old code had no waiting at all, so a single collision failed permanently (on restricted or externally-occupied drives like A: this shows up as "the whole memory database is broken"). The review rejected `PRAGMA busy_timeout` (`DatabaseSync` from `node:sqlite` is a **synchronous API**, so busy-waiting blocks the host event loop and freezes all of DSH), so we added `withDbRetry()` instead: on a lock, **yield the event loop, sleep briefly and retry** (linear backoff, up to 3 attempts), only on **write routes**; once headers have been sent retrying is explicitly forbidden (`e.noRetry`, preventing double writes), and non-lock errors (data/syntax) are tried once and rethrown unchanged. Tests cover: retry until success / one attempt for non-busy errors / `noRetry` honoured / exhaustion / a source guard against sneaking `busy_timeout` back in. `npm test` now runs **335 checks** |
|
|
373
|
+
| **v0.10.25** | 2026-10-13 | **"Cannot be determined" no longer masquerades as "unmanaged", plus a unified UX for an unwritable DB (closing layer ③ of issue #33)**. ① **Managed state becomes three-valued**: `/version-check` now also reports `resident.managedKnown`, `lock:{path,writable,error}` and `pid` — in restricted environments (sandbox write bans, ACLs, non-system drives) where the watchdog lock file cannot be written, the panel no longer says "unmanaged" (which made users think the instance was broken and restart it repeatedly) but "**management state unknown**: the lock file cannot be written, so it cannot be determined; keep-alive usually still works", together with the real cause and path; `writeWatchdogLock` failures are no longer fully silent either (one traceable line per process). ② **Unified UX when the DB is unwritable**: write routes (delete / merge / card / refine-apply / optimize-execute / …) now **fail fast at the entrance** (503 + `DB_UNAVAILABLE` + recovery guidance), and any write path not enumerated is caught by the outermost wrapper translating raw SQLite text into the same reply; the panel shows a **cross-tab banner** at the top: "the memory database is currently not writable — card reads/writes will fail, but **settings can still be saved**". ③ `dsh-memory doctor` now reports watchdog lock writability (visible at a glance in restricted environments). `npm test` now runs **334 checks** |
|
|
372
374
|
| **v0.10.24** | 2026-10-13 | **"Same goal, different approach" now clusters too (third and final slice of issue #36)**. The two previous versions covered "merge duplicates" and "judge conflicts", but the last piece — one purpose, several workflows/codebases — is **undetectable by body similarity** ("compress with 7z", "distribute as tar.gz", "slim the npm package" read very differently yet share a goal). Approach: **the model first writes one line per card stating what problem it solves, then we cluster by goal** — goals are cached in `refine-goals.json` (each card extracted once; a second scan makes zero calls) and clustered with union-find; hits are grouped as "one goal, N cards" with two actions: **keep all** (records pairwise rulings so it stops asking) or **merge this group** (via the existing /merge; the new card enters the audit queue). *Read-only throughout — the only write is the goal cache.* Entry points: the panel (organize → 🎯 same goal, different approach) and APIs `/refine-goals`, `/refine-goals-keep`; CLI `dsh-memory refine --goals --keep --path A --path B` (the clustering itself needs a model, run it in the panel). `npm test` now runs **332 checks** |
|
|
373
375
|
| **v0.10.23** | 2026-10-13 | **Existing "injected junk cards" can be cleaned up in one click (second slice of issue #36)**. The previous version stopped such cards from being **created** (skill catalogs / `<system-reminder>` arriving as user messages were captured as knowledge after their tags were stripped), but the **backlog remained**: a scan of a real vault immediately found **32** cards unrelated to any conversation (catalog lines like `- \`code-review\`: Review the changes…`, fragments like `用户system-reminder-you-are-teammate-c…`). Adds `findInjectedCards()` (a **proposal only**, it changes nothing; criteria: injection tags present / catalog lines ≥30% of the body / under 200 characters left after stripping / a fragmentary title) and `purgeInjected()` (**goes to the recycle bin, recoverable**, reporting failures per card). Entry points: the panel (organize → 🧹 injected junk cards: scan, recycle one, recycle all) and the CLI `dsh-memory refine --junk [--purge --path P] [--json]`; API `/refine-junk`, `/refine-junk-purge`. `npm test` now runs **331 checks** |
|
|
374
376
|
| **v0.10.22** | 2026-10-13 | **"Conflict scan": similar cards may be merged, conflicts must be judged by a human (first slice of issue #36)**. Until now `/optimize` merged **blindly** on similarity (≥0.55) — yet two semantically close cards with mutually exclusive conclusions score just as high (0.93 measured), so a blind merge quietly "reconciles" the conflict, and `mergeCards` **permanently deletes** the sources. New `lib/refine.js` plus a "organize → 🧭 Conflict scan" panel section: ① **wide recall** (≥0.30, upper bound lifted to 1.0, deliberately including the near-duplicate band — the most dangerous one for blind merging); ② the model only classifies each pair as "duplicate / complement / conflict / unrelated" and states the conflict in one line plus who to keep; ③ **the human decides**: keep A / keep B / keep both / merge; the **scan never writes**, and `keepA|keepB` only moves the yielding card to the **recycle bin** (recoverable); ④ rulings are recorded in `refine-decisions.json` so the same pair is not raised again; ⑤ **safety interlock**: pairs ruled conflicting are **excluded from one-click optimize** entirely. Also fixes one source of redundancy: skill-catalog injections (`- \`skill-name\`: …`) are no longer captured as junk cards (real examples already existed in the vault). API: `/refine`, `/refine-scan`, `/refine-apply`; CLI: `dsh-memory refine` (lists candidates without calling a model; `--apply` records a ruling). `npm test` now runs **329 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.
|
|
397
|
-
| **v0.10.
|
|
396
|
+
| **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 项** |
|
|
397
|
+
| **v0.10.27** | 2026-10-13 | **「损坏时从备份重建」从引导升级成可执行(issue #31 的现实出路)**。WAL 头一旦损坏,代码修不了**数据** —— 上一版只能告诉你「有哪些备份、按什么顺序替换」,现在多了 `dsh-memory doctor --restore latest\|备份名`:**不带 `--yes` 只演练**(一个文件都不动,先告诉你备份里有几张卡、会不会覆盖可写库);执行时四道闸门:① **校验备份**(只读打开 + `PRAGMA quick_check` + 数卡片);② 当前库**此刻**完好时要求显式 `--force`(用**当场探测**判,不信缓存的体检结论);③ 检测到常驻看门狗还活着就**中止**(避免边写边换,除非 `--force`);④ 恢复后**复核**(重新打开 + 写探针 + 卡片数)。原现场**一律改名 `*.corrupt-<时间戳>` 留证,绝不删除**(库 + `-wal` + `-shm` 一起)。新增 `tests/db-restore.test.mjs`(4 项:坏库重建 / 完好库需 force 且演练不动任何文件 / 拒绝危险来源 / 体检结论刷新)。`npm test` 共 **339 项** |
|
|
398
398
|
|
|
399
399
|
<details>
|
|
400
|
-
<summary>更早的更新日志(v0.10.
|
|
400
|
+
<summary>更早的更新日志(v0.10.26 及以前 · 共 53 版,点开查看)</summary>
|
|
401
401
|
|
|
402
402
|
| 版本 | 日期 | 关键改动 |
|
|
403
403
|
|---|---|---|
|
|
404
|
+
| **v0.10.26** | 2026-10-13 | **撞锁改用「应用层异步重试」,而不是 `busy_timeout`(issue #31/#33 实测对症)**。本机对照实验:外部进程持写事务时我方立刻拿到 `database is locked`,而且**对方被强杀之后仍然锁着** —— 老实现没有忙等,一撞锁就永久失败(在 A: 盘那类受限 / 被外部占用的现场,表现出来就是「整个记忆库坏了」)。评审已否决 `PRAGMA busy_timeout`(`node:sqlite` 的 `DatabaseSync` 是**同步 API**,忙等会阻塞宿主事件循环、把整个 DSH 卡住),所以改成新增 `withDbRetry()`:撞锁时**让出事件循环睡一小会儿再试**(线性退避,最多 3 次),只用**写路由**;「响应已发出」时显式禁止重试(`e.noRetry`,避免重复写),非锁类错误(数据 / 语法)只试一次并原样抛。测试覆盖:重试到成功 / 非忙错误只试一次 / `noRetry` 生效 / 试满仍失败 / 源码守卫(不许偷偷加 `busy_timeout`)。`npm test` 共 **335 项** |
|
|
405
|
+
| **v0.10.25** | 2026-10-13 | **「判不出来」不再冒充「未托管」+ 库不可写时的统一 UX(issue #33 第③层收口)**。① **托管状态变三态**:`/version-check` 增报 `resident.managedKnown`、`lock:{path,writable,error}` 与 `pid` —— 受限环境(沙箱禁写 / ACL / 非系统盘)写不进看门狗锁文件时,面板不再显示「未托管」(那会让用户以为实例坏了、反复重启),改为「**托管状态未知**:锁文件写不进去 → 无法判断;保活通常仍然有效」,并同时显示真实原因与路径;`writeWatchdogLock` 的失败也不再全静默(每个进程留一条可查的痕)。② **库不可写时统一 UX**:写路由(delete / merge / card / refine-apply / optimize-execute / …)在入口**短路径失败**(503 + `DB_UNAVAILABLE` + 恢复指引),没枚举到的写路径由最外层兜底把 SQLite 原文翻译成同一套答复;面板**跨标签顶部**明示「记忆库当前不可写 —— 卡片读写会失败,但**配置仍可保存**」。③ `dsh-memory doctor` 增报看门狗锁可写性(受限环境一眼可见)。`npm test` 共 **334 项** |
|
|
404
406
|
| **v0.10.24** | 2026-10-13 | **「同目标、不同做法」也能归到一起了(issue #36 第三块,闭环)**。前两版解决了「冗余合并」与「矛盾判别」,但最后一块 —— 同一目的、不同工作流/代码 —— 按正文相似度**根本抓不到**(「用 7z 压缩」「用 tar.gz 分发」「npm 包瘦身」字面差很远、目标却一样)。做法:**先让模型给每张卡抽一句「这张卡解决什么问题」,再按目标聚类** —— 目标抽取结果缓存在 `refine-goals.json`(同一张卡只抽一次,第二次扫描 0 次调用),聚类用并查集;命中后按「同一目标 N 张卡」成组给出,动作二选一:**都保留**(记两两决策,下次不再提示)或**合并这一组**(走现有 /merge,新卡进审核队列)。*全程只读,唯一写入是目标缓存*。入口:面板「整理 → 🎯 同目标不同做法」+ API `/refine-goals`、`/refine-goals-keep`;CLI `dsh-memory refine --goals --keep --path A --path B`(聚类本身需要模型,在面板跑)。`npm test` 共 **332 项** |
|
|
405
407
|
| **v0.10.23** | 2026-10-13 | **存量「注入垃圾卡」可一键清理(issue #36 第二刀)**。上一版修掉了**新生成**的注入垃圾卡(技能目录 / `<system-reminder>` 被当 user 消息 → 剥标签后仍被当知识沉淀),但**存量还在**:在真实库里一跑就找出 **32 张**「和对话毫无关系」的卡(`- \`code-review\`: Review the changes…` 这种目录行、`用户system-reminder-you-are-teammate-c…` 这种碎片)。新增 `findInjectedCards()`(只**提议**、不动数据;判据:含注入标签 / 技能目录条目占比 ≥30% / 剥离注入后只剩 <200 字 / 标题是碎片)与 `purgeInjected()`(**走回收站、可还原**,逐个报告失败)。入口:面板「整理 → 🧹 注入垃圾卡」(扫描存量 / 单张回收 / 全部回收)+ CLI `dsh-memory refine --junk [--purge --path P] [--json]`;API `/refine-junk`、`/refine-junk-purge`。`npm test` 共 **331 项** |
|
|
406
408
|
| **v0.10.22** | 2026-10-13 | **「矛盾扫描」:相似可以合并,矛盾必须人判断(issue #36 第一刀)**。现状是 `/optimize` 只按相似度(≥0.55)**盲合并** —— 而两张语义相近、结论互斥的卡相似度同样很高(实测 0.93),盲合并会把冲突悄悄「和解」掉,且 `mergeCards` 会**永久删除**源卡。新增 `lib/refine.js` + 客户端「整理 → 🧭 矛盾扫描」:① **宽召回**(≥0.30,上限放到 1.0,特意把近重复那一段纳入 —— 那正是盲合并最危险的区间);② 模型只判「重复 / 补充 / 矛盾 / 无关」并给一句冲突点 + 建议保留谁;③ **人拍板**:保留 A / 保留 B / 都保留 / 合并;**扫描绝不写库**,`keepA|keepB` 只把让位那张送**回收站**(可还原);④ 决策记进 `refine-decisions.json`,同一对不再重复打扰;⑤ **安全联锁**:被判为矛盾的对**绝不参与一键优化**的盲合并。另修一处冗余来源:技能目录(`- \`skill-name\`: …`)这类注入文本不再被抓成垃圾卡(实测库里已有这种卡)。API:`/refine`、`/refine-scan`、`/refine-apply`;CLI:`dsh-memory refine`(列候选不调模型,`--apply` 落裁决)。`npm test` 共 **329 项** |
|
package/bin/dsh-memory.mjs
CHANGED
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
// dsh-memory setup [--claude-only|--codex-only|--cursor-only] [--dry-run] [--no-hooks]
|
|
11
11
|
// 自动挂载 MCP 到已装的 agent
|
|
12
12
|
// dsh-memory sweep <dir> 挖掘 Claude Code 会话 JSONL
|
|
13
|
+
// dsh-memory align [--json] [--fix] 版本对齐检查(宿主/常驻/npm 版本差 + MCP/hooks 旧接线),--fix 重写接线
|
|
13
14
|
// dsh-memory doctor [--vault DIR] [--json] 库健康自检(journal 模式 / 写探针 / 备份清单 / 恢复引导)
|
|
15
|
+
// dsh-memory doctor --restore latest|备份名 [--yes] [--force] 从备份重建(不带 --yes 只演练;当前库可写时需 --force)
|
|
14
16
|
// dsh-memory refine [--vault DIR] [--json] 矛盾扫描候选(不调模型);--apply --a A --b B --action keepA|keepB|merge|keepBoth 落裁决
|
|
15
17
|
// dsh-memory refine --junk [--purge --path P] [--json] 注入垃圾卡(技能目录等注入文本沉淀成的卡):列清单 / 送回收站
|
|
16
18
|
// dsh-memory refine --goals [--keep --path A --path B] 同目标不同做法:聚类需模型(面板里跑);--keep 落"都保留"
|
|
@@ -234,8 +236,69 @@ async function main() {
|
|
|
234
236
|
console.log('落裁决:dsh-memory refine --apply --a <路径> --b <路径> --action keepA|keepB|merge|keepBoth')
|
|
235
237
|
return
|
|
236
238
|
}
|
|
239
|
+
case 'align': {
|
|
240
|
+
// 版本对齐检查:宿主/常驻/npm 的版本差 + 持久接线(MCP / hooks)里的旧路径与旧事件键
|
|
241
|
+
const { inspectAlignment } = await import('../lib/align.js')
|
|
242
|
+
const port = Number(argOf('--port')) || 7999
|
|
243
|
+
const rep = await inspectAlignment({ hostVersion: process.env.MEMORY_ETERNAL_VERSION || '', residentPort: port })
|
|
244
|
+
if (has('--json')) { console.log(JSON.stringify(rep, null, 2)); if (!rep.ok) process.exitCode = 1; return }
|
|
245
|
+
console.log('记忆核心 · 版本对齐检查')
|
|
246
|
+
console.log('磁盘 v' + (rep.onDisk || '?') + (rep.residentServed ? ' · 常驻实例 v' + rep.residentServed : '') + (rep.latest ? ' · npm 最新 v' + rep.latest : ''))
|
|
247
|
+
console.log('')
|
|
248
|
+
for (const x of rep.findings) {
|
|
249
|
+
console.log((x.level === 'fix' ? '✗ ' : x.level === 'warn' ? '⚠ ' : '✓ ') + x.title)
|
|
250
|
+
if (x.detail) console.log(' ' + x.detail)
|
|
251
|
+
if (x.command) console.log(' → ' + x.command)
|
|
252
|
+
}
|
|
253
|
+
if (has('--fix')) {
|
|
254
|
+
const { runSetup } = await import('../lib/setup.js')
|
|
255
|
+
console.log('')
|
|
256
|
+
console.log('重写接线(只碰自己的键:mcpServers.memory / 带标记的 hooks):')
|
|
257
|
+
await runSetup({ log: (m) => console.log(' ' + m) })
|
|
258
|
+
console.log('完成。若上面有「宿主进程旧」,仍需**完全退出并重开 DSH**。')
|
|
259
|
+
} else if (!rep.ok) {
|
|
260
|
+
console.log('')
|
|
261
|
+
console.log('执行 dsh-memory align --fix 可重写 MCP / hooks 接线(不动你的数据与配置)')
|
|
262
|
+
}
|
|
263
|
+
if (!rep.ok) process.exitCode = 1
|
|
264
|
+
return
|
|
265
|
+
}
|
|
237
266
|
case 'doctor': {
|
|
238
267
|
// ⑤ 损坏/不可写时的「引导」(issue #33):只体检、只列备份、只打印下一步 —— 绝不自动替换/删除数据。
|
|
268
|
+
// --restore:把「损坏时从备份重建」从**引导**升级成**可执行**(#31 里 WAL 头损坏的唯一现实出路)。
|
|
269
|
+
// 安全设计:不带 --yes 只做演练(dryRun,一个文件都不动);带 --yes 才真正执行,
|
|
270
|
+
// 且执行前检查「有没有常驻实例正开着这个库」(有就先停下来,除非 --force)。
|
|
271
|
+
if (argOf('--restore')) {
|
|
272
|
+
const { defaultVaultDir: dvd } = await import('../lib/capture-run.js')
|
|
273
|
+
const { restoreFromBackup } = await import('../lib/db.js')
|
|
274
|
+
const { readWatchdogLock, isPidAlive } = await import('../lib/watchdog.js')
|
|
275
|
+
const root = argOf('--vault') ? path.resolve(argOf('--vault')) : dvd()
|
|
276
|
+
const from = argOf('--restore')
|
|
277
|
+
const live = readWatchdogLock(process.env).watchdogs.filter((w) => isPidAlive(w.pid))
|
|
278
|
+
const plan = await restoreFromBackup(root, { from, dryRun: true, force: has('--force') })
|
|
279
|
+
if (has('--json')) { console.log(JSON.stringify({ planned: plan, liveWatchdogs: live.map((w) => w.pid) }, null, 2)); if (!plan.ok) process.exitCode = 1; return }
|
|
280
|
+
if (!plan.ok) { console.error('不能恢复:' + plan.error); if (plan.code === 'DB_HEALTHY') console.error(' (确认要覆盖可写库:加 --force)'); process.exitCode = 1; return }
|
|
281
|
+
console.log('备份:' + plan.from)
|
|
282
|
+
console.log('备份里的卡片数:' + (plan.cards >= 0 ? plan.cards : '(读不出,可能不是本插件的库)'))
|
|
283
|
+
console.log('当前库:' + root + '\\memory-eternal.db')
|
|
284
|
+
if (live.length) {
|
|
285
|
+
console.log('⚠ 检测到常驻看门狗还活着(pid ' + live.map((w) => w.pid).join(', ') + ')—— 恢复前应先:dsh-memory stop --port 7999(或完全退出 DSH)')
|
|
286
|
+
if (!has('--force')) { console.error('为避免边写边换,已中止。确认可强做:加 --force'); process.exitCode = 1; return }
|
|
287
|
+
}
|
|
288
|
+
if (!has('--yes')) {
|
|
289
|
+
console.log('')
|
|
290
|
+
console.log('这是**演练**(没有改动任何文件)。确认执行请加 --yes:')
|
|
291
|
+
console.log(' dsh-memory doctor --restore ' + from + ' --yes' + (argOf('--vault') ? ' --vault "' + root + '"' : ''))
|
|
292
|
+
process.exitCode = 1
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
const out = await restoreFromBackup(root, { from, force: has('--force') })
|
|
296
|
+
if (!out.ok) { console.error('恢复失败:' + out.error); process.exitCode = 1; return }
|
|
297
|
+
console.log('✓ 已用备份重建:卡片 ' + out.cards + ' 张,写探针 ' + (out.health.writable ? 'OK' : '仍失败'))
|
|
298
|
+
for (const m of out.movedAside || []) console.log(' 原现场留证:' + m)
|
|
299
|
+
console.log(' ' + out.note)
|
|
300
|
+
return
|
|
301
|
+
}
|
|
239
302
|
const { defaultVaultDir } = await import('../lib/capture-run.js')
|
|
240
303
|
const { getDb, dbStatus, checkDbHealth, rebuildGuidance } = await import('../lib/db.js')
|
|
241
304
|
const fsPromises = await import('node:fs/promises')
|
package/index.js
CHANGED
|
@@ -1054,6 +1054,8 @@ export function apply(ctx, config) {
|
|
|
1054
1054
|
vaultDir, vaultRoots, getSettings: settings.get,
|
|
1055
1055
|
// 矛盾扫描(#36)在宿主侧跑模型:这里给它 llm 句柄,独立 web 进程拿不到就会如实报错
|
|
1056
1056
|
getLlm: () => ctx.get('llm'),
|
|
1057
|
+
// 版本对齐检查用:当前 schema 的键(用来发现共享配置里的旧键残留)
|
|
1058
|
+
getConfigKeys: () => Object.keys(Config.dict || {}),
|
|
1057
1059
|
// 自动沉淀运行轨迹 + 健康状态:供「用量/今日」页排查「为什么没写卡」,异常时页面顶部亮红。
|
|
1058
1060
|
getCaptureLog: () => captureLog.slice().reverse(),
|
|
1059
1061
|
getCaptureHealth: () => health.snapshot(),
|
package/lib/align.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// 版本对齐检查(对「是不是插件迭代导致接口变更、旧记录还在」的正面解法)
|
|
2
|
+
//
|
|
3
|
+
// 为什么需要它 —— memory-eternal 升级只换**磁盘文件**,有两类「旧」不会自己消失:
|
|
4
|
+
//
|
|
5
|
+
// ① **运行时**:DSH 宿主进程内存里注册的事件订阅(`agent/turn-stopping`、`agent/inbox/claimed`…)
|
|
6
|
+
// 来自它**启动时加载的那份代码**。升级插件不重启 DSH,宿主就一直在用旧接口 ——
|
|
7
|
+
// 那类「轮次收尾事件疑似失效」多半就是这么来的(旧判据 + 旧订阅)。
|
|
8
|
+
// ② **持久接线**:插件写进 `~/.claude.json`、`~/.claude/settings.json`、`~/.codex/config.toml`、
|
|
9
|
+
// `~/.cursor/mcp.json` 的 MCP 与 hooks 条目 —— 里面含**旧安装路径 / 旧 node 路径 / 旧事件键**,
|
|
10
|
+
// 升级后不会自动改写;旧路径一旦不存在,那个 hook 就永远不触发。
|
|
11
|
+
//
|
|
12
|
+
// 本模块把两类都查出来,并给**可执行**的对齐动作(重启 DSH / 重写接线 / 更新插件)。
|
|
13
|
+
import fs from 'node:fs'
|
|
14
|
+
import os from 'node:os'
|
|
15
|
+
import path from 'node:path'
|
|
16
|
+
import { getSetupStatus, PACKAGE_ROOT } from './setup.js'
|
|
17
|
+
import { configFilePath } from './capture-run.js'
|
|
18
|
+
import { probeServedVersion } from './watchdog.js'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 纯函数:把「版本现状 + 接线现状」翻译成结论清单(便于单测,无任何 IO)。
|
|
22
|
+
* @returns {{ok:boolean, findings:Array<{id:string,level:'ok'|'warn'|'fix',title:string,detail:string,command:string}>, counts:{ok:number,warn:number,fix:number}}}
|
|
23
|
+
*/
|
|
24
|
+
export function alignFindings({
|
|
25
|
+
hostVersion = '', onDisk = '', residentServed = '', residentPort = 7999,
|
|
26
|
+
latest = '', updateCommand = '', wiring = [], hookPaths = [], configMirror = null, knownKeys = null,
|
|
27
|
+
} = {}) {
|
|
28
|
+
const findings = []
|
|
29
|
+
const push = (id, level, title, detail, command) => findings.push({ id, level, title, detail: detail || '', command: command || '' })
|
|
30
|
+
|
|
31
|
+
// ① 宿主进程(最容易被忽略、也是"旧接口"的主因)
|
|
32
|
+
if (hostVersion && onDisk && hostVersion !== onDisk) {
|
|
33
|
+
push('host-stale', 'fix', `服务面板的宿主进程加载的是 v${hostVersion},磁盘已是 v${onDisk}`,
|
|
34
|
+
'DSH 的事件订阅(agent/turn-stopping、agent/inbox/claimed 等)是宿主进程**内存里注册**的,来自它启动时加载的那份代码 —— 升级插件只换磁盘文件,不重启 DSH 就会一直用旧接口(「轮次收尾事件疑似失效」多半就是它)。',
|
|
35
|
+
'完全退出并重开 DSH(不是「重启常驻实例」)')
|
|
36
|
+
} else if (hostVersion && onDisk) {
|
|
37
|
+
push('host-ok', 'ok', `宿主进程 v${hostVersion} = 磁盘 v${onDisk}`)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ② 常驻实例(端口上那个进程)
|
|
41
|
+
if (residentServed && onDisk && residentServed !== onDisk) {
|
|
42
|
+
push('resident-stale', 'fix', `常驻实例服务的是 v${residentServed},磁盘已是 v${onDisk}`,
|
|
43
|
+
'端口上那个进程仍是旧代码;面板/独立页的部分路由会按它的版本行为。',
|
|
44
|
+
`dsh-memory restart --port ${residentPort}`)
|
|
45
|
+
} else if (residentServed && onDisk) {
|
|
46
|
+
push('resident-ok', 'ok', `常驻实例 v${residentServed} = 磁盘 v${onDisk}`)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ③ npm 上是否已有更新
|
|
50
|
+
if (latest && onDisk && latest !== onDisk) {
|
|
51
|
+
push('update-available', 'warn', `npm 上已有 v${latest}(本机 v${onDisk})`,
|
|
52
|
+
'当前运行的是本地/较早版本,装最新版后再重启 DSH 才能拿到全部修复。',
|
|
53
|
+
updateCommand || 'dsh plugin --profile <你的 profile> add memory-eternal@latest')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ④ MCP / hooks 接线(持久文件,跨版本残留)
|
|
57
|
+
for (const a of wiring) {
|
|
58
|
+
if (!a || !a.installed) continue
|
|
59
|
+
if (!a.mcpConfigured) {
|
|
60
|
+
push('wiring-mcp-' + a.name, 'fix', `${a.name}:MCP 未配置(或条目被删)`,
|
|
61
|
+
'agent 侧回忆不到记忆核心;这条是持久文件里的记录,插件升级不会自动补。', 'dsh-memory setup')
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
if (a.mcpMatchesCurrentNode === false) {
|
|
65
|
+
// 措辞必须准确:`mcpMatchesCurrentNode` 比较的是**整条 MCP 条目**(node 路径 + 脚本路径 + 参数),
|
|
66
|
+
// 两条路径相同时也可能因为**脚本路径/参数是旧写法**而不一致(升级后最常见)。
|
|
67
|
+
const sameNode = String(a.configuredNodePath || '') === String(a.currentNodePath || '')
|
|
68
|
+
push('wiring-node-' + a.name, 'fix',
|
|
69
|
+
sameNode ? `${a.name}:MCP 条目与当前版本写出的不一致(脚本路径 / 参数是旧写法)` : `${a.name}:MCP 记的 node 路径与当前不一致`,
|
|
70
|
+
sameNode
|
|
71
|
+
? `node 路径一致(${a.currentNodePath || '(空)'}),但条目内容不同 —— 老版本写进去的参数 / 脚本路径还在,可能指向已不存在的文件。`
|
|
72
|
+
: `配置里是 ${a.configuredNodePath || '(空)'},当前是 ${a.currentNodePath || '(空)'} —— 旧路径可能已不存在,MCP 会起不来。`,
|
|
73
|
+
'dsh-memory setup')
|
|
74
|
+
} else {
|
|
75
|
+
push('wiring-mcp-ok-' + a.name, 'ok', `${a.name}:MCP 接的是当前 node`)
|
|
76
|
+
}
|
|
77
|
+
if (a.hook === 'missing') {
|
|
78
|
+
push('wiring-hook-' + a.name, 'fix', `${a.name}:会话结束 hook 未配置`,
|
|
79
|
+
'没有 hook,会话结束就不会触发沉淀(收尾事件由 agent 侧 hook 触发)。', 'dsh-memory setup')
|
|
80
|
+
} else if (a.hook === 'configured') {
|
|
81
|
+
push('wiring-hook-ok-' + a.name, 'ok', `${a.name}:会话结束 hook 已配置`)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ⑤ hooks 里写死的脚本路径(升级换目录后最容易失效的东西)
|
|
86
|
+
for (const h of hookPaths) {
|
|
87
|
+
if (!h || !h.path) continue
|
|
88
|
+
if (!h.exists) {
|
|
89
|
+
push('hookpath-missing-' + h.agent, 'fix', `${h.agent}:hook 指向的脚本不存在`,
|
|
90
|
+
`配置里写的是 ${h.path}(当前安装目录 ${PACKAGE_ROOT})—— 典型是升级/换 profile 后没重写接线。`,
|
|
91
|
+
'dsh-memory setup')
|
|
92
|
+
} else if (h.underCurrent === false) {
|
|
93
|
+
push('hookpath-other-' + h.agent, 'fix', `${h.agent}:hook 指向的是**另一个安装目录**`,
|
|
94
|
+
`配置里写的是 ${h.path},当前安装目录是 ${PACKAGE_ROOT} —— 触发的是旧那份代码。`,
|
|
95
|
+
'dsh-memory setup')
|
|
96
|
+
} else {
|
|
97
|
+
push('hookpath-ok-' + h.agent, 'ok', `${h.agent}:hook 指回当前安装目录`)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ⑥ 共享配置镜像里的未知键(旧版本写进去、新版本已经不认识)
|
|
102
|
+
if (configMirror && configMirror.exists) {
|
|
103
|
+
const unknown = Array.isArray(configMirror.unknownKeys) ? configMirror.unknownKeys : []
|
|
104
|
+
if (Array.isArray(knownKeys) && unknown.length) {
|
|
105
|
+
push('config-unknown', 'warn', `共享配置里有当前版本不认识的键:${unknown.join('、')}`,
|
|
106
|
+
'多半是旧版本写入后残留(或被降级写入)。无害,但会让"两处配置不一致"更难排查。',
|
|
107
|
+
'在「记忆 → 配置」里保存一次(会用当前白名单重写),或手工删除这些键')
|
|
108
|
+
} else if (unknown.length === 0) {
|
|
109
|
+
push('config-ok', 'ok', '共享配置的键与当前版本一致')
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const counts = { ok: findings.filter((x) => x.level === 'ok').length, warn: findings.filter((x) => x.level === 'warn').length, fix: findings.filter((x) => x.level === 'fix').length }
|
|
114
|
+
return { ok: counts.fix === 0, findings, counts }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 读一个持久 hooks 配置,抽出它写死的 capture.mjs / secret-guard.mjs 路径。 */
|
|
118
|
+
async function hookPathsFrom(file, agent, pick) {
|
|
119
|
+
try {
|
|
120
|
+
const cfg = JSON.parse(await fs.promises.readFile(file, 'utf8'))
|
|
121
|
+
const cmds = pick(cfg)
|
|
122
|
+
for (const c of cmds) {
|
|
123
|
+
const m = /("[^"]+"|'[^']+'|\S+)[\s\S]*?((?:[A-Za-z]:[\\\\/]|\/)[^"'\s]*hooks[\\\\/](?:capture|secret-guard)\.mjs)/.exec(String(c || ''))
|
|
124
|
+
const p = m ? m[2] : (/hooks[\\\\/](?:capture|secret-guard)\.mjs/.exec(String(c || '')) || [])[0]
|
|
125
|
+
if (!p) continue
|
|
126
|
+
const abs = path.isAbsolute(p) ? p : ''
|
|
127
|
+
if (!abs) continue
|
|
128
|
+
return { agent, path: abs, exists: fs.existsSync(abs), underCurrent: abs.startsWith(PACKAGE_ROOT) }
|
|
129
|
+
}
|
|
130
|
+
return null
|
|
131
|
+
} catch { return null }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 实际检查(IO 版):运行时版本 + 持久接线。宿主调用时把 hostVersion / latest / knownKeys 传进来。
|
|
136
|
+
* @param {{hostVersion?:string, residentPort?:number, latest?:string, updateCommand?:string, knownKeys?:string[]|null}} opts
|
|
137
|
+
*/
|
|
138
|
+
export async function inspectAlignment({ hostVersion = '', residentPort = 7999, latest = '', updateCommand = '', knownKeys = null } = {}) {
|
|
139
|
+
let onDisk = ''
|
|
140
|
+
try { onDisk = String(JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8')).version || '') } catch { onDisk = '' }
|
|
141
|
+
let residentServed = ''
|
|
142
|
+
try { residentServed = String((await probeServedVersion(residentPort)) || '') } catch { residentServed = '' }
|
|
143
|
+
let wiring = []
|
|
144
|
+
try { wiring = (await getSetupStatus()).agents || [] } catch { wiring = [] }
|
|
145
|
+
const hookPaths = []
|
|
146
|
+
const home = os.homedir()
|
|
147
|
+
const claudeHooks = await hookPathsFrom(path.join(home, '.claude', 'settings.json'), 'claude-code', (cfg) => {
|
|
148
|
+
const out = []
|
|
149
|
+
for (const list of Object.values(cfg?.hooks || {})) for (const h of (Array.isArray(list) ? list : [])) for (const c of (h?.hooks || [])) out.push(c?.command)
|
|
150
|
+
return out
|
|
151
|
+
})
|
|
152
|
+
if (claudeHooks) hookPaths.push(claudeHooks)
|
|
153
|
+
const codexHooks = await hookPathsFrom(path.join(home, '.codex', 'hooks.json'), 'codex', (cfg) => {
|
|
154
|
+
const out = []
|
|
155
|
+
for (const list of Object.values(cfg?.hooks || {})) for (const h of (Array.isArray(list) ? list : [])) out.push(typeof h === 'object' ? (h.command || (h.hooks || []).map((x) => x.command).join(' ')) : h)
|
|
156
|
+
return out
|
|
157
|
+
})
|
|
158
|
+
if (codexHooks) hookPaths.push(codexHooks)
|
|
159
|
+
const cursorHooks = await hookPathsFrom(path.join(home, '.cursor', 'hooks.json'), 'cursor', (cfg) => {
|
|
160
|
+
const out = []
|
|
161
|
+
for (const list of Object.values(cfg?.hooks || {})) for (const h of (Array.isArray(list) ? list : [])) out.push(typeof h === 'object' ? (h.command || (h.hooks || []).map((x) => x.command).join(' ')) : h)
|
|
162
|
+
return out
|
|
163
|
+
})
|
|
164
|
+
if (cursorHooks) hookPaths.push(cursorHooks)
|
|
165
|
+
|
|
166
|
+
let configMirror = null
|
|
167
|
+
try {
|
|
168
|
+
const file = configFilePath()
|
|
169
|
+
if (fs.existsSync(file)) {
|
|
170
|
+
const data = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
171
|
+
const keys = Object.keys(data || {}).filter((k) => !k.startsWith('_'))
|
|
172
|
+
const known = Array.isArray(knownKeys) ? new Set(knownKeys) : null
|
|
173
|
+
configMirror = { exists: true, path: file, keys, unknownKeys: known ? keys.filter((k) => !known.has(k)) : [] }
|
|
174
|
+
} else {
|
|
175
|
+
configMirror = { exists: false, path: configFilePath(), keys: [], unknownKeys: [] }
|
|
176
|
+
}
|
|
177
|
+
} catch { configMirror = { exists: false, path: '', keys: [], unknownKeys: [] } }
|
|
178
|
+
|
|
179
|
+
const report = alignFindings({ hostVersion, onDisk, residentServed, residentPort, latest, updateCommand, wiring, hookPaths, configMirror, knownKeys })
|
|
180
|
+
return { ...report, onDisk, hostVersion, residentServed, latest, wiring, hookPaths, configMirror }
|
|
181
|
+
}
|
package/lib/api.js
CHANGED
|
@@ -663,6 +663,21 @@ export function createApi(deps = {}) {
|
|
|
663
663
|
json(res, out.ok ? 200 : 400, out)
|
|
664
664
|
return
|
|
665
665
|
}
|
|
666
|
+
case '/align': {
|
|
667
|
+
// 版本对齐检查:把「宿主/常驻/npm 的版本差」与「持久接线里的旧路径/旧事件键」一次查清,
|
|
668
|
+
// 每条给可执行动作(重启 DSH / 重启常驻 / 重写接线 / 更新插件)。只读,不改任何东西。
|
|
669
|
+
const { inspectAlignment } = await import('./align.js')
|
|
670
|
+
const cfgV = getSettings() || {}
|
|
671
|
+
const rep = await inspectAlignment({
|
|
672
|
+
hostVersion: (getDshInfo() || {}).version || process.env.MEMORY_ETERNAL_VERSION || '',
|
|
673
|
+
residentPort: selfPort() || Number(cfgV.webPort) || 7999,
|
|
674
|
+
latest: versionCache.latest || '',
|
|
675
|
+
updateCommand: 'dsh plugin --profile <你的 profile> add memory-eternal@latest',
|
|
676
|
+
knownKeys: typeof deps.getConfigKeys === 'function' ? deps.getConfigKeys() : null,
|
|
677
|
+
})
|
|
678
|
+
json(res, 200, rep)
|
|
679
|
+
return
|
|
680
|
+
}
|
|
666
681
|
case '/refine-junk': {
|
|
667
682
|
// 注入垃圾卡的**提议清单**(只读):正文其实是被注入的运行时文本(技能目录 / system-reminder…)。
|
|
668
683
|
// 新版本已不再生成这类卡,这里负责把**存量**找出来,由人决定是否回收。
|