@kira905/dsh-memory-toolkit 0.0.0-stage → 0.1.1

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.
Files changed (132) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/LICENSE +661 -0
  3. package/README.en.md +110 -0
  4. package/README.md +102 -2
  5. package/dsh-memory-local/FORK-BASE.json +24 -0
  6. package/dsh-memory-local/README.md +113 -0
  7. package/dsh-memory-local/build.mjs +101 -0
  8. package/dsh-memory-local/cordis.patch.yml +20 -0
  9. package/dsh-memory-local/deploy.mjs +306 -0
  10. package/dsh-memory-local/entity-status.mjs +75 -0
  11. package/dsh-memory-local/lib/bridge.mjs +108 -0
  12. package/dsh-memory-local/lib/host-adapter.js +1171 -0
  13. package/dsh-memory-local/lib/index.d.ts +128 -0
  14. package/dsh-memory-local/lib/index.js +944 -0
  15. package/dsh-memory-local/lib/injector.mjs +400 -0
  16. package/dsh-memory-local/lib/memory-read.mjs +116 -0
  17. package/dsh-memory-local/lib/module-gate.mjs +68 -0
  18. package/dsh-memory-local/lib/pointer.mjs +152 -0
  19. package/dsh-memory-local/lib/prod-gate.mjs +121 -0
  20. package/dsh-memory-local/lib/recall-log.mjs +118 -0
  21. package/dsh-memory-local/lib/recall.mjs +192 -0
  22. package/dsh-memory-local/lib/schema-migrate.mjs +365 -0
  23. package/dsh-memory-local/lib/user-message.mjs +32 -0
  24. package/dsh-memory-local/lib/vectors-read.mjs +110 -0
  25. package/dsh-memory-local/package.json +51 -0
  26. package/dsh-memory-local/restore.mjs +259 -0
  27. package/dsh-memory-local/rollback-injector-fix.mjs +166 -0
  28. package/dsh-memory-local/src/bridge.mjs +108 -0
  29. package/dsh-memory-local/src/host-adapter.js +1171 -0
  30. package/dsh-memory-local/src/index.d.ts +128 -0
  31. package/dsh-memory-local/src/index.js +944 -0
  32. package/dsh-memory-local/src/injector.mjs +400 -0
  33. package/dsh-memory-local/src/memory-read.mjs +116 -0
  34. package/dsh-memory-local/src/module-gate.mjs +68 -0
  35. package/dsh-memory-local/src/pointer.mjs +152 -0
  36. package/dsh-memory-local/src/recall-log.mjs +118 -0
  37. package/dsh-memory-local/src/recall.mjs +192 -0
  38. package/dsh-memory-local/src/schema-migrate.mjs +365 -0
  39. package/dsh-memory-local/src/user-message.mjs +32 -0
  40. package/dsh-memory-local/src/vectors-read.mjs +110 -0
  41. package/dsh-memory-local/test/fixtures-s2b.mjs +216 -0
  42. package/dsh-memory-local/test/probe-entity.mjs +170 -0
  43. package/dsh-memory-local/test/regression.mjs +270 -0
  44. package/dsh-memory-local/test/s2b.test.mjs +568 -0
  45. package/dsh-memory-local/test/schema-migrate.test.mjs +334 -0
  46. package/dsh-memory-local/test/test-adapter-converge.mjs +127 -0
  47. package/dsh-memory-local/test/visible-injections.test.mjs +239 -0
  48. package/dsh-memory-local/verify-after-restart.mjs +181 -0
  49. package/lib/deploy-guard.mjs +391 -0
  50. package/lib/guard-http.mjs +93 -0
  51. package/lib/pitfall-form-map.mjs +130 -0
  52. package/lib/pitfall-score.mjs +185 -0
  53. package/lib/session-file-name.mjs +160 -0
  54. package/lib/zstd-frames.mjs +78 -0
  55. package/memory-db-tools/README.md +68 -0
  56. package/memory-db-tools/_test/test-memory-scope.mjs +302 -0
  57. package/memory-db-tools/audit-analyze.mjs +352 -0
  58. package/memory-db-tools/audit-dump-ids.mjs +245 -0
  59. package/memory-db-tools/audit-export.mjs +270 -0
  60. package/memory-db-tools/audit-hit-probe.mjs +227 -0
  61. package/memory-db-tools/audit-ledger-probe.mjs +236 -0
  62. package/memory-db-tools/audit-persona-overlap.mjs +277 -0
  63. package/memory-db-tools/audit-struct-probe.mjs +213 -0
  64. package/memory-db-tools/audit-verify-unchanged.mjs +276 -0
  65. package/memory-db-tools/check-memory-db.mjs +8 -0
  66. package/memory-db-tools/dump-memory-db.mjs +12 -0
  67. package/memory-db-tools/lib/memory-scope.mjs +446 -0
  68. package/memory-db-tools/list-memory-summary.mjs +477 -0
  69. package/memory-db-tools/memory-identity-check.mjs +101 -0
  70. package/memory-db-tools/memory-schema-migrate.mjs +290 -0
  71. package/memory-db-tools/memory-scope-fixture.mjs +197 -0
  72. package/memory-db-tools/read-memories.mjs +26 -0
  73. package/memory-db-tools/read-memory-db.mjs +20 -0
  74. package/memory-db-tools/refine-memories.mjs +247 -0
  75. package/memory-db-tools/show-mem-times.mjs +22 -0
  76. package/memory-engine/README.md +70 -0
  77. package/memory-engine/bench/bench.mjs +221 -0
  78. package/memory-engine/cli/distill-rollback.mjs +86 -0
  79. package/memory-engine/cli/distill.mjs +156 -0
  80. package/memory-engine/cli/doc-index-rebuild.mjs +155 -0
  81. package/memory-engine/cli/doc-index.mjs +48 -0
  82. package/memory-engine/cli/domain-tags.mjs +251 -0
  83. package/memory-engine/cli/pitfall-hook.mjs +91 -0
  84. package/memory-engine/cli/recall.mjs +132 -0
  85. package/memory-engine/cli/selfcheck.mjs +57 -0
  86. package/memory-engine/cli/serve-start.mjs +247 -0
  87. package/memory-engine/cli/sweep.mjs +58 -0
  88. package/memory-engine/cli/sync.mjs +69 -0
  89. package/memory-engine/lib/cli-util.mjs +197 -0
  90. package/memory-engine/lib/config.mjs +135 -0
  91. package/memory-engine/lib/dirty-ledger.mjs +174 -0
  92. package/memory-engine/lib/distill-rollback.mjs +178 -0
  93. package/memory-engine/lib/distill.mjs +898 -0
  94. package/memory-engine/lib/doc-index-store.mjs +325 -0
  95. package/memory-engine/lib/doc-index.mjs +529 -0
  96. package/memory-engine/lib/doc-scan.mjs +271 -0
  97. package/memory-engine/lib/doc-summarize.mjs +305 -0
  98. package/memory-engine/lib/domain-tags.mjs +429 -0
  99. package/memory-engine/lib/embedder.mjs +242 -0
  100. package/memory-engine/lib/hashes.mjs +12 -0
  101. package/memory-engine/lib/memory-source.mjs +127 -0
  102. package/memory-engine/lib/pitfall-source.mjs +170 -0
  103. package/memory-engine/lib/policy-source.mjs +371 -0
  104. package/memory-engine/lib/recall.mjs +331 -0
  105. package/memory-engine/lib/selfcheck.mjs +131 -0
  106. package/memory-engine/lib/sweep.mjs +159 -0
  107. package/memory-engine/lib/sync.mjs +180 -0
  108. package/memory-engine/lib/tick-hook.mjs +218 -0
  109. package/memory-engine/lib/vectors-store.mjs +292 -0
  110. package/memory-engine/package-lock.json +1086 -0
  111. package/memory-engine/package.json +22 -0
  112. package/memory-engine/serve.mjs +171 -0
  113. package/memory-engine/test/01-store.test.mjs +127 -0
  114. package/memory-engine/test/02-sync.test.mjs +196 -0
  115. package/memory-engine/test/03-sweep.test.mjs +145 -0
  116. package/memory-engine/test/04-recall.test.mjs +214 -0
  117. package/memory-engine/test/05-selfcheck.test.mjs +175 -0
  118. package/memory-engine/test/06-doc-index.test.mjs +928 -0
  119. package/memory-engine/test/07-domain-tags.test.mjs +298 -0
  120. package/memory-engine/test/08-distill.test.mjs +686 -0
  121. package/memory-engine/test/09-pitfall-source.test.mjs +365 -0
  122. package/memory-engine/test/10-tick-hook.test.mjs +252 -0
  123. package/memory-engine/test/11-policy-source.test.mjs +224 -0
  124. package/memory-engine/test/_tickhook-worker.mjs +56 -0
  125. package/memory-engine/test/fixtures.mjs +205 -0
  126. package/memory-engine/test/harness.mjs +73 -0
  127. package/memory-engine/test/run.mjs +35 -0
  128. package/memory-engine/tools/bridge-smoke.mjs +80 -0
  129. package/memory-engine/tools/fetch-model.mjs +128 -0
  130. package/memory-engine/tools/smoke-recall.mjs +76 -0
  131. package/package.json +53 -4
  132. package/persona-memory-audit.mjs +217 -0
@@ -0,0 +1,477 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import fs from "node:fs";
3
+ import zlib from "node:zlib";
4
+
5
+ // 只读记忆简报:列全部记忆的 id / pinned / 标题,pinned 条自动附全文,供新会话开场过全貌用。
6
+ // 用法:node list-memory-summary.mjs (dsh 运行中也可安全执行,WAL 模式只读无冲突)
7
+ //
8
+ // 【2026-10-10 · 记忆引擎 A3 · 第 8 步接线】末尾「坑库 Top 3」段改为 **向量召回优先 + 固定 Top3 兜底**:
9
+ // · 目标:按「当前会话上下文」做向量召回坑库条目(替代与本次任务无关的固定 Top3 —— 233 条里永远只看最高 3 条)。
10
+ // · 硬要求 **fail-open**(本段绝不让简报报错、绝不让简报卡住):
11
+ // 无上下文 / 向量库缺失 / 坑向量 0 条 / 编码桥不可达 / 整段超时 / 零命中 / 任何异常
12
+ // ⇒ 一律回落原有 `topN(pitfalls, 3)` **固定 Top3**(固定 Top3 的代码**原样保留,绝不删**)。
13
+ // · 超时:整段召回硬预算 `--recall-timeout-ms`(默认 **900ms**);编码桥单次 ≤300ms;超时即回落。
14
+ // 不起子进程、不写任何生产文件(向量库/记忆库一律**只读**打开、无 DDL、无 meta 写)。
15
+ // · 注入参数(隔离测试用,**均不影响生产默认**):--home / --memory-db / --vectors-db / --pitfalls-file
16
+ // / --session / --query / --bridge-port / --top-k / --no-recall / --debug
17
+ // ── --help(纯文本,不读任何库) ─────────────────────────────────────────────
18
+ if (process.argv.includes("--help") || process.argv.includes("-h")) {
19
+ const usage = [
20
+ "list-memory-summary.mjs — 新会话开工简报(只读,dsh 运行中也可安全执行)",
21
+ "",
22
+ "用法:node list-memory-summary.mjs [选项]",
23
+ "",
24
+ " --home <dir> DSH home(缺省 $DSH_HOME 或 ~/.dsh)",
25
+ " --memory-db <file> 记忆库路径(缺省 <home>/memory/memory.db)",
26
+ " --vectors-db <file> 向量库路径(缺省 <home>/memory-vectors/vectors.db)",
27
+ " --pitfalls-file <file> 坑库文件(缺省 <home>/butler-pitfalls.json)",
28
+ " --session <id> 指定会话 id(缺省读 DSH_SESSION_ID)",
29
+ " --query <text> 直接给召回查询文本(缺省取会话上下文)",
30
+ " --top-k <n> 坑库显示条数(默认 3)",
31
+ " --no-recall 跳过向量召回,直接用固定 TopN",
32
+ " --recall-timeout-ms <n> 召回整段硬预算(默认 900ms)",
33
+ " --bridge-port <port> 编码桥端口(默认 8201)",
34
+ " --debug 打印召回诊断",
35
+ " -h, --help 本帮助",
36
+ "",
37
+ "说明:输出含真实记忆正文 ⇒ 属于你的私有数据,只在本机打印,本工具不做任何网络请求。",
38
+ ].join("\n");
39
+ process.stdout.write(usage + "\n", () => process.exit(0));
40
+ }
41
+ const ARGS = parseArgs(process.argv.slice(2));
42
+ const HOME = normPath(ARGS.home || process.env.DSH_HOME || `${process.env.USERPROFILE ?? process.env.HOME ?? ""}/.dsh`);
43
+ const MEMORY_DB = ARGS["memory-db"] || `${HOME}/memory/memory.db`;
44
+ const PITFALLS_FILE = ARGS["pitfalls-file"] || `${HOME}/butler-pitfalls.json`;
45
+ const VECTORS_DB = ARGS["vectors-db"] || `${HOME}/memory-vectors/vectors.db`;
46
+ const BRIDGE_PORT = numOr(ARGS["bridge-port"] ?? process.env.MEMORY_ENGINE_BRIDGE_PORT, 8201);
47
+ const RECALL_BUDGET_MS = numOr(ARGS["recall-timeout-ms"], 900);
48
+ const BRIDGE_TIMEOUT_MS = Math.min(450, RECALL_BUDGET_MS);
49
+ /** 召回尝试次数上限:桥偶发慢/连接冷启动时给一次重试(**只对异常/超时重试**;零命中是确定结果,不重试) */
50
+ const RECALL_ATTEMPTS = 2;
51
+ const TOP_K = numOr(ARGS["top-k"], 3);
52
+ const NO_RECALL = ARGS["no-recall"] === true;
53
+ const DEBUG = ARGS.debug === true;
54
+ const SESSION_ID = typeof ARGS.session === "string" ? ARGS.session : (process.env.DSH_SESSION_ID ?? "");
55
+ const EXPLICIT_QUERY = typeof ARGS.query === "string" ? ARGS.query : "";
56
+ /** 喂给编码桥的查询文本上限(bge-small-zh 的 max_length=512 token ⇒ 中文按 1 字 ≈ 1 token 取 500 字封顶) */
57
+ const QUERY_MAX_CHARS = 500;
58
+ /** 记忆引擎模块缓存(懒加载;**必须声明在使用点之前** —— 顶层 await 会先跑到坑库段,`let` 落在后面会撞 TDZ) */
59
+ let _mods = null;
60
+
61
+ // ===== 当前设备识别 =====
62
+ // 本鱼新会话开工必跑本简报 → 把「当前在哪台机」打在最前面,防写记忆/流水时标错设备。
63
+ // 设备名直接读环境变量即可(任何进程都能取,不需要开机脚本预写文件)。
64
+ // 新增设备:在 HOST_ROLES 里补一行(键用大写主机名)。
65
+ const hostName = process.env.COMPUTERNAME || "UNKNOWN";
66
+ /** 坑库文档目录(仅用于打印;可经 DSH_PITFALLS_DOC_DIR 覆盖) */
67
+ const PITFALLS_DOC_DIR = process.env.DSH_PITFALLS_DOC_DIR || "docs/坑库";
68
+ const HOST_ROLES = (() => {
69
+ try { return JSON.parse(process.env.MEMORY_HOST_ROLES || "{}"); } catch { return {}; }
70
+ })();
71
+ const hostRole = HOST_ROLES[hostName.toUpperCase()] ?? "未登记设备(可用环境变量 MEMORY_HOST_ROLES 补一行)";
72
+ console.log(`当前设备:${hostName} — ${hostRole}|用户 ${process.env.USERNAME ?? "?"}`);
73
+
74
+ // ===== 插电保护状态行(2026-10-16 加)=====
75
+ // 为什么加:新会话开工第一眼必须能看见「现在在不在保护中、该按哪套纪律走」。
76
+ // 起因(2026-10-16 主人反馈):新建的主会话在「要不要自己交接 / 小活要不要建卡」上犹豫、等主人发话,
77
+ // 主人以为是"没开整夜模式" —— 实测保护已连开 8 天没关。**真根因 = 会话看不见保护态**:
78
+ // persona 的自主授权全是「整夜模式期间…」的条件句,而这个条件恰恰不在开工面上 ⇒ 会话无从判断
79
+ // ⇒ 保守走常规口径、等主人发话。⇒ 把"条件"打进入口,比"再提醒一次"治本。
80
+ // 为什么用「读状态文件」而不是跑 `overnight-mode.mjs status`(2026-10-16 实测):
81
+ // `status` 要逐方案核 4×7 项 + 屏保 3 项 + WU + job,本机实测 **15.7s / 17.0s**(技能里旧记的"~1s"已过期),
82
+ // 塞进开工简报 = 每个会话开场白都要等 17 秒 ⇒ 不可接受。改用**状态文件轻判据**(<10ms):
83
+ // `~/.dsh/overnight-mode-<host>.json` 有 `createdAt` 且无 `restoredAt` ⇒ 上次 on 之后没被 off 过。
84
+ // ⚠️ 本行给的是「口径」不是「体检」:它不核电源设置是否真还在(历史出现过屏保单独漂回 ⇒ 假绿)。
85
+ // 健康核对归周期 job(`overnight-guard` 每日自检)与「插电保护」本体,**不阻塞开工**。
86
+ // 纪律:① fail-open(任何异常只打一行提示,绝不报错、绝不卡住简报)② 只读、不写任何文件
87
+ // 真源(口径别在这抄第二份):persona【铁律·插电保护禁停与自动交棒】(2026-10-16 由「整夜模式禁停与自动交棒」改名,语义不变)
88
+ // + `docs\整夜模式-自主推进与自动交接口径-20260925.md`(档名保留历史名,不随称呼改)
89
+ // + 判据面改由 `auto` 常驻维持(`dsh-start.ps1` 3f 段 + tickd job `plug-guard` 10 分钟一轮)
90
+ await printPowerGuardLine();
91
+
92
+ const db = new DatabaseSync(MEMORY_DB, { readOnly: true });
93
+ const rows = db.prepare("SELECT id, text, tags, pinned FROM memories ORDER BY id").all();
94
+ console.log(`TOTAL: ${rows.length} pinned: ${rows.filter(r => r.pinned === 1).length}`);
95
+ for (const r of rows) {
96
+ const firstLine = r.text.split("\n")[0].slice(0, 80);
97
+ console.log(`#${String(r.id).padStart(3)} [pinned=${r.pinned === 1 ? "Y" : "-"}] ${firstLine}`);
98
+ }
99
+ const pinned = rows.filter(r => r.pinned === 1);
100
+ if (pinned.length) {
101
+ console.log("\n===== PINNED 全文 =====");
102
+ for (const r of pinned) {
103
+ console.log(`\n--- #${r.id} ---\n${r.text}`);
104
+ }
105
+ }
106
+ db.close();
107
+
108
+ // ===== 坑库段(向量召回优先 · 失败回落固定 Top 3)=====
109
+ // 说明:数据源仍是 `~/.dsh/butler-pitfalls.json`(P4 tick 每 30 分钟增量更新);
110
+ // 走向量召回时,除该 json(用于把召回命中的 refId 映射成可读条目)外只读 `vectors.db`(source='pitfall')。
111
+ try {
112
+ await runPitfallSection();
113
+ } catch (e) {
114
+ console.log(`\n[坑库 Top3 读取失败(不影响简报其余部分):${e?.message ?? e}]`);
115
+ }
116
+
117
+ /** 坑库段主流程:读 json → 试向量召回 → 成功打印召回段,否则回落固定 Top3(永不抛到调用方) */
118
+ async function runPitfallSection() {
119
+ const st = readJsonSafe(PITFALLS_FILE);
120
+ if (!st) return; // 与改动前一致:坑库文件不存在时本段整体不打印
121
+ const pitfalls = Array.isArray(st.pitfalls) ? st.pitfalls : [];
122
+ const nowMs = Date.now();
123
+
124
+ let recall = { ok: false, reason: NO_RECALL ? "--no-recall(本次强制固定 Top 3)" : null };
125
+ if (!NO_RECALL) recall = await tryVectorRecall();
126
+
127
+ if (recall.ok) {
128
+ const byId = new Map(pitfalls.filter((p) => p && p.id != null).map((p) => [String(p.id), p]));
129
+ printRecallSection(recall, byId, st);
130
+ } else {
131
+ const diag = recall.reason ? `${recall.reason}${recall.detail ? ` · ${recall.detail}` : ""}` : null;
132
+ await printTopFallback(pitfalls, nowMs, st, diag);
133
+ }
134
+ }
135
+
136
+ /**
137
+ * 试向量召回坑库(**fail-open**:任何异常/超时都返回 `{ok:false, reason}`,不抛)。
138
+ *
139
+ * 为什么不 spawn `cli\recall.mjs`:① 提示词要「禁止无超时的子进程等待」,库内直调可精确控超时
140
+ * ② 不起子进程 = 不弹黑窗、不碰 EDR 口子 ③ 失败只在 catch 里落一行,带不崩简报。
141
+ */
142
+ async function tryVectorRecall() {
143
+ const t0 = performance.now();
144
+ let db = null;
145
+ try {
146
+ if (!fs.existsSync(VECTORS_DB)) return { ok: false, reason: "无向量库", detail: VECTORS_DB };
147
+
148
+ const qc = await buildQueryContext();
149
+ if (DEBUG) console.log(` [debug] 查询上下文来源=${qc.source} 取到 ${qc.text.length} 字:${qc.text.slice(0, 140)}`);
150
+ if (!qc.text) return { ok: false, reason: qc.reason || "无可用查询上下文" };
151
+
152
+ const mods = await importRecallModules();
153
+ // ⚠️ 只读打开:不用 openVectorsStore(它会建库/建表/写 meta,且 busy_timeout=5s 会拖住简报)
154
+ db = new DatabaseSync(VECTORS_DB, { readOnly: true });
155
+ const index = mods.buildVectorIndex(db, { source: "pitfall" });
156
+ if (!index.count) return { ok: false, reason: "坑源无向量", detail: "vectors.db 里 source='pitfall' 为 0 条" };
157
+
158
+ const budgetLeft = RECALL_BUDGET_MS - (performance.now() - t0);
159
+ if (budgetLeft < 150) return { ok: false, reason: "超时(预算已用尽)", detail: `已用 ${(performance.now() - t0).toFixed(0)}ms` };
160
+
161
+ let lastFail = null;
162
+ for (let attempt = 1; attempt <= RECALL_ATTEMPTS; attempt += 1) {
163
+ const left = RECALL_BUDGET_MS - (performance.now() - t0);
164
+ if (left < 120) { lastFail = lastFail ?? { reason: "超时(重试前预算已用尽)", detail: `已用 ${(performance.now() - t0).toFixed(0)}ms` }; break; }
165
+ const embedder = mods.createHttpEmbedder({
166
+ host: "127.0.0.1",
167
+ port: BRIDGE_PORT,
168
+ timeoutMs: Math.min(BRIDGE_TIMEOUT_MS, Math.max(80, left - 150)), // 留 150ms 给余弦+打印
169
+ });
170
+ const res = await withTimeout(
171
+ mods.recall({
172
+ query: qc.text,
173
+ store: { db },
174
+ source: makePitfallSourceStub(), // 坑源只被用到 pinnedIds()/ftsSearch(),见 stub 注释
175
+ embedder,
176
+ index,
177
+ sourceName: "pitfall",
178
+ vectorTopK: 20,
179
+ ftsTopK: 0,
180
+ resultTopK: TOP_K,
181
+ log: () => {},
182
+ }),
183
+ left,
184
+ );
185
+ if (res.ok && res.triggered && res.hits.length) {
186
+ return {
187
+ ok: true,
188
+ hits: res.hits,
189
+ query: qc.text,
190
+ querySource: qc.source,
191
+ queryChars: qc.text.length,
192
+ queryMs: qc.ms,
193
+ indexSize: index.count,
194
+ model: Array.isArray(index.models) ? index.models[0] ?? null : null,
195
+ attempts: attempt,
196
+ totalMs: performance.now() - t0,
197
+ };
198
+ }
199
+ lastFail = res.ok
200
+ ? { reason: res.triggered ? "召回零命中" : `查询未触发召回(${res.reason})` }
201
+ : { reason: `召回异常(第 ${attempt} 次)`, detail: res.detail ?? res.reason };
202
+ if (res.ok) break; // 零命中/未触发 = 确定结果,重试无意义
203
+ }
204
+ return { ok: false, ...(lastFail ?? { reason: "召回失败" }) };
205
+ } catch (e) {
206
+ return { ok: false, reason: "召回异常", detail: String(e?.message ?? e).replace(/\s+/g, " ").slice(0, 140) };
207
+ } finally {
208
+ if (db) { try { db.close(); } catch { /* ignore */ } }
209
+ }
210
+ }
211
+
212
+ /** 动态 import 记忆引擎模块(缺件/装坏 ⇒ 抛错由 tryVectorRecall 兜住 ⇒ 回落固定 Top3) */
213
+ async function importRecallModules() {
214
+ if (_mods) return _mods;
215
+ const [recallMod, embedderMod] = await Promise.all([
216
+ import("../memory-engine/lib/recall.mjs"),
217
+ import("../memory-engine/lib/embedder.mjs"),
218
+ ]);
219
+ _mods = {
220
+ recall: recallMod.recall,
221
+ buildVectorIndex: recallMod.buildVectorIndex,
222
+ createHttpEmbedder: embedderMod.createHttpEmbedder,
223
+ };
224
+ return _mods;
225
+ }
226
+
227
+ /**
228
+ * 坑源 stub(**形状与 `memory-engine\lib\pitfall-source.mjs` 的契约一致**,只实现召回真正会用到的方法)。
229
+ * 依据:`lib\recall.mjs` 对 source 只用两处 —— `pinnedIds()`(坑库无置顶 ⇒ 空集)
230
+ * 与 `ftsSearch()`(坑库无 FTS5 ⇒ 返回 `{error}` 对象走降级、**不能返回数组**)。
231
+ * 不读 `butler-pitfalls.json` 第二遍(条目映射在本文件里已由 st 完成)。
232
+ */
233
+ function makePitfallSourceStub() {
234
+ return {
235
+ sourceName: "pitfall",
236
+ readOnly: true,
237
+ pinnedIds: () => [],
238
+ ftsSearch: () => ({ error: "坑库无 FTS5(pitfall source 无 FTS 补位路)" }),
239
+ close: () => {},
240
+ };
241
+ }
242
+
243
+ /**
244
+ * 组装「当前会话上下文」查询文本(**同步读、有上限、失败即空**)。
245
+ * 优先级:显式 `--query` > 会话日志尾部最近真实用户消息 > projcache(上轮请求/标题/首条用户消息)。
246
+ * ⚠️ 只认 `data.source.kind === 'user'` 的 `user/message`(插件注入/技能目录/AGENTS.md 也走 user/message,必须排除)。
247
+ */
248
+ async function buildQueryContext() {
249
+ const t0 = performance.now();
250
+ if (EXPLICIT_QUERY.trim()) {
251
+ return { text: clipText(EXPLICIT_QUERY, QUERY_MAX_CHARS), source: "--query 显式传入", ms: performance.now() - t0 };
252
+ }
253
+ if (!SESSION_ID) {
254
+ return { text: "", source: "none", reason: "无查询上下文(无 --query,且环境无 DSH_SESSION_ID)", ms: 0 };
255
+ }
256
+ const parts = [];
257
+ parts.push(...(await readRecentUserMessages()));
258
+ const pc = readProjcacheContext();
259
+ if (pc.lastTurnPrompt) parts.push(pc.lastTurnPrompt);
260
+ if (pc.title) parts.push(pc.title);
261
+ if (pc.firstUser) parts.push(pc.firstUser);
262
+
263
+ const uniq = dedupeParts(parts);
264
+ const text = clipText(uniq.join(" / "), QUERY_MAX_CHARS);
265
+ if (!text) return { text: "", source: "none", reason: "会话上下文为空", ms: performance.now() - t0 };
266
+ return { text, source: `会话上下文(${uniq.length} 段)`, ms: performance.now() - t0 };
267
+ }
268
+
269
+ /** 会话日志尾部最近的真实用户消息(只读、限 512KB 尾部、只解完整帧;任何失败返回 []) */
270
+ async function readRecentUserMessages() {
271
+ try {
272
+ const mods = await import("../lib/zstd-frames.mjs");
273
+ const pickMod = await import("../lib/session-file-name.mjs");
274
+ const id = String(SESSION_ID).replace(/^session-/, "");
275
+ const root = `${HOME}/sessions`;
276
+ let dir = null;
277
+ for (const d of fs.readdirSync(root)) {
278
+ const cand = `${root}/${d}/session-${id}`;
279
+ if (fs.existsSync(cand)) { dir = cand; break; }
280
+ }
281
+ if (!dir) return [];
282
+ const file = pickMod.pickSessionFileInDir(dir); // 认全世代(session[.vN].jsonl.zstd)
283
+ if (!file) return [];
284
+ const size = fs.statSync(file).size;
285
+ const readLen = Math.min(512 * 1024, size);
286
+ const buf = Buffer.alloc(readLen);
287
+ const fd = fs.openSync(file, "r");
288
+ try {
289
+ fs.readSync(fd, buf, 0, readLen, size - readLen);
290
+ } finally {
291
+ fs.closeSync(fd);
292
+ }
293
+ const { frames } = mods.scanZstdFrames(buf); // 尾部切片可能撕裂首帧 ⇒ 该帧被丢弃,其余帧照解
294
+ const out = [];
295
+ for (const f of frames) {
296
+ let txt;
297
+ try {
298
+ txt = zlib.zstdDecompressSync(buf.subarray(f.start, f.end)).toString("utf8");
299
+ } catch {
300
+ continue;
301
+ }
302
+ for (const ln of txt.split(/\r?\n/)) {
303
+ if (!ln.trim()) continue;
304
+ let o = null;
305
+ try { o = JSON.parse(ln); } catch { continue; }
306
+ if (o.type !== "user/message") continue;
307
+ if (o?.data?.source?.kind !== "user") continue; // 排除 AGENTS.md / 技能目录 / 插件快照等注入
308
+ const text = Array.isArray(o.data.content)
309
+ ? o.data.content.filter((c) => c && c.type === "text").map((c) => c.text).join(" ")
310
+ : "";
311
+ if (text.trim()) out.push(text);
312
+ }
313
+ }
314
+ return out.slice(-2); // 只要最近 2 条真实用户消息(越近越贴当前任务)
315
+ } catch {
316
+ return [];
317
+ }
318
+ }
319
+
320
+ /** projcache 里的轻量上下文(标题 / 上一轮请求 / 首条用户消息;读不到返回 {}) */
321
+ function readProjcacheContext() {
322
+ try {
323
+ const p = `${HOME}/storages/session_projcache/sessions/${SESSION_ID}.json`;
324
+ const j = JSON.parse(fs.readFileSync(p, "utf8"));
325
+ const r = j?.record?.rows ?? {};
326
+ const val = (k) => r[k]?.val;
327
+ const title = typeof val("title") === "string" ? val("title") : "";
328
+ const ti = val("titleInput");
329
+ const firstUser = typeof ti?.first?.text === "string" ? ti.first.text : "";
330
+ const outline = val("turnOutline");
331
+ const turns = Array.isArray(outline?.turns) ? outline.turns : [];
332
+ const lastTurnPrompt = turns.length ? String(turns[turns.length - 1]?.prompt ?? "") : "";
333
+ return { title, firstUser, lastTurnPrompt };
334
+ } catch {
335
+ return {};
336
+ }
337
+ }
338
+
339
+ /** 打印向量召回段(标题保留「坑库 Top N」字样,便于既有 grep/文档指针继续命中) */
340
+ function printRecallSection(r, byId, st) {
341
+ console.log(`\n===== 坑库 Top ${r.hits.length}(向量召回 · 按本会话上下文)=====`);
342
+ r.hits.forEach((h, i) => {
343
+ const p = byId.get(String(h.refId));
344
+ if (p) {
345
+ const sessionCount = Number(p.sessionCount ?? (Array.isArray(p.sessions) ? p.sessions.length : 0));
346
+ const tag = p.source === "seed" ? "(seed)" : "";
347
+ console.log(
348
+ ` ${i + 1}. [${p.category ?? "?"}] ${p.name ?? ""}|${p.code ?? ""} — ${p.count ?? 0} 次/${sessionCount} 会话,召回 ${Number(h.score).toFixed(4)}${tag}`,
349
+ );
350
+ console.log(` ${String(p.normText ?? "").replace(/\s+/g, " ").slice(0, 110)}`);
351
+ } else {
352
+ console.log(` ${i + 1}. [坑库中已不存在 ${String(h.refId).slice(0, 12)}…] 召回 ${Number(h.score).toFixed(4)}(可能是被合并/淘汰的旧条目)`);
353
+ }
354
+ });
355
+ console.log(
356
+ ` 查询来源:${r.querySource} · ${r.queryChars} 字|坑向量 ${r.indexSize} 条${r.model ? ` · ${r.model}` : ""}|耗时 ${r.totalMs.toFixed(0)}ms${r.attempts > 1 ? `(第 ${r.attempts} 次尝试成功)` : ""}`,
357
+ );
358
+ console.log(` 主题坑库:${PITFALLS_DOC_DIR} | 数据更新:${st.updatedAt ?? "?"}`);
359
+ }
360
+
361
+ /** 固定 Top 3(**改动前原样**,除回落诊断行外输出逐字不变) */
362
+ async function printTopFallback(pitfalls, nowMs, st, diag) {
363
+ const mod = await import("../lib/pitfall-score.mjs");
364
+ const top = mod.topN(pitfalls ?? [], TOP_K, nowMs);
365
+ if (top.length) {
366
+ console.log(`\n===== 坑库 Top ${TOP_K}(P4 自动,动手前先看)=====`);
367
+ for (const p of top) {
368
+ const tag = p.likelyFixed ? "(疑似已修复)" : p.stage === "candidate" ? "(升级候选)" : "";
369
+ console.log(` [${p.category}] ${p.name}|${p.code} — ${p.count} 次/${p.sessionCount} 会话,score ${p.score}${tag}`);
370
+ console.log(` ${String(p.normText ?? "").replace(/\s+/g, " ").slice(0, 110)}`);
371
+ }
372
+ console.log(` 主题坑库:${PITFALLS_DOC_DIR} | 数据更新:${st.updatedAt ?? "?"}`);
373
+ }
374
+ if (diag) console.log(` (向量召回未启用:${diag} ⇒ 已回落固定 Top ${TOP_K})`);
375
+ if (DEBUG && !top.length) console.log(" (坑库无可用条目)");
376
+ }
377
+
378
+ // ===== 小工具 =====
379
+ function parseArgs(argv) {
380
+ const out = { _: [] };
381
+ for (let i = 0; i < argv.length; i += 1) {
382
+ const a = String(argv[i]);
383
+ if (!a.startsWith("--")) { out._.push(a); continue; }
384
+ const key = a.slice(2);
385
+ const next = argv[i + 1];
386
+ if (next !== undefined && !String(next).startsWith("--")) { out[key] = String(next); i += 1; }
387
+ else out[key] = true;
388
+ }
389
+ return out;
390
+ }
391
+ function numOr(v, dflt) {
392
+ const n = Number(v);
393
+ return Number.isFinite(n) ? n : dflt;
394
+ }
395
+ function normPath(p) {
396
+ return String(p).replaceAll("\\", "/").replace(/\/+$/, "");
397
+ }
398
+ function readJsonSafe(p) {
399
+ try {
400
+ return JSON.parse(fs.readFileSync(p, "utf8"));
401
+ } catch {
402
+ return null;
403
+ }
404
+ }
405
+ function clipText(s, n) {
406
+ return String(s ?? "").replace(/\s+/g, " ").trim().slice(0, n);
407
+ }
408
+ /** 去重:丢掉空串,以及「已被已有段包含 / 已包含已有段」的重复段 */
409
+ function dedupeParts(arr) {
410
+ const out = [];
411
+ for (const raw of arr) {
412
+ const s = clipText(raw, 1200);
413
+ if (!s) continue;
414
+ if (out.some((o) => o.includes(s) || s.includes(o))) continue;
415
+ out.push(s);
416
+ }
417
+ return out;
418
+ }
419
+ /** 超时闸:`ms` 内没 settle 就返回 `{ok:false, reason:'超时'}`(不抛、不中断已发出的 fetch —— 桥侧自带 AbortController) */
420
+ function withTimeout(promise, ms) {
421
+ return new Promise((resolve) => {
422
+ let done = false;
423
+ const timer = setTimeout(() => {
424
+ if (done) return;
425
+ done = true;
426
+ resolve({ ok: false, reason: "超时", detail: `${Math.round(ms)}ms 预算内未返回` });
427
+ }, Math.max(1, ms));
428
+ promise.then(
429
+ (v) => { if (!done) { done = true; clearTimeout(timer); resolve({ ok: true, ...v }); } },
430
+ (e) => { if (!done) { done = true; clearTimeout(timer); resolve({ ok: false, reason: "异常", detail: String(e?.message ?? e).slice(0, 140) }); } },
431
+ );
432
+ });
433
+ }
434
+
435
+ /** 打印「插电保护」口径行(只读状态文件,微秒级;失败只提示,永不抛到调用方 —— 同坑库段的 fail-open 纪律) */
436
+ async function printPowerGuardLine() {
437
+ try {
438
+ const st = readJsonSafe(`${HOME}/overnight-mode-${hostName.toLowerCase()}.json`);
439
+ if (!st) {
440
+ console.log("🛡 插电保护:⚪ 无记录(从没开过)|口径 = 常规:要拍板的攒清单问主人");
441
+ return;
442
+ }
443
+ const offRaw = String(st.restoredAt ?? "").trim();
444
+ const onAt = fmtUtc(st.createdAt);
445
+ if (!offRaw) {
446
+ console.log(`🛡 插电保护:🟢 保护期(自 ${onAt ?? "?"} 起未恢复)|口径 = 保护期口径(原「整夜口径」· 2026-10-16 改名):禁停 · 甲档自拍(建卡/run/交接自己做)· 到点自己跑 relay 交棒`);
447
+ } else {
448
+ console.log(`🛡 插电保护:⚪ 未在保护(${fmtUtc(offRaw) ?? "已恢复"})|口径 = 常规:要拍板的攒清单问主人`);
449
+ }
450
+ } catch (e) {
451
+ console.log(`🛡 插电保护:状态读取失败(不影响简报其余部分):${String(e?.message ?? e).replace(/\s+/g, " ").slice(0, 120)}`);
452
+ }
453
+ }
454
+
455
+ /**
456
+ * 把状态文件里的 **UTC** 时间戳转成本地可读串(认两种来源格式,认不出返回 null ⇒ 调用方显示 `?`,绝不猜):
457
+ * · `createdAt` = `"10/08/2026 03:54:59"` —— 工具自己格式化的 UTC、**无时区标记**(直接 new Date 会被当本地时间,差 8 小时)
458
+ * · `restoredAt` = ISO 带 `Z`(如 `"2026-10-16T03:32:47.485Z"`)
459
+ * 实测锚点:文件 `createdAt` = 10/08 03:54:59Z ↔ `status` 打印「上次开启:2026/10/8 11:54:59」= 本地 UTC+8。
460
+ */
461
+ function fmtUtc(v) {
462
+ const s = String(v ?? "").trim();
463
+ if (!s) return null;
464
+ try {
465
+ let dt = null;
466
+ const m = /^(\d{1,2})\/(\d{1,2})\/(\d{4})\s+(\d{1,2}):(\d{2}):(\d{2})$/.exec(s);
467
+ if (m) dt = new Date(`${m[3]}-${m[1].padStart(2, "0")}-${m[2].padStart(2, "0")}T${m[4].padStart(2, "0")}:${m[5]}:${m[6]}Z`);
468
+ else if (/Z$|[+-]\d{2}:?\d{2}$/.test(s)) dt = new Date(s);
469
+ if (!dt || Number.isNaN(dt.getTime())) return null;
470
+ return dt.toLocaleString("zh-CN", {
471
+ timeZone: "Asia/Shanghai", month: "2-digit", day: "2-digit",
472
+ hour: "2-digit", minute: "2-digit", hour12: false,
473
+ });
474
+ } catch {
475
+ return null;
476
+ }
477
+ }
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env node
2
+ // tools/memory-db-tools/memory-identity-check.mjs
3
+ //
4
+ // ⑤ 包 §5 判据 4「身份绑定一致」CLI(施工卡 ⑤-3 交付)。
5
+ //
6
+ // 判据(§3.3-3 / §5-3):`secretary-user list` 的 userid 集合 ⊇ `SELECT DISTINCT owner_user FROM memories`
7
+ // ⇒ **差集 = 0**(新 owner 一旦写库就必须在身份表里;未登记即判红)。
8
+ //
9
+ // 身份表来源优先级(家人档 §3.3 的 CLI 尚未落地 ⇒ 本脚本必须能降级):
10
+ // ① `--users <file>` 显式指定;② `<home>/secretary-users.json`;③ 都不在 ⇒ **判"不可用"(exit 2),不判过**。
11
+ //
12
+ // 用法:
13
+ // node memory-identity-check.mjs [--db <path>] [--home <.dsh 目录>] [--users <file>] [--json]
14
+ // 退出码:0 = 差集为 0(过);1 = 有未登记 owner(判红);2 = 不可用(身份表或库读不到);3 = 环境不可用
15
+
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+ import { DatabaseSync } from "node:sqlite";
19
+ import { MEMORIES_TABLE, isProductionDb, ownerBindingDiff, productionDbPath, readIdentityTable } from "./lib/memory-scope.mjs";
20
+
21
+ const argv = process.argv.slice(2);
22
+ function argRaw(name) {
23
+ const i = argv.indexOf(name);
24
+ if (i >= 0) {
25
+ const v = argv[i + 1];
26
+ return v && !v.startsWith("--") ? v : true;
27
+ }
28
+ const kv = argv.find((a) => a.startsWith(`${name}=`));
29
+ return kv ? kv.slice(name.length + 1) : null;
30
+ }
31
+ const JSON_OUT = argv.includes("--json");
32
+ const log = (...a) => {
33
+ if (!JSON_OUT) console.log(...a);
34
+ };
35
+
36
+ if (argv.includes("--help") || argv.includes("-h")) {
37
+ console.log("用法见本文件头部注释;开关:--db <path> --home <.dsh> --users <file> --json");
38
+ process.exitCode = 0;
39
+ } else {
40
+ main();
41
+ }
42
+
43
+ function main() {
44
+ const homeOpt = argRaw("--home");
45
+ const dbOpt = argRaw("--db");
46
+ const usersOpt = argRaw("--users");
47
+ const out = { at: new Date().toISOString(), check: "判据4-身份绑定一致", ok: false, exitCode: 0 };
48
+ if (homeOpt === true || dbOpt === true || usersOpt === true) {
49
+ return finish(out, 2, "参数错:--home / --db / --users 都需要一个值");
50
+ }
51
+ const dshDir = homeOpt ? path.resolve(String(homeOpt)) : path.dirname(path.dirname(productionDbPath()));
52
+ const dbPath = dbOpt ? path.resolve(String(dbOpt)) : path.join(dshDir, "memory", "memory.db");
53
+ const usersFile = usersOpt ? path.resolve(String(usersOpt)) : path.join(dshDir, "secretary-users.json");
54
+ out.db = dbPath;
55
+ out.dbIsProduction = isProductionDb(dbPath);
56
+ out.usersFile = usersFile;
57
+
58
+ if (!fs.existsSync(dbPath)) return finish(out, 3, `环境不可用:库不存在 ${dbPath}`);
59
+
60
+ const identity = readIdentityTable(usersFile);
61
+ out.identity = { path: identity.path, exists: identity.exists, ok: identity.ok, userids: identity.userids, owner: identity.owner, error: identity.error };
62
+
63
+ const db = new DatabaseSync(dbPath, { readOnly: true });
64
+ let diff;
65
+ let hasColumn = true;
66
+ try {
67
+ const cols = db.prepare(`PRAGMA table_info(${MEMORIES_TABLE})`).all().map((r) => r.name);
68
+ hasColumn = cols.includes("owner_user");
69
+ diff = hasColumn ? ownerBindingDiff(db, identity) : { owners: [], registered: identity.userids || [], unregistered: [], ok: false, identityUsable: identity.ok };
70
+ } finally {
71
+ db.close();
72
+ }
73
+ out.columnExists = hasColumn;
74
+ out.diff = diff;
75
+
76
+ if (!hasColumn) {
77
+ return finish(out, 2, `不可用:${MEMORIES_TABLE} 表尚无 owner_user 列(两列迁移未跑)—— 不算通过`);
78
+ }
79
+ if (!identity.ok) {
80
+ return finish(out, 2, `不可用:身份表读不到(${identity.exists ? identity.error : "不存在"}:${identity.path})—— 按"不可用不判过"口径,不算通过`);
81
+ }
82
+
83
+ log(`库:${dbPath}${out.dbIsProduction ? "(生产)" : ""}`);
84
+ log(`身份表:${identity.path} · ${identity.userids.length} 个 userid · owner=${identity.owner ?? "(无 role=owner 条目)"}`);
85
+ log(`库内 owner_user 去重:${diff.owners.length} 个 [${diff.owners.join(", ")}]`);
86
+ if (diff.unregistered.length) {
87
+ log(`未登记(判红):${diff.unregistered.join(", ")}`);
88
+ return finish(out, 1, `判据 4 未过:${diff.unregistered.length} 个 owner_user 不在身份表里`);
89
+ }
90
+ log("差集 = 0 ✓");
91
+ return finish(out, 0, "判据 4 通过:所有 owner_user 都在身份表里");
92
+ }
93
+
94
+ function finish(out, code, message) {
95
+ out.exitCode = code;
96
+ out.ok = code === 0;
97
+ out.message = message;
98
+ if (JSON_OUT) console.log(JSON.stringify(out, null, 2));
99
+ else console.log(`\n[${code === 0 ? "PASS" : code === 2 ? "UNAVAILABLE" : "FAIL"}] ${message}`);
100
+ process.exitCode = code;
101
+ }