@kira905/dsh-memory-toolkit 0.1.3 → 0.2.0

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 (75) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/README.en.md +225 -2
  3. package/README.md +180 -2
  4. package/lib/deploy-guard.mjs +463 -275
  5. package/lib/guard-http.mjs +59 -62
  6. package/lib/pitfall-form-map.mjs +94 -84
  7. package/lib/pitfall-score.mjs +262 -185
  8. package/lib/session-file-name.mjs +116 -100
  9. package/lib/zstd-frames.mjs +88 -67
  10. package/memory-db-tools/README.md +29 -1
  11. package/memory-db-tools/audit-analyze.mjs +1 -1
  12. package/memory-db-tools/audit-export.mjs +60 -16
  13. package/memory-db-tools/audit-struct-probe.mjs +29 -8
  14. package/memory-db-tools/lib/known-devices.mjs +137 -0
  15. package/memory-engine/README.md +58 -4
  16. package/memory-engine/bench/bench.mjs +1 -1
  17. package/memory-engine/cli/band-sentinel.mjs +164 -0
  18. package/memory-engine/cli/distill-rollback.mjs +112 -44
  19. package/memory-engine/cli/distill-sessions.mjs +237 -0
  20. package/memory-engine/cli/distill.mjs +196 -95
  21. package/memory-engine/cli/doc-index-rebuild.mjs +142 -108
  22. package/memory-engine/cli/doc-index.mjs +47 -38
  23. package/memory-engine/cli/domain-tags.mjs +246 -162
  24. package/memory-engine/cli/layer-migrate.mjs +176 -0
  25. package/memory-engine/cli/layer-move.mjs +189 -0
  26. package/memory-engine/cli/pitfall-hook.mjs +136 -70
  27. package/memory-engine/cli/recall.mjs +243 -110
  28. package/memory-engine/cli/selfcheck.mjs +93 -44
  29. package/memory-engine/cli/serve-start.mjs +221 -140
  30. package/memory-engine/cli/sweep.mjs +83 -44
  31. package/memory-engine/cli/sync.mjs +93 -57
  32. package/memory-engine/config/audit-devices.json +6 -0
  33. package/memory-engine/config/bands.json +7 -0
  34. package/memory-engine/config/layers.json +7 -0
  35. package/memory-engine/dict/LICENSE-/350/257/215/345/205/270.md +53 -0
  36. package/memory-engine/dict/lexicon-config.json +11 -0
  37. package/memory-engine/dict//350/257/215/345/205/270.txt +109750 -0
  38. package/memory-engine/dict//350/257/215/351/242/221.txt +20000 -0
  39. package/memory-engine/lib/band-sentinel.mjs +417 -0
  40. package/memory-engine/lib/cli-strict.mjs +128 -0
  41. package/memory-engine/lib/cli-util.mjs +147 -125
  42. package/memory-engine/lib/config.mjs +83 -96
  43. package/memory-engine/lib/dirty-ledger.mjs +127 -94
  44. package/memory-engine/lib/distill-rollback.mjs +173 -133
  45. package/memory-engine/lib/distill.mjs +878 -585
  46. package/memory-engine/lib/doc-index-store.mjs +208 -235
  47. package/memory-engine/lib/doc-index.mjs +351 -350
  48. package/memory-engine/lib/doc-scan.mjs +178 -156
  49. package/memory-engine/lib/doc-summarize.mjs +220 -205
  50. package/memory-engine/lib/domain-tags.mjs +358 -277
  51. package/memory-engine/lib/embedder.mjs +175 -194
  52. package/memory-engine/lib/hashes.mjs +22 -7
  53. package/memory-engine/lib/layer-migrate.mjs +330 -0
  54. package/memory-engine/lib/layer-move.mjs +307 -0
  55. package/memory-engine/lib/layers.mjs +191 -0
  56. package/memory-engine/lib/lexicon-recall.mjs +473 -0
  57. package/memory-engine/lib/memory-source.mjs +78 -80
  58. package/memory-engine/lib/pitfall-source.mjs +115 -114
  59. package/memory-engine/lib/policy-source.mjs +279 -261
  60. package/memory-engine/lib/recall-log.mjs +285 -0
  61. package/memory-engine/lib/recall.mjs +565 -240
  62. package/memory-engine/lib/selfcheck.mjs +142 -106
  63. package/memory-engine/lib/session-distill.mjs +603 -0
  64. package/memory-engine/lib/session-source.mjs +421 -0
  65. package/memory-engine/lib/sweep.mjs +107 -130
  66. package/memory-engine/lib/sync.mjs +110 -140
  67. package/memory-engine/lib/tick-hook.mjs +123 -148
  68. package/memory-engine/lib/vectors-store.mjs +213 -188
  69. package/memory-engine/test/12-layering.test.mjs +539 -0
  70. package/memory-engine/test/13-session-distill.test.mjs +616 -0
  71. package/memory-engine/tools/bridge-smoke.mjs +94 -55
  72. package/memory-engine/tools/fetch-model.mjs +167 -87
  73. package/memory-engine/tools/smoke-recall.mjs +94 -38
  74. package/memory-engine/tools/verify-recall-chain.mjs +413 -0
  75. package/package.json +1 -1
@@ -1,160 +1,176 @@
1
- // DSH / tools/lib/session-file-name.mjs —— 会话日志文件名判据 · **单一真源**(零依赖)
1
+ // 会话日志文件名:认得哪几个世代、以及从名字里读出代际(零依赖的单一真源)。
2
2
  //
3
- // 背景(2026-09-24 · 卡「三机同步步A-V3代际适配」):
4
- // 主包 0.1.5 起把会话日志改名 `session.jsonl.zstd` → `session.v3.jsonl.zstd`;
5
- // 主包 0.2.x 起**再换代** `session.v3.jsonl.zstd` → `session.v4.jsonl.zstd`(**现行世代**)。
6
- // (2026-10-16 · 卡「harness · 成本与路由 · 债 D-1/D-3 收口」把**写入侧常量与文案**一并推到 v4;
7
- // 读取侧判据本已认任意 vN,未动。真源 = 本文件。)
8
- // 凡是「按精确文件名比较」的**读取侧**判据都会整条失明(实测 pitfall-scan 的
9
- // discoverSessionFiles() 活跃树覆盖 0 / 212),而归档里仍有 1040 个旧名文件、
10
- // 活跃树 212 个全是 v3 名 ⇒ 读取侧必须**认全世代**。
3
+ // 文件名换过好几代:旧的是 `session.jsonl.zstd`,后来加了版本号 `session.vN.jsonl.zstd`,
4
+ // 当前写入侧写的是 v4。归档树里旧名的文件还成堆躺着,活跃树里又都是新名 ——
5
+ // 只要有一处判据写成"精确比较当前文件名",那一处就对着另一半文件整条失明。
11
6
  //
12
- // 三条口径(写死,别自由发挥):
13
- // ① 读取侧一律用 isSessionFileName() / sessionFileGeneration()(前缀 + 代际),**禁精确字符串比较**;
14
- // ② 需要"代际"时从文件名提取(旧名 = 0,`session.vN....` = N),**禁硬编码**;
15
- // ③ 写入侧(真要写文件名时)用 SESSION_FILE_NAME_CURRENT(当前世代 = v4,0.2.x 起)。
7
+ // 三条规则(写死,别临场发挥):
8
+ // ① 读取侧一律走 isSessionFileName() / sessionFileGeneration():前缀 + 代际,认出任意 vN,
9
+ // 以后换代也不用回来改判据;
10
+ // ② 需要"第几代"就从文件名里提取(旧名 = 0,`session.vN...` = N),不要在调用处硬编码;
11
+ // ③ 写入侧真要落文件名时用 SESSION_FILE_NAME_CURRENT。
16
12
  //
17
- // ⚠️ PowerShell 侧无法 import 本模块:`tools\dsh-backup-sessions.ps1` 内写等价 PS 判据
18
- // ($SessionFileFilter / Test-SessionFileName / $SessionFileNameCurrent)。**改一处必须同步另一处。**
19
- // 已知同类读取侧判据(**★2026-09-24 收尾清账批逐处核实后重写**,原清单两处已修、又漏了一族):
20
- // ✅ 已消费本模块:tools\lib\attach-refs.mjs · tools\board-ops\session-tail.mjs · tools\dsh-sessions-land.mjs ·
21
- // tools\dsh-butler\{session-ledger.mjs, lib\pitfall-scan.mjs, lib\session-header.mjs, lib\restart.mjs} ·
22
- // tools\archive-card-sessions.mjs · tools\archive-inactive-sessions.mjs · tools\dsh-archive-sessions\lib\index.js ·
23
- // tools\dsh-butler-panel\lib\state.js(★A2:判据 +「取哪份」取法一并统一;checks.mjs 经它复用,无第二份实现)。
24
- // ⚠️ restart-coordinator.mjs **无自有判据**(经 lib\restart.mjs#scanSessionLogFiles 间接同源)——原清单点名它属误记。
25
- // ⏳ 仍**只认旧名**的读取侧(低频/一次性工具,待各自 owner 认领;行号 = 2026-09-24 快照):
26
- // tools\dsh-archive-sessions\{clean-empty-shells.mjs:78, scan-empty.mjs:48, scan-small.mjs:57} ·
27
- // tools\projcache-restore-archived.mjs:107 · tools\restore-shell-sessions.mjs:76 · tools\test-single-archive.mjs:21 ·
28
- // tools\{session-tail-for-llm.mjs:81, session-tail-audit.mjs:96, session-impact.mjs:78, session-cot.mjs:77,
29
- // dsh-session-tail-dump.mjs:23}。**要长期用再改**,改法照本文件(认全世代 + 需要代际时提取)。
30
- // ⚠️ 已知风险登记(A6 · 2026-09-24 **先登记不修**):「两代并存」目录里新世代文件可能只是**补写壳**
31
- // (实测 v3 仅 2 事件 / 244 B,旧名才是完整对话 2621 事件 / 2.7 MB)⇒ **需要读内容的消费者必须走候选链**
32
- // (rankSessionFilesInDir / collectSessionFilesByDir 的 files),只做扫描/移动的取 current 即可。
33
- // 当前无消费方受影响(面板只扫活跃树:231/231 全 v3、0 并存);若将来把归档并存目录纳入扫描,先接候选链再开。
34
- // (插件包内为**派生副本** `lib/session-file-name.mjs`,由 tools\sync-session-file-name.mjs 生成,
35
- // deploy 时 fail-closed 校验副本与真源 sha 一致 —— 改真源必须同批重跑该同步器。)
13
+ // 跨语言提醒:脚本侧(PowerShell 之类)没法 import 本模块,那边有一份等价判据。
14
+ // 改这里必须同批改那边,否则两边会各认一半世代。
15
+ //
16
+ // 另一个已知坑:换代后新世代文件可能只是个"补写壳"(只有会话头一小段),完整对话还在
17
+ // 旧名文件里。所以"当前那份"不等于"内容最全的那份" —— 要读内容的调用方必须把
18
+ // rankSessionFilesInDir / collectSessionFilesByDir 给出的整条候选链走完;只做扫描或
19
+ // 搬迁的调用方取第一个就够。
36
20
  import fs from 'node:fs';
37
21
  import path from 'node:path';
38
22
 
39
- /** 旧世代文件名(≤ 0.1.1;归档树里仍有大量此名)。 */
23
+ /** 旧世代文件名(更早的版本;归档树里大量存在) */
40
24
  export const SESSION_FILE_NAME_LEGACY = 'session.jsonl.zstd';
41
25
 
42
- /** v3 世代文件名(0.1.5 起;**已被 0.2.x 换代**,仍须认得)。 */
26
+ /** 带 v3 的世代名(更早的一次换代;仍必须认得) */
43
27
  export const SESSION_FILE_NAME_V3 = 'session.v3.jsonl.zstd';
44
28
 
45
- /** 当前世代文件名(0.2.x 起)。 */
29
+ /** 当前世代文件名 */
46
30
  export const SESSION_FILE_NAME_V4 = 'session.v4.jsonl.zstd';
47
31
 
48
- /** 写入侧应使用的当前世代文件名。 */
32
+ /** 写入侧该用的名字(当前世代) */
49
33
  export const SESSION_FILE_NAME_CURRENT = SESSION_FILE_NAME_V4;
50
34
 
51
- /** 认得的三个世代名(读取侧清单;顺序 = 旧 → 新)。 */
35
+ /** 全部认得的世代名(顺序 = 旧 → 新) */
52
36
  export const SESSION_FILE_NAMES = [SESSION_FILE_NAME_LEGACY, SESSION_FILE_NAME_V3, SESSION_FILE_NAME_V4];
53
37
 
54
- /** 宽匹配模式(shell / robocopy / Get-ChildItem -Filter 用;**读取侧**)。 */
38
+ /** 宽匹配模式:给 shell / 复制工具 / 目录枚举当过滤器用(读取侧) */
55
39
  export const SESSION_FILE_GLOB = 'session*.jsonl.zstd';
56
40
 
57
41
  /**
58
- * 读取侧判据(前缀 + 代际)。命中的形态:
59
- * `session.jsonl.zstd`(旧世代)· `session.v3.jsonl.zstd`(0.1.5 世代)· `session.v4.jsonl.zstd`(当前世代)
60
- * · 更高的 `session.vN.jsonl.zstd`(未来世代)—— **本判据认任意 vN,换代时无需改**。
61
- * 不命中:`session.jsonl`(明文形态,另有判据)· `session.jsonl.zstd.bak` · 其它任意文件。
42
+ * 读取侧判据:前缀 `session` + 可选的 `.vN` + `.jsonl.zstd`。
43
+ * 命中:`session.jsonl.zstd` / `session.v3.jsonl.zstd` / `session.v4.jsonl.zstd` / 未来的 `session.v5...`。
44
+ * 不命中:明文 `session.jsonl`、备份 `session.jsonl.zstd.bak`、以及其它任何文件。
62
45
  */
63
46
  export const SESSION_FILE_RE = /^session(?:\.v(\d+))?\.jsonl\.zstd$/;
64
47
 
65
- /** 是否会话日志文件(读取侧唯一判据)。 */
48
+ /** 是不是会话日志文件(读取侧唯一判据,别在调用处另写一套) */
66
49
  export function isSessionFileName(name) {
67
50
  return SESSION_FILE_RE.test(String(name));
68
51
  }
69
52
 
70
53
  /**
71
- * 从文件名提取代际:旧世代 = 0;`session.vN.jsonl.zstd` = N;非会话文件 = null。
54
+ * 从文件名读代际:旧名 = 0;`session.vN.jsonl.zstd` = N;不是会话文件 = null。
55
+ * @param {string} name
72
56
  * @returns {number|null}
73
57
  */
74
58
  export function sessionFileGeneration(name) {
75
- const m = SESSION_FILE_RE.exec(String(name));
76
- if (!m) return null;
77
- return m[1] ? Number(m[1]) : 0;
59
+ const matched = SESSION_FILE_RE.exec(String(name));
60
+ if (!matched) return null;
61
+ return matched[1] ? Number(matched[1]) : 0;
62
+ }
63
+
64
+ // 目录枚举统一走"可注入的 fs":测试塞夹具时不必碰真盘
65
+ function ioOf(opts) {
66
+ return opts?.fs ?? fs;
78
67
  }
79
68
 
80
69
  /**
81
- * 目录形态的会话文件清单(不递归)。
70
+ * 列出一个目录里的会话文件(不递归)。
71
+ * @param {string} dir
72
+ * @param {{fs?:object}} [opts]
82
73
  * @returns {{file:string,name:string,generation:number,bytes:number,mtimeMs:number}[]}
83
74
  */
84
75
  export function listSessionFilesInDir(dir, opts = {}) {
85
- const fsImpl = opts.fs ?? fs;
86
- let ents;
87
- try { ents = fsImpl.readdirSync(dir, { withFileTypes: true }); } catch { return []; }
88
- const out = [];
89
- for (const e of ents) {
90
- if (!e.isFile()) continue;
91
- if (!isSessionFileName(e.name)) continue;
92
- const full = path.join(dir, e.name);
93
- let st;
94
- try { st = fsImpl.statSync(full); } catch { continue; }
95
- out.push({
96
- file: full, name: e.name, generation: sessionFileGeneration(e.name),
97
- bytes: st.size, mtimeMs: Math.round(st.mtimeMs),
76
+ const io = ioOf(opts);
77
+ let entries;
78
+ try {
79
+ entries = io.readdirSync(dir, { withFileTypes: true });
80
+ } catch {
81
+ return [];
82
+ }
83
+
84
+ const rows = [];
85
+ for (const dent of entries) {
86
+ if (!dent.isFile()) continue;
87
+ if (!isSessionFileName(dent.name)) continue;
88
+ const file = path.join(dir, dent.name);
89
+ let info;
90
+ try {
91
+ info = io.statSync(file);
92
+ } catch {
93
+ continue; // 枚举与 stat 之间被删掉:跳过,不当异常
94
+ }
95
+ rows.push({
96
+ file,
97
+ name: dent.name,
98
+ generation: sessionFileGeneration(dent.name),
99
+ bytes: info.size,
100
+ mtimeMs: Math.round(info.mtimeMs),
98
101
  });
99
102
  }
100
- return out;
103
+ return rows;
101
104
  }
102
105
 
103
106
  /**
104
- * 同一会话目录内的候选文件,按优先级排序(第一个 = 「当前那份」):
105
- * mtime 最新优先 → 平局取代际大者 → 再平局取名字典序小者(确定性)。
106
- *
107
- * ⚠️ **「当前那份」≠「内容最全的那份」**(2026-09-24 实测,卡「修·V3判据④」):
108
- * 换代后新世代文件可能只是"补写壳"——归档树 28 个两代并存目录里,`session.v3.jsonl.zstd`
109
- * 只有 2 个事件(`session` 头 + `session/end-seed`、有效内容 244 B),而旧名文件才是完整对话
110
- * (2621 个事件 / 2.7 MB 解压文本)。**需要读内容的消费者必须走完候选链**(见下),
111
- * 只做"扫描/移动"的消费者取第一个即可。
107
+ * 同一会话目录里的候选文件,按"先取哪一份"排好序(第一个就是当前那份):
108
+ * mtime 最新 → 平局取代际大的 → 再平局取名字典序小的(最后的平局也要确定,不能靠运气)。
109
+ * 要读内容时别只看第一个:两代并存时新世代可能只是补写壳,得顺着整条链继续往下看。
112
110
  * @returns {{file:string,name:string,generation:number,bytes:number,mtimeMs:number}[]}
113
111
  */
114
112
  export function rankSessionFilesInDir(dir, opts = {}) {
115
- const hits = listSessionFilesInDir(dir, opts);
116
- hits.sort((a, b) => (b.mtimeMs - a.mtimeMs) || (b.generation - a.generation) || (a.name < b.name ? -1 : 1));
117
- return hits;
113
+ return listSessionFilesInDir(dir, opts).sort((left, right) => (
114
+ right.mtimeMs - left.mtimeMs
115
+ || right.generation - left.generation
116
+ || (left.name < right.name ? -1 : 1)
117
+ ));
118
118
  }
119
119
 
120
120
  /**
121
- * 在一个会话目录里挑出"当前那份"会话文件(两代并存时的取法):
122
- * mtime 最新优先 → 平局取代际大者 → 再平局取名字典序小者(确定性)。
123
- * @returns {string|null} 绝对路径;目录不存在 / 无会话文件 = null
121
+ * 挑出"当前那份"会话文件的绝对路径。
122
+ * @param {string} dir
123
+ * @param {{fs?:object}} [opts]
124
+ * @returns {string|null} 目录不存在 / 目录里没有会话文件 → null
124
125
  */
125
126
  export function pickSessionFileInDir(dir, opts = {}) {
126
- const hits = rankSessionFilesInDir(dir, opts);
127
- return hits.length ? hits[0].file : null;
127
+ const [best] = rankSessionFilesInDir(dir, opts);
128
+ return best ? best.file : null;
128
129
  }
129
130
 
130
131
  /**
131
- * 递归收集会话目录(**每个目录聚合一次**,不做逐文件扁平化)。
132
- * 用途:归档器的扫描面 —— 一个会话目录 = 一个会话,两代并存时 `current` 是"当前那份"、
133
- * `files` 是该目录全部世代(移动校验/尾部判据/内容回落都要用到全量)。
134
- * @returns {{dir:string, current:string, files:object[]}[]} 按 dir 字典序(确定性)
132
+ * 递归收集"每个会话目录一条"(不逐文件摊平)。
133
+ * 用途:归档这类按目录当单位处理的场景 —— current 是当前那份,files 是该目录全部世代
134
+ * (搬运校验、尾部判据、内容回落都要用到全量)。
135
+ * @param {string} root
136
+ * @param {{fs?:object}} [opts]
137
+ * @returns {{dir:string, current:string, files:object[]}[]} 按目录名字典序(确定性)
135
138
  */
136
139
  export function collectSessionFilesByDir(root, opts = {}) {
137
- const fsImpl = opts.fs ?? fs;
138
- const out = [];
139
- const stack = [root];
140
- while (stack.length) {
141
- const d = stack.pop();
142
- let ents;
143
- try { ents = fsImpl.readdirSync(d, { withFileTypes: true }); } catch { continue; }
144
- let hasSessionFile = false;
145
- for (const e of ents) {
146
- if (e.isDirectory()) { stack.push(path.join(d, e.name)); continue; }
147
- if (e.isFile() && isSessionFileName(e.name)) hasSessionFile = true;
140
+ const io = ioOf(opts);
141
+ const buckets = [];
142
+ const pending = [root];
143
+
144
+ while (pending.length > 0) {
145
+ const dir = pending.pop();
146
+ let entries;
147
+ try {
148
+ entries = io.readdirSync(dir, { withFileTypes: true });
149
+ } catch {
150
+ continue; // 读不动的目录直接跳过(权限/竞态删除),不影响其余
151
+ }
152
+
153
+ const subdirs = [];
154
+ let holdsLog = false;
155
+ for (const dent of entries) {
156
+ if (dent.isDirectory()) {
157
+ subdirs.push(path.join(dir, dent.name));
158
+ continue;
159
+ }
160
+ if (dent.isFile() && isSessionFileName(dent.name)) holdsLog = true;
148
161
  }
149
- if (!hasSessionFile) continue;
150
- const files = rankSessionFilesInDir(d, opts);
151
- if (files.length) out.push({ dir: d, current: files[0].file, files });
162
+ pending.push(...subdirs);
163
+ if (!holdsLog) continue;
164
+
165
+ const files = rankSessionFilesInDir(dir, opts);
166
+ if (files.length > 0) buckets.push({ dir, current: files[0].file, files });
152
167
  }
153
- out.sort((a, b) => (a.dir < b.dir ? -1 : 1));
154
- return out;
168
+
169
+ buckets.sort((left, right) => (left.dir < right.dir ? -1 : 1));
170
+ return buckets;
155
171
  }
156
172
 
157
- /** 递归收集「每目录当前那份」的绝对路径(collectSessionFilesByDir 的扁平形态)。 */
173
+ /** 递归收集「每个目录当前那份」的绝对路径(collectSessionFilesByDir 的扁平形态) */
158
174
  export function collectSessionFilesInTree(root, opts = {}) {
159
- return collectSessionFilesByDir(root, opts).map((x) => x.current);
175
+ return collectSessionFilesByDir(root, opts).map((bucket) => bucket.current);
160
176
  }
@@ -1,78 +1,99 @@
1
- // zstd-frames.mjs —— zstd 多帧日志「拆帧」的 **CLI 侧唯一源**(严格结构解析)。
1
+ // 多帧 zstd 日志的拆帧器(按帧结构解析,不靠搜魔数)。
2
2
  //
3
- // 用途:会话日志(`session.jsonl.zstd` / `session.v3.jsonl.zstd`)是**多帧**追加写的
4
- // —— 一帧 ≈ 一次 flush 的一批事件。要取「尾部 N 帧」或「全部帧」,就得先把多帧缓冲切成逐帧。
3
+ // 会话日志是一个帧接一个帧追加出来的:一帧大约对应一次 flush 的那批事件。
4
+ // 想只取尾部若干帧、或者要逐帧解压,第一步就得把这一整块缓冲切成帧边界。
5
5
  //
6
- // 算法(严格结构解析,**不吃"魔数搜索"那套启发式**):
7
- // ZSTD magic(0xFD2FB528 LE)→ frame header descriptor(含 dictionary / content-size 字段宽度)
8
- // → block 循环(block type 3 = reserved ⇒ 断)→ optional 4 字节 checksum;
9
- // 遇**不完整帧**返回 `tornStart`(该帧起点),**不猜长度、也不把残字节当完整帧**。
10
- // 对比"魔数搜索 + 游标推进"启发式的两个弱点:
11
- // ① 压缩数据里**恰好出现 zstd magic 字节序列**时会把一帧切成两半(本件靠帧结构解析不会);
12
- // ② 多份实现各自演化 ⇒ 一处修另一处不跟(2026-09-29 的「隔帧取样」缺陷族就是这么来的)。
6
+ // 做法:读到 magic → 读帧头描述符(里面塞着字典 id、内容长度的宽度)→ 一帧一帧读 block
7
+ // (block type 3 是保留值,见着就停)→ 有校验位就再跳 4 字节。任何一步拿不够字节就
8
+ // 判定"撕裂",把该帧起点交回去;**不猜长度,也不把半截字节凑成完整帧**。
13
9
  //
14
- // 三条必读:
15
- // ① **本件是 CLI 侧唯一源**(2026-09-29 卡「归档帧扫描同源化」建立):`tools\archive-card-sessions.mjs`
16
- // 与 `tools\archive-inactive-sessions.mjs` 的 **6 处**拆帧点(tailTypes / readSessionMeta / framesOf、
17
- // tailState / allTypes / framesOf)全部消费本件。原魔数搜索实现已删除。
18
- // ② 与插件侧 `tools\dsh-archive-sessions\lib\zstd-frame-rewrite.mjs#scanZstdFrames` **同源但不是同一
19
- // 文件**:插件部署时只同步插件目录 ⇒ 插件**不能 import 插件目录外的文件**(CLI 侧也不该反向依赖
20
- // 插件目录 = 跨包部署边界)。两份实现靠 `tools\_test\test-archive-framescan.mjs` 的**一致性单测**
21
- // 守住:两文件并排跑同一批样本(含魔数字节样本 / 撕裂帧样本 / 生产形态样本),帧边界必须逐帧相同。
22
- // ⇒ **禁再抄第三份**:要改算法,先改这两份并让一致性单测过。
23
- // ③ 与本件同算法的另两份**历史副本**(`tools\archive-meta-backfill.mjs` 约 L169 /
24
- // `tools\projcache-restore-archived.mjs` 约 L64 各自的内联版:无 tornStart、撕裂即 break)
25
- // **登记为待收口** —— 它们属其它卡的产物,本件建立时**未动**,留给后续卡(避免与在途改动互相踩)。
10
+ // 为什么不用"搜魔数 + 游标推进":压缩后的字节流里可能正好出现 magic 的字节序列,
11
+ // 那种做法会把一帧劈成两半;而且每份实现各写一套,改了一处另一处不跟,缺陷就会
12
+ // 隔一段时间冒一次。宁可多写几十行结构解析。
13
+ //
14
+ // 边界说明:这里只负责切,不负责解压(解压由调用方用平台自带的 zstd 解)。
15
+ // 另一份同样的算法存在于归档插件内部 —— 部署时插件只能带走自己目录里的文件,
16
+ // 所以两边各留一份实现,靠一致性单测对齐帧边界;要改算法就两边一起改、让单测过。
17
+
18
+ /** zstd 帧魔数(小端读出来的 32 位值) */
19
+ export const ZSTD_MAGIC = 4247762216;
20
+
21
+ const MAGIC_BYTES = 4;
22
+ const DESCRIPTOR_BYTES = 1;
23
+ const BLOCK_HEADER_BYTES = 3;
24
+ const CHECKSUM_BYTES = 4;
25
+ const BLOCK_TYPE_RESERVED = 3;
26
+ const BLOCK_TYPE_RLE = 1;
27
+
28
+ /**
29
+ * 解析单帧,返回它的结束偏移。
30
+ * @param {Buffer} buffer
31
+ * @param {number} from 帧起点
32
+ * @returns {{end:number}|null} null = 这帧不完整(或魔数对不上),由调用方记为撕裂点
33
+ */
34
+ function parseFrameAt(buffer, from) {
35
+ if (buffer.length - from < MAGIC_BYTES) return null;
36
+ if (buffer.readUInt32LE(from) !== ZSTD_MAGIC) return null;
37
+
38
+ let at = from + MAGIC_BYTES;
39
+ if (at === buffer.length) return null;
40
+
41
+ const descriptor = buffer.readUInt8(at);
42
+ at += DESCRIPTOR_BYTES;
43
+ // 描述符里第 3、4 位保留,非零说明不是我们认识的帧头
44
+ if ((descriptor & 0b1_1000) !== 0) return null;
26
45
 
27
- export const ZSTD_MAGIC = 4247762216 // 0xFD2FB528 LE
46
+ const contentSizeFlag = descriptor >>> 6;
47
+ const singleSegment = (descriptor & 0b10_0000) !== 0;
48
+ const hasChecksum = (descriptor & 0b100) !== 0;
49
+ const dictionaryFlag = descriptor & 0b11;
50
+ const dictionaryBytes = dictionaryFlag === 0b11 ? 4 : dictionaryFlag;
51
+ // 单段帧不写 window descriptor;内容长度字段按 flag 给出的宽度(flag 0 时单段帧占 1 字节)
52
+ const contentSizeBytes = contentSizeFlag === 0 ? (singleSegment ? 1 : 0) : 1 << contentSizeFlag;
53
+ const windowBytes = singleSegment ? 0 : 1;
54
+
55
+ at += windowBytes + dictionaryBytes + contentSizeBytes;
56
+ if (at > buffer.length) return null;
57
+
58
+ for (;;) {
59
+ if (buffer.length - at < BLOCK_HEADER_BYTES) return null;
60
+ const blockHeader = buffer.readUIntLE(at, BLOCK_HEADER_BYTES);
61
+ at += BLOCK_HEADER_BYTES;
62
+
63
+ const isLastBlock = (blockHeader & 0b1) !== 0;
64
+ const blockType = (blockHeader >>> 1) & 0b11;
65
+ const blockSize = blockHeader >>> 3;
66
+ if (blockType === BLOCK_TYPE_RESERVED) return null;
67
+
68
+ // RLE 块整块只有 1 个字节在流里,长度由 blockSize 说;其余类型的长度就是 blockSize
69
+ at += blockType === BLOCK_TYPE_RLE ? 1 : blockSize;
70
+ if (at > buffer.length) return null;
71
+ if (isLastBlock) break;
72
+ }
73
+
74
+ if (hasChecksum) {
75
+ at += CHECKSUM_BYTES;
76
+ if (at > buffer.length) return null;
77
+ }
78
+ return { end: at };
79
+ }
28
80
 
29
81
  /**
30
- * 扫描 zstd 多帧边界(严格帧结构解析;与插件侧 scanZstdFrames 逐行同源)。
31
- * @param {Buffer} buffer 整个多帧缓冲(调用方负责截断/切片)
82
+ * 扫出一整块缓冲里的完整帧。
83
+ * @param {Buffer} buffer
32
84
  * @returns {{frames: {start:number,end:number}[], tornStart: number|undefined}}
33
- * frames —— **完整帧**的 [start, end) 区间,顺序即文件内顺序;
34
- * tornStart —— 第一个**不完整**帧的起点(没有 = undefined)。调用方须"遇撕裂不猜":
35
- * 撕裂帧一律丢弃,绝不把残字节当完整帧去解。
85
+ * frames —— 完整帧的 [start, end) 区间,顺序同文件内顺序;
86
+ * tornStart —— 第一处不完整帧的起点(没有则 undefined)。调用方遇到它就停:
87
+ * 撕裂的那帧整体丢弃,绝不能把残字节当完整帧去解压。
36
88
  */
37
89
  export function scanZstdFrames(buffer) {
38
- const frames = []
39
- let offset = 0
40
- while (offset < buffer.length) {
41
- const start = offset
42
- if (buffer.length - offset < 4) return { frames, tornStart: start }
43
- if (buffer.readUInt32LE(offset) !== ZSTD_MAGIC) return { frames, tornStart: start }
44
- offset += 4
45
- if (offset === buffer.length) return { frames, tornStart: start }
46
- const descriptor = buffer.readUInt8(offset)
47
- offset += 1
48
- if ((descriptor & 24) !== 0) return { frames, tornStart: start }
49
- const contentSizeFlag = descriptor >>> 6
50
- const singleSegment = (descriptor & 32) !== 0
51
- const checksum = (descriptor & 4) !== 0
52
- const dictionaryFlag = descriptor & 3
53
- const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag
54
- const contentSizeBytes = contentSizeFlag === 0 ? (singleSegment ? 1 : 0) : 1 << contentSizeFlag
55
- const remainingHeaderBytes = (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes
56
- if (buffer.length - offset < remainingHeaderBytes) return { frames, tornStart: start }
57
- offset += remainingHeaderBytes
58
- for (;;) {
59
- if (buffer.length - offset < 3) return { frames, tornStart: start }
60
- const blockHeader = buffer.readUIntLE(offset, 3)
61
- offset += 3
62
- const lastBlock = (blockHeader & 1) !== 0
63
- const blockType = (blockHeader >>> 1) & 3
64
- const blockSize = blockHeader >>> 3
65
- if (blockType === 3) return { frames, tornStart: start }
66
- const payloadBytes = blockType === 1 ? 1 : blockSize
67
- if (buffer.length - offset < payloadBytes) return { frames, tornStart: start }
68
- offset += payloadBytes
69
- if (lastBlock) break
70
- }
71
- if (checksum) {
72
- if (buffer.length - offset < 4) return { frames, tornStart: start }
73
- offset += 4
74
- }
75
- frames.push({ start, end: offset })
90
+ const frames = [];
91
+ let cursor = 0;
92
+ while (cursor < buffer.length) {
93
+ const parsed = parseFrameAt(buffer, cursor);
94
+ if (!parsed) return { frames, tornStart: cursor };
95
+ frames.push({ start: cursor, end: parsed.end });
96
+ cursor = parsed.end;
76
97
  }
77
- return { frames, tornStart: undefined }
98
+ return { frames, tornStart: undefined };
78
99
  }
@@ -19,7 +19,7 @@
19
19
  |---|---|---|---|---|---|
20
20
  | `audit-export.mjs` | 第 1 站:把记忆库全量导成 `memories.json`,给下面所有探针当数据源 | `node audit-export.mjs` | `--db` `<库路径>`、`--out` `<文件>`、`--force`(覆盖已存在)、`--json`、`--quiet` | **只读库**(可写 `--out`) | `memories.json` + 统计(TOTAL/PINNED/CHARS/设备分布) |
21
21
  | `audit-analyze.mjs` | 第 2 站:六项分析——首行清单 / 漏标设备候选 / 重复对 / 过期关键词 / pinned 全文 / 统计 | `node audit-analyze.mjs` | `--in` `<memories.json>`、`--out` `<目录>`、`--json`、`--quiet` | 只读输入(可写 6 个产物) | `list-lines.txt`、`list-nodev.txt`、`dup-pairs.txt`、`keyword-hits.txt`、`pinned-full.txt`、`stats.json` |
22
- | `audit-struct-probe.mjs` | 结构异常:设备标不在已知清单(OTHER)
22
+ | `audit-struct-probe.mjs` | 结构异常:设备标不在「已知设备名」清单(OTHER)+ 一条里两个设备标(DOUBLE)+ 长度分布 | `node audit-struct-probe.mjs` | `--in` `<memories.json>`、`--top` `<n>`、`--json` | **只读,不写文件** | 标准输出(OTHER / DOUBLE / PINNED_CHARS / TOP_LONG_NONPINNED) |
23
23
  | `audit-hit-probe.mjs` | 给 id 列表,报每条命中了哪些机器特征词(自证「漏标设备」判据站得住) | `node audit-hit-probe.mjs --ids 40,46,67` | `--ids`(必填)、`--in`、`--json` | **只读,不写文件** | 标准输出(每行 `#id [命中词] 首行`) |
24
24
  | `audit-dump-ids.mjs` | 按 id 取记忆全文(分批精读,规避 stdout 编码/截断坑) | `node audit-dump-ids.mjs --ids 40,46,67` | `--ids`(必填)、`--out`、`--stdout`、`--db`、`--json` | **只读库**(可写 `--out`) | `dump-ids-<本地时间>.txt`(默认临时目录)或 stdout |
25
25
  | `audit-persona-overlap.mjs` | 量化每条记忆有多少比例已出现在 persona 里(找「焊接残留」条目) | `node audit-persona-overlap.mjs` | `--db`、`--persona`、`--out`、`--top` `<n>`、`--json` | **只读库与 persona**(可写 `--out`) | `persona-overlap.json` + 覆盖比 Top N / pinned 覆盖比 |
@@ -49,6 +49,34 @@ node $T\audit-verify-unchanged.mjs --baseline "$W\memories.json" --expect-unchan
49
49
  - **过期关键词表**:`audit-analyze.mjs` 的 `STALE_KEYWORDS`(19 组;过期项增减改这一处)。
50
50
  - **重复对阈值**:`audit-analyze.mjs` 的 `analyze(mems, { shingle, minShared, jacThreshold })`(默认 6-gram / 共享 shingle ≥ 6 / Jaccard ≥ 0.15)。
51
51
 
52
+ ### 已知设备名(改这里,不是改代码)
53
+
54
+ `audit-export.mjs` 的设备分布统计与 `audit-struct-probe.mjs` 的 OTHER 判据,都要知道「哪些设备名算已知」。
55
+ **代码里不写死任何机器名**(不带任何真实机器名,换机器不用改代码)。优先级从高到低:
56
+
57
+ 1. **环境变量** `MEMORY_DB_AUDIT_DEVICES` —— 逗号或空格分隔,例如
58
+ `$env:MEMORY_DB_AUDIT_DEVICES = 'laptop,desktop'`(临时起效,适合偶尔跑一次);
59
+ 2. **配置文件** `memory-engine/config/audit-devices.json` 里的 `devices` 数组 —— 随包发布,改它长期生效:
60
+
61
+ ```json
62
+ { "devices": ["alpha", "beta"] }
63
+ ```
64
+
65
+ 3. 两者都没给时退回**内置中性示例名** `alpha` / `beta`(只是让工具装完能跑,不代表任何真实机器)。
66
+
67
+ 匹配规则:只看 `【设备:X】` 里的 X 那段,拿名单里每个名字做**大小写敏感**的子串匹配;
68
+ 名单不认识的一律归 **OTHER**(结构探针会列出来让人复核);名单里出现 `NONE` / `OTHER` / `DOUBLE`
69
+ (统计结果里的固定键)或形态不合法的名字会被剔掉,并在 stderr 打一行警告。
70
+ 名单为空或全不合法 ⇒ 退回内置缺省(不是「全都算 OTHER」)。
71
+
72
+ > ⚠️ **别把本机真实设备名写进仓里那份 `audit-devices.json`** —— 它会随包发布到公开仓。
73
+ > 本机跑体检要认真名,用环境变量(或写进自己的 PowerShell profile / 启动脚本),
74
+ > 让仓里的那份始终是**中性洁本**。反过来说:缺省配置不认识你的机器名时,
75
+ > 全部设备标都会落进 OTHER —— 这是**故意**的(宁可让你看见一堆"不认识",也不把机器名刻进代码)。
76
+ >
77
+ > 统计输出(`DEV={...}`)的形状固定 = 名单里每个名字一个计数 + `NONE` / `OTHER` / `DOUBLE` 三个固定键;
78
+ > 名单换了,键名跟着换,**键的数量与顺序规则不变**。改名单不会改动任何 CLI 参数 / 缺省值 / 退出码。
79
+
52
80
  ### 自检
53
81
 
54
82
  每个工具都有 `--help`(用法/参数/退出码)与 `--self-test`(合成夹具跑一遍 + 自证只读:合成库 sha 前后一致、主体逻辑段每次开库都带 `readOnly: true`):
@@ -298,7 +298,7 @@ export function selfTest() {
298
298
  const inFile = path.join(tmp, "memories.json");
299
299
  const outDir = path.join(tmp, "out");
300
300
  try {
301
- const base = "【设备:ROG】重复内容判定用长文本片段甲乙丙丁戊己庚辛壬癸子丑寅卯辰巳午未申酉戌亥";
301
+ const base = "【设备:alpha】重复内容判定用长文本片段甲乙丙丁戊己庚辛壬癸子丑寅卯辰巳午未申酉戌亥";
302
302
  const DRV = path.parse(process.cwd()).root;
303
303
  const mems = [
304
304
  { id: 1, text: base, pinned: true, tags: "", created_at: 1, updated_at: 1 },