@modusensus/dsh-mneme 0.8.5 → 0.8.7
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/README.md +45 -147
- package/bin/cli.mjs +689 -603
- package/lib/api-standalone.js +57 -1
- package/lib/api.js +34 -11
- package/lib/client.js +289 -59
- package/lib/config.js +78 -11
- package/lib/content-hash.js +54 -0
- package/lib/document-index.js +94 -0
- package/lib/document.js +93 -11
- package/lib/dream/sleep.js +122 -8
- package/lib/dream.js +1354 -1347
- package/lib/index.js +115 -14
- package/lib/inject.js +6 -2
- package/lib/lang.js +15 -4
- package/lib/local-embedder.js +44 -6
- package/lib/maintenance.js +142 -0
- package/lib/mirror.js +147 -15
- package/lib/recall-stats.js +52 -2
- package/lib/scope.js +34 -6
- package/lib/service.js +86 -7
- package/lib/settings.js +6 -3
- package/lib/store.js +248 -21
- package/lib/tools.js +27 -9
- package/lib/write-admission.js +259 -0
- package/package.json +4 -4
- package/src/api-standalone.js +57 -1
- package/src/api.js +34 -11
- package/src/config.js +78 -11
- package/src/content-hash.js +54 -0
- package/src/document-index.js +94 -0
- package/src/document.js +93 -11
- package/src/dream/sleep.js +122 -8
- package/src/dream.js +1354 -1347
- package/src/index.js +115 -14
- package/src/inject.js +6 -2
- package/src/lang.js +15 -4
- package/src/local-embedder.js +44 -6
- package/src/maintenance.js +142 -0
- package/src/mirror.js +147 -15
- package/src/recall-stats.js +52 -2
- package/src/scope.js +34 -6
- package/src/service.js +86 -7
- package/src/settings.js +6 -3
- package/src/store.js +248 -21
- package/src/tools.js +27 -9
- package/src/write-admission.js +259 -0
- package/test/api.test.js +15 -2
- package/test/client.test.js +90 -5
- package/test/content-hash.test.js +204 -0
- package/test/document-dir.test.js +233 -0
- package/test/document.test.js +151 -3
- package/test/inject-parent-gate.test.js +106 -0
- package/test/inject-pools.test.js +8 -3
- package/test/lastrunat-persist.test.js +101 -0
- package/test/llm-audit.test.js +136 -0
- package/test/local-embedder.test.js +45 -2
- package/test/maintenance.test.js +391 -0
- package/test/mirror-types.test.js +208 -0
- package/test/recall-evals.test.js +18 -0
- package/test/recall-stats.test.js +66 -0
- package/test/sleep.test.js +117 -0
- package/test/write-admission.test.js +345 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// #254 写入准入(第一阶段:只计量,不拦截)。
|
|
2
|
+
//
|
|
3
|
+
// 为什么先只计量:会话写入预算的 N 与同话题冷却的 X 在没有真实分布时拍任何数字
|
|
4
|
+
// 都是误杀面(维护者 2026-09-23 拍板「先记录不拦,由遥测定」)。所以这一阶段只把
|
|
5
|
+
// 两个闸门的原始测量点写进 llm_audit_logs(status='skipped'、metadata.gate='g1'
|
|
6
|
+
// 且同话题重复时附 metadata.g2),不做判定、不改任何写入行为;第二阶段把拦截打开
|
|
7
|
+
// 时才引入阈值与二次确认(memory_save({confirm:true}))。
|
|
8
|
+
//
|
|
9
|
+
// 为什么复用 llm_audit_logs:它是仓库既有的「后台动作回执」表(bookkeeping,不
|
|
10
|
+
// 触发写钩子,见 store.js 的表注释)。第二阶段落地时「第一次 skipped/gate →
|
|
11
|
+
// 第二次 confirm:true 放行」在审计里成对出现,因果可回放。
|
|
12
|
+
//
|
|
13
|
+
// 一行一个新建行(不是一行一个信号):这样「会话内新建条数」就是按 session_key
|
|
14
|
+
// 的直接计数,不需要任何 JSON 解析;g2 命中时作为该行的 metadata.g2 附带,不做第
|
|
15
|
+
// 二行。测量点只取在新行上——并入已有行是去重机制在正常工作,既不进预算也不记行。
|
|
16
|
+
// 会话身份来自工具 exec(scope.js › sessionKeyOf):没有会话身份的写入(dream /
|
|
17
|
+
// summarize / import / organize)不进预算,预算管的是「一次对话里写飞了」。
|
|
18
|
+
//
|
|
19
|
+
// 穿透口(与 #275 拍板 5 的 pinned 豁免同口径):constraint / preference 是 #249
|
|
20
|
+
// 第一批立的逐字保真池,永不进预算;仍记一行审计(metadata.exempt='pinned')以便
|
|
21
|
+
// 观察穿透频率。穿透行既不参与 g2 判定,也不推进同话题的时间基准——它整个不在闸门
|
|
22
|
+
// 里,成为下一次比较的基准会把 g2 的样本混进穿透流量。
|
|
23
|
+
//
|
|
24
|
+
// 内容哈希(exact duplicate,第三类测量点,维护者 2026-09-24 拍板):归一化口径定在
|
|
25
|
+
// content-hash.js,命中即「同一内容又被写了一次」。判据用哈希而不是相似度——相似度
|
|
26
|
+
// 阈值在同一件事换个说法 / 不同的事共享话题词之间来回挪,只会换一种错法。候选集先按
|
|
27
|
+
// type 与 scope 三维等值收窄再比哈希(不是全表扫),并把归档行一并纳入:「出口止体积、
|
|
28
|
+
// 不止重复」,被质量闸归档的同一个事实不在 saveWithDedupe 的候选集里(store.list 默认
|
|
29
|
+
// 排除归档),同样的内容再写一次就是一条新行,这正是这条信号要量的穿透。两个穿透口与
|
|
30
|
+
// pinned 豁免同口径:pinned 在这里返回得更早,连候选集查询都不发。
|
|
31
|
+
//
|
|
32
|
+
// 信号跟着同一行审计走,所以内容哈希与 g1/g2 覆盖同一批写入(会话内新建行);无会话
|
|
33
|
+
// 身份的系统写入(dream / summarize / import / organize)要另立行形状,不在本批。
|
|
34
|
+
//
|
|
35
|
+
// 与 llmAudit.enabled 的关系:那个开关同时关掉审计行的启动期清理(index.js 的
|
|
36
|
+
// deleteOldLlmAudits),所以关掉时本模块一行都不写,否则就是在无保留期的表里做
|
|
37
|
+
// 按写入频次增长。
|
|
38
|
+
import { PINNED_MEMORY_TYPES } from "./service.js";
|
|
39
|
+
import { contentHashOf } from "./content-hash.js";
|
|
40
|
+
|
|
41
|
+
// 审计口径(llm_audit_logs 的既有列):trigger_source = 组件名,operation_type =
|
|
42
|
+
// 动作名,status='skipped' = 本行没有产生任何 LLM 花费(与 summarize 的间隔门、
|
|
43
|
+
// 尖峰门同款读法)。
|
|
44
|
+
export const ADMISSION_TRIGGER_SOURCE = "writeAdmission";
|
|
45
|
+
export const ADMISSION_OPERATION_TYPE = "write_admission";
|
|
46
|
+
export const ADMISSION_GATE_SESSION_BUDGET = "g1";
|
|
47
|
+
|
|
48
|
+
// 首次见到某会话时的回填深度:话题表由审计行重建,只回填最近这么多行。更早的
|
|
49
|
+
// 话题会漏(测量口径可接受),换来的是一次查询与有界的常驻内存。
|
|
50
|
+
export const TOPIC_LOOKBACK_ROWS = 200;
|
|
51
|
+
|
|
52
|
+
// 话题锚只收机械可判的两类:issue/PR 引用与文件路径。两条都带前缀或分隔符,误报
|
|
53
|
+
// 面小;自由文本里看不出确定性的「同话题」,硬判只会把误杀面提前引进来(#254 已
|
|
54
|
+
// 定:计量信号用确定性锚,不用相似度阈值——同一件事换个说法就掉下去,不同的事
|
|
55
|
+
// 共享话题词又顶上来,阈值怎么挪都是在两种误判之间来回)。
|
|
56
|
+
// 两处收紧都来自误报样本:`(?<![#\w])` 挡掉 Markdown 标题 `###1` 与紧跟标识符的
|
|
57
|
+
// 井号;扩展名要求首字符是字母,挡掉 `3.5/2.0` 这类比值(否则一个算式就成了话题)。
|
|
58
|
+
const ISSUE_REF = /(?<![#\w])#\d{1,7}\b/g;
|
|
59
|
+
const FILE_PATH = /(?:[\w.-]+[\\/])+[\w.-]+\.[A-Za-z][A-Za-z0-9]{0,5}\b/g;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 写入文本里的确定性话题锚:小写归一 + 路径分隔符统一为 `/` + 去重 + 排序。
|
|
63
|
+
* 同一话题在不同轮次必须逐字节相等,才能用等值比较而不是相似度比较——所以
|
|
64
|
+
* `src\service.js` 与 `src/service.js` 必须归到同一个锚。
|
|
65
|
+
* @param {{title?: string, content?: string, tags?: string[]}} memory
|
|
66
|
+
* @returns {string[]} 排序后的话题锚
|
|
67
|
+
*/
|
|
68
|
+
export function extractTopicKeys(memory) {
|
|
69
|
+
const text = [memory?.title, memory?.content, ...(Array.isArray(memory?.tags) ? memory.tags : [])]
|
|
70
|
+
.filter((s) => typeof s === "string")
|
|
71
|
+
.join("\n");
|
|
72
|
+
const keys = new Set();
|
|
73
|
+
const add = (raw) => keys.add(raw.toLowerCase().replace(/\\/g, "/"));
|
|
74
|
+
for (const m of text.matchAll(ISSUE_REF)) add(m[0]);
|
|
75
|
+
for (const m of text.matchAll(FILE_PATH)) add(m[0]);
|
|
76
|
+
return [...keys].sort();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 写入准入(#254)。返回的两个方法就是三个消费方共用的那份接口:
|
|
81
|
+
* evaluate 给出决策形状(阶段一恒 allow),record 把测量点写成审计行。
|
|
82
|
+
* 阶段二在 evaluate 里引入阈值与 confirm 分支;#249 的注入提示与 #275 的水位计数
|
|
83
|
+
* 都读同一份 decision/审计行,而不是各写一套判定。
|
|
84
|
+
*
|
|
85
|
+
* @param {object} deps
|
|
86
|
+
* @param {object} deps.store 存储层(listLlmAudits / saveLlmAudit)
|
|
87
|
+
* @param {object} [deps.config] 已解析配置(读 llmAudit.enabled)
|
|
88
|
+
* @param {object} [deps.logger]
|
|
89
|
+
* @param {() => number} [deps.now] 时钟注入(测试用)
|
|
90
|
+
*/
|
|
91
|
+
export function createWriteAdmission({ store, config, logger, now = Date.now } = {}) {
|
|
92
|
+
// sessionKey → Map<话题锚, 最近一次新建行时刻(ms)>。真相源是审计行,这里只是它的
|
|
93
|
+
// 增量视图:进程重启后按需回填。FIFO 上限防长驻进程内存无界(同 store.js 的
|
|
94
|
+
// embedding 解析缓存口径)。
|
|
95
|
+
const topicTables = new Map();
|
|
96
|
+
const SESSION_CACHE_MAX = 200;
|
|
97
|
+
|
|
98
|
+
const warn = (msg) => {
|
|
99
|
+
// 计量是旁路:任何一环失败都不能影响写入,日志自身故障也一样。
|
|
100
|
+
try {
|
|
101
|
+
logger?.warn?.(msg);
|
|
102
|
+
} catch { /* ignore */ }
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** 某会话的话题表(首见时从审计行回填,之后走内存增量)。 */
|
|
106
|
+
function topicTable(sessionKey) {
|
|
107
|
+
const cached = topicTables.get(sessionKey);
|
|
108
|
+
if (cached) return cached;
|
|
109
|
+
const table = new Map();
|
|
110
|
+
try {
|
|
111
|
+
const rows = store?.listLlmAudits?.({ sessionKey, limit: TOPIC_LOOKBACK_ROWS }) ?? [];
|
|
112
|
+
for (const row of rows) {
|
|
113
|
+
const metadata = row?.metadata;
|
|
114
|
+
if (!metadata || typeof metadata !== "object" || !Array.isArray(metadata.topics)) continue;
|
|
115
|
+
// 穿透行(pinned)整个不在闸门里,也就不该进基准表——只挡内存路径不挡回填,
|
|
116
|
+
// 重启后口径就变了(同一个话题会因为一条穿透行而被判成重复)。
|
|
117
|
+
if (metadata.exempt) continue;
|
|
118
|
+
const at = Date.parse(row.timestamp);
|
|
119
|
+
// 坏时间戳跳过:落到 0 会让 gap 变成几十年,一个离群值就能带偏按分位定的 X。
|
|
120
|
+
if (!Number.isFinite(at)) continue;
|
|
121
|
+
for (const topic of metadata.topics) {
|
|
122
|
+
// 审计行按时间倒序返回,先到的那条就是该话题最近一次写入——后来的不覆盖。
|
|
123
|
+
if (!table.has(topic)) table.set(topic, at);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
} catch (e) {
|
|
127
|
+
// 回填失败降级为「本会话此前的话题不可见」:漏一个 g2 测量点,不影响写入。
|
|
128
|
+
warn(`[dsh-mneme] write admission topic backfill failed: ${String(e)}`);
|
|
129
|
+
}
|
|
130
|
+
if (topicTables.size >= SESSION_CACHE_MAX && !topicTables.has(sessionKey)) {
|
|
131
|
+
topicTables.delete(topicTables.keys().next().value);
|
|
132
|
+
}
|
|
133
|
+
topicTables.set(sessionKey, table);
|
|
134
|
+
return table;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 内容哈希候选集查询(#254 第三类测量点)。失败降级为「没命中」:漏一个测量点,
|
|
139
|
+
* 不影响写入,也不让计量反噬。
|
|
140
|
+
* 多条命中时的取舍(活区优先):有活跃/未遗忘的命中就报它(判据是「去重候选集本该
|
|
141
|
+
* 拦住」),只有归档/遗忘行命中才报它们。store 侧已把活跃行排在窗口头部,这里的 find
|
|
142
|
+
* 是防御性重复:排序口径将来再变,本判据也不跟着变。
|
|
143
|
+
* archived 与 forgotten 分开回:两者都是「出口」,但穿透含义不同——只命中已遗忘行时
|
|
144
|
+
* 若只回 archived=false,读审计的人会以为库里有活跃重复;而 saveWithDedupe 的候选集
|
|
145
|
+
* 本来就排除遗忘行(store.list 默认 includeForgotten=false),这类命中同样是穿透。
|
|
146
|
+
* @returns {{memory_id: string, archived: boolean, forgotten: boolean}|null}
|
|
147
|
+
*/
|
|
148
|
+
function lookupContentDup(memory) {
|
|
149
|
+
try {
|
|
150
|
+
const hash = contentHashOf(memory);
|
|
151
|
+
if (!hash) return null;
|
|
152
|
+
const hits = store?.findContentHashMatches?.({
|
|
153
|
+
type: memory?.type,
|
|
154
|
+
hash,
|
|
155
|
+
agent_scope: memory?.agent_scope,
|
|
156
|
+
workspace_scope: memory?.workspace_scope,
|
|
157
|
+
sensitivity: memory?.sensitivity
|
|
158
|
+
}) ?? [];
|
|
159
|
+
const hit = hits.find((h) => !h.archived && !h.forgotten) ?? hits[0];
|
|
160
|
+
return hit ? { memory_id: hit.id, archived: hit.archived === true, forgotten: hit.forgotten === true } : null;
|
|
161
|
+
} catch (e) {
|
|
162
|
+
warn(`[dsh-mneme] write admission content hash lookup failed: ${String(e)}`);
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 阶段一决策:恒 allow(本模块里没有阈值),只算出三个闸门的测量点。返回形状一次
|
|
169
|
+
* 定死,阶段二只往里加分支、不改字段:
|
|
170
|
+
* decision — "allow" | "confirm"(阶段一只可能 allow)
|
|
171
|
+
* gate — "g1" | null(null = 这次写入不进预算,不记行)
|
|
172
|
+
* topics — 本次写入的确定性话题锚
|
|
173
|
+
* repeat — {topic, gapMs} | null(g2 的命中面)
|
|
174
|
+
* dup — {memory_id, archived, forgotten} | null(内容哈希的命中面)
|
|
175
|
+
* exempt — null | "pinned"
|
|
176
|
+
* @param {{memory: object, sessionKey?: string|null}} input
|
|
177
|
+
*/
|
|
178
|
+
function evaluate({ memory, sessionKey } = {}) {
|
|
179
|
+
const verdict = { decision: "allow", gate: null, topics: [], repeat: null, dup: null, exempt: null };
|
|
180
|
+
// 无会话身份 = 系统写入(dream / summarize / import / organize),不进预算。
|
|
181
|
+
if (!sessionKey) return verdict;
|
|
182
|
+
// 审计关掉时不记行、也不推进话题表(见文件头:那个开关连启动期清理一起关掉)。
|
|
183
|
+
if (config?.llmAudit?.enabled === false) return verdict;
|
|
184
|
+
const topics = extractTopicKeys(memory);
|
|
185
|
+
const pinned = PINNED_MEMORY_TYPES.has(String(memory?.type ?? ""));
|
|
186
|
+
if (pinned) return { ...verdict, gate: ADMISSION_GATE_SESSION_BUDGET, topics, exempt: "pinned" };
|
|
187
|
+
const dup = lookupContentDup(memory);
|
|
188
|
+
const table = topicTable(sessionKey);
|
|
189
|
+
const at = now();
|
|
190
|
+
let repeat = null;
|
|
191
|
+
for (const topic of topics) {
|
|
192
|
+
if (!table.has(topic)) continue;
|
|
193
|
+
const gapMs = Math.max(0, at - table.get(topic));
|
|
194
|
+
// 同一行可能命中多个锚:取最近的那次(间隔最短=最有价值的那次重复)。
|
|
195
|
+
if (!repeat || gapMs < repeat.gapMs) repeat = { topic, gapMs };
|
|
196
|
+
}
|
|
197
|
+
return { ...verdict, gate: ADMISSION_GATE_SESSION_BUDGET, topics, repeat, dup };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* 把测量点落成审计行(一行一个新建行)。三个信号的读法:
|
|
202
|
+
* g1 会话写入预算:`GROUP BY session_key` 计数即得「会话内新建条数」分布;要剔
|
|
203
|
+
* 掉穿透行(pinned 不进预算)就加 `json_extract(metadata,'$.exempt') IS NULL`。
|
|
204
|
+
* g2 同话题冷却:`json_extract(metadata,'$.g2.gap_ms')` 即得「同话题新建行间隔」
|
|
205
|
+
* 分布。间隔算的是同一话题两次**新建行**之间——标题命中走并入的那次不在这里
|
|
206
|
+
* (并入正是冷却要做的事,已经做到了)。
|
|
207
|
+
* dup 内容哈希:`json_extract(metadata,'$.dup.memory_id')` 非空即「这条新行的内容
|
|
208
|
+
* 与某条既有行归一化后逐字节相同」;`$.dup.archived` 区分命中那条在活区还是
|
|
209
|
+
* 归档区——归档区命中就是去重候选集漏掉的那一类穿透。
|
|
210
|
+
* 计量绝不影响写入:任何异常只 warn。
|
|
211
|
+
* @returns {object[]} 落下的审计行(无测量点或失败时为空数组)
|
|
212
|
+
*/
|
|
213
|
+
function record({ sessionKey, verdict, memoryId } = {}) {
|
|
214
|
+
if (!sessionKey || !verdict || verdict.gate == null) return [];
|
|
215
|
+
const at = now();
|
|
216
|
+
const topics = Array.isArray(verdict.topics) ? verdict.topics : [];
|
|
217
|
+
const rows = [];
|
|
218
|
+
try {
|
|
219
|
+
rows.push(store.saveLlmAudit({
|
|
220
|
+
timestamp: new Date(at).toISOString(),
|
|
221
|
+
trigger_source: ADMISSION_TRIGGER_SOURCE,
|
|
222
|
+
operation_type: ADMISSION_OPERATION_TYPE,
|
|
223
|
+
// 不是模型调用,但该列 NOT NULL——用显式占位串,免得被当成某条路由的
|
|
224
|
+
// 花费来源统计进去。
|
|
225
|
+
model_id: "-",
|
|
226
|
+
status: "skipped",
|
|
227
|
+
related_memory_ids: memoryId ? [memoryId] : [],
|
|
228
|
+
session_key: sessionKey,
|
|
229
|
+
metadata: {
|
|
230
|
+
gate: verdict.gate,
|
|
231
|
+
topics,
|
|
232
|
+
...(verdict.exempt ? { exempt: verdict.exempt } : {}),
|
|
233
|
+
...(verdict.repeat ? { g2: { topic: verdict.repeat.topic, gap_ms: verdict.repeat.gapMs } } : {}),
|
|
234
|
+
// dup 两个出口分字段落盘:archived 与 forgotten 是两类不同的穿透,合成一个布尔
|
|
235
|
+
// 会让「只剩遗忘行命中」读起来像「库里有活跃重复」(见 lookupContentDup 注释)。
|
|
236
|
+
...(verdict.dup
|
|
237
|
+
? { dup: {
|
|
238
|
+
memory_id: verdict.dup.memory_id,
|
|
239
|
+
archived: verdict.dup.archived === true,
|
|
240
|
+
forgotten: verdict.dup.forgotten === true
|
|
241
|
+
} }
|
|
242
|
+
: {})
|
|
243
|
+
}
|
|
244
|
+
}));
|
|
245
|
+
} catch (e) {
|
|
246
|
+
// 审计失败只 warn(同 saveLlmAudit 的调用惯例),写入本身照常完成。
|
|
247
|
+
warn(`[dsh-mneme] write admission audit failed: ${String(e)}`);
|
|
248
|
+
}
|
|
249
|
+
// 只有真正落库、且不是穿透口的行才推进话题表:审计写失败时内存视图不能跑在审计
|
|
250
|
+
// 前面(重启回填会得到另一条时间线),pinned 写入整个不在闸门里。
|
|
251
|
+
if (rows.length > 0 && !verdict.exempt) {
|
|
252
|
+
const table = topicTable(sessionKey);
|
|
253
|
+
for (const topic of topics) table.set(topic, at);
|
|
254
|
+
}
|
|
255
|
+
return rows;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return { evaluate, record };
|
|
259
|
+
}
|
package/package.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modusensus/dsh-mneme",
|
|
3
3
|
"description": "Cross-session memory plugin for DeepSeek Harness with autoDream consolidation: SQLite store, Markdown mirrors, 9 model tools, automatic injection, session summarization, user profile/rules, custom slash commands, vector (semantic) search, and a Web GUI panel",
|
|
4
|
-
"version": "0.8.
|
|
4
|
+
"version": "0.8.7",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
|
-
"url": "git+https://github.com/slow-stack/
|
|
8
|
+
"url": "git+https://github.com/slow-stack/mneme.git"
|
|
9
9
|
},
|
|
10
|
-
"homepage": "https://github.com/slow-stack/
|
|
10
|
+
"homepage": "https://github.com/slow-stack/mneme#readme",
|
|
11
11
|
"bugs": {
|
|
12
|
-
"url": "https://github.com/slow-stack/
|
|
12
|
+
"url": "https://github.com/slow-stack/mneme/issues"
|
|
13
13
|
},
|
|
14
14
|
"type": "module",
|
|
15
15
|
"main": "lib/index.js",
|
package/src/api-standalone.js
CHANGED
|
@@ -197,7 +197,7 @@ async function handlePutBody(res, service, logger, id, text) {
|
|
|
197
197
|
* port (updated to the OS-assigned one after `ready` resolves when asked to
|
|
198
198
|
* bind port 0), `ready` resolves once listening and rejects if the bind fails.
|
|
199
199
|
*/
|
|
200
|
-
export function createStandaloneApi({ service, store, config = {}, logger, settings, port, host }) {
|
|
200
|
+
export function createStandaloneApi({ service, store, config = {}, logger, settings, port, host, maintenance }) {
|
|
201
201
|
const persisted = settings?.getExternalApi?.() ?? {};
|
|
202
202
|
|
|
203
203
|
let token = typeof persisted.token === "string" ? persisted.token : "";
|
|
@@ -409,6 +409,62 @@ export function createStandaloneApi({ service, store, config = {}, logger, setti
|
|
|
409
409
|
return;
|
|
410
410
|
}
|
|
411
411
|
|
|
412
|
+
// --- POST /maintenance/reclaim: 无损回收(#275 第一批,手动入口)-------
|
|
413
|
+
// 默认 dry-run:只有显式 confirm:true 才真改数据。这两步都是不可逆的内容丢弃
|
|
414
|
+
// (历史 run 的输入快照置空、归档行向量置空),所以不给「带 body 就执行」这种
|
|
415
|
+
// 省事路径——确认必须建立在看得见的数字上。
|
|
416
|
+
if (req.method === "POST" && pathname === "/maintenance/reclaim") {
|
|
417
|
+
void readBody(req).then((text) => {
|
|
418
|
+
let body;
|
|
419
|
+
try {
|
|
420
|
+
body = JSON.parse(text || "{}");
|
|
421
|
+
} catch {
|
|
422
|
+
sendJson(res, 400, { error: "invalid-json" });
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
if (body === null || typeof body !== "object" || Array.isArray(body)) {
|
|
426
|
+
sendJson(res, 400, { error: "invalid-body" });
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
if (!maintenance) {
|
|
430
|
+
sendJson(res, 503, { error: "maintenance-unavailable" });
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
const dryRun = body.dryRun !== false;
|
|
434
|
+
// 只收 JSON 数字:`Number()` 会把 ""/[]/false 都化成 0,而 0 在这里是
|
|
435
|
+
// 「清掉全部快照」——最坏解释绝不能让一个畸形输入静默拿到。
|
|
436
|
+
const olderThanDays = body.olderThanDays === undefined || body.olderThanDays === null
|
|
437
|
+
? undefined
|
|
438
|
+
: body.olderThanDays;
|
|
439
|
+
if (olderThanDays !== undefined && (!Number.isInteger(olderThanDays) || olderThanDays < 0)) {
|
|
440
|
+
sendJson(res, 400, { error: "invalid-older-than-days" });
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
if (body.vacuum !== undefined && typeof body.vacuum !== "boolean") {
|
|
444
|
+
sendJson(res, 400, { error: "invalid-vacuum" });
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
if (!dryRun && body.confirm !== true) {
|
|
448
|
+
// 拒执行,但把 dry-run 报告一并回给调用方:确认的依据就是这份数字。报告
|
|
449
|
+
// 必须描述**将要执行的那个动作**,所以 vacuum 原样带上——否则调用方看到
|
|
450
|
+
// 的是「不 VACUUM」的数字,却带着 vacuum 去执行。
|
|
451
|
+
sendJson(res, 400, {
|
|
452
|
+
error: "confirm-required",
|
|
453
|
+
hint: "先看 dry-run 报告,确认后带 {\"dryRun\":false,\"confirm\":true} 再发",
|
|
454
|
+
report: maintenance.reclaim({ olderThanDays, vacuum: body.vacuum === true, dryRun: true })
|
|
455
|
+
});
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
try {
|
|
459
|
+
sendJson(res, 200, maintenance.reclaim({ olderThanDays, vacuum: body.vacuum === true, dryRun }));
|
|
460
|
+
} catch (error) {
|
|
461
|
+
logger?.warn?.(`[dsh-mneme] storage reclaim failed: ${String(error)}`);
|
|
462
|
+
sendJson(res, 500, { error: "internal" });
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
|
|
412
468
|
// --- POST /bootstrap: cold-start memories from repo files (#220) -------
|
|
413
469
|
// 显式收 dir(插件没有工作区根目录概念,不猜路径);确定性解析零 LLM;
|
|
414
470
|
// 幂等骑 saveWithDedupe 的 (type, title, scope) 去重 + _overwrite 刷新。
|
package/src/api.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { URL } from "node:url";
|
|
2
2
|
import { timingSafeEqual, randomBytes } from "node:crypto";
|
|
3
3
|
import { FEATURE_FLAG_SPEC } from "./settings.js";
|
|
4
|
-
import { TYPE_FILE, renderMirrorText, parseHumanEdits } from "./mirror.js";
|
|
4
|
+
import { TYPE_FILE, renderMirrorText, renderFileHeader, parseHumanEdits, MIRROR_READONLY_TYPES } from "./mirror.js";
|
|
5
5
|
import { langOf } from "./lang.js";
|
|
6
6
|
import { computeHeat } from "./heat.js";
|
|
7
7
|
import { describeStreamFailure, resolveRoute } from "./dream.js";
|
|
@@ -788,7 +788,11 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
788
788
|
const page = Math.max(1, Number(url.searchParams.get("page") ?? 1) || 1);
|
|
789
789
|
const pageSize = Math.min(200, Math.max(1, Number(url.searchParams.get("pageSize") ?? 50) || 50));
|
|
790
790
|
const source = url.searchParams.get("source") ?? undefined;
|
|
791
|
-
|
|
791
|
+
// 写入准入(#254)的 session_key 是内部计数键——session_id 不是对外的归属
|
|
792
|
+
// 标识(见 scope.js 文件头),而本端点按注释在 apiToken 之下也保持开放,所以
|
|
793
|
+
// 不回传它。按会话离线聚合直接读库。
|
|
794
|
+
const items = (service.listLlmAudits?.({ limit: pageSize, offset: (page - 1) * pageSize, source }) ?? [])
|
|
795
|
+
.map((row) => (row.session_key === undefined ? row : { ...row, session_key: undefined }));
|
|
792
796
|
const total = service.countLlmAudits?.({ source }) ?? items.length;
|
|
793
797
|
sendJson(res, 200, { items, total, page, pageSize });
|
|
794
798
|
} catch {
|
|
@@ -973,9 +977,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
973
977
|
});
|
|
974
978
|
|
|
975
979
|
// --- 导出(面板备份/迁移)---------------------------------------------------
|
|
976
|
-
// 只读。json:全字段行(含 archived/forgotten,布尔化),updated_at DESC
|
|
977
|
-
// markdown
|
|
978
|
-
//
|
|
980
|
+
// 只读。json:全字段行(含 archived/forgotten,布尔化),updated_at DESC。
|
|
981
|
+
// markdown:一个文档 = 文档级 frontmatter + 按类型分节。frontmatter 只放文档
|
|
982
|
+
// 最前一份(分节不再各自带文件头,否则 9 个 `---` 块串在一个 .md 里,外部
|
|
983
|
+
// frontmatter 解析器只认第一段、其余降级成正文),它的 coverage: all 就是一句
|
|
984
|
+
// 看得见的声明:这份导出含 archived/forgotten(document 指针行除外——它两个
|
|
985
|
+
// 落点都不进,见 mirror.js 的 MIRROR_EXCLUDED_TYPES)。条目块与磁盘镜像同构(共用
|
|
986
|
+
// renderMirrorText 的条目渲染),所以导出文本能被 /import 原样吃回;「同构」
|
|
987
|
+
// 止于条目——磁盘镜像只写活跃集,导出的行集更宽,两者的覆盖声明因此不同
|
|
988
|
+
// (active-only vs all)。
|
|
979
989
|
// 全量一次性取回(store.all 按 updated_at DESC)——导出是一次性备份动作,
|
|
980
990
|
// 不需要流式。
|
|
981
991
|
register({
|
|
@@ -1002,13 +1012,21 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1002
1012
|
}
|
|
1003
1013
|
const byType = {};
|
|
1004
1014
|
for (const row of rows) {
|
|
1005
|
-
|
|
1015
|
+
// 没有 TYPE_FILE 条目的 type 不进 markdown 导出,只读 type(#296 的
|
|
1016
|
+
// document 指针行)同样不进:导出/导入是 round-trip 通道,只读视图没有
|
|
1017
|
+
// 可回填的正文。两个排除都在 mirror.js 那张表里(MIRROR_EXCLUDED_TYPES /
|
|
1018
|
+
// MIRROR_READONLY_TYPES)。json 分支是全字段导出,不受影响。
|
|
1019
|
+
if (TYPE_FILE[row.type] && !MIRROR_READONLY_TYPES.has(row.type)) (byType[row.type] ??= []).push(row);
|
|
1006
1020
|
}
|
|
1007
1021
|
const sections = [];
|
|
1008
1022
|
for (const type of Object.keys(TYPE_FILE)) {
|
|
1009
|
-
if (byType[type]?.length)
|
|
1023
|
+
if (byType[type]?.length) {
|
|
1024
|
+
sections.push(renderMirrorText(type, byType[type], langOf(config), { fileHeader: false }));
|
|
1025
|
+
}
|
|
1010
1026
|
}
|
|
1011
|
-
|
|
1027
|
+
const included = Object.values(byType).flat();
|
|
1028
|
+
const docHeader = renderFileHeader("memory-export", included, "all");
|
|
1029
|
+
sendAttachment(res, 200, "text/markdown; charset=utf-8", `dsh-mneme-export-${stamp}.md`, docHeader + sections.join("\n"));
|
|
1012
1030
|
} catch {
|
|
1013
1031
|
sendJson(res, 500, { error: "internal" });
|
|
1014
1032
|
}
|
|
@@ -1016,9 +1034,8 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1016
1034
|
});
|
|
1017
1035
|
|
|
1018
1036
|
// --- 导入(Markdown 镜像回填)----------------------------------------------
|
|
1019
|
-
// 写路径(requireAuth)。body {type, markdown}:type 是镜像 TYPE_FILE
|
|
1020
|
-
//
|
|
1021
|
-
// 的文本。解析复用 readHumanEdits 的纯函数核心 parseHumanEdits(同一实现,
|
|
1037
|
+
// 写路径(requireAuth)。body {type, markdown}:type 是镜像 TYPE_FILE 键,
|
|
1038
|
+
// markdown 是与镜像文件同构的文本。解析复用 readHumanEdits 的纯函数核心 parseHumanEdits(同一实现,
|
|
1022
1039
|
// 行为一致是硬约束),合并走 mergeHumanEdits(只吃 title/content;digest 命
|
|
1023
1040
|
// 中或无差异的条目在 service 侧自动跳过)。解析出 0 条不算错误。
|
|
1024
1041
|
register({
|
|
@@ -1037,6 +1054,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1037
1054
|
sendJson(res, 400, { error: "invalid-type" });
|
|
1038
1055
|
return;
|
|
1039
1056
|
}
|
|
1057
|
+
// 只读 type 是合法 type,只是没有可回填的正文(#296):单独的码,调用方
|
|
1058
|
+
// 不会被误导去改 type 名重试。
|
|
1059
|
+
if (MIRROR_READONLY_TYPES.has(body.type)) {
|
|
1060
|
+
sendJson(res, 400, { error: "readonly-type" });
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1040
1063
|
if (typeof body.markdown !== "string" || !body.markdown.trim()) {
|
|
1041
1064
|
sendJson(res, 400, { error: "invalid-markdown" });
|
|
1042
1065
|
return;
|
package/src/config.js
CHANGED
|
@@ -77,12 +77,16 @@ export const Config = z.object({
|
|
|
77
77
|
// maxInjectedItems 上限——只做**单向收缩**,绝不越过用户配置的上限;判据只看
|
|
78
78
|
// 查询本身,不做额外检索(先探针检索等于白付一次 fuseRecall)。
|
|
79
79
|
injectUncertaintyAdaptive: z.boolean().default(false),
|
|
80
|
-
// #249
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
|
|
80
|
+
// #249(第一批;第二批归位为注入子开关):能力说明——「怎么用记忆」的判断
|
|
81
|
+
// 指引。落两个零注入成本的位:①`memory_search` / `memory_save` 的工具描述补
|
|
82
|
+
// 一句判断指引(工具描述是常驻文本,不进每轮上下文);②一段 order 150 的
|
|
83
|
+
// 系统提示段(一次性、同会话内不随轮次变化,因此不作废前缀缓存),只讲总则
|
|
84
|
+
// (优先序、何时查、何时写、何时 no-op)。默认开=基础档:#249 §10 的判据是
|
|
85
|
+
// 「只修正既有位、不引入新注入时机/新表面/额外 LLM 调用」的子项随父开关
|
|
86
|
+
// 生效——「agent 不知道何时该查、何时该写」是已实测的缺口,把它默认关着是反的。
|
|
87
|
+
// 父开关 `autoInject` 关闭时它不生效(闸门见 injectChildEnabled);用户显式写进
|
|
88
|
+
// feature_flags 的值永远优先于这里的默认值。
|
|
89
|
+
injectGuidanceEnabled: z.boolean().default(true),
|
|
86
90
|
// #249(第一批):B1 pin 池预算——约束/偏好类注入条目的独立小上限。约束与
|
|
87
91
|
// 偏好被静默降级是本议题的立项核心(同类知识与情景日志同池同速率摘要,实测
|
|
88
92
|
// 一轮压缩后仅保 53%、五轮 10%),故这两类不进相关性竞争、不参与跨轮轮换、
|
|
@@ -208,6 +212,15 @@ export const Config = z.object({
|
|
|
208
212
|
// document 摘要行的注入预算(#230 拍板):次优先档内最多注入的 document
|
|
209
213
|
// 行数,超预算跳过由后续候选补位。只约束注入,不约束检索。
|
|
210
214
|
documentInjectBudget: z.natural().min(1).max(5).default(2),
|
|
215
|
+
// document 的 managed 落盘目录(#296 第二批):空串 = 跟随 memoryDir 的
|
|
216
|
+
// `<memoryDir>/documents/`。`~` / `~/` / `~\` 展开到 home,绝对路径原样用,相对
|
|
217
|
+
// 路径落在 memoryDir 下(这是本键自己的规则:memoryDir 的相对路径是原样留着的
|
|
218
|
+
// cwd 语义,两者刻意不同;`~user` 形式两边都不展开)。目录由 mneme 建,里面的
|
|
219
|
+
// `index.md`
|
|
220
|
+
// 整文件机器所有。这个目录**之外**的文件只登记指针行,正文一个字节都不碰
|
|
221
|
+
// (= #230 的「管线对正文零读零写」);与 memoryDir 一样是路径配置,不是
|
|
222
|
+
// 行为开关,故不进 settings.js 的 feature flags 白名单、也不上面板。
|
|
223
|
+
documentDir: z.string().default(""),
|
|
211
224
|
// 隐式 keep(v0.4.4):LLM 未提及的 snapshot 记忆自动补 {action:"keep"},
|
|
212
225
|
// 避免"未覆盖即全拒"白白浪费整轮 run。设为 false 时保留旧的严格校验
|
|
213
226
|
// (未覆盖即拒绝整单)。
|
|
@@ -248,6 +261,18 @@ export const Config = z.object({
|
|
|
248
261
|
localEmbedDimension: z.natural().default(512),
|
|
249
262
|
localEmbedDevice: z.union([z.const("cpu"), z.const("gpu")]).default("cpu"),
|
|
250
263
|
localEmbedBatchSize: z.natural().min(1).max(64).default(8),
|
|
264
|
+
// 本地嵌入的池化方式(**bug 修复,不是新能力**):BGE 系是按 CLS 池化训练的——
|
|
265
|
+
// 模型自带的 `1_Pooling/config.json` 明确写着 `pooling_mode_cls_token: true` /
|
|
266
|
+
// `pooling_mode_mean_tokens: false`,官方 README 也是「select the last hidden state of
|
|
267
|
+
// the first token」+ L2 normalize;而 transformers.js 的 `feature-extraction` 默认 mean。
|
|
268
|
+
// ⇒ 此前本地嵌入对 BGE 系一直用错池化:**不报错、只是向量系统性偏差**,检索质量静默受损。
|
|
269
|
+
// 'auto' = 按模型族判定(BGE → cls,其余 → mean,未受影响的模型行为不变);也可显式钉住。
|
|
270
|
+
// ⚠️ 池化决定向量空间,改它会改变 modelHash(见 local-embedder.js),既有索引会被判失配并重建。
|
|
271
|
+
localEmbedPooling: z.union([
|
|
272
|
+
z.const("auto"),
|
|
273
|
+
z.const("cls"),
|
|
274
|
+
z.const("mean")
|
|
275
|
+
]).default("auto"),
|
|
251
276
|
|
|
252
277
|
// Ollama embedder.
|
|
253
278
|
ollamaBaseUrl: z.string().default("http://localhost:11434"),
|
|
@@ -488,11 +513,18 @@ export const Config = z.object({
|
|
|
488
513
|
}).default({}),
|
|
489
514
|
|
|
490
515
|
// --- LLM audit trail (Bug8) ------------------------------------------------
|
|
491
|
-
// Records every background LLM call
|
|
492
|
-
//
|
|
493
|
-
// and
|
|
494
|
-
//
|
|
495
|
-
//
|
|
516
|
+
// Records every background LLM call into llm_audit_logs: tokens, duration,
|
|
517
|
+
// status and which trigger produced it. Failures are recorded as
|
|
518
|
+
// status='error' and never block the feature. retentionDays bounds the table:
|
|
519
|
+
// older rows are purged on boot.
|
|
520
|
+
//
|
|
521
|
+
// Covered trigger_source values: autoDream (consolidation + summary),
|
|
522
|
+
// autoSummarize (compression), sleep (conflict + pattern) and entityExtract
|
|
523
|
+
// (issue #250 — the last two were structurally missing the hook, not a
|
|
524
|
+
// deliberate narrowing). entityExtract runs on every memory write, so row
|
|
525
|
+
// growth scales with write volume; retentionDays caps the ceiling but not the
|
|
526
|
+
// rate. If that turns out too fast, sample by operation_type or give it a
|
|
527
|
+
// separate retention — do not add a second gate: this flag is the single one.
|
|
496
528
|
llmAudit: z.object({
|
|
497
529
|
enabled: z.boolean().default(true),
|
|
498
530
|
retentionDays: z.natural().min(1).max(3650).default(90)
|
|
@@ -588,6 +620,9 @@ const LIGHT_MODE_OFF = [
|
|
|
588
620
|
"rerankEnabled",
|
|
589
621
|
"autoReindexOnBoot",
|
|
590
622
|
"hybridInject",
|
|
623
|
+
// #249 第二批:能力说明在轻量档保持关闭——它是工具描述与提示段上的额外常驻
|
|
624
|
+
// 文本,轻量档(小模型 / 小上下文)不该因默认值翻转而多付这份提示成本。
|
|
625
|
+
"injectGuidanceEnabled",
|
|
591
626
|
"searchSemanticDedup",
|
|
592
627
|
"selectiveInjectEnabled",
|
|
593
628
|
"bm25SearchEnabled",
|
|
@@ -613,3 +648,35 @@ export function applyLightModePreset(cfg) {
|
|
|
613
648
|
for (const key of LIGHT_MODE_OFF) preset[key] = false;
|
|
614
649
|
return preset;
|
|
615
650
|
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* #249 第二批:注入形态的父/子开关。父 = `autoInject`(既有的挂载总闸,默认
|
|
654
|
+
* 开)。子项按 #249 §10 的判据分档——看它是否引入**新的注入时机/新的注入
|
|
655
|
+
* 表面/额外成本**:
|
|
656
|
+
*
|
|
657
|
+
* - 只修正既有每轮块内的位(能力说明)→ 随父开关生效,键的默认值给开;
|
|
658
|
+
* - 需要新时机或新表面(N2 回合结束提醒、N3 压缩边缘)→ 独立开关、默认关,
|
|
659
|
+
* 实现时挂到这张表下(今天还没有这两个键,故表里只有能力说明一项)。
|
|
660
|
+
*
|
|
661
|
+
* 语义:**父关 = 子项一律不生效,但子项的持久值原样保留**(父关是「不生效」,
|
|
662
|
+
* 不是「重置用户配置」)。用户显式写进 kv 的值也不因父关被改写。
|
|
663
|
+
*
|
|
664
|
+
* 闸门放在消费点而不是合并后的 cfg 上:压 cfg 会让 `/features` 的 effective
|
|
665
|
+
* 失去「子项自己勾着、父关时当前不生效」这个状态,面板就显示不出来。跨文件
|
|
666
|
+
* 契约见 test/inject-parent-gate.test.js(含面板侧关系的漂移检查)。
|
|
667
|
+
*
|
|
668
|
+
* 表里只放**顶层扁平键**:点号键(如 `memoryQualityFilter.enabled`)在合并后的
|
|
669
|
+
* cfg 里是嵌套对象,`cfg?.["a.b"]` 取不到,挂进来会恒判不生效且不报错。N2/N3
|
|
670
|
+
* 是扁平键,不受这条限制。
|
|
671
|
+
*/
|
|
672
|
+
export const INJECT_CHILD_FLAGS = Object.freeze({
|
|
673
|
+
autoInject: Object.freeze(["injectGuidanceEnabled"])
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
/** 子开关的运行时生效值:父开关显式关(false)时恒不生效。 */
|
|
677
|
+
export function injectChildEnabled(cfg, key) {
|
|
678
|
+
for (const [parent, children] of Object.entries(INJECT_CHILD_FLAGS)) {
|
|
679
|
+
if (children.includes(key) && cfg?.[parent] === false) return false;
|
|
680
|
+
}
|
|
681
|
+
return cfg?.[key] === true;
|
|
682
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// #254 写入准入的确定性计量锚:内容归一化哈希(exact duplicate 判定)。
|
|
2
|
+
//
|
|
3
|
+
// 为什么用哈希而不是相似度:计量阶段要的是一个零成本、可复算、不留争议的判据。
|
|
4
|
+
// 相似度阈值两边都是误判——同一件事换个说法就掉到线下,不同的事共享话题词又顶到
|
|
5
|
+
// 线上,阈值往哪边挪都只是换一种错法(#254 拍板:计量信号用内容哈希)。哈希只认
|
|
6
|
+
// 「归一化后逐字节相同」,算不出来就只有一种解释。
|
|
7
|
+
//
|
|
8
|
+
// 但它不等于「原文逐字节相同」:归一化会折掉格式,也会折掉标点,于是 `版本 3.5` 与
|
|
9
|
+
// `版本 35`、`a-b` 与 `ab` 落同一个键。折叠范围(大小写 / 空白 / 标点)是口径本身
|
|
10
|
+
// 定的,#254 已拍板——误报面在计量阶段可接受(只标记、不拦截),但「命中即精确重复」
|
|
11
|
+
// 这句话别升级成「命中即内容相同」。要更窄的判据就得改归一化口径,而那要带存量重算。
|
|
12
|
+
//
|
|
13
|
+
// 归一化口径一次定死(NFKC → 小写 → 去标点 → 空白折叠 → trim),动机是让「只差
|
|
14
|
+
// 格式」的两次写入落在同一个键上:全角/半角、大小写、行内换行与缩进、句末标点都
|
|
15
|
+
// 不该让同一件事变成两条。反过来它不折叠同义词、不排序词序——那些只能靠相似度,
|
|
16
|
+
// 而相似度已判定不可用于本信号。
|
|
17
|
+
//
|
|
18
|
+
// 改口径必须带存量重算策略:memories.content_hash 是派生列,口径一变,库里已有的
|
|
19
|
+
// 值全部按旧口径算——补 NULL 修不好,只能整列重算(UPDATE 全表 + 重建索引)。
|
|
20
|
+
// 所以口径只能在这里改,且改要单独成批,不要混进别的改动里。
|
|
21
|
+
//
|
|
22
|
+
// 空内容(标题与正文归一后都为空)返回 null:这类写入没有可比内容,落 NULL 让它们
|
|
23
|
+
// 不互相匹配成「全是重复」。
|
|
24
|
+
//
|
|
25
|
+
// 另有一处形状相近、口径不同的哈希:service.js 里镜像人工编辑的 digest 校验(两处)
|
|
26
|
+
// 用 sha256(title\0content),那里**不**归一化(要逐字节保真)。两把哈希用途不同,别互换。
|
|
27
|
+
import { createHash } from "node:crypto";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 归一化一段文本用于内容比对(口径见文件头)。
|
|
31
|
+
* @param {unknown} value
|
|
32
|
+
* @returns {string}
|
|
33
|
+
*/
|
|
34
|
+
export function normalizeForHash(value) {
|
|
35
|
+
return String(value ?? "")
|
|
36
|
+
.normalize("NFKC")
|
|
37
|
+
.toLowerCase()
|
|
38
|
+
.replace(/\p{P}/gu, "")
|
|
39
|
+
.replace(/\s+/g, " ")
|
|
40
|
+
.trim();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 一行记忆的内容锚:标题与正文分别归一后用 NUL 拼接再取 sha256(十六进制全串)。
|
|
45
|
+
* NUL 分隔是防拼接歧义——('ab','c') 与 ('a','bc') 不能落到同一个键上。
|
|
46
|
+
* @param {{title?: string, content?: string}|null|undefined} memory
|
|
47
|
+
* @returns {string|null} 归一后无内容时返回 null
|
|
48
|
+
*/
|
|
49
|
+
export function contentHashOf(memory) {
|
|
50
|
+
const title = normalizeForHash(memory?.title);
|
|
51
|
+
const content = normalizeForHash(memory?.content);
|
|
52
|
+
if (!title && !content) return null;
|
|
53
|
+
return createHash("sha256").update(`${title}\u0000${content}`).digest("hex");
|
|
54
|
+
}
|