@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.
- package/CHANGELOG.md +92 -0
- package/README.en.md +225 -2
- package/README.md +180 -2
- package/lib/deploy-guard.mjs +463 -275
- package/lib/guard-http.mjs +59 -62
- package/lib/pitfall-form-map.mjs +94 -84
- package/lib/pitfall-score.mjs +262 -185
- package/lib/session-file-name.mjs +116 -100
- package/lib/zstd-frames.mjs +88 -67
- package/memory-db-tools/README.md +29 -1
- package/memory-db-tools/audit-analyze.mjs +1 -1
- package/memory-db-tools/audit-export.mjs +60 -16
- package/memory-db-tools/audit-struct-probe.mjs +29 -8
- package/memory-db-tools/lib/known-devices.mjs +137 -0
- package/memory-engine/README.md +58 -4
- package/memory-engine/bench/bench.mjs +1 -1
- package/memory-engine/cli/band-sentinel.mjs +164 -0
- package/memory-engine/cli/distill-rollback.mjs +112 -44
- package/memory-engine/cli/distill-sessions.mjs +237 -0
- package/memory-engine/cli/distill.mjs +196 -95
- package/memory-engine/cli/doc-index-rebuild.mjs +142 -108
- package/memory-engine/cli/doc-index.mjs +47 -38
- package/memory-engine/cli/domain-tags.mjs +246 -162
- package/memory-engine/cli/layer-migrate.mjs +176 -0
- package/memory-engine/cli/layer-move.mjs +189 -0
- package/memory-engine/cli/pitfall-hook.mjs +136 -70
- package/memory-engine/cli/recall.mjs +243 -110
- package/memory-engine/cli/selfcheck.mjs +93 -44
- package/memory-engine/cli/serve-start.mjs +221 -140
- package/memory-engine/cli/sweep.mjs +83 -44
- package/memory-engine/cli/sync.mjs +93 -57
- package/memory-engine/config/audit-devices.json +6 -0
- package/memory-engine/config/bands.json +7 -0
- package/memory-engine/config/layers.json +7 -0
- package/memory-engine/dict/LICENSE-/350/257/215/345/205/270.md +53 -0
- package/memory-engine/dict/lexicon-config.json +11 -0
- package/memory-engine/dict//350/257/215/345/205/270.txt +109750 -0
- package/memory-engine/dict//350/257/215/351/242/221.txt +20000 -0
- package/memory-engine/lib/band-sentinel.mjs +417 -0
- package/memory-engine/lib/cli-strict.mjs +128 -0
- package/memory-engine/lib/cli-util.mjs +147 -125
- package/memory-engine/lib/config.mjs +83 -96
- package/memory-engine/lib/dirty-ledger.mjs +127 -94
- package/memory-engine/lib/distill-rollback.mjs +173 -133
- package/memory-engine/lib/distill.mjs +878 -585
- package/memory-engine/lib/doc-index-store.mjs +208 -235
- package/memory-engine/lib/doc-index.mjs +351 -350
- package/memory-engine/lib/doc-scan.mjs +178 -156
- package/memory-engine/lib/doc-summarize.mjs +220 -205
- package/memory-engine/lib/domain-tags.mjs +358 -277
- package/memory-engine/lib/embedder.mjs +175 -194
- package/memory-engine/lib/hashes.mjs +22 -7
- package/memory-engine/lib/layer-migrate.mjs +330 -0
- package/memory-engine/lib/layer-move.mjs +307 -0
- package/memory-engine/lib/layers.mjs +191 -0
- package/memory-engine/lib/lexicon-recall.mjs +473 -0
- package/memory-engine/lib/memory-source.mjs +78 -80
- package/memory-engine/lib/pitfall-source.mjs +115 -114
- package/memory-engine/lib/policy-source.mjs +279 -261
- package/memory-engine/lib/recall-log.mjs +285 -0
- package/memory-engine/lib/recall.mjs +565 -240
- package/memory-engine/lib/selfcheck.mjs +142 -106
- package/memory-engine/lib/session-distill.mjs +603 -0
- package/memory-engine/lib/session-source.mjs +421 -0
- package/memory-engine/lib/sweep.mjs +107 -130
- package/memory-engine/lib/sync.mjs +110 -140
- package/memory-engine/lib/tick-hook.mjs +123 -148
- package/memory-engine/lib/vectors-store.mjs +213 -188
- package/memory-engine/test/12-layering.test.mjs +539 -0
- package/memory-engine/test/13-session-distill.test.mjs +616 -0
- package/memory-engine/tools/bridge-smoke.mjs +94 -55
- package/memory-engine/tools/fetch-model.mjs +167 -87
- package/memory-engine/tools/smoke-recall.mjs +94 -38
- package/memory-engine/tools/verify-recall-chain.mjs +413 -0
- package/package.json +1 -1
|
@@ -1,160 +1,176 @@
|
|
|
1
|
-
//
|
|
1
|
+
// 会话日志文件名:认得哪几个世代、以及从名字里读出代际(零依赖的单一真源)。
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
-
// ①
|
|
14
|
-
//
|
|
15
|
-
//
|
|
7
|
+
// 三条规则(写死,别临场发挥):
|
|
8
|
+
// ① 读取侧一律走 isSessionFileName() / sessionFileGeneration():前缀 + 代际,认出任意 vN,
|
|
9
|
+
// 以后换代也不用回来改判据;
|
|
10
|
+
// ② 需要"第几代"就从文件名里提取(旧名 = 0,`session.vN...` = N),不要在调用处硬编码;
|
|
11
|
+
// ③ 写入侧真要落文件名时用 SESSION_FILE_NAME_CURRENT。
|
|
16
12
|
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
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
|
-
/**
|
|
23
|
+
/** 旧世代文件名(更早的版本;归档树里大量存在) */
|
|
40
24
|
export const SESSION_FILE_NAME_LEGACY = 'session.jsonl.zstd';
|
|
41
25
|
|
|
42
|
-
/** v3
|
|
26
|
+
/** 带 v3 的世代名(更早的一次换代;仍必须认得) */
|
|
43
27
|
export const SESSION_FILE_NAME_V3 = 'session.v3.jsonl.zstd';
|
|
44
28
|
|
|
45
|
-
/**
|
|
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
|
-
/**
|
|
38
|
+
/** 宽匹配模式:给 shell / 复制工具 / 目录枚举当过滤器用(读取侧) */
|
|
55
39
|
export const SESSION_FILE_GLOB = 'session*.jsonl.zstd';
|
|
56
40
|
|
|
57
41
|
/**
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
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
|
-
*
|
|
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
|
|
76
|
-
if (!
|
|
77
|
-
return
|
|
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
|
|
86
|
-
let
|
|
87
|
-
try {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
*
|
|
123
|
-
* @
|
|
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
|
|
127
|
-
return
|
|
127
|
+
const [best] = rankSessionFilesInDir(dir, opts);
|
|
128
|
+
return best ? best.file : null;
|
|
128
129
|
}
|
|
129
130
|
|
|
130
131
|
/**
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
* @
|
|
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
|
|
138
|
-
const
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
154
|
-
|
|
168
|
+
|
|
169
|
+
buckets.sort((left, right) => (left.dir < right.dir ? -1 : 1));
|
|
170
|
+
return buckets;
|
|
155
171
|
}
|
|
156
172
|
|
|
157
|
-
/**
|
|
173
|
+
/** 递归收集「每个目录当前那份」的绝对路径(collectSessionFilesByDir 的扁平形态) */
|
|
158
174
|
export function collectSessionFilesInTree(root, opts = {}) {
|
|
159
|
-
return collectSessionFilesByDir(root, opts).map((
|
|
175
|
+
return collectSessionFilesByDir(root, opts).map((bucket) => bucket.current);
|
|
160
176
|
}
|
package/lib/zstd-frames.mjs
CHANGED
|
@@ -1,78 +1,99 @@
|
|
|
1
|
-
//
|
|
1
|
+
// 多帧 zstd 日志的拆帧器(按帧结构解析,不靠搜魔数)。
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
3
|
+
// 会话日志是一个帧接一个帧追加出来的:一帧大约对应一次 flush 的那批事件。
|
|
4
|
+
// 想只取尾部若干帧、或者要逐帧解压,第一步就得把这一整块缓冲切成帧边界。
|
|
5
5
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// 遇**不完整帧**返回 `tornStart`(该帧起点),**不猜长度、也不把残字节当完整帧**。
|
|
10
|
-
// 对比"魔数搜索 + 游标推进"启发式的两个弱点:
|
|
11
|
-
// ① 压缩数据里**恰好出现 zstd magic 字节序列**时会把一帧切成两半(本件靠帧结构解析不会);
|
|
12
|
-
// ② 多份实现各自演化 ⇒ 一处修另一处不跟(2026-09-29 的「隔帧取样」缺陷族就是这么来的)。
|
|
6
|
+
// 做法:读到 magic → 读帧头描述符(里面塞着字典 id、内容长度的宽度)→ 一帧一帧读 block
|
|
7
|
+
// (block type 3 是保留值,见着就停)→ 有校验位就再跳 4 字节。任何一步拿不够字节就
|
|
8
|
+
// 判定"撕裂",把该帧起点交回去;**不猜长度,也不把半截字节凑成完整帧**。
|
|
13
9
|
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
31
|
-
* @param {Buffer} buffer
|
|
82
|
+
* 扫出一整块缓冲里的完整帧。
|
|
83
|
+
* @param {Buffer} buffer
|
|
32
84
|
* @returns {{frames: {start:number,end:number}[], tornStart: number|undefined}}
|
|
33
|
-
* frames ——
|
|
34
|
-
* tornStart ——
|
|
35
|
-
*
|
|
85
|
+
* frames —— 完整帧的 [start, end) 区间,顺序同文件内顺序;
|
|
86
|
+
* tornStart —— 第一处不完整帧的起点(没有则 undefined)。调用方遇到它就停:
|
|
87
|
+
* 撕裂的那帧整体丢弃,绝不能把残字节当完整帧去解压。
|
|
36
88
|
*/
|
|
37
89
|
export function scanZstdFrames(buffer) {
|
|
38
|
-
const frames = []
|
|
39
|
-
let
|
|
40
|
-
while (
|
|
41
|
-
const
|
|
42
|
-
if (
|
|
43
|
-
|
|
44
|
-
|
|
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` |
|
|
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 = "【设备:
|
|
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 },
|