memory-eternal 0.10.20 → 0.10.22
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 +61 -0
- package/index.js +2 -0
- package/lib/api.js +59 -4
- package/lib/capture.js +59 -1
- package/lib/client.js +14 -14
- package/lib/db.js +75 -0
- package/lib/refine.js +168 -0
- package/lib/vault.js +9 -1
- package/package.json +2 -2
- package/scripts/release.mjs +49 -8
- package/web/app.js +17 -17
|
@@ -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.22",
|
|
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.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** |
|
|
365
|
+
| **v0.10.21** | 2026-10-13 | **"DB unavailable" goes from silent to visible (issue #33, scoped by review)**. ① **Health check at open**: the old code ran `PRAGMA journal_mode=WAL` and assumed success (that pragma does not throw on failure — it just returns the actual mode), so a read-only file or unwritable directory let the plugin run on silently; now the real journal mode is **read back** and a **write probe** runs (create then drop a table, in the main DB rather than temp), the verdict lands in `dbStatus()`, and an unwritable DB immediately logs the reason plus where to look. ② **`/overview` degrades instead of 500**: when the DB or directory is unavailable it no longer returns 500 (probes, the panel and scripts read 500 as "this port is broken / not our service", which historically started the restart storm and port drift); it now returns 200 with `degraded:true`, `dbError` and `db:{journalMode,writable,error}` while keeping the `vaultDir` / `version` identity fields — so "this is us, but the DB is broken" can finally be expressed separately. ③ **New `dsh-memory doctor`**: DB health self-check (real journal mode / write probe / backup list / recovery guidance) with `--json` for scripting; when the DB is unwritable or suspected corrupt it prints **guidance that never touches data** (stop writers → copy the scene aside → list backups → swap → re-verify). **Deliberately not done** (review conclusion, now guarded by tests so nobody adds it back casually): `busy_timeout` (`DatabaseSync` is a synchronous API, so waiting on a lock blocks the host event loop) and automatic WAL→DELETE downgrade (it **permanently** changes the DB mode, and a rollback journal has worse concurrency, which could worsen lock contention). `npm test` now runs **322 checks** |
|
|
366
366
|
|
|
367
367
|
<details>
|
|
368
|
-
<summary>Older release notes (v0.10.
|
|
368
|
+
<summary>Older release notes (v0.10.20 and earlier · 47 releases, click to expand)</summary>
|
|
369
369
|
|
|
370
370
|
| Version | Date | Highlights |
|
|
371
371
|
|---|---|---|
|
|
372
|
+
| **v0.10.20** | 2026-10-12 | **Fixing the three-layer "DB unavailable → the watchdog kills its own web → the port drifts to 8006" scene (issues #31 / #33, measured by AkiMei0721)**. ① **A non-200 `/overview` is not a dead port**: `/overview` touches the database, so a read-only / WAL-corrupted DB makes it 500, and the watchdog declared the port offline → `spawnWeb()` (which first SIGTERMs its own previous child) → **it killed and respawned its own healthy web, drifting the port from 7999 all the way to 8006 (six instances)**; a new pure `decideTickAction()` treats **a reachable TCP port as alive** (`api-degraded-but-listening`, resetting the restart counter), and the readiness check for a new instance likewise accepts "listening but `/overview` non-200". ② **The version probe now asks `/version-check`** (no database access; falls back to `/overview`) — the root symptom was "the port is clearly alive yet no version comes back", so verify reported "(no response)" and `status` even misjudged it as "a pre-v0.10.4 build". ③ **No more crash in restricted sandboxes**: the default `stdio:'pipe'` makes `spawn()` **throw EPERM synchronously** (the old code only guarded the `'error'` event, which never ran), so `dsh-memory watchdog --reap` crashed and process enumeration stayed permanently empty; `spawn` is now wrapped in `try/catch` and stdout goes through a **temp file**, touching no named pipes. ④ The stall check also gained a "minimum uptime" (30 minutes): a freshly restarted process necessarily has zero stops, so it must not report "turn-stop events are broken" (a false alarm was measured 1.5 minutes after a restart). Adds `tests/issue-fixes-8.test.mjs` (4 checks) plus a too-early case in `tests/stall.test.mjs`. `npm test` now runs **318 checks** |
|
|
373
|
+
| **v0.10.19** | 2026-10-12 | **`UNSUPPORTED_REASONING_EFFORT` no longer kills the whole distillation (issue #34)**. The scene (zafir-zhong and others): many **gateways** claim reasoning-effort support in their capability list and only reject `reasoningEffort: 'off'` when it is actually sent (measured: workbuddy/hy3, FallbacksChain/Auto, new-api-resp/gpt-6-luna), so the entire candidate is marked failed and the pipeline moves to the next provider — auto-capture stops dead for those users (that FAIL shows up in the diagnostics of #37 / #38 / #40 too). The existing design only covered "the provider **says** it does not support it → do not send the field" (#15); the runtime half was missing. Fix: an in-process memo `markEffortUnsupported()\/isEffortUnsupported()` — on the first rejection the call **retries once without the field** (same candidate, same provider), remembers that `provider/model`, and from then on the first request for it carries no effort at all; the memo is **keyed by provider/model**, so other providers are untouched. Guards added in `tests/capture-route.test.mjs` (sent → rejected → retried without and got a card → later only one request → other providers unaffected). `npm test` now runs **313 checks** |
|
|
372
374
|
| **v0.10.18** | 2026-10-12 | **"Save config" was being rejected unconditionally (issue #35)**. The scene (Dzifxazy): a fresh install, change nothing, hit save → **HTTP 400 "field must not be empty: secretHint"**, the whole submission rejected, so none of the numbers or toggles the user did change were stored; the wording also pointed troubleshooting at "some numeric box left blank". Root cause: the host-side pre-check treated **any empty string** as "numeric field not filled" and only exempted a hard-coded list (vaultDir / captureProvider / captureModel / recallEmbedding / activeVault) — while `secretHint`'s default **is** an empty string (`z.string().default('')`) and was not on that list; since the panel submits the whole form, every save hit it. Fix: new `emptyNumericFields(patch, Config.dict)` decides which keys were cleared by looking at the schema (`type === 'number'`), and the hard-coded list is gone (future nullable string fields need nobody to remember an exemption). Adds `tests/config-validate.test.mjs` (2 checks: empty strings only count for number fields — including a brand-new string field that appears on no list as a regression nail, plus a source guard against reintroducing the list). `npm test` now runs **312 checks** |
|
|
373
375
|
| **v0.10.17** | 2026-10-12 | **Root fix for the "resident is serving but nobody manages it" self-contradiction (issue #28)**. The scene: after upgrading to 0.10.16 the reporter had no resident instance at all, and the panel showed **neither a restart button nor any hint** (the banner only rendered when `vc.stale`, which is false when the host and the port report the same version), only an "auto-capture anomaly"; reproduced the other half locally: after `stop` the port was **still serving**, the lock was empty, and `status` said "no registered watchdog" — because that web had been spawned directly by the host's `ensureWeb()` (no `webPid` in the lock) and `stop` only killed the watchdog. Five fixes: ① **`/version-check` now reports `resident.managed`** (a live watchdog in the lock is what makes it managed) — "serving" is not "managed"; ② **panel**: both the **not-running** and the **unmanaged** resident show a restart button with dedicated wording ("not running" / "unmanaged") instead of only the version-drift case; ③ **`/restart-self`**: `alreadyCurrent` now requires a version match **and** a live manager, otherwise it replaces as usual (an orphan web does not get to stay just because its version is new); ④ **an explicit `dsh-memory stop` frees the port** (`forcePortOccupant: true`, same gate as ever: only processes `looksLikeOurWeb` recognises are terminated); ⑤ **the self-check now demands a fresh takeover**: `restartResident` no longer accepts "the port serves the expected version" (before and after an upgrade the version is often identical, so the **old** instance fooled the check — measuring "self-check passed (waited 0s)" immediately followed by `status` reporting no registered watchdog); the new `waitForTakeover()` requires a matching version **and** a live watchdog whose pid is not in the pre-replacement set, and failure reasons are split accordingly into "wrong version" vs "nobody took over". Measured on this machine in the same scenario: `41672/17432` before → `28136/31976` after, `restart` waited 13s and passed, and the **very next `status` showed the new watchdog registered** (no contradiction). `npm test` now runs **310 checks** |
|
|
374
376
|
| **v0.10.16** | 2026-10-09 | **`--reap` no longer says "nothing was reaped" when it did reap something** (reported by alario-tang, measured on macOS with 0.10.13). The scene: after `kill -9` on the watchdog leaves a `PPID=1` orphan web, `dsh-memory watchdog --reap --port 7999` really freed the port and really killed the web, yet the screen read "**scanned 1 watchdog process, cleaned 0 orphans**" — because 0.10.13 made `scanned` include webs while `webKilled` was never printed. Fix: a pure `formatReapSummary()` unifies the wording (scan counts split into `watchdog A / web B`, cleaned entries listed per kind with pids), `reapStaleWatchdogs` gains `scannedWatchdogs` / `scannedWebs` (`scanned` stays their sum for older callers), and the CLI uses it. Two checks added (never say "cleaned 0 orphans" when a web was reaped / scan counts must be split), and `tests/watchdog-orphan-web.test.mjs` now records the reproduction note: **such an orphan can only be created with `kill -9` — SIGTERM runs the watchdog's graceful exit, which also stops the web it spawned** (the same coin as #19's "on Windows SIGTERM is unconditional termination"). `npm test` now runs **308 checks** |
|
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.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 项** |
|
|
397
|
+
| **v0.10.21** | 2026-10-13 | **「库不可用」从「静默运行」变成「看得清」(issue #33,按评审裁剪范围)**。① **打开即体检**:老实现执行完 `PRAGMA journal_mode=WAL` 就当成功(这条 PRAGMA 失败时**不抛错**,只把真实模式当一行返回),文件只读 / 目录不可写时插件会一路静默跑下去;现在打开后**读回真实 journal 模式 + 真写一次探针**(建表→删表,写主库而非 temp 库),结论记进 `dbStatus()`,不可写时立即把原因与排查方向打到日志。② **`/overview` 降级返回**:库/目录不可用时不再吐 500(500 会被探针、面板、脚本读成「这个端口坏了 / 不是我们的服务」,历史上正是重启风暴与端口漂移的入口),改成 200 + `degraded:true` + `dbError` + `db:{journalMode,writable,error}`,并保住 `vaultDir` / `version` 身份字段 —— 「这是我们、但库有问题」从此可以被分开表达。③ **新增 `dsh-memory doctor`**:库健康自检(真实 journal 模式 / 写探针 / 备份清单 / 恢复引导),带 `--json` 可脚本化;不可写或疑似损坏时给出**只引导、不动数据**的步骤(先停写者 → 复制现场留证 → 查备份 → 替换 → 复核)。**刻意不做**(评审结论,已写成测试守卫防止顺手加回):`busy_timeout`(`DatabaseSync` 是同步 API,撞锁会阻塞宿主事件循环)与 WAL→DELETE 自动降级(会**永久**改变库模式,且 rollback journal 并发更差、可能反过来加剧锁竞争)。`npm test` 共 **322 项** |
|
|
398
398
|
|
|
399
399
|
<details>
|
|
400
|
-
<summary>更早的更新日志(v0.10.
|
|
400
|
+
<summary>更早的更新日志(v0.10.20 及以前 · 共 47 版,点开查看)</summary>
|
|
401
401
|
|
|
402
402
|
| 版本 | 日期 | 关键改动 |
|
|
403
403
|
|---|---|---|
|
|
404
|
+
| **v0.10.20** | 2026-10-12 | **「库不可用 → 看门狗自杀自拉 → 端口漂到 8006」三层现场一起修(issue #31 / #33,AkiMei0721 实测)**。① **`/overview` 非 200 ≠ 端口死了**:`/overview` 会碰数据库,库只读 / WAL 损坏时它返回 500,而看门狗据此判「离线」→ 走 `spawnWeb()`(开头先 SIGTERM 掉自己的上一个 child)→ **自杀自拉、端口从 7999 一路漂到 8006(6 个实例)**;新增 `decideTickAction()` 纯决策:只要 **TCP 还能连**就视为存活(`api-degraded-but-listening`,并重置重启计数),新实例的就绪判定同样接受「已监听但 `/overview` 非 200」。② **版本探测改走 `/version-check`**(不碰库;拿不到才回落 `/overview`)—— 病根是「端口上明明活着、版本却拿不到」,于是 verify 报「(无响应)」、`status` 甚至误判成「v0.10.4 之前的旧版」。③ **受限沙箱里不再崩**:默认 `stdio:'pipe'` 会在 `spawn()` 时**同步抛 EPERM**(老实现只在 `'error'` 事件里兜底,根本没机会执行)→ `dsh-memory watchdog --reap` 直接崩、进程枚举永远空名单;现在 `try/catch` 包住 spawn,并**用临时文件接 stdout**、完全不碰命名管道。④ 顺带给 stall 判据加「最短运行时长(30 分钟)」:刚落进程必然 0 收尾,不该据此报「收尾事件失效」(实测重启 1.5 分钟后就误报过)。新增 `tests/issue-fixes-8.test.mjs`(4 项)+ `tests/stall.test.mjs` 的 too-early 用例。`npm test` 共 **318 项** |
|
|
405
|
+
| **v0.10.19** | 2026-10-12 | **`UNSUPPORTED_REASONING_EFFORT` 不再让整条蒸馏熔断(issue #34)**。现场(zafir-zhong 等):很多**网关**在能力自述里说支持 reasoning effort,真把 `reasoningEffort: 'off'` 发出去才回 `UNSUPPORTED_REASONING_EFFORT`(实测:workbuddy/hy3、FallbacksChain/Auto、new-api-resp/gpt-6-luna),于是整个候选被判失败、换下一个 provider —— 用户的自动沉淀直接停摆(#37 / #38 / #40 的诊断信息里都能看到这条 FAIL)。既有设计只覆盖「provider **自述**不支持 → 不传该字段」(#15),缺了运行时这一半。修法:新增进程内记忆 `markEffortUnsupported()\/isEffortUnsupported()` —— 首次被拒时**不带该字段重试一次**(同一候选、同一 provider),并记住这个 `provider/model`,此后同一进程内它第一次请求就不再带 effort;记忆**按 provider/model 分开**,不误伤别的 provider。回归守卫加在 `tests/capture-route.test.mjs`(第一次带 → 被拒 → 第二次不带并拿到卡 → 后续只发一次 → 别的 provider 不受影响)。`npm test` 共 **313 项** |
|
|
404
406
|
| **v0.10.18** | 2026-10-12 | **「保存配置」被无条件拒绝的修复(issue #35)**。现场(Dzifxazy):全新安装、什么都不动、直接点保存 → **HTTP 400「字段不能为空:secretHint」**,整单被拒,用户改的数字与开关**一个都存不进去**;文案还会把排查方向误导到「某个数字格子没填」。根因:宿主侧的前置校验把**任何空字符串**都当成「数字没填」,只豁免一份硬编码名单(vaultDir / captureProvider / captureModel / recallEmbedding / activeVault)—— 而 `secretHint` 的默认值**就是**空串(`z.string().default('')`)又不在名单里;面板提交的是整张表单,于是每次保存必然命中。修法:新增 `emptyNumericFields(patch, Config.dict)`,**按 schema 的 `type === 'number'` 判定**「哪些键被清空」,彻底删掉硬编码名单(以后新增可空字符串字段不需要谁记得去补)。新增 `tests/config-validate.test.mjs`(2 项:空串只对 number 字段报「没填」——里面放了一个「不在任何名单里」的新字符串字段做回归钉子;源码守卫不许退回名单)。`npm test` 共 **312 项** |
|
|
405
407
|
| **v0.10.17** | 2026-10-12 | **「常驻实例在服务、却没人托管」这类状态自相矛盾的根治(issue #28)**。现场:用户升级到 0.10.16 后常驻实例没起来,面板**既没有重启按钮也没有任何提示**(横幅只在 `vc.stale` 时渲染,而宿主与端口版本号一致时 `stale` 为假),只看到一条「自动沉淀异常」;本机复现出另一半:`stop` 之后端口上**还活着**、锁里却空了、`status` 说「没有登记的看门狗」—— 因为端口上那个 web 是宿主 `ensureWeb()` 直接拉起的(锁里没有 `webPid`),`stop` 只杀了看门狗。五处一起修:① **`/version-check` 报出 `resident.managed`**(锁里有活着的看门狗才算被托管)—— 「在服务」≠「被托管」;② **面板**:常驻实例**没在跑**或**没被托管**时都显示重启按钮与专门文案(「未运行」/「未托管」),不再只认「版本落后」这一种;③ **`/restart-self`**:`alreadyCurrent` 必须同时满足「版本一致 **且** 被托管」,否则照常替换(孤儿 web 版本再新也得重新托管);④ **显式 `dsh-memory stop` 把端口腾出来**(`forcePortOccupant: true`,闸门不变:只终止 `looksLikeOurWeb` 认得出是自家的进程);⑤ **自检改为「新实例接管」**:`restartResident` 不再只看「端口服着期望版本」(升级前后版本号常常相同、旧实例本就是这个版本 → 自检会被**旧实例**骗过,实测打印「自检通过(等待 0s)」后紧接着 `status` 说没有登记的看门狗),新增 `waitForTakeover()`:必须「版本一致 **且** 锁里出现一个不在替换前 pid 集合里的活看门狗」才算成功,失败原因也据此分离成「版本不对」与「没人接管」。本机同一现场实测:替换前 `41672/17432` → 替换后 `28136/31976`,`restart` 等待 13s 后自检通过,**紧接着的 `status` 立刻显示新看门狗已登记**(不再矛盾)。`npm test` 共 **310 项** |
|
|
406
408
|
| **v0.10.16** | 2026-10-09 | **`--reap` 的输出不再「明明收了却说没收」**(alario-tang 反馈,macOS + 0.10.13 实测)。现场:`kill -9` 掉 watchdog 造出 `PPID=1` 的孤儿 web 后跑 `dsh-memory watchdog --reap --port 7999`,端口确实被释放、web 确实被收掉,屏幕上却写着「**扫描到 1 个 watchdog 进程,清理孤儿 0 个**」—— 因为 0.10.13 让 `scanned` 含了 web、而 `webKilled` 一个字都没打印。修法:新增纯函数 `formatReapSummary()` 统一口径(扫描数拆成 `watchdog A / web B`、清理项按类分别列出 pid),`reapStaleWatchdogs` 返回值补 `scannedWatchdogs` / `scannedWebs`(`scanned` 仍是两者之和,兼容旧调用方),CLI 改用它。测试补 2 项(收了 web 时不许再说「清理孤儿 0 个」/ 扫描计数必须拆开),并在 `tests/watchdog-orphan-web.test.mjs` 头部记下复现要点:**这类孤儿只能用 `kill -9` 造 —— SIGTERM 会走 watchdog 的优雅退出、把它拉起的 web 一起停掉**(与 #19 里「Windows 上 SIGTERM 即无条件终止」是同一枚硬币的两面)。`npm test` 共 **308 项** |
|
package/bin/dsh-memory.mjs
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
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 doctor [--vault DIR] [--json] 库健康自检(journal 模式 / 写探针 / 备份清单 / 恢复引导)
|
|
14
|
+
// dsh-memory refine [--vault DIR] [--json] 矛盾扫描候选(不调模型);--apply --a A --b B --action keepA|keepB|merge|keepBoth 落裁决
|
|
13
15
|
//
|
|
14
16
|
// 环境变量:
|
|
15
17
|
// MEMORY_VAULT_DIR vault 目录(默认 ~/.dsh/memory-vault)
|
|
@@ -163,6 +165,65 @@ async function main() {
|
|
|
163
165
|
}
|
|
164
166
|
return
|
|
165
167
|
}
|
|
168
|
+
case 'refine': {
|
|
169
|
+
// issue #36:矛盾扫描的 CLI 入口。**列候选不调模型、不写库**;裁决可直接落(只做可恢复动作)。
|
|
170
|
+
const { defaultVaultDir } = await import('../lib/capture-run.js')
|
|
171
|
+
const { refineCandidates, refineApply, readDecisions } = await import('../lib/refine.js')
|
|
172
|
+
const root = argOf('--vault') ? path.resolve(argOf('--vault')) : defaultVaultDir()
|
|
173
|
+
if (has('--apply')) {
|
|
174
|
+
const out = await refineApply(root, {
|
|
175
|
+
a: argOf('--a'), b: argOf('--b'), action: argOf('--action'),
|
|
176
|
+
note: argOf('--note') || '', relation: argOf('--relation') || '',
|
|
177
|
+
})
|
|
178
|
+
if (has('--json')) { console.log(JSON.stringify(out, null, 2)); if (!out.ok) process.exitCode = 1; return }
|
|
179
|
+
if (out.ok) console.log('已记录裁决:' + out.action + (out.removed ? '(' + out.removed + ' 已进回收站,可从回收中心还原)' : ''))
|
|
180
|
+
else { console.error('失败:' + out.error); process.exitCode = 1 }
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
const cands = await refineCandidates(root)
|
|
184
|
+
const decisions = Object.keys(readDecisions(root)).length
|
|
185
|
+
// 只出元信息(path/title/kind)—— 正文是给模型判别用的,CLI 里没必要吐 2KB×N
|
|
186
|
+
const slim = (x) => ({ path: x.path, title: x.title, kind: x.kind })
|
|
187
|
+
if (has('--json')) { console.log(JSON.stringify({ ok: true, candidates: cands.map((c) => ({ a: slim(c.a), b: slim(c.b), sim: c.sim })), decisions }, null, 2)); return }
|
|
188
|
+
console.log(`矛盾扫描 · 候选 ${cands.length} 对(已裁决 ${decisions} 对)`)
|
|
189
|
+
for (const c of cands) console.log(` sim ${c.sim} · ${c.a.path} ⇄ ${c.b.path}`)
|
|
190
|
+
console.log('')
|
|
191
|
+
console.log('(这一步不调模型、不写库)模型判别「重复 / 补充 / 矛盾 / 无关」请在 DSH 面板:记忆 → 整理 → 🧭 矛盾扫描')
|
|
192
|
+
console.log('落裁决:dsh-memory refine --apply --a <路径> --b <路径> --action keepA|keepB|merge|keepBoth')
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
case 'doctor': {
|
|
196
|
+
// ⑤ 损坏/不可写时的「引导」(issue #33):只体检、只列备份、只打印下一步 —— 绝不自动替换/删除数据。
|
|
197
|
+
const { defaultVaultDir } = await import('../lib/capture-run.js')
|
|
198
|
+
const { getDb, dbStatus, checkDbHealth, rebuildGuidance } = await import('../lib/db.js')
|
|
199
|
+
const fsPromises = await import('node:fs/promises')
|
|
200
|
+
const root = argOf('--vault') ? path.resolve(argOf('--vault')) : defaultVaultDir()
|
|
201
|
+
let health = null
|
|
202
|
+
try {
|
|
203
|
+
const db = getDb(root) // 打开即体检(写探针在建表之后跑一次)
|
|
204
|
+
health = dbStatus(root) || checkDbHealth(db)
|
|
205
|
+
} catch (e) {
|
|
206
|
+
health = { journalMode: '', writable: false, error: String((e && e.message) || e), corrupt: /malformed|not a database|corrupt/i.test(String((e && e.message) || e)), checkedAt: Date.now() }
|
|
207
|
+
}
|
|
208
|
+
const backupDir = path.join(root, 'backups')
|
|
209
|
+
const backups = (await fsPromises.readdir(backupDir).catch(() => []))
|
|
210
|
+
.filter((x) => x.startsWith('memory-eternal-') && x.endsWith('.db')).sort().reverse()
|
|
211
|
+
const guidance = rebuildGuidance(root, health)
|
|
212
|
+
if (has('--json')) {
|
|
213
|
+
console.log(JSON.stringify({ ok: Boolean(health.writable), vault: root, health, backups, guidance }, null, 2))
|
|
214
|
+
if (!health.writable) process.exitCode = 1
|
|
215
|
+
return
|
|
216
|
+
}
|
|
217
|
+
console.log('记忆核心 · 自检(doctor)')
|
|
218
|
+
console.log('vault:' + root)
|
|
219
|
+
console.log('journal_mode:' + (health.journalMode || '(未知)'))
|
|
220
|
+
console.log('写探针:' + (health.writable ? 'OK(这个库现在可写)' : '失败 → ' + health.error))
|
|
221
|
+
if (health.corrupt) console.log('损坏判定:疑似文件损坏(见下方引导)')
|
|
222
|
+
console.log('备份:' + (backups.length ? backups.length + ' 个(最近:' + backups[0] + ')' : '(还没有备份 —— 可在面板或 backupDb 生成)'))
|
|
223
|
+
if (guidance.length) { console.log(''); for (const line of guidance) console.log('· ' + line) }
|
|
224
|
+
if (!health.writable) process.exitCode = 1
|
|
225
|
+
return
|
|
226
|
+
}
|
|
166
227
|
case 'stop': {
|
|
167
228
|
const { stopWatchdogs } = await import('../lib/watchdog.js')
|
|
168
229
|
const port = argOf('--port')
|
package/index.js
CHANGED
|
@@ -1052,6 +1052,8 @@ export function apply(ctx, config) {
|
|
|
1052
1052
|
beatHostMarker()
|
|
1053
1053
|
const handleApi = createApi({
|
|
1054
1054
|
vaultDir, vaultRoots, getSettings: settings.get,
|
|
1055
|
+
// 矛盾扫描(#36)在宿主侧跑模型:这里给它 llm 句柄,独立 web 进程拿不到就会如实报错
|
|
1056
|
+
getLlm: () => ctx.get('llm'),
|
|
1055
1057
|
// 自动沉淀运行轨迹 + 健康状态:供「用量/今日」页排查「为什么没写卡」,异常时页面顶部亮红。
|
|
1056
1058
|
getCaptureLog: () => captureLog.slice().reverse(),
|
|
1057
1059
|
getCaptureHealth: () => health.snapshot(),
|
package/lib/api.js
CHANGED
|
@@ -18,7 +18,7 @@ import { listCards, countCards, readCard, search, searchAll, graph, graphAll, ov
|
|
|
18
18
|
import { compressExcerpt, DEFAULT_CAPTURE_MAX_TOKENS } from './capture.js'
|
|
19
19
|
import { buildDiagnostics } from './feedback.js'
|
|
20
20
|
import { getSetupStatus, runSetup, mcpAgentAction } from './setup.js'
|
|
21
|
-
import { backupDb } from './db.js'
|
|
21
|
+
import { backupDb, dbStatus, rebuildGuidance } from './db.js'
|
|
22
22
|
import { createHub } from './sse.js'
|
|
23
23
|
import { probeServedVersion, restartResident, currentPkgVersion, readWatchdogLock, isPidAlive } from './watchdog.js'
|
|
24
24
|
|
|
@@ -136,6 +136,8 @@ export function createApi(deps = {}) {
|
|
|
136
136
|
// 自动沉淀运行轨迹(最新在前)+ 健康状态(异常时 UI 顶部亮红)。
|
|
137
137
|
const getCaptureLog = deps.getCaptureLog ?? (() => null)
|
|
138
138
|
const getCaptureHealth = deps.getCaptureHealth ?? (() => null)
|
|
139
|
+
// 矛盾扫描(issue #36)要用模型:宿主把 ctx.get('llm') 传进来;独立 web 进程拿不到就如实报错
|
|
140
|
+
const getLlm = deps.getLlm ?? (() => null)
|
|
139
141
|
// 「重启常驻实例」的实现(宿主与独立 web 共用 lib/watchdog.js 的那一份;注入仅为单测)
|
|
140
142
|
const doRestartResident = deps.restartResident ?? restartResident
|
|
141
143
|
const probeResidentVersion = deps.probeServedVersion ?? probeServedVersion
|
|
@@ -156,15 +158,31 @@ export function createApi(deps = {}) {
|
|
|
156
158
|
|
|
157
159
|
switch (route) {
|
|
158
160
|
case '/overview': {
|
|
159
|
-
await ensureVault(vaultRoot)
|
|
160
161
|
// version = **当前正在服务这个端口的进程**加载的包版本(issue #23):
|
|
161
162
|
// 磁盘上升级了、端口上跑的还是旧进程时,只有这个字段说得清真相(锁文件里的
|
|
162
163
|
// pkgVersion 是 watchdog 自己写的,与端口上真正服务的代码无关)。
|
|
164
|
+
const servingVersion = (getDshInfo() || {}).version || process.env.MEMORY_ETERNAL_VERSION || ''
|
|
165
|
+
const degraded = (error) => {
|
|
166
|
+
// #33:库/目录不可用时**不再吐 500**。500 会被上层(探针、面板、脚本)读成
|
|
167
|
+
// 「这个端口坏了 / 不是我们的服务」,历史上就是这么引出重启风暴与端口漂移的。
|
|
168
|
+
// 降级成 200 + degraded 标记,身份字段(vaultDir/version)照旧带上 ——
|
|
169
|
+
// 「这是我们、但库有问题」从此可以被分开表达,用户也能看到真实原因。
|
|
170
|
+
const status = { writable: false, error: String(error), ...(dbStatus(vaultRoot) || {}) }
|
|
171
|
+
json(res, 200, {
|
|
172
|
+
ok: true, degraded: true, vaultDir: vaultRoot, version: servingVersion,
|
|
173
|
+
total: 0, byKind: {}, roots: {}, db: status, dbError: status.error,
|
|
174
|
+
guidance: rebuildGuidance(vaultRoot, status),
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
try { await ensureVault(vaultRoot) } catch (e) { return degraded((e && e.message) || e) }
|
|
178
|
+
let data
|
|
179
|
+
try { data = await overview(vaultRoot) } catch (e) { return degraded((e && e.message) || e) }
|
|
163
180
|
json(res, 200, {
|
|
164
181
|
ok: true,
|
|
165
182
|
vaultDir: vaultRoot,
|
|
166
|
-
version:
|
|
167
|
-
|
|
183
|
+
version: servingVersion,
|
|
184
|
+
db: dbStatus(vaultRoot),
|
|
185
|
+
...data,
|
|
168
186
|
})
|
|
169
187
|
return
|
|
170
188
|
}
|
|
@@ -548,6 +566,43 @@ export function createApi(deps = {}) {
|
|
|
548
566
|
json(res, 200, result)
|
|
549
567
|
return
|
|
550
568
|
}
|
|
569
|
+
case '/refine': {
|
|
570
|
+
// 矛盾扫描 · 候选预览(**不调模型、不写库**):宽召回相似度 >= 0.30 的卡片对,含近重复那一段
|
|
571
|
+
const { refineCandidates, readDecisions } = await import('./refine.js')
|
|
572
|
+
const cands = await refineCandidates(vaultRoot)
|
|
573
|
+
json(res, 200, {
|
|
574
|
+
ok: true,
|
|
575
|
+
candidates: cands.map((c) => ({ a: c.a, b: c.b, sim: c.sim })),
|
|
576
|
+
decisions: Object.keys(readDecisions(vaultRoot)).length,
|
|
577
|
+
})
|
|
578
|
+
return
|
|
579
|
+
}
|
|
580
|
+
case '/refine-scan': {
|
|
581
|
+
// 逐对判别(调模型)。POST {limit?, provider?, model?};**只读**,结论要人来 apply。
|
|
582
|
+
if (req.method !== 'POST') { json(res, 405, { ok: false, error: '需 POST' }); return }
|
|
583
|
+
const { refineScan } = await import('./refine.js')
|
|
584
|
+
let opts = {}
|
|
585
|
+
try { let raw = ''; for await (const chunk of req) raw += chunk; opts = JSON.parse(raw || '{}') } catch { opts = {} }
|
|
586
|
+
const out = await refineScan(vaultRoot, {
|
|
587
|
+
llm: getLlm(),
|
|
588
|
+
limit: Number(opts.limit) || 12,
|
|
589
|
+
provider: opts.provider,
|
|
590
|
+
model: opts.model,
|
|
591
|
+
})
|
|
592
|
+
json(res, out.ok ? 200 : 400, out)
|
|
593
|
+
return
|
|
594
|
+
}
|
|
595
|
+
case '/refine-apply': {
|
|
596
|
+
// 人拍板:POST {a, b, action: keepA|keepB|merge|keepBoth, note?, relation?}
|
|
597
|
+
// 只做可恢复动作(keepA/keepB → 另一张进回收站,可还原),并记进 refine-decisions.json
|
|
598
|
+
if (req.method !== 'POST') { json(res, 405, { ok: false, error: '需 POST' }); return }
|
|
599
|
+
const { refineApply } = await import('./refine.js')
|
|
600
|
+
let payload = {}
|
|
601
|
+
try { let raw = ''; for await (const chunk of req) raw += chunk; payload = JSON.parse(raw || '{}') } catch { return json(res, 400, { ok: false, error: 'JSON 解析失败' }) }
|
|
602
|
+
const out = await refineApply(vaultRoot, payload)
|
|
603
|
+
json(res, out.ok ? 200 : 400, out)
|
|
604
|
+
return
|
|
605
|
+
}
|
|
551
606
|
case '/setup-run': {
|
|
552
607
|
// 「补全 MCP」:真正执行 runSetup(写外部 agent 配置)。共享层(独立 web 也可用)
|
|
553
608
|
if (req.method !== 'POST') return json(res, 405, { ok: false, error: '需 POST' })
|
package/lib/capture.js
CHANGED
|
@@ -61,6 +61,14 @@ const NOISE_BLOCKS = [
|
|
|
61
61
|
]
|
|
62
62
|
/** 运行时注入的行前缀:整行丢掉。 */
|
|
63
63
|
const NOISE_LINE = /^\s*(相关上下文|Current runtime context|Runtime context|Approval prompts are disabled|DSH file policy|Current DSH file policy|You are a teammate|Team Lead|Teammate |Subagent |Note: the user|系统提示|环境快照)[::]?/i
|
|
64
|
+
/**
|
|
65
|
+
* 「技能目录」条目(形如 `- skill-name: 说明…`)。
|
|
66
|
+
* 来源(实测):DSH 的 available-skills 目录会被当成 user 消息注入,剥掉标签后仍留一长串目录行;
|
|
67
|
+
* 抓下来就成了和对话毫无关系的垃圾卡(#36 说的「冗余」来源之一)。用 \x60 表示反引号,避免转义地狱。
|
|
68
|
+
*/
|
|
69
|
+
const SKILL_CATALOG_LINE = /^\s*[-*]\s*\x60[a-z0-9][a-z0-9-]{2,40}\x60:\s/
|
|
70
|
+
/** available-skills 目录块(有的宿主带标签)。 */
|
|
71
|
+
NOISE_BLOCKS.push(/<available_skills>[\s\S]*?<\/available_skills>/gi)
|
|
64
72
|
/** 工具/命令使用说明的标题行(`# create-hook`、`## `codex create-hook``)。 */
|
|
65
73
|
const TOOL_DOC_HEADING = /^\s*#{1,4}\s*`?[a-z][a-z0-9-]{2,40}`?\s*$/i
|
|
66
74
|
|
|
@@ -71,7 +79,7 @@ export function stripRuntimeNoise(text) {
|
|
|
71
79
|
for (const re of NOISE_BLOCKS) out = out.replace(re, '')
|
|
72
80
|
const kept = []
|
|
73
81
|
for (const line of out.split('\n')) {
|
|
74
|
-
if (NOISE_LINE.test(line) || TOOL_DOC_HEADING.test(line)) continue
|
|
82
|
+
if (NOISE_LINE.test(line) || TOOL_DOC_HEADING.test(line) || SKILL_CATALOG_LINE.test(line)) continue
|
|
75
83
|
kept.push(line)
|
|
76
84
|
}
|
|
77
85
|
return kept.join('\n').trim()
|
|
@@ -217,6 +225,56 @@ export function isEffortUnsupported(provider, model) { return effortRejected.has
|
|
|
217
225
|
/** 仅供测试:清空记忆。 */
|
|
218
226
|
export function resetEffortUnsupported() { effortRejected.clear() }
|
|
219
227
|
|
|
228
|
+
/**
|
|
229
|
+
* 单次 JSON 调用(把「发请求 → 收 finish → 抽文本」这条链复用给非沉淀场景,如矛盾判别)。
|
|
230
|
+
* 与 summarizeTurnDetailed 共享同一套兼容处理:失败经 finish 暴露(不静默成"无输出")、
|
|
231
|
+
* 运行时被拒的 reasoning effort 不带该字段重试一次(#34)。
|
|
232
|
+
*
|
|
233
|
+
* @returns {Promise<{text?:string, failure?:{code:string,message:string}}>}
|
|
234
|
+
*/
|
|
235
|
+
export async function callLlmJson(llm, route, { system = '', user = '', maxTokens = 1500, purpose = 'memory-refine', signal } = {}) {
|
|
236
|
+
const { BlockAssembler, createUserMessage } = await importLlmHelpers()
|
|
237
|
+
const messages = [createUserMessage({
|
|
238
|
+
content: [{ type: 'text', text: String(user || '') }],
|
|
239
|
+
source: { kind: 'plugin', plugin: 'memory-eternal' },
|
|
240
|
+
})]
|
|
241
|
+
const effort = route.reasoningEffort === undefined
|
|
242
|
+
? (isEffortUnsupported(route.provider, route.model) ? null : 'off')
|
|
243
|
+
: route.reasoningEffort
|
|
244
|
+
const options = {
|
|
245
|
+
provider: route.provider,
|
|
246
|
+
model: route.model,
|
|
247
|
+
messages,
|
|
248
|
+
...(system ? { system } : {}),
|
|
249
|
+
maxTokens,
|
|
250
|
+
...(effort ? { reasoningEffort: effort } : {}),
|
|
251
|
+
purpose,
|
|
252
|
+
signal: signal ?? AbortSignal.timeout(60000),
|
|
253
|
+
}
|
|
254
|
+
const runOnce = async (o) => {
|
|
255
|
+
const asm = new BlockAssembler()
|
|
256
|
+
for await (const chunk of llm.stream(o)) asm.push(chunk)
|
|
257
|
+
return asm
|
|
258
|
+
}
|
|
259
|
+
let asm = await runOnce(options)
|
|
260
|
+
let finish = asm.finish && typeof asm.finish === 'object' ? asm.finish : { kind: 'stop' }
|
|
261
|
+
if (finish.kind === 'error' && 'reasoningEffort' in options) {
|
|
262
|
+
const f0 = finish.failure || {}
|
|
263
|
+
if (/UNSUPPORTED_REASONING_EFFORT/i.test(String(f0.code || '') + ' ' + String(f0.message || f0.detail || ''))) {
|
|
264
|
+
markEffortUnsupported(route.provider, route.model)
|
|
265
|
+
const { reasoningEffort, ...without } = options
|
|
266
|
+
asm = await runOnce(without)
|
|
267
|
+
finish = asm.finish && typeof asm.finish === 'object' ? asm.finish : { kind: 'stop' }
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (finish.kind === 'error' || finish.kind === 'aborted') {
|
|
271
|
+
const f = finish.failure || {}
|
|
272
|
+
return { failure: { code: String(f.code || f.kind || finish.kind), message: String(f.message || f.detail || '模型调用失败') } }
|
|
273
|
+
}
|
|
274
|
+
const text = asm.blocks().filter((b) => b.type === 'text').map((b) => b.text).join('').trim()
|
|
275
|
+
return { text }
|
|
276
|
+
}
|
|
277
|
+
|
|
220
278
|
export async function summarizeTurnDetailed(llm, route, conversation, opts = {}) {
|
|
221
279
|
const text = String(conversation || '').trim()
|
|
222
280
|
if (text.length < 120) return { skip: 'too-short' } // 太短,没有沉淀价值
|