memory-eternal 0.10.21 → 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.
@@ -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.21",
4
+ "version": "0.10.22",
5
5
  "description": "记忆核心(Memory Eternal):对话结束后自动沉淀知识卡到本地记忆库(~/.dsh/memory-vault),支持检索 / 召回 / 审核中心。",
6
6
  "author": {
7
7
  "name": "EternalNight996",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memory-eternal",
3
- "version": "0.10.21",
3
+ "version": "0.10.22",
4
4
  "description": "记忆核心(Memory Eternal):对话结束后自动沉淀知识卡到本地记忆库(~/.dsh/memory-vault),支持检索 / 召回 / 审核中心。",
5
5
  "author": {
6
6
  "name": "EternalNight996",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memory-eternal",
3
- "version": "0.10.21",
3
+ "version": "0.10.22",
4
4
  "description": "记忆核心(Memory Eternal):对话结束后自动沉淀知识卡到本地记忆库(~/.dsh/memory-vault),支持检索 / 召回 / 审核中心。",
5
5
  "author": {
6
6
  "name": "EternalNight996",
package/README.en.md CHANGED
@@ -361,14 +361,15 @@ 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.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** |
364
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** |
365
- | **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** |
366
366
 
367
367
  <details>
368
- <summary>Older release notes (v0.10.19 and earlier · 46 releases, click to expand)</summary>
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** |
372
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** |
373
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** |
374
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** |
package/README.md CHANGED
@@ -393,14 +393,15 @@ curl http://127.0.0.1:7999/memory-eternal/api/web-info # 独立 web 是否活
393
393
 
394
394
  | 版本 | 日期 | 关键改动 |
395
395
  |---|---|---|
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 项** |
396
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 项** |
397
- | **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 项** |
398
398
 
399
399
  <details>
400
- <summary>更早的更新日志(v0.10.19 及以前 · 共 46 版,点开查看)</summary>
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 项** |
404
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 项** |
405
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 项** |
406
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 项** |
@@ -11,6 +11,7 @@
11
11
  // 自动挂载 MCP 到已装的 agent
12
12
  // dsh-memory sweep <dir> 挖掘 Claude Code 会话 JSONL
13
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 落裁决
14
15
  //
15
16
  // 环境变量:
16
17
  // MEMORY_VAULT_DIR vault 目录(默认 ~/.dsh/memory-vault)
@@ -164,6 +165,33 @@ async function main() {
164
165
  }
165
166
  return
166
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
+ }
167
195
  case 'doctor': {
168
196
  // ⑤ 损坏/不可写时的「引导」(issue #33):只体检、只列备份、只打印下一步 —— 绝不自动替换/删除数据。
169
197
  const { defaultVaultDir } = await import('../lib/capture-run.js')
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
@@ -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
@@ -564,6 +566,43 @@ export function createApi(deps = {}) {
564
566
  json(res, 200, result)
565
567
  return
566
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
+ }
567
606
  case '/setup-run': {
568
607
  // 「补全 MCP」:真正执行 runSetup(写外部 agent 配置)。共享层(独立 web 也可用)
569
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' } // 太短,没有沉淀价值