@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
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// 召回留痕:每一轮召回的旁路 JSONL(谁问的、命中哪几条、多少分、是不是降级出来的)。
|
|
2
|
+
//
|
|
3
|
+
// 为什么要有它:召回质量是「事后才知道」的事 —— 没有一份可回放的记录,"这次查偏了"
|
|
4
|
+
// 就只能靠记忆猜。所以这里只做一件事:把每轮结果压成一行 JSON 追加到旁路文件,
|
|
5
|
+
// 与注入主路完全解耦(写失败绝不影响召回)。
|
|
6
|
+
//
|
|
7
|
+
// 三条边界:
|
|
8
|
+
// ① **不缝进主路**:主路只在拿到结果后调一次 `recordRecall()`;本模块抛错一律被吞。
|
|
9
|
+
// ② **不会长成黑洞**:字节上限触发轮转(保留 N 代)+ 天数上限清理旧代,两个闸同时管;
|
|
10
|
+
// `keepFiles: 0` 表示"只留当前文件,超限就清空重开"。
|
|
11
|
+
// ③ **默认不写**:库不该在纯查询里偷偷落盘(query 可能带隐私)。开关 = 显式给目录 /
|
|
12
|
+
// 环境变量 `DSH_MEMORY_RECALL_LOG_DIR`;`DSH_MEMORY_RECALL_LOG=off` 一键关回。
|
|
13
|
+
//
|
|
14
|
+
// 行格式(字段只增不改,方便下游按 `v` 兼容):
|
|
15
|
+
// { t, v:1, event:'recall', source, query, queryChars, triggered, hitCount,
|
|
16
|
+
// degraded:{mode,reason,topK}|null, encodeError:{name,ms}|null,
|
|
17
|
+
// lexicon:{enabled,status,hits}|null, timings:{encodeMs,lexiconMs,totalMs},
|
|
18
|
+
// hits:[{rank, id, refId, score, sources}] }
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
|
|
22
|
+
/** 出厂默认:单文件 512 KB、留 3 代、14 天,query 只留前 200 字,命中最多记 8 条。 */
|
|
23
|
+
export const RECALL_LOG_DEFAULTS = Object.freeze({
|
|
24
|
+
file: 'recall.jsonl',
|
|
25
|
+
maxBytes: 512 * 1024,
|
|
26
|
+
keepFiles: 3,
|
|
27
|
+
keepDays: 14,
|
|
28
|
+
maxQueryChars: 200,
|
|
29
|
+
maxHits: 8,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const ENV_DIR = 'DSH_MEMORY_RECALL_LOG_DIR';
|
|
33
|
+
const ENV_SWITCH = 'DSH_MEMORY_RECALL_LOG';
|
|
34
|
+
|
|
35
|
+
const OFF_WORDS = new Set(['0', 'off', 'false', 'no', 'disable', 'disabled']);
|
|
36
|
+
const ON_WORDS = new Set(['1', 'on', 'true', 'yes', 'enable', 'enabled']);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 开关:`DSH_MEMORY_RECALL_LOG` = on/off。返回 null 表示"没表态"(由有没有给目录决定)。
|
|
40
|
+
* @param {object} [env]
|
|
41
|
+
* @returns {boolean|null}
|
|
42
|
+
*/
|
|
43
|
+
export function recallLogSwitch(env = process.env) {
|
|
44
|
+
const raw = env ? env[ENV_SWITCH] : undefined;
|
|
45
|
+
if (raw === undefined || raw === null || raw === '') return null;
|
|
46
|
+
const text = String(raw).trim().toLowerCase();
|
|
47
|
+
if (OFF_WORDS.has(text)) return false;
|
|
48
|
+
if (ON_WORDS.has(text)) return true;
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 日志目录:显式入参 > 环境变量 `DSH_MEMORY_RECALL_LOG_DIR`;都没有 ⇒ null(不启用)。 */
|
|
53
|
+
export function resolveRecallLogDir({ dir = null, env = process.env } = {}) {
|
|
54
|
+
if (dir) return path.resolve(String(dir));
|
|
55
|
+
const fromEnv = env ? env[ENV_DIR] : null;
|
|
56
|
+
return fromEnv ? path.resolve(String(fromEnv)) : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const clip = (value, limit) => {
|
|
60
|
+
const text = String(value ?? '');
|
|
61
|
+
if (!limit || limit <= 0 || text.length <= limit) return text;
|
|
62
|
+
return [...text].slice(0, limit).join('');
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const roundTo = (value, digits = 3) => {
|
|
66
|
+
const parsed = Number(value);
|
|
67
|
+
if (!Number.isFinite(parsed)) return null;
|
|
68
|
+
const scale = 10 ** digits;
|
|
69
|
+
return Math.round(parsed * scale) / scale;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 召回结果 → 一行记录(只留判读需要的字段;记忆正文一概不记)。
|
|
74
|
+
* @param {object} result `recall()` / `recallMulti()` 的返回值
|
|
75
|
+
* @param {{maxQueryChars?:number, maxHits?:number}} [options]
|
|
76
|
+
*/
|
|
77
|
+
export function buildRecallRecord(result, options = {}) {
|
|
78
|
+
const maxQueryChars = options.maxQueryChars ?? RECALL_LOG_DEFAULTS.maxQueryChars;
|
|
79
|
+
const maxHits = options.maxHits ?? RECALL_LOG_DEFAULTS.maxHits;
|
|
80
|
+
const hits = Array.isArray(result?.hits) ? result.hits : [];
|
|
81
|
+
const stats = result?.stats || {};
|
|
82
|
+
const timings = result?.timings || {};
|
|
83
|
+
const kept = maxHits > 0 ? hits.slice(0, maxHits) : hits;
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
v: 1,
|
|
87
|
+
event: 'recall',
|
|
88
|
+
source: stats.sourceName || (Array.isArray(stats.sources) ? stats.sources.join(',') : null),
|
|
89
|
+
query: clip(result?.query, maxQueryChars),
|
|
90
|
+
queryChars: String(result?.query ?? '').length,
|
|
91
|
+
triggered: Boolean(result?.triggered),
|
|
92
|
+
reason: result?.reason ?? null,
|
|
93
|
+
hitCount: hits.length,
|
|
94
|
+
degraded: result?.degraded ?? null,
|
|
95
|
+
encodeError: result?.encodeError ?? null,
|
|
96
|
+
lexicon: stats.lexiconEnabled === undefined
|
|
97
|
+
? null
|
|
98
|
+
: { enabled: Boolean(stats.lexiconEnabled), status: stats.lexiconStatus ?? null, hits: stats.lexiconHits ?? 0 },
|
|
99
|
+
timings: {
|
|
100
|
+
encodeMs: roundTo(timings.encodeMs),
|
|
101
|
+
lexiconMs: roundTo(timings.lexiconMs),
|
|
102
|
+
totalMs: roundTo(timings.totalMs),
|
|
103
|
+
},
|
|
104
|
+
hits: kept.map((hit, at) => ({
|
|
105
|
+
rank: at + 1,
|
|
106
|
+
id: hit?.id ?? null,
|
|
107
|
+
refId: hit?.refId ?? null,
|
|
108
|
+
score: roundTo(hit?.score, 6),
|
|
109
|
+
sources: Array.isArray(hit?.sources) ? hit.sources : [],
|
|
110
|
+
})),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 写一条记录。logger 为 null / 已关 ⇒ 空操作(返回 false),**永不抛错**。
|
|
116
|
+
* @param {{record?:Function}|null} logger
|
|
117
|
+
* @param {object} result
|
|
118
|
+
* @param {object} [options]
|
|
119
|
+
* @returns {boolean}
|
|
120
|
+
*/
|
|
121
|
+
export function recordRecall(logger, result, options = {}) {
|
|
122
|
+
if (!logger || typeof logger.record !== 'function') return false;
|
|
123
|
+
try {
|
|
124
|
+
return logger.record(buildRecallRecord(result, options));
|
|
125
|
+
} catch {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 文件名里不许出现目录分隔符:轮转后代由它派生,别给外部路径留口子。
|
|
131
|
+
const assertPlainName = (name) => {
|
|
132
|
+
if (!name || name.includes('/') || name.includes('\\') || name === '.' || name === '..') {
|
|
133
|
+
throw new Error(`recall-log: 文件名不能带路径(收到 ${JSON.stringify(name)})`);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 旁路日志器:单文件追加 + 按字节轮转 + 按天数清旧代。
|
|
139
|
+
*
|
|
140
|
+
* 目录必须先给(没给就是编程错误,当场抛);一旦建好,所有写操作都吞错。
|
|
141
|
+
* @param {{dir:string, file?:string, maxBytes?:number, keepFiles?:number, keepDays?:number,
|
|
142
|
+
* maxQueryChars?:number, maxHits?:number, enabled?:boolean, now?:Function,
|
|
143
|
+
* fsImpl?:object, echo?:Function|null}} config
|
|
144
|
+
*/
|
|
145
|
+
export function createRecallLog(config = {}) {
|
|
146
|
+
const dir = config.dir ? path.resolve(String(config.dir)) : '';
|
|
147
|
+
if (!dir) throw new Error('recall-log: 需要 dir(日志目录)');
|
|
148
|
+
const file = String(config.file || RECALL_LOG_DEFAULTS.file);
|
|
149
|
+
assertPlainName(file);
|
|
150
|
+
const fsImpl = config.fsImpl || fs;
|
|
151
|
+
const now = config.now || Date.now;
|
|
152
|
+
const enabled = config.enabled !== false;
|
|
153
|
+
const maxBytes = Math.max(0, Number(config.maxBytes ?? RECALL_LOG_DEFAULTS.maxBytes));
|
|
154
|
+
const keepFiles = Math.max(0, Math.trunc(Number(config.keepFiles ?? RECALL_LOG_DEFAULTS.keepFiles)));
|
|
155
|
+
const keepDays = Math.max(0, Number(config.keepDays ?? RECALL_LOG_DEFAULTS.keepDays));
|
|
156
|
+
const recordOptions = {
|
|
157
|
+
maxQueryChars: config.maxQueryChars ?? RECALL_LOG_DEFAULTS.maxQueryChars,
|
|
158
|
+
maxHits: config.maxHits ?? RECALL_LOG_DEFAULTS.maxHits,
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const active = path.join(dir, file);
|
|
162
|
+
const ext = path.extname(file) || '.jsonl';
|
|
163
|
+
const base = path.basename(file, ext);
|
|
164
|
+
const generationPath = (n) => path.join(dir, `${base}.${n}${ext}`);
|
|
165
|
+
const generationPattern = new RegExp(
|
|
166
|
+
`^${base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\.(\\d+)${ext.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`,
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
const stats = { written: 0, rotations: 0, pruned: 0, errors: 0 };
|
|
170
|
+
const sizeOf = (target) => {
|
|
171
|
+
try {
|
|
172
|
+
return fsImpl.statSync(target).size;
|
|
173
|
+
} catch {
|
|
174
|
+
return 0;
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// 只清理本目录下、由本文件派生的轮转代(不接受任何外部路径)
|
|
179
|
+
const prune = () => {
|
|
180
|
+
let names = [];
|
|
181
|
+
try {
|
|
182
|
+
names = fsImpl.readdirSync(dir).filter((name) => generationPattern.test(name));
|
|
183
|
+
} catch {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const cutoff = keepDays > 0 ? now() - keepDays * 86400000 : 0;
|
|
187
|
+
for (const name of names) {
|
|
188
|
+
const target = path.join(dir, name);
|
|
189
|
+
const generation = Number(generationPattern.exec(name)[1]);
|
|
190
|
+
const tooMany = keepFiles > 0 && generation > keepFiles;
|
|
191
|
+
let tooOld = false;
|
|
192
|
+
if (cutoff > 0) {
|
|
193
|
+
try {
|
|
194
|
+
tooOld = fsImpl.statSync(target).mtimeMs < cutoff;
|
|
195
|
+
} catch {
|
|
196
|
+
tooOld = false;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (!tooMany && !tooOld) continue;
|
|
200
|
+
try {
|
|
201
|
+
fsImpl.rmSync(target, { force: true });
|
|
202
|
+
stats.pruned += 1;
|
|
203
|
+
} catch {
|
|
204
|
+
/* 删不掉不算错:下一轮轮转还会再试 */
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const rotate = () => {
|
|
210
|
+
stats.rotations += 1;
|
|
211
|
+
if (keepFiles === 0) {
|
|
212
|
+
// 只留当前文件:超限就把旧内容丢掉重开(不留代)
|
|
213
|
+
try {
|
|
214
|
+
fsImpl.rmSync(active, { force: true });
|
|
215
|
+
} catch {
|
|
216
|
+
stats.errors += 1;
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
// 逐代右移:active → .1 → .2 …,最老的一代被覆盖
|
|
222
|
+
for (let n = keepFiles; n >= 1; n -= 1) {
|
|
223
|
+
const from = n === 1 ? active : generationPath(n - 1);
|
|
224
|
+
const to = generationPath(n);
|
|
225
|
+
const size = sizeOf(from);
|
|
226
|
+
if (size <= 0) continue;
|
|
227
|
+
try {
|
|
228
|
+
fsImpl.rmSync(to, { force: true });
|
|
229
|
+
fsImpl.renameSync(from, to);
|
|
230
|
+
} catch {
|
|
231
|
+
stats.errors += 1;
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
prune();
|
|
236
|
+
return true;
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const record = (payload) => {
|
|
240
|
+
if (!enabled) return false;
|
|
241
|
+
try {
|
|
242
|
+
const line = `${JSON.stringify({ t: new Date(now()).toISOString(), ...payload })}\n`;
|
|
243
|
+
fsImpl.mkdirSync(dir, { recursive: true });
|
|
244
|
+
if (maxBytes > 0 && sizeOf(active) + line.length > maxBytes) rotate();
|
|
245
|
+
fsImpl.appendFileSync(active, line, 'utf8');
|
|
246
|
+
stats.written += 1;
|
|
247
|
+
return true;
|
|
248
|
+
} catch {
|
|
249
|
+
stats.errors += 1;
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
enabled,
|
|
256
|
+
dir,
|
|
257
|
+
file: active,
|
|
258
|
+
record,
|
|
259
|
+
rotate,
|
|
260
|
+
prune,
|
|
261
|
+
recordOptions,
|
|
262
|
+
stats: () => ({ ...stats, bytes: sizeOf(active) }),
|
|
263
|
+
close: () => {},
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* 按「显式目录 / 环境变量」开一个日志器;没配置就返回 null(= 不写)。
|
|
269
|
+
* 开关 `DSH_MEMORY_RECALL_LOG=off` 时即使配了目录也不写。
|
|
270
|
+
* @param {{dir?:string|null, enabled?:boolean|null, env?:object}} [options]
|
|
271
|
+
*/
|
|
272
|
+
export function openRecallLog(options = {}) {
|
|
273
|
+
const env = options.env || process.env;
|
|
274
|
+
const wanted = options.enabled === undefined || options.enabled === null
|
|
275
|
+
? recallLogSwitch(env)
|
|
276
|
+
: options.enabled;
|
|
277
|
+
if (wanted === false) return null;
|
|
278
|
+
const dir = resolveRecallLogDir({ dir: options.dir, env });
|
|
279
|
+
if (!dir) return null;
|
|
280
|
+
try {
|
|
281
|
+
return createRecallLog({ ...options, dir });
|
|
282
|
+
} catch {
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
}
|