@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,302 @@
1
+ #!/usr/bin/env node
2
+ // tools/memory-db-tools/_test/test-memory-scope.mjs
3
+ //
4
+ // ⑤-3(字段与身份)单测:口径库纯函数 + 库级迁移 + CLI 反向用例(fail-closed 闸门)。
5
+ // 铁律:**全程不碰生产 memory.db**(开测/收尾各比一次 sha 哨兵);所有库操作都在 os.tmpdir() 里。
6
+ //
7
+ // 跑法:node _test/test-memory-scope.mjs
8
+ // 退出码:0 全绿 / 1 有失败
9
+
10
+ import fs from "node:fs";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+ import { spawnSync } from "node:child_process";
14
+ import { DatabaseSync } from "node:sqlite";
15
+ import { fileURLToPath } from "node:url";
16
+ import {
17
+ DEFAULT_VISIBILITY,
18
+ MEMORIES_TABLE,
19
+ NEW_COLUMNS,
20
+ VISIBILITY_VALUES,
21
+ applyMigration,
22
+ buildAddColumnSql,
23
+ columnsOf,
24
+ describeDb,
25
+ familyScopeSql,
26
+ hasIndex,
27
+ isProductionDb,
28
+ migrationPlan,
29
+ ownerBindingDiff,
30
+ ownerScopeSql,
31
+ parseIdentityTable,
32
+ productionDbPath,
33
+ quoteLiteral,
34
+ readIdentityTable,
35
+ resolveDefaultOwner,
36
+ selectMemories,
37
+ sha256File,
38
+ } from "../lib/memory-scope.mjs";
39
+
40
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
41
+ const TOOLS = path.join(HERE, "..");
42
+ const CLI = {
43
+ migrate: path.join(TOOLS, "memory-schema-migrate.mjs"),
44
+ identity: path.join(TOOLS, "memory-identity-check.mjs"),
45
+ fixture: path.join(TOOLS, "memory-scope-fixture.mjs"),
46
+ fieldname: path.join(TOOLS, "memory-fieldname-check.mjs"),
47
+ };
48
+
49
+ let pass = 0;
50
+ const fails = [];
51
+ function ok(name, cond, detail = "") {
52
+ if (cond) {
53
+ pass += 1;
54
+ } else {
55
+ fails.push(`${name}${detail ? ` —— ${detail}` : ""}`);
56
+ console.log(` ✗ ${name}${detail ? ` —— ${detail}` : ""}`);
57
+ }
58
+ }
59
+ function eq(name, a, b) {
60
+ ok(name, Object.is(a, b) || JSON.stringify(a) === JSON.stringify(b), `实得 ${JSON.stringify(a)} · 期望 ${JSON.stringify(b)}`);
61
+ }
62
+ function section(t) {
63
+ console.log(`\n== ${t} ==`);
64
+ }
65
+
66
+ const PROD = productionDbPath();
67
+ const prodShaBefore = fs.existsSync(PROD) ? sha256File(PROD) : null;
68
+
69
+ const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "card053-"));
70
+ const TMP = path.join(ROOT, "sandbox");
71
+ fs.mkdirSync(TMP, { recursive: true });
72
+
73
+ /** 造一个最小同构库(6 列 + 2 条)。 */
74
+ function makeDb(file) {
75
+ const db = new DatabaseSync(file);
76
+ db.exec(`CREATE TABLE ${MEMORIES_TABLE} (
77
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
78
+ text TEXT NOT NULL,
79
+ tags TEXT NOT NULL DEFAULT '',
80
+ pinned INTEGER NOT NULL DEFAULT 0,
81
+ created_at INTEGER NOT NULL,
82
+ updated_at INTEGER NOT NULL
83
+ )`);
84
+ const ins = db.prepare(`INSERT INTO ${MEMORIES_TABLE} (text, tags, created_at, updated_at) VALUES (?,?,?,?)`);
85
+ ins.run("第一条", "demo", 1, 1);
86
+ ins.run("第二条", "demo", 2, 2);
87
+ db.close();
88
+ return file;
89
+ }
90
+ const runCli = (script, args, env = {}) =>
91
+ spawnSync(process.execPath, [script, ...args], { encoding: "utf8", env: { ...process.env, ...env } });
92
+
93
+ // ---------------------------------------------------------------- ① 纯函数
94
+ section("① 纯函数(身份表解析 / 默认值解析 / DDL / 过滤 SQL)");
95
+
96
+ {
97
+ const good = parseIdentityTable(JSON.stringify({ _comment: "注释", LiJianXiao: { role: "owner" }, wife_x: { role: "member" } }));
98
+ ok("身份表:正常解析", good.ok, good.error || "");
99
+ eq("身份表:跳过 _ 前缀注释键", good.userids, ["LiJianXiao", "wife_x"]);
100
+ eq("身份表:取 role=owner", good.owner, "LiJianXiao");
101
+
102
+ const bad = parseIdentityTable("{不是 JSON}");
103
+ ok("身份表:坏 JSON ⇒ ok=false(不抛)", bad.ok === false && bad.userids.length === 0);
104
+
105
+ const arr = parseIdentityTable("[1,2]");
106
+ ok("身份表:数组根 ⇒ ok=false", arr.ok === false);
107
+
108
+ const noOwner = resolveDefaultOwner({ ok: true, exists: true, owner: null, userids: ["a"], path: "x" });
109
+ ok("默认值:无 owner 条目 ⇒ 回落常量 + 有说明行", noOwner.source === "fallback" && noOwner.notes.length > 0);
110
+ const withOwner = resolveDefaultOwner({ ok: true, exists: true, owner: "LiJianXiao", userids: ["LiJianXiao"], path: "x" });
111
+ ok("默认值:有 owner 条目 ⇒ 取身份表(非硬编码)", withOwner.ownerUser === "LiJianXiao" && withOwner.source === "identity-table");
112
+ const broken = resolveDefaultOwner({ ok: false, exists: true, error: "坏了", userids: [], owner: null, path: "x" });
113
+ ok("默认值:身份表坏 ⇒ 回落 + 报警", broken.source === "fallback" && broken.notes.some((n) => n.includes("不可用")));
114
+
115
+ eq("DDL:owner_user", buildAddColumnSql(NEW_COLUMNS[0], "LiJianXiao"), "ALTER TABLE memories ADD COLUMN owner_user TEXT NOT NULL DEFAULT 'LiJianXiao'");
116
+ eq("DDL:visibility", buildAddColumnSql(NEW_COLUMNS[1], DEFAULT_VISIBILITY), "ALTER TABLE memories ADD COLUMN visibility TEXT NOT NULL DEFAULT 'private_owner'");
117
+ eq("字面量转义:单引号翻倍", quoteLiteral("O'Brien"), "'O''Brien'");
118
+ eq("visibility 取值域", [...VISIBILITY_VALUES], ["private_owner", "family", "public"]);
119
+
120
+ const fBase = familyScopeSql("wife_x");
121
+ ok("家人侧基础句 = §3.3-4 原文", fBase.mode === "base" && fBase.where === "(visibility IN ('family','public') OR owner_user = ?)" && fBase.params[0] === "wife_x");
122
+ const fStrict = familyScopeSql("wife_x", { registeredOwners: ["LiJianXiao", "wife_x", "wife_x"] });
123
+ ok("家人侧严格句:带已登记护栏且去重", fStrict.mode === "strict" && fStrict.where.includes("owner_user IN (?, ?)") && fStrict.params.length === 3, JSON.stringify(fStrict));
124
+ const fEmpty = familyScopeSql("wife_x", { registeredOwners: [] });
125
+ ok("身份表空 ⇒ fail-closed 只留本人条", fEmpty.mode === "strict-empty" && fEmpty.where === "owner_user = ?");
126
+ let threw = false;
127
+ try {
128
+ familyScopeSql(null);
129
+ } catch {
130
+ threw = true;
131
+ }
132
+ ok("缺 selfUser ⇒ 抛错(不静默放行)", threw);
133
+
134
+ eq("主人注入层:默认 owner-only", ownerScopeSql("LiJianXiao").where, "owner_user = ?");
135
+ ok("主人注入层:可选含共享", ownerScopeSql("LiJianXiao", { includeShared: true }).where.includes("family"));
136
+
137
+ ok("生产路径判定:真生产 = true", isProductionDb(PROD));
138
+ ok("生产路径判定:沙箱 = false", isProductionDb(path.join(ROOT, "sandbox", "memory.db")) === false);
139
+ }
140
+
141
+ // ---------------------------------------------------------------- ② 库级迁移
142
+ section("② 库级:计划 / 迁移 / 幂等 / 干跑零写入");
143
+
144
+ {
145
+ const dbFile = makeDb(path.join(TMP, "t1.db"));
146
+ const db = new DatabaseSync(dbFile, { readOnly: true });
147
+ const before = describeDb(db);
148
+ const plan = migrationPlan(db, { identity: { ok: true, exists: true, owner: "LiJianXiao", userids: ["LiJianXiao"], path: "x" } });
149
+ db.close();
150
+ eq("迁移前:6 列", before.columns.length, 6);
151
+ eq("计划:缺两列", plan.missing, ["owner_user", "visibility"]);
152
+ eq("计划:3 条语句(两列 + 索引)", plan.statements.length, 3);
153
+ ok("计划:默认值来自身份表", plan.defaultOwner.source === "identity-table" && plan.statements[0].includes("'LiJianXiao'"));
154
+
155
+ const rw = new DatabaseSync(dbFile);
156
+ const ran = applyMigration(rw, plan);
157
+ eq("执行:3 条 DDL", ran.length, 3);
158
+ eq("迁移后:8 列", columnsOf(rw).length, 8);
159
+ eq("迁移后:条数不变", rw.prepare(`SELECT COUNT(*) c FROM ${MEMORIES_TABLE}`).get().c, 2);
160
+ ok("迁移后:索引在位", hasIndex(rw, "memories_visibility"));
161
+ const all = rw.prepare(`SELECT owner_user o, visibility v FROM ${MEMORIES_TABLE}`).all();
162
+ ok("存量行取默认值(主人私有)", all.every((r) => r.o === "LiJianXiao" && r.v === "private_owner"));
163
+ const plan2 = migrationPlan(rw, { identity: null });
164
+ eq("幂等:第二次计划 0 语句", plan2.statements.length, 0);
165
+ eq("幂等:两列已在位", plan2.present.sort(), ["owner_user", "visibility"]);
166
+ rw.close();
167
+
168
+ // dry-run 零写入(sha 前后一致)
169
+ const dbFile2 = makeDb(path.join(TMP, "t2.db"));
170
+ const shaA = sha256File(dbFile2);
171
+ const r = runCli(CLI.migrate, ["--db", dbFile2, "--json"]);
172
+ const shaB = sha256File(dbFile2);
173
+ eq("CLI dry-run:exit 0", r.status, 0);
174
+ eq("CLI dry-run:源库 sha 未变(零写入)", shaA, shaB);
175
+ let parsed = null;
176
+ try {
177
+ parsed = JSON.parse(r.stdout);
178
+ } catch {
179
+ /* 下面断言会报 */
180
+ }
181
+ ok("CLI --json:stdout 是纯 JSON(日志不混入 stdout)", Boolean(parsed) && parsed.mode === "dry-run", r.stdout.slice(0, 200));
182
+ eq("CLI dry-run:列数仍 6", parsed && parsed.before.columns.length, 6);
183
+
184
+ // 真改(沙箱路径,无需 --allow-production)
185
+ const r2 = runCli(CLI.migrate, ["--db", dbFile2, "--apply", "--json"]);
186
+ eq("CLI apply(沙箱):exit 0", r2.status, 0);
187
+ const p2 = JSON.parse(r2.stdout);
188
+ eq("CLI apply:迁移后 8 列", p2.after.columns.length, 8);
189
+ ok("CLI apply:条数一致", p2.after.count === p2.before.count);
190
+ const r3 = runCli(CLI.migrate, ["--db", dbFile2, "--apply", "--json"]);
191
+ ok("CLI apply:二次幂等(无语句可跑)", r3.status === 0 && JSON.parse(r3.stdout).statementsRan === undefined);
192
+ }
193
+
194
+ // ---------------------------------------------------------------- ③ CLI 反向用例(fail-closed 闸门)
195
+ section("③ CLI 反向:打生产的三道闸 + 不可用不判过");
196
+
197
+ {
198
+ // 伪造 USERPROFILE ⇒ 这个临时库"在 CLI 眼里就是生产库"(真实生产绝不被触碰)
199
+ const fakeHome = path.join(ROOT, "fakehome");
200
+ const fakeProdDir = path.join(fakeHome, ".dsh", "memory");
201
+ fs.mkdirSync(fakeProdDir, { recursive: true });
202
+ const fakeProd = makeDb(path.join(fakeProdDir, "memory.db"));
203
+ const sha0 = sha256File(fakeProd);
204
+
205
+ const r1 = runCli(CLI.migrate, ["--db", fakeProd, "--apply", "--json"], { USERPROFILE: fakeHome });
206
+ eq("闸①:打生产未给 --allow-production ⇒ exit 2", r1.status, 2);
207
+ eq("闸①:库零改动", sha256File(fakeProd), sha0);
208
+
209
+ const r2 = runCli(CLI.migrate, ["--db", fakeProd, "--apply", "--allow-production", "--json"], { USERPROFILE: fakeHome });
210
+ eq("闸②:打生产未给 --snapshot ⇒ exit 2", r2.status, 2);
211
+ eq("闸②:库零改动", sha256File(fakeProd), sha0);
212
+
213
+ const snapDir = path.join(ROOT, "snap");
214
+ const r3 = runCli(CLI.migrate, ["--db", fakeProd, "--apply", "--allow-production", "--snapshot", snapDir, "--json"], { USERPROFILE: fakeHome });
215
+ // 闸③ = 停机窗口判据:本机 3080 在跑 ⇒ 应被拒(exit 1);若恰在停机窗口跑测,则等于放行 —— 两种都记录
216
+ ok("闸③:停机窗口判据生效(3080 在跑 ⇒ 拒绝)", r3.status === 1 || r3.status === 0, `exit=${r3.status}`);
217
+ const p3 = JSON.parse(r3.stdout);
218
+ if (r3.status === 1) {
219
+ ok("闸③:拒绝理由含停机窗口", String(p3.message).includes("停机窗口"), p3.message);
220
+ eq("闸③:库零改动(被拒后不写)", sha256File(fakeProd), sha0);
221
+ ok("闸③:未落快照", !fs.existsSync(path.join(snapDir, "memory.db")));
222
+ } else {
223
+ ok("闸③:本次无 DSH 在跑 ⇒ 放行(等价窗口场景)", fs.existsSync(path.join(snapDir, "memory.db")));
224
+ }
225
+
226
+ // --snapshot 但非生产:应正常出快照 + 迁移(沙箱演练路径)
227
+ const dbS = makeDb(path.join(TMP, "t3.db"));
228
+ const r4 = runCli(CLI.migrate, ["--db", dbS, "--apply", "--snapshot", path.join(ROOT, "snap2"), "--json"]);
229
+ eq("沙箱 apply + 快照:exit 0", r4.status, 0);
230
+ const p4 = JSON.parse(r4.stdout);
231
+ ok("沙箱 apply:快照条数 = 源库条数", p4.snapshot && p4.snapshot.snapshotCount === p4.before.count);
232
+ ok("沙箱 apply:快照 sha 已记", typeof p4.snapshot.snapshotSha256 === "string" && p4.snapshot.snapshotSha256.length === 64);
233
+
234
+ // 夹具脚本:硬拒绝生产路径
235
+ const rf = runCli(CLI.fixture, ["--db", fakeProd, "--json"], { USERPROFILE: fakeHome });
236
+ eq("夹具:打生产路径 ⇒ exit 2(不接受逃生舱)", rf.status, 2);
237
+ eq("夹具:库零改动", sha256File(fakeProd), sha0);
238
+
239
+ // 判据 4:库里没有 owner_user 列 ⇒ exit 2(不可用不判过)
240
+ const dbNoCol = makeDb(path.join(TMP, "t4.db"));
241
+ const ri = runCli(CLI.identity, ["--db", dbNoCol, "--users", path.join(ROOT, "none.json"), "--json"]);
242
+ eq("判据 4:缺列 ⇒ exit 2(不算通过)", ri.status, 2);
243
+ const dbCol = makeDb(path.join(TMP, "t5.db"));
244
+ runCli(CLI.migrate, ["--db", dbCol, "--apply", "--json"]);
245
+ const ri2 = runCli(CLI.identity, ["--db", dbCol, "--users", path.join(ROOT, "none.json"), "--json"]);
246
+ eq("判据 4:身份表缺位 ⇒ exit 2(不可用不判过)", ri2.status, 2);
247
+ const usersFile = path.join(ROOT, "users.json");
248
+ fs.writeFileSync(usersFile, JSON.stringify({ LiJianXiao: { role: "owner" } }), "utf8");
249
+ const ri3 = runCli(CLI.identity, ["--db", dbCol, "--users", usersFile, "--json"]);
250
+ eq("判据 4:差集 0 ⇒ exit 0", ri3.status, 0);
251
+ eq("判据 4:库内 owner 只有主人", JSON.parse(ri3.stdout).diff.owners, ["LiJianXiao"]);
252
+
253
+ // 判据 4 反向:造一条未登记 owner
254
+ const db = new DatabaseSync(dbCol);
255
+ db.prepare(`INSERT INTO ${MEMORIES_TABLE} (text,tags,created_at,updated_at,owner_user,visibility) VALUES ('x','x',1,1,'ghost_user','private_owner')`).run();
256
+ db.close();
257
+ const ri4 = runCli(CLI.identity, ["--db", dbCol, "--users", usersFile, "--json"]);
258
+ eq("判据 4:未登记 owner ⇒ exit 1(判红)", ri4.status, 1);
259
+ eq("判据 4:红名单 = ghost_user", JSON.parse(ri4.stdout).diff.unregistered, ["ghost_user"]);
260
+
261
+ // 判据 3:白名单不可用 ⇒ exit 2(fail-closed)
262
+ const rw2 = runCli(CLI.fieldname, ["--whitelist", path.join(ROOT, "no-such-whitelist.json"), "--json"]);
263
+ eq("判据 3:白名单不可用 ⇒ exit 2(不算通过)", rw2.status, 2);
264
+
265
+ // 判据 5 夹具:真跑沙箱库(造 6 条 + 断言)
266
+ const dbFx = makeDb(path.join(TMP, "t6.db"));
267
+ runCli(CLI.migrate, ["--db", dbFx, "--apply", "--json"]);
268
+ const rfx = runCli(CLI.fixture, ["--db", dbFx, "--cleanup", "--json"]);
269
+ eq("判据 5:夹具全绿 ⇒ exit 0", rfx.status, 0, rfx.stdout.slice(0, 400));
270
+ const pfx = JSON.parse(rfx.stdout);
271
+ ok("判据 5:至少 10 条断言且无失败", pfx.assertions.length >= 10 && pfx.failed.length === 0, JSON.stringify(pfx.failed));
272
+ ok("判据 5:严格模式挡掉超域条(断言在位)", pfx.assertions.some((a) => a.key.includes("超域条") && a.pass));
273
+ ok("判据 5:基础模式命中超域条被如实登记为已知边界", pfx.knownBoundaries.length === 1);
274
+ eq("判据 5:--cleanup 清空夹具条", pfx.cleanedUp, 6);
275
+ }
276
+
277
+ // ---------------------------------------------------------------- ④ 生产哨兵
278
+ section("④ 生产 memory.db 只读哨兵");
279
+ {
280
+ const prodShaAfter = fs.existsSync(PROD) ? sha256File(PROD) : null;
281
+ ok("生产库:全程未被本测试触碰(sha 一致)", prodShaBefore === prodShaAfter, `${String(prodShaBefore).slice(0, 12)} vs ${String(prodShaAfter).slice(0, 12)}`);
282
+ const db = new DatabaseSync(PROD, { readOnly: true });
283
+ const cols = columnsOf(db);
284
+ const cnt = (() => { try { return db.prepare(`SELECT COUNT(*) AS c FROM ${MEMORIES_TABLE}`).get().c; } catch { return null; } })();
285
+ db.close();
286
+ // ⚠️ 2026-10-11 订正(卡【翻译官·批4·家人档地基】):本条原写"仍 6 列(本卡未获窗口,不得迁移)"——
287
+ // 那是 2026-09-20 ⑤-3 卡当时的**绝对断言**,生产库其后**已按口径迁移到 8 列**
288
+ // (生产库实测 `PRAGMA table_info(memories)` = 8 列 + 1276 条),该断言因此**过期**、恒红。
289
+ // 改为**相对断言**(照 dsh-butler §10-③ 的既有做法):本测试只负责"生产库全程未被本测试触碰",
290
+ // schema 版本另由 `memory-identity-check.mjs` 判 —— 这里只做"没被降级回 6 列"的守门 + 打印现值。
291
+ ok("生产库:仍是 6 列(本卡未获窗口,不得迁移)", cols.length >= 6, `${cols.length} 列(现值:${cols.join(",")} · ${cnt} 条)`);
292
+ ok("生产库:两列扩面仍在(owner_user / visibility)", cols.includes("owner_user") && cols.includes("visibility"), cols.join(","));
293
+ }
294
+
295
+ console.log(`\n==== 单测结果:${pass} 通过 / ${fails.length} 失败 ====`);
296
+ for (const f of fails) console.log(` ✗ ${f}`);
297
+ try {
298
+ fs.rmSync(ROOT, { recursive: true, force: true });
299
+ } catch {
300
+ /* 临时目录清不掉不影响结论 */
301
+ }
302
+ process.exitCode = fails.length ? 1 : 0;
@@ -0,0 +1,352 @@
1
+ #!/usr/bin/env node
2
+ // audit-analyze.mjs — 季度记忆库体检 · 第 2 站:六项分析(消费 memories.json)
3
+ // 固化自早期临时脚本(2026-09-25 体检工具链固化)
4
+ // · 去硬编码:输入/输出目录改为 --in <memories.json> / --out <dir>,默认落系统临时目录,不再写死 _tmp
5
+ // · 加 --help / --self-test / --json · 收尾 process.exitCode
6
+ // 产物(6 个):list-lines.txt(首行清单)· list-nodev.txt(含机器特征词却无【设备:】的候选)·
7
+ // dup-pairs.txt(重复对,6-gram Jaccard)· keyword-hits.txt(过期关键词命中)·
8
+ // pinned-full.txt(pinned 全文)· stats.json(统计)
9
+ // 只读:只读 memories.json;不碰 memory.db;产物只写 --out(默认临时目录)。
10
+
11
+ import fs from "node:fs";
12
+ import os from "node:os";
13
+ import path from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ export const SELF = fileURLToPath(import.meta.url);
17
+ export const TOOL = path.basename(SELF);
18
+
19
+ export function resolveDshHome() {
20
+ if (process.env.DSH_HOME) return process.env.DSH_HOME;
21
+ return path.join(process.env.USERPROFILE || os.homedir(), ".dsh");
22
+ }
23
+ export const DSH_HOME = resolveDshHome();
24
+ export const DEFAULT_WORK_DIR = path.join(os.tmpdir(), "dsh-mem-audit");
25
+ export const DEFAULT_IN = path.join(DEFAULT_WORK_DIR, "memories.json");
26
+
27
+ export function isMainEntry() {
28
+ const a = process.argv[1];
29
+ if (!a) return false;
30
+ try {
31
+ return fs.realpathSync(a) === fs.realpathSync(SELF);
32
+ } catch {
33
+ return false;
34
+ }
35
+ }
36
+
37
+ export function parseArgs(argv) {
38
+ const out = { _: [] };
39
+ for (let i = 0; i < argv.length; i++) {
40
+ const a = argv[i];
41
+ if (a === "--") {
42
+ out._.push(...argv.slice(i + 1));
43
+ break;
44
+ }
45
+ if (a.startsWith("--")) {
46
+ const eq = a.indexOf("=");
47
+ if (eq > 0) out[a.slice(2, eq)] = a.slice(eq + 1);
48
+ else {
49
+ const k = a.slice(2);
50
+ const next = argv[i + 1];
51
+ if (next !== undefined && !next.startsWith("--")) {
52
+ out[k] = next;
53
+ i++;
54
+ } else out[k] = true;
55
+ }
56
+ } else out._.push(a);
57
+ }
58
+ return out;
59
+ }
60
+
61
+ export function makeAsserter() {
62
+ let pass = 0,
63
+ total = 0;
64
+ const fails = [];
65
+ return {
66
+ ok(cond, label) {
67
+ total++;
68
+ if (cond) pass++;
69
+ else fails.push(label);
70
+ },
71
+ eq(actual, expected, label) {
72
+ this.ok(actual === expected, `${label}(期望 ${JSON.stringify(expected)},实际 ${JSON.stringify(actual)})`);
73
+ },
74
+ report() {
75
+ if (fails.length) {
76
+ console.log(`[self-test] FAIL ${pass}/${total}`);
77
+ for (const f of fails) console.log(` - ${f}`);
78
+ return 1;
79
+ }
80
+ console.log(`[self-test] PASS ${pass}/${total}`);
81
+ return 0;
82
+ },
83
+ };
84
+ }
85
+
86
+ // ---------- 体检口径常量 ----------
87
+ // 「含机器特征词」判据(用于找漏标设备的记忆)。跨机通用:盘符绝对路径 / 用户目录 泛匹配。
88
+ export const MACHINE_RE =
89
+ /([A-Za-z]:[\\/]|[\\/]Users[\\/][^\\/]+|\.dsh|dsh-|guard|守护鱼|30[89][0-9])/;
90
+
91
+ // 「过期关键词」扫描表 —— 季度体检口径,过期项增减改这里(真源说明见 README)。
92
+ export const STALE_KEYWORDS = [
93
+ ["百度盘同步", /百度/],
94
+ ["坚果云", /坚果云/],
95
+ ["H盘测试根", /H:[\\/]DSH-test/],
96
+ ["hold-zstd", /hold-zstd/],
97
+ ["旧计划任务", /schtasks|计划任务/],
98
+ ["browser-skill可用", /browser[-_ ]skill/],
99
+ ["789条旧数", /789/],
100
+ ["539条旧数", /539/],
101
+ ["204条旧数", /合并.*204|204 条/],
102
+ ["百度Sync", /BaiduSync/],
103
+ ["IPAdapter锁脸", /IPAdapter/],
104
+ ["LoRA女仆A", /LoRA|女仆A/],
105
+ ["F盘测试根", /F:[\\/]DSH-test/],
106
+ ["0.1.1旧主包", /0\.1\.1-rc/],
107
+ ["3081手机", /3081/],
108
+ ["Tailscale", /Tailscale/],
109
+ ["whale-drag补丁", /re-patch-whale-client/],
110
+ ["notifyRecentWindow旧", /notifyRecentWindow/],
111
+ ["用户目录下的 .dsh", /[\\/]Users[\\/][^\\/]+[\\/]\.dsh/g],
112
+ ];
113
+
114
+ export function normalizeText(s) {
115
+ return s
116
+ .replace(/【设备[::][^】]*】/g, "")
117
+ .replace(/\s+/g, "")
118
+ .replace(/[,。、;:!?()【】《》“”‘’`\-—…·.,;:!?()\[\]<>"']/g, "");
119
+ }
120
+
121
+ // ---------- 核心逻辑:纯函数,返回 { files, summary } ----------
122
+ export function analyze(mems, opts = {}) {
123
+ const K = opts.shingle ?? 6;
124
+ const minShared = opts.minShared ?? 6;
125
+ const jacThreshold = opts.jacThreshold ?? 0.15;
126
+ const byId = new Map(mems.map((m) => [m.id, m]));
127
+ const files = {};
128
+
129
+ // 1) 首行清单
130
+ const lines = mems.map((m) => {
131
+ const first = m.text.split("\n")[0].replace(/\s+/g, " ").slice(0, 96);
132
+ return `#${m.id}${m.pinned ? "*" : ""} ${String(m.text.length).padStart(5)}c ${first}`;
133
+ });
134
+ files["list-lines.txt"] = lines.join("\n");
135
+
136
+ // 2) 设备标缺失(含机器特征词却无【设备:】)
137
+ const nodev = mems.filter((m) => !/【设备[::]/.test(m.text) && MACHINE_RE.test(m.text));
138
+ files["list-nodev.txt"] = nodev
139
+ .map((m) => `#${m.id} ${m.text.length}c | ${m.text.split("\n")[0].replace(/\s+/g, " ").slice(0, 110)}`)
140
+ .join("\n");
141
+
142
+ // 3) 重复对(倒排 + Jaccard)
143
+ const shingles = new Map();
144
+ for (const m of mems) {
145
+ const t = normalizeText(m.text);
146
+ const set = new Set();
147
+ for (let i = 0; i + K <= t.length; i++) set.add(t.slice(i, i + K));
148
+ if (t.length < K) set.add(t);
149
+ shingles.set(m.id, set);
150
+ }
151
+ const inv = new Map();
152
+ for (const [id, set] of shingles) {
153
+ for (const s of set) {
154
+ let arr = inv.get(s);
155
+ if (!arr) inv.set(s, (arr = []));
156
+ if (arr.length < 25) arr.push(id);
157
+ }
158
+ }
159
+ const pairCount = new Map();
160
+ for (const arr of inv.values()) {
161
+ if (arr.length < 2) continue;
162
+ for (let i = 0; i < arr.length; i++)
163
+ for (let j = i + 1; j < arr.length; j++) {
164
+ const key = arr[i] < arr[j] ? `${arr[i]}|${arr[j]}` : `${arr[j]}|${arr[i]}`;
165
+ pairCount.set(key, (pairCount.get(key) ?? 0) + 1);
166
+ }
167
+ }
168
+ const pairs = [];
169
+ for (const [key, c] of pairCount) {
170
+ if (c < minShared) continue;
171
+ const [a, b] = key.split("|").map(Number);
172
+ const A = shingles.get(a),
173
+ B = shingles.get(b);
174
+ let inter = 0;
175
+ for (const s of A) if (B.has(s)) inter++;
176
+ const jac = inter / (A.size + B.size - inter);
177
+ if (jac >= jacThreshold) pairs.push({ a, b, jac: Number(jac.toFixed(3)), inter });
178
+ }
179
+ pairs.sort((x, y) => y.jac - x.jac);
180
+ files["dup-pairs.txt"] = pairs
181
+ .map((p) => {
182
+ const A = byId.get(p.a),
183
+ B = byId.get(p.b);
184
+ return `${p.jac} #${p.a}(${A.text.length}c${A.pinned ? ",P" : ""}) <> #${p.b}(${B.text.length}c${B.pinned ? ",P" : ""}) | ${A.text.split("\n")[0].slice(0, 60)} || ${B.text.split("\n")[0].slice(0, 60)}`;
185
+ })
186
+ .join("\n");
187
+
188
+ // 4) 过期关键词扫描
189
+ const hits = [];
190
+ for (const [name, re] of STALE_KEYWORDS) {
191
+ const ids = mems.filter((m) => re.test(m.text)).map((m) => m.id);
192
+ hits.push(`${name} (${ids.length}): ${ids.join(",")}`);
193
+ }
194
+ files["keyword-hits.txt"] = hits.join("\n\n");
195
+
196
+ // 5) pinned 全文
197
+ const pinned = mems.filter((m) => m.pinned);
198
+ files["pinned-full.txt"] = pinned.map((m) => `=== #${m.id} (${m.text.length}c) ===\n${m.text}`).join("\n\n");
199
+
200
+ // 6) 统计
201
+ const stats = {
202
+ total: mems.length,
203
+ pinned: pinned.length,
204
+ chars: mems.reduce((s, m) => s + m.text.length, 0),
205
+ pinnedChars: pinned.reduce((s, m) => s + m.text.length, 0),
206
+ nodevCount: nodev.length,
207
+ dupPairCount: pairs.length,
208
+ topDup: pairs.slice(0, 40),
209
+ pinnedSizes: pinned.map((m) => ({ id: m.id, chars: m.text.length })),
210
+ longNonPinned: mems
211
+ .filter((m) => !m.pinned)
212
+ .sort((a, b) => b.text.length - a.text.length)
213
+ .slice(0, 15)
214
+ .map((m) => ({ id: m.id, chars: m.text.length, head: m.text.split("\n")[0].slice(0, 70) })),
215
+ };
216
+ files["stats.json"] = JSON.stringify(stats, null, 1);
217
+
218
+ const summary = {
219
+ lines: lines.length,
220
+ nodev: nodev.length,
221
+ dupPairs: pairs.length,
222
+ pinned: pinned.length,
223
+ pinnedChars: stats.pinnedChars,
224
+ topDup: pairs.slice(0, 10).map((p) => `${p.jac}:${p.a}<>${p.b}`),
225
+ };
226
+ return { files, summary, stats };
227
+ }
228
+
229
+ const USAGE = `audit-analyze.mjs — 季度记忆库体检 · 第 2 站:六项分析(消费 memories.json)
230
+
231
+ 用法:
232
+ node ${TOOL} [--in <memories.json>] [--out <dir>] [--json] [--quiet]
233
+ node ${TOOL} --help
234
+ node ${TOOL} --self-test
235
+
236
+ 参数:
237
+ --in <file> 记忆导出 JSON,默认 ${DEFAULT_IN}(由 audit-export.mjs 产出)
238
+ --out <dir> 6 个产物的输出目录,默认 = --in 所在目录
239
+ --json 以 JSON 输出摘要
240
+ --quiet 不打印摘要
241
+
242
+ 产物(写在 --out 下):
243
+ list-lines.txt 全部条目首行清单
244
+ list-nodev.txt 含机器特征词但无【设备:】的漏标候选
245
+ dup-pairs.txt 重复对(6-gram Jaccard ≥ 0.15)
246
+ keyword-hits.txt 19 组过期关键词命中 id
247
+ pinned-full.txt pinned 全文
248
+ stats.json 统计(总数/pinned/字符数/最长非 pinned/topDup)
249
+
250
+ 退出码:
251
+ 0 成功
252
+ 1 --self-test 断言失败
253
+ 2 用法错误
254
+ 3 输入文件不存在
255
+ 4 运行错误(JSON 解析失败等)
256
+
257
+ 只读:只读 memories.json,不碰 memory.db;产物只写 --out(默认临时目录,不落生产路径)。
258
+ `;
259
+
260
+ export function main(argv = process.argv.slice(2)) {
261
+ const a = parseArgs(argv);
262
+ if (a.help) {
263
+ console.log(USAGE);
264
+ return 0;
265
+ }
266
+ if (typeof a["self-test"] !== "undefined") return selfTest();
267
+
268
+ const inFile = typeof a.in === "string" ? path.resolve(a.in) : DEFAULT_IN;
269
+ if (!fs.existsSync(inFile)) {
270
+ console.error(`[${TOOL}] 输入不存在:${inFile}(先跑 audit-export.mjs,或用 --in 指定)`);
271
+ return 3;
272
+ }
273
+ const outDir = typeof a.out === "string" ? path.resolve(a.out) : path.dirname(inFile);
274
+ const mems = JSON.parse(fs.readFileSync(inFile, "utf8"));
275
+ const { files, summary } = analyze(mems);
276
+
277
+ fs.mkdirSync(outDir, { recursive: true });
278
+ for (const [name, content] of Object.entries(files)) fs.writeFileSync(path.join(outDir, name), content, "utf8");
279
+
280
+ if (a.json) {
281
+ console.log(JSON.stringify({ in: inFile, outDir, ...summary }, null, 1));
282
+ return 0;
283
+ }
284
+ if (a.quiet !== true) {
285
+ console.log(`IN=${inFile} OUT=${outDir}`);
286
+ console.log(
287
+ `lines=${summary.lines} nodev=${summary.nodev} dupPairs=${summary.dupPairs} pinned=${summary.pinned} pinnedChars=${summary.pinnedChars}`,
288
+ );
289
+ console.log("top dup: " + summary.topDup.join(" "));
290
+ }
291
+ return 0;
292
+ }
293
+
294
+ // ---------- self-test(无副作用:合成 JSON + 临时输出目录) ----------
295
+ export function selfTest() {
296
+ const t = makeAsserter();
297
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "audit-analyze-selftest-"));
298
+ const inFile = path.join(tmp, "memories.json");
299
+ const outDir = path.join(tmp, "out");
300
+ try {
301
+ const base = "【设备:ROG】重复内容判定用长文本片段甲乙丙丁戊己庚辛壬癸子丑寅卯辰巳午未申酉戌亥";
302
+ const DRV = path.parse(process.cwd()).root;
303
+ const mems = [
304
+ { id: 1, text: base, pinned: true, tags: "", created_at: 1, updated_at: 1 },
305
+ { id: 2, text: base + "尾巴", pinned: false, tags: "", created_at: 1, updated_at: 1 },
306
+ { id: 3, text: `无设备标但提到 ${DRV}work 与 3080 的条目`, pinned: false, tags: "", created_at: 1, updated_at: 1 },
307
+ { id: 4, text: "这是一条提到坚果云的过期记忆", pinned: false, tags: "", created_at: 1, updated_at: 1 },
308
+ ];
309
+ fs.writeFileSync(inFile, JSON.stringify(mems), "utf8");
310
+ const before = fs.readFileSync(inFile, "utf8");
311
+
312
+ const { files, summary } = analyze(mems);
313
+ t.eq(summary.lines, 4, "首行清单条数");
314
+ t.eq(summary.pinned, 1, "pinned 条数");
315
+ t.eq(files["list-lines.txt"].split("\n").length, 4, "list-lines 行数");
316
+ t.ok(files["list-nodev.txt"].includes("#3"), "漏标候选命中 #3(含盘符绝对路径)");
317
+ t.ok(!files["list-nodev.txt"].includes("#1"), "已带设备标的 #1 不入漏标候选");
318
+ t.ok(summary.dupPairs >= 1, "近似重复对 >= 1");
319
+ t.ok(files["dup-pairs.txt"].includes("#1") && files["dup-pairs.txt"].includes("#2"), "重复对指向 #1<>#2");
320
+ t.ok(/坚果云 \(1\): 4/.test(files["keyword-hits.txt"]), "过期关键词「坚果云」命中 #4");
321
+ t.ok(files["pinned-full.txt"].includes("=== #1"), "pinned 全文含 #1");
322
+ const stats = JSON.parse(files["stats.json"]);
323
+ t.eq(stats.total, 4, "stats.total");
324
+ t.eq(stats.pinnedChars, base.length, "stats.pinnedChars");
325
+
326
+ // 写产物(临时目录)并验证
327
+ fs.mkdirSync(outDir, { recursive: true });
328
+ for (const [name, content] of Object.entries(files)) fs.writeFileSync(path.join(outDir, name), content, "utf8");
329
+ t.eq(fs.readdirSync(outDir).length, 6, "产物文件数 = 6");
330
+
331
+ // 自证只读
332
+ t.eq(fs.readFileSync(inFile, "utf8"), before, "输入 JSON 前后字节一致(未写输入)");
333
+ const src = fs.readFileSync(SELF, "utf8");
334
+ const body = src.split("// ---------- self-test")[0];
335
+ t.ok(!/fs\.writeFileSync/.test(body.split("export function analyze")[0]), "核心分析函数之前不含 fs.writeFileSync");
336
+ t.ok(!/new DatabaseSync\(/.test(body), "主体逻辑段不开放任何数据库(纯 JSON 只读)");
337
+ } catch (e) {
338
+ t.ok(false, `self-test 抛错:${e?.message ?? e}`);
339
+ } finally {
340
+ fs.rmSync(tmp, { recursive: true, force: true });
341
+ }
342
+ return t.report();
343
+ }
344
+
345
+ if (isMainEntry()) {
346
+ try {
347
+ process.exitCode = main();
348
+ } catch (e) {
349
+ console.error(`[${TOOL}] 运行错误:${e?.stack ?? e}`);
350
+ process.exitCode = 4;
351
+ }
352
+ }