@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,242 +1,223 @@
1
- // 记忆引擎 v2 · 离线层 —— 编码器(可注入)
2
- //
3
- // 三种实现,接口一致(encode / encodeBatch / health / describe):
4
- // 1. createLocalEmbedder —— 本地 ONNX(@huggingface/transformers + bge-small-zh-v1.5)
5
- // 2. createHttpEmbedder —— 走回环编码桥(serve.mjs,127.0.0.1:8201)
6
- // 3. createStubEmbedder —— 零依赖确定性哈希编码器:**仅供测试骨架**,无任何语义质量
7
- //
8
- // 设计要点:同步/召回核心一律依赖本接口,不直接 import 模型运行时
9
- // ⇒ 模型拉不动时,骨架与单测仍可完整跑通(本卡 §二.6 的退路)。
10
-
11
- import crypto from 'node:crypto';
1
+ // 编码器层:同一个接口的三种实现,调用方只认 encode / encodeBatch / health / describe。
2
+ // · stub —— 确定性哈希,零依赖,只给骨架与单测用(没有半点语义质量)
3
+ // · http —— 回环编码桥的客户端(超时默认 250ms)
4
+ // · local —— 本地 ONNX 推理(bge 小模型,CPU 上跑)
5
+ // 模型运行时只在 local 里动态 import ⇒ 权重拉不下来时,其余实现与测试照样能跑。
6
+
7
+ import { createHash } from 'node:crypto';
12
8
  import fs from 'node:fs';
13
9
  import path from 'node:path';
14
10
 
15
- /** 确定性 stub:char n-gram 哈希到 dim 维 + L2 归一化(测试用,禁止用于生产召回) */
16
- export function createStubEmbedder({ dim = 512, modelId = 'stub-ngram', sleepMs = 0 } = {}) {
11
+ const STUB_MAX_N = 3;
12
+ const HEALTH_TIMEOUT_MS = 2000;
13
+ const BATCH_TIMEOUT_FLOOR_MS = 30000;
14
+ const LOCAL_PROBE_TEXT = '维度自检';
15
+
16
+ const errorText = (e) => (e && e.message ? e.message : String(e));
17
+ const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
18
+
19
+ // 原地单位化;零向量按长度 1 处理,免得除零把整条变成 NaN。
20
+ function unitize(vec) {
21
+ let squareSum = 0;
22
+ for (let i = 0; i < vec.length; i += 1) squareSum += vec[i] * vec[i];
23
+ const length = Math.sqrt(squareSum) || 1;
24
+ for (let i = 0; i < vec.length; i += 1) vec[i] /= length;
25
+ return vec;
26
+ }
27
+
28
+ // 连着的 n 元切片,n = 1..maxN;先统一小写再切。
29
+ function ngramsOf(text, maxN) {
30
+ const body = text.toLowerCase();
31
+ const grams = [];
32
+ for (let n = 1; n <= maxN; n += 1) {
33
+ for (let at = 0; at + n <= body.length; at += 1) grams.push(body.slice(at, at + n));
34
+ }
35
+ return grams;
36
+ }
37
+
38
+ /** 测试骨架用的确定性编码器:每个 n 元算一次 md5,头两字节定槽位、第三字节定正负。 */
39
+ export function createStubEmbedder({ dim = 512, sleepMs = 0, modelId = 'stub-ngram' } = {}) {
17
40
  let encodeCount = 0;
18
- const encodeOne = (text) => {
19
- const v = new Float32Array(dim);
20
- const s = String(text ?? '').toLowerCase();
21
- const grams = [];
22
- for (let n = 1; n <= 3; n += 1) {
23
- for (let i = 0; i + n <= s.length; i += 1) grams.push(s.slice(i, i + n));
24
- }
25
- for (const g of grams) {
26
- const h = crypto.createHash('md5').update(g).digest();
27
- const idx = ((h[0] << 8) | h[1]) % dim;
28
- const sign = h[2] % 2 === 0 ? 1 : -1;
29
- v[idx] += sign;
41
+
42
+ const hashToVector = (text) => {
43
+ const vec = new Float32Array(dim);
44
+ for (const gram of ngramsOf(String(text ?? ''), STUB_MAX_N)) {
45
+ const digest = createHash('md5').update(gram).digest();
46
+ const slot = ((digest[0] << 8) | digest[1]) % dim;
47
+ vec[slot] += digest[2] % 2 === 0 ? 1 : -1;
30
48
  }
31
- let norm = 0;
32
- for (let i = 0; i < dim; i += 1) norm += v[i] * v[i];
33
- norm = Math.sqrt(norm) || 1;
34
- for (let i = 0; i < dim; i += 1) v[i] /= norm;
35
- return v;
49
+ return unitize(vec);
36
50
  };
51
+
52
+ // 每次编码都过这里:模拟耗时 + 计数(两者都是测试要观测的)
53
+ const encodeOnce = async (text) => {
54
+ if (sleepMs) await sleep(sleepMs);
55
+ encodeCount = encodeCount + 1;
56
+ return hashToVector(text);
57
+ };
58
+
37
59
  return {
38
- kind: 'stub',
39
- modelId,
40
- dim,
41
- describe: () => ({ kind: 'stub', modelId, dim, note: '确定性哈希编码器,仅用于骨架自测' }),
42
- async health() {
43
- return { ready: true, modelLoading: false, model: modelId, dim };
44
- },
45
- async encode(text) {
46
- if (sleepMs) await new Promise((r) => setTimeout(r, sleepMs));
47
- encodeCount += 1;
48
- return encodeOne(text);
60
+ kind: 'stub', modelId, dim,
61
+ describe: () => ({
62
+ kind: 'stub',
63
+ modelId,
64
+ dim,
65
+ note: '确定性哈希编码器,仅用于骨架自测',
66
+ }),
67
+ health: async () => ({ ready: true, modelLoading: false, model: modelId, dim }),
68
+ encode: (text) => encodeOnce(text),
69
+ encodeBatch: async (texts) => {
70
+ const vectors = [];
71
+ for (const text of texts) vectors.push(await encodeOnce(text));
72
+ return vectors;
49
73
  },
50
- async encodeBatch(texts) {
51
- const out = [];
52
- for (const t of texts) {
53
- if (sleepMs) await new Promise((r) => setTimeout(r, sleepMs));
54
- encodeCount += 1;
55
- out.push(encodeOne(t));
56
- }
57
- return out;
74
+ stats() {
75
+ return { encodeCount };
58
76
  },
59
- stats: () => ({ encodeCount }),
60
77
  };
61
78
  }
62
79
 
63
- /** 回环编码桥客户端(插件侧 / CLI 侧共用;方案 §3.1 编码通道契约:超时 250ms) */
64
- export function createHttpEmbedder({
65
- host = '127.0.0.1',
66
- port = 8201,
67
- timeoutMs = 250,
68
- modelId = null,
69
- dim = null,
70
- } = {}) {
80
+ /** 回环编码桥的客户端:只发 JSON,一切都带超时,桥不在就如实报错而不是挂着。 */
81
+ export function createHttpEmbedder({ host = '127.0.0.1', port = 8201, timeoutMs = 250, modelId = null, dim = null } = {}) {
71
82
  const base = `http://${host}:${port}`;
72
- let healthCache = null;
73
- const fetchJson = async (url, init = {}, ms = timeoutMs) => {
74
- const ac = new AbortController();
75
- const t = setTimeout(() => ac.abort(), ms);
83
+ let lastHealth = null;
84
+
85
+ // payload 为 null ⇒ 走 GET;否则按 JSON POST 发出去。
86
+ const callJson = async (url, payload, ms) => {
87
+ const controller = new AbortController();
88
+ const timer = setTimeout(() => controller.abort(), ms);
76
89
  try {
77
- const res = await fetch(url, { ...init, signal: ac.signal });
90
+ const init = payload
91
+ ? {
92
+ method: 'POST',
93
+ headers: { 'content-type': 'application/json' },
94
+ body: JSON.stringify(payload),
95
+ signal: controller.signal,
96
+ }
97
+ : { signal: controller.signal };
98
+ const res = await fetch(url, init);
78
99
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
79
100
  return await res.json();
80
101
  } finally {
81
- clearTimeout(t);
102
+ clearTimeout(timer);
82
103
  }
83
104
  };
105
+
84
106
  return {
85
107
  kind: 'http',
86
- get modelId() {
87
- return modelId || (healthCache && healthCache.model) || 'unknown';
88
- },
89
- get dim() {
90
- return dim || (healthCache && healthCache.dim) || 0;
91
- },
108
+ // 显式给了就用手上的;没给就跟着最近一次探活的结果走(读那一瞬才算)
109
+ get modelId() { return modelId || (lastHealth && lastHealth.model) || 'unknown'; },
110
+ get dim() { return dim || (lastHealth && lastHealth.dim) || 0; },
92
111
  describe: () => ({ kind: 'http', base, modelId, dim, timeoutMs }),
93
- async health() {
112
+ health: async () => {
94
113
  try {
95
- healthCache = await fetchJson(`${base}/health`, {}, 2000);
96
- return { ...healthCache, bridge: base };
114
+ lastHealth = await callJson(`${base}/health`, null, HEALTH_TIMEOUT_MS);
115
+ return { ...lastHealth, bridge: base };
97
116
  } catch (e) {
98
- return { ready: false, modelLoading: false, error: String(e && e.message ? e.message : e), bridge: base };
117
+ return { ready: false, modelLoading: false, error: errorText(e), bridge: base };
99
118
  }
100
119
  },
101
- async encode(text) {
102
- const out = await fetchJson(
103
- `${base}/encode`,
104
- {
105
- method: 'POST',
106
- headers: { 'content-type': 'application/json' },
107
- body: JSON.stringify({ texts: [String(text ?? '')] }),
108
- },
109
- timeoutMs,
110
- );
111
- return Float32Array.from(out.vectors[0]);
120
+ encode: async (text) => {
121
+ const answer = await callJson(`${base}/encode`, { texts: [String(text ?? '')] }, timeoutMs);
122
+ return Float32Array.from(answer.vectors[0]);
112
123
  },
113
- async encodeBatch(texts) {
114
- if (!texts.length) return [];
115
- const out = await fetchJson(
124
+ encodeBatch: async (texts) => {
125
+ if (!texts.length) {
126
+ return [];
127
+ }
128
+ const answer = await callJson(
116
129
  `${base}/encode`,
117
- {
118
- method: 'POST',
119
- headers: { 'content-type': 'application/json' },
120
- body: JSON.stringify({ texts: texts.map((t) => String(t ?? '')) }),
121
- },
122
- Math.max(timeoutMs, 30000),
130
+ { texts: texts.map((text) => String(text ?? '')) },
131
+ Math.max(timeoutMs, BATCH_TIMEOUT_FLOOR_MS),
123
132
  );
124
- return out.vectors.map((v) => Float32Array.from(v));
133
+ return answer.vectors.map((row) => Float32Array.from(row));
134
+ },
135
+ stats() {
136
+ return { encodeCount: -1 };
125
137
  },
126
- stats: () => ({ encodeCount: -1 }),
127
138
  };
128
139
  }
129
140
 
141
+ // 权重目录有两种摆法:要么 config.json 就在这一层,要么下面还套一层以模型名命名的子目录。
142
+ // 推理库要的是"根目录 + 子目录名",所以先探一下再决定怎么喂。
143
+ function resolveWeightLayout(modelDir, modelId) {
144
+ if (!fs.existsSync(path.join(modelDir, 'config.json'))) {
145
+ return { root: modelDir, id: modelId };
146
+ }
147
+ return { root: path.dirname(modelDir), id: path.basename(modelDir) };
148
+ }
149
+
130
150
  /**
131
- * 本地 ONNX 编码器(bge-small-zh-v1.5,512 维,CPU)。
132
- * 动态 import 模型运行时 ⇒ 未安装依赖时本模块仍可被 import(骨架/单测不受影响)。
151
+ * 本地 ONNX 编码器:默认取 cls 位做向量,也可以按注意力掩码做均值池化。
152
+ * 维度以**实测**为准(模型真吐几维就是几维),配置里那个数只作比对。
133
153
  */
134
- export async function createLocalEmbedder({
135
- modelDir,
136
- modelId = 'bge-small-zh-v1.5',
137
- dim = 512,
138
- quantized = false,
139
- dtype = null,
140
- maxLength = 512,
141
- pooling = 'cls',
142
- queryPrefix = '',
143
- } = {}) {
144
- const mod = await import('@huggingface/transformers');
145
- const { env, AutoTokenizer, AutoModel } = mod;
146
- // 布局自适应:transformers.js 的 env.localModelPath 是「模型根目录」(其下按 modelId 建子目录),
147
- // 而本项目的 modelDir 语义是「直接放 config.json 的权重目录」⇒ 两种布局都支持。
148
- let localRoot = modelDir;
149
- let localId = modelId;
150
- if (fs.existsSync(path.join(modelDir, 'config.json'))) {
151
- localRoot = path.dirname(modelDir);
152
- localId = path.basename(modelDir);
153
- }
154
- env.allowRemoteModels = false;
155
- env.allowLocalModels = true;
156
- env.localModelPath = localRoot;
157
- env.useBrowserCache = false;
158
-
159
- const loadOpts = dtype
160
- ? { dtype }
161
- : quantized
162
- ? { dtype: 'q8' }
163
- : {};
164
- const tokenizer = await AutoTokenizer.from_pretrained(localId, { local_files_only: true });
165
- const model = await AutoModel.from_pretrained(localId, { local_files_only: true, ...loadOpts });
166
-
167
- const encodeOne = async (text) => {
168
- const input = String(text ?? '');
169
- const inputs = await tokenizer(input, { padding: true, truncation: true, max_length: maxLength });
170
- const out = await model(inputs);
171
- const hidden = out.last_hidden_state ?? out[Object.keys(out)[0]];
172
- const [, seq, d] = hidden.dims;
173
- const data = hidden.data;
174
- const v = new Float32Array(d);
154
+ export async function createLocalEmbedder({ modelDir, modelId = 'bge-small-zh-v1.5', dim = 512, quantized = false, dtype = null, maxLength = 512, pooling = 'cls', queryPrefix = '' } = {}) {
155
+ const runtime = await import('@huggingface/transformers');
156
+ const { env, AutoTokenizer, AutoModel } = runtime;
157
+ const layout = resolveWeightLayout(modelDir, modelId);
158
+
159
+ // 全离线:只认本地目录,不碰远端、不占浏览器缓存
160
+ Object.assign(env, {
161
+ allowRemoteModels: false,
162
+ allowLocalModels: true,
163
+ localModelPath: layout.root,
164
+ useBrowserCache: false,
165
+ });
166
+
167
+ const loadOptions = { local_files_only: true };
168
+ if (dtype) loadOptions.dtype = dtype;
169
+ else if (quantized) loadOptions.dtype = 'q8';
170
+ const tokenizer = await AutoTokenizer.from_pretrained(layout.id, { local_files_only: true });
171
+ const model = await AutoModel.from_pretrained(layout.id, loadOptions);
172
+
173
+ const embedOnce = async (text) => {
174
+ const inputs = await tokenizer(String(text ?? ''), {
175
+ padding: true,
176
+ truncation: true,
177
+ max_length: maxLength,
178
+ });
179
+ const output = await model(inputs);
180
+ const hidden = output.last_hidden_state ?? output[Object.keys(output)[0]];
181
+ const [, tokenCount, width] = hidden.dims;
182
+ const vec = new Float32Array(width);
175
183
  if (pooling === 'cls') {
176
- for (let i = 0; i < d; i += 1) v[i] = data[i];
177
- } else {
178
- // mean pooling(按 attention_mask 加权)
179
- const mask = inputs.attention_mask?.data;
180
- let n = 0;
181
- for (let t = 0; t < seq; t += 1) {
182
- const m = mask ? Number(mask[t]) : 1;
183
- if (!m) continue;
184
- n += 1;
185
- for (let i = 0; i < d; i += 1) v[i] += data[t * d + i];
186
- }
187
- n = n || 1;
188
- for (let i = 0; i < d; i += 1) v[i] /= n;
184
+ vec.set(hidden.data.subarray(0, width));
185
+ return unitize(vec);
186
+ }
187
+ // 均值池化:被掩码盖掉的位不参与平均
188
+ const mask = inputs.attention_mask?.data;
189
+ let kept = 0;
190
+ for (let token = 0; token < tokenCount; token += 1) {
191
+ if (mask && !Number(mask[token])) continue;
192
+ kept += 1;
193
+ for (let i = 0; i < width; i += 1) vec[i] += hidden.data[token * width + i];
189
194
  }
190
- let norm = 0;
191
- for (let i = 0; i < d; i += 1) norm += v[i] * v[i];
192
- norm = Math.sqrt(norm) || 1;
193
- for (let i = 0; i < d; i += 1) v[i] /= norm;
194
- return v;
195
+ const divisor = kept || 1;
196
+ for (let i = 0; i < width; i += 1) vec[i] /= divisor;
197
+ return unitize(vec);
195
198
  };
196
199
 
197
- const actualDim = await (async () => {
198
- const probe = await encodeOne('维度自检');
199
- return probe.length;
200
- })();
200
+ // 用一句固定探针量真维度(此时不加查询前缀)
201
+ const measuredDim = (await embedOnce(LOCAL_PROBE_TEXT)).length;
201
202
 
202
203
  return {
203
- kind: 'local',
204
- modelId,
205
- dim: actualDim,
206
- configuredDim: dim,
207
- describe: () => ({
208
- kind: 'local',
209
- modelId,
210
- modelDir,
211
- dim: actualDim,
212
- pooling,
213
- quantized,
214
- dtype: dtype || (quantized ? 'q8' : 'fp32'),
215
- queryPrefix,
216
- }),
217
- async health() {
218
- return {
219
- ready: true,
220
- modelLoading: false,
221
- model: modelId,
222
- dim: actualDim,
223
- modelDir,
224
- };
225
- },
226
- async encode(text) {
227
- return encodeOne(String(queryPrefix || '') + String(text ?? ''));
204
+ kind: 'local', modelId, dim: measuredDim, configuredDim: dim,
205
+ describe: () => ({ kind: 'local', modelId, modelDir, dim: measuredDim, pooling, quantized, dtype: dtype || (quantized ? 'q8' : 'fp32'), queryPrefix }),
206
+ health: async () => ({ ready: true, modelLoading: false, model: modelId, dim: measuredDim, modelDir }),
207
+ encode: (text) => embedOnce(String(queryPrefix || '') + String(text ?? '')),
208
+ encodeBatch: async (texts) => {
209
+ const vectors = [];
210
+ for (const text of texts) vectors.push(await embedOnce(text));
211
+ return vectors;
228
212
  },
229
- async encodeBatch(texts) {
230
- const out = [];
231
- for (const t of texts) out.push(await encodeOne(t));
232
- return out;
213
+ stats() {
214
+ return { encodeCount: -1 };
233
215
  },
234
- stats: () => ({ encodeCount: -1 }),
235
216
  };
236
217
  }
237
218
 
238
- /** 按配置挑一个可用编码器(桥优先 → 本地 → 报错) */
219
+ /** 按偏好挑一个:要 http 就走桥,其余一律走本地。 */
239
220
  export async function createAutoEmbedder({ prefer = 'local', ...opts } = {}) {
240
- if (prefer === 'http') return createHttpEmbedder(opts);
241
- return createLocalEmbedder(opts);
221
+ const build = prefer === 'http' ? createHttpEmbedder : createLocalEmbedder;
222
+ return build(opts);
242
223
  }
@@ -1,12 +1,27 @@
1
- // 记忆引擎 v2 · 离线层 —— 哈希工具
2
- import crypto from 'node:crypto';
1
+ // 正文与标识的摘要工具。
2
+ //
3
+ // 全仓只用一种算法:sha256 的十六进制形态。选它的理由是**跨机可复算** ——
4
+ // 日志、台账、去重键都拿这个值对齐,换机器重算必须得到同一串。
5
+ import { createHash } from 'node:crypto';
3
6
 
4
- /** 记忆正文哈希(方案 §3.1:text_hash = sha256(text)) */
7
+ const ALGORITHM = 'sha256';
8
+ const SHORT_LEN = 12;
9
+
10
+ /**
11
+ * 正文指纹。
12
+ * 入参先归一到字符串(null / undefined 折成空串),
13
+ * 免得同一份正文因为传参形态不同算出两个哈希。
14
+ */
5
15
  export function textHash(text) {
6
- return crypto.createHash('sha256').update(String(text ?? ''), 'utf8').digest('hex');
16
+ const source = text == null ? '' : String(text);
17
+ return createHash(ALGORITHM).update(source, 'utf8').digest('hex');
7
18
  }
8
19
 
9
- /** 稳定摘要(日志/报告用) */
10
- export function shortHash(h, n = 12) {
11
- return String(h || '').slice(0, n);
20
+ /**
21
+ * 截短显示(日志与报告里用)。
22
+ * 哈希本身是 ASCII 十六进制,按码元切与按码点切结果相同,这里沿用 String#slice。
23
+ */
24
+ export function shortHash(h, n = SHORT_LEN) {
25
+ const value = h || '';
26
+ return String(value).slice(0, n);
12
27
  }