@modusensus/dsh-mneme 0.8.9 → 0.8.10
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 +4 -3
- package/lib/api-standalone.js +11 -1
- package/lib/api.js +6 -1
- package/lib/client.js +18 -1
- package/lib/config.js +26 -0
- package/lib/index.js +10 -4
- package/lib/inject.js +68 -9
- package/lib/service.js +28 -3
- package/lib/settings.js +9 -1
- package/lib/tools.js +17 -8
- package/lib/write-admission.js +165 -22
- package/package.json +1 -1
- package/scripts/benchmark-recall.js +57 -7
- package/src/api-standalone.js +11 -1
- package/src/api.js +6 -1
- package/src/config.js +26 -0
- package/src/index.js +10 -4
- package/src/inject.js +68 -9
- package/src/service.js +28 -3
- package/src/settings.js +9 -1
- package/src/tools.js +17 -8
- package/src/write-admission.js +165 -22
- package/test/api.test.js +17 -2
- package/test/epistemic.test.js +27 -0
- package/test/helpers/write-admission-samples.js +308 -0
- package/test/inject.test.js +137 -0
- package/test/recall-anti-update.test.js +82 -0
- package/test/write-admission.test.js +208 -0
package/lib/write-admission.js
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
|
-
// #254
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
1
|
+
// #254 写入准入。两个阶段:
|
|
2
|
+
// 阶段一(已合入):只计量、不拦截。会话写入预算的 N 与同话题冷却的 X 在没有真实
|
|
3
|
+
// 分布时拍任何数字都是误杀面(维护者 2026-09-23 拍板「先记录不拦,由遥测定」)。
|
|
4
|
+
// 所以那两个闸门只把原始测量点写进 llm_audit_logs(status='skipped'、
|
|
5
|
+
// metadata.gate='g1' 且同话题重复时附 metadata.g2),不做判定、不改任何写入行为;
|
|
6
|
+
// 阈值与二次确认(memory_save({confirm:true}))等遥测有分布再单独提。
|
|
7
|
+
// 阶段一之后(本批):第 1 级确定性拒绝。按 2026-09-21/09-22 两轮收窄的口径,
|
|
8
|
+
// 第 1 级只剩**密钥/PII 与空白噪声**两类判据走硬拒;去重键命中判 write-update 放行
|
|
9
|
+
// (不把同步去重升级成拒绝门——2606.24535 记的那个失效模式:同步近重复门抢在异步
|
|
10
|
+
// 矛盾检测之前,本该被裁决的矛盾写入被门直接拒掉)。两类判据都零 LLM、纯确定性。
|
|
11
|
+
// 两个开关都默认关:enabled 打开判定,enforce 打开拦截(关 = 仅告警 + 留审计)。
|
|
12
|
+
// 默认关时 evaluate 的返回与阶段一逐字段一致(验收第 1 条「默认路径零行为变化」)。
|
|
13
|
+
// 密钥/PII 那一类的判据实现不在本模块:按验收第 4 条它是 #164 A2 的判据来源,由
|
|
14
|
+
// createWriteAdmission 的 sensitiveScan 注入,本模块只定义它怎么被消费。
|
|
8
15
|
//
|
|
9
16
|
// 为什么复用 llm_audit_logs:它是仓库既有的「后台动作回执」表(bookkeeping,不
|
|
10
17
|
// 触发写钩子,见 store.js 的表注释)。第二阶段落地时「第一次 skipped/gate →
|
|
@@ -36,7 +43,7 @@
|
|
|
36
43
|
// deleteOldLlmAudits),所以关掉时本模块一行都不写,否则就是在无保留期的表里做
|
|
37
44
|
// 按写入频次增长。
|
|
38
45
|
import { PINNED_MEMORY_TYPES } from "./service.js";
|
|
39
|
-
import { contentHashOf } from "./content-hash.js";
|
|
46
|
+
import { contentHashOf, normalizeForHash } from "./content-hash.js";
|
|
40
47
|
|
|
41
48
|
// 审计口径(llm_audit_logs 的既有列):trigger_source = 组件名,operation_type =
|
|
42
49
|
// 动作名,status='skipped' = 本行没有产生任何 LLM 花费(与 summarize 的间隔门、
|
|
@@ -45,6 +52,16 @@ export const ADMISSION_TRIGGER_SOURCE = "writeAdmission";
|
|
|
45
52
|
export const ADMISSION_OPERATION_TYPE = "write_admission";
|
|
46
53
|
export const ADMISSION_GATE_SESSION_BUDGET = "g1";
|
|
47
54
|
|
|
55
|
+
// 决策两值。阶段一恒 allow;第 1 级判定命中且 enforce 打开时才 deny。
|
|
56
|
+
export const ADMISSION_DECISION_ALLOW = "allow";
|
|
57
|
+
export const ADMISSION_DECISION_DENY = "deny";
|
|
58
|
+
|
|
59
|
+
// 第 1 级的拒绝原因(#254 拍板:硬拒绝只留密钥/PII 与空白噪声;去重键命中归
|
|
60
|
+
// write-update 放行,G1/G2 走二次确认)。reason 是审计行里的稳定键,别当展示文案用。
|
|
61
|
+
export const ADMISSION_DENY_BLANK = "blank";
|
|
62
|
+
export const ADMISSION_DENY_NOISE = "noise";
|
|
63
|
+
export const ADMISSION_DENY_SENSITIVE = "sensitive";
|
|
64
|
+
|
|
48
65
|
// 首次见到某会话时的回填深度:话题表由审计行重建,只回填最近这么多行。更早的
|
|
49
66
|
// 话题会漏(测量口径可接受),换来的是一次查询与有界的常驻内存。
|
|
50
67
|
export const TOPIC_LOOKBACK_ROWS = 200;
|
|
@@ -76,19 +93,58 @@ export function extractTopicKeys(memory) {
|
|
|
76
93
|
return [...keys].sort();
|
|
77
94
|
}
|
|
78
95
|
|
|
96
|
+
/**
|
|
97
|
+
* 第 1 级「空白 / 纯噪声」判据(#254,零 LLM、纯函数、无状态)。
|
|
98
|
+
*
|
|
99
|
+
* 为什么这两类够格进硬拒:它们的命中面**没有解释空间**——一条归一化后什么都不剩的
|
|
100
|
+
* 写入,进库之后既检索不到也注入不了,写它只有成本没有收益,不存在「误杀有价值内容」
|
|
101
|
+
* 的可能。这正是硬拒与二次确认的分界:有解释空间的(去重命中、预算/冷却)走二次确认,
|
|
102
|
+
* 没有解释空间的才硬拒。
|
|
103
|
+
*
|
|
104
|
+
* 为什么复用 content-hash 的归一化而不另写一套:判据要的是「有没有信息」,而
|
|
105
|
+
* normalizeForHash 的口径(NFKC → 小写 → 去标点 → 空白折叠)恰好就是「把格式折掉之后
|
|
106
|
+
* 还剩什么」。另写一套只会多出一个会漂移的口径(同 content-hash.js 文件头的存量重算
|
|
107
|
+
* 理由)。两个 reason 分开报是为了可解释:`blank` 是真的什么都没写(提交一个空表单),
|
|
108
|
+
* `noise` 是写了但只有标点/空白(`...`、`---`)——两者的修法不同。
|
|
109
|
+
*
|
|
110
|
+
* 为什么不用 quality-filter 的 `repetitive`(dedupRatio < 0.3)一起判:那是**写入后**
|
|
111
|
+
* 的评分扣分项,扣分可以错(只影响排序与归档),硬拒不能错。一条字符多样性低的正常
|
|
112
|
+
* 记忆(同一种分隔符排出的长清单)会被它判成 repetitive,本判据不背这个误杀面。
|
|
113
|
+
*
|
|
114
|
+
* @param {{title?: string, content?: string}|null|undefined} memory
|
|
115
|
+
* @returns {"blank"|"noise"|null} 命中即返回 reason,否则 null
|
|
116
|
+
*/
|
|
117
|
+
export function informationlessReason(memory) {
|
|
118
|
+
const title = String(memory?.title ?? "").trim();
|
|
119
|
+
const content = String(memory?.content ?? "").trim();
|
|
120
|
+
if (!title && !content) return ADMISSION_DENY_BLANK;
|
|
121
|
+
if (!normalizeForHash(title) && !normalizeForHash(content)) return ADMISSION_DENY_NOISE;
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
79
125
|
/**
|
|
80
126
|
* 写入准入(#254)。返回的两个方法就是三个消费方共用的那份接口:
|
|
81
|
-
* evaluate
|
|
82
|
-
*
|
|
83
|
-
*
|
|
127
|
+
* evaluate 给出决策形状,record 把测量点/拒绝面写成审计行。
|
|
128
|
+
* #249 的注入提示与 #275 的水位计数都读同一份 decision/审计行,而不是各写一套判定。
|
|
129
|
+
*
|
|
130
|
+
* 两个开关(config.writeAdmission,都默认关——验收第 1 条「默认路径零行为变化」):
|
|
131
|
+
* enabled — 打开第 1 级判定(空白/噪声 + 注入进来的密钥/PII 判据)。
|
|
132
|
+
* 关时 evaluate 的返回与阶段一逐字段一致,只算三个测量点。
|
|
133
|
+
* enforce — 命中时真的拒绝。关时判定照跑、照留审计,但决策回落 allow(仅告警)。
|
|
134
|
+
* 密钥/PII 那一类判据不在这里实现:按 #254 验收第 4 条,它是 #164 A2 的判据来源,
|
|
135
|
+
* 由 `sensitiveScan` 注入(见下),本模块只定义它怎么被消费。
|
|
84
136
|
*
|
|
85
137
|
* @param {object} deps
|
|
86
138
|
* @param {object} deps.store 存储层(listLlmAudits / saveLlmAudit)
|
|
87
|
-
* @param {object} [deps.config] 已解析配置(读 llmAudit.enabled
|
|
139
|
+
* @param {object} [deps.config] 已解析配置(读 llmAudit.enabled、writeAdmission.*)
|
|
88
140
|
* @param {object} [deps.logger]
|
|
89
141
|
* @param {() => number} [deps.now] 时钟注入(测试用)
|
|
142
|
+
* @param {(memory: object) => ({reason: string, kind?: string, label?: string}|null)} [deps.sensitiveScan]
|
|
143
|
+
* 第 1 级的密钥/PII 判据(#164 A2 的落点)。约定:命中返回 `{kind, label}`,未命中
|
|
144
|
+
* 返回 null,**不抛**(抛出按未命中处理并 warn,判据故障不能让写入变成不可用)。
|
|
145
|
+
* 缺省时这一类判据整个不参与——本模块在 A2 落地前只跑空白/噪声。
|
|
90
146
|
*/
|
|
91
|
-
export function createWriteAdmission({ store, config, logger, now = Date.now } = {}) {
|
|
147
|
+
export function createWriteAdmission({ store, config, logger, now = Date.now, sensitiveScan = null } = {}) {
|
|
92
148
|
// sessionKey → Map<话题锚, 最近一次新建行时刻(ms)>。真相源是审计行,这里只是它的
|
|
93
149
|
// 增量视图:进程重启后按需回填。FIFO 上限防长驻进程内存无界(同 store.js 的
|
|
94
150
|
// embedding 解析缓存口径)。
|
|
@@ -115,6 +171,11 @@ export function createWriteAdmission({ store, config, logger, now = Date.now } =
|
|
|
115
171
|
// 穿透行(pinned)整个不在闸门里,也就不该进基准表——只挡内存路径不挡回填,
|
|
116
172
|
// 重启后口径就变了(同一个话题会因为一条穿透行而被判成重复)。
|
|
117
173
|
if (metadata.exempt) continue;
|
|
174
|
+
// 被第 1 级拦下的行同理:它根本没进库。内存路径已经跳过了(见 record 的
|
|
175
|
+
// enforced 判断),回填路径必须同口径——否则同一条时间线会随进程重启而变:
|
|
176
|
+
// 重启前不重复、重启后变重复。仅告警档(deny 非空但 decision=allow)要留,
|
|
177
|
+
// 那条行确实落库了。
|
|
178
|
+
if (metadata.deny?.enforced === true) continue;
|
|
118
179
|
const at = Date.parse(row.timestamp);
|
|
119
180
|
// 坏时间戳跳过:落到 0 会让 gap 变成几十年,一个离群值就能带偏按分位定的 X。
|
|
120
181
|
if (!Number.isFinite(at)) continue;
|
|
@@ -165,25 +226,82 @@ export function createWriteAdmission({ store, config, logger, now = Date.now } =
|
|
|
165
226
|
}
|
|
166
227
|
|
|
167
228
|
/**
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
229
|
+
* 第 1 级判据的汇总口(#254):空白 / 噪声 + 注入进来的密钥 / PII。
|
|
230
|
+
* 顺序按成本排:字符串判据在前,注入的扫描器在后;扫描器抛异常按「未命中」处理
|
|
231
|
+
* (warn)——判据故障不能让所有写入变成不可用,这是与计量同一条不反噬原则。
|
|
232
|
+
* @returns {{reason: string, kind?: string, label?: string}|null}
|
|
233
|
+
*/
|
|
234
|
+
function firstLevelHit(memory) {
|
|
235
|
+
const info = informationlessReason(memory);
|
|
236
|
+
if (info) return { reason: info };
|
|
237
|
+
if (typeof sensitiveScan !== "function") return null;
|
|
238
|
+
try {
|
|
239
|
+
const hit = sensitiveScan(memory);
|
|
240
|
+
if (!hit) return null;
|
|
241
|
+
const deny = { reason: ADMISSION_DENY_SENSITIVE };
|
|
242
|
+
// kind / label 只在判据真给了才落盘:给 undefined 会让审计行里出现一个
|
|
243
|
+
// 存在但无值的键,读的人分不清「没报」和「报了空串」。
|
|
244
|
+
if (hit.kind !== undefined) deny.kind = hit.kind;
|
|
245
|
+
if (hit.label !== undefined) deny.label = hit.label;
|
|
246
|
+
return deny;
|
|
247
|
+
} catch (e) {
|
|
248
|
+
warn(`[dsh-mneme] write admission sensitive scan failed: ${String(e)}`);
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* 决策形状一次定死,后续只加分支、不改字段:
|
|
255
|
+
* decision — "allow" | "deny"
|
|
171
256
|
* gate — "g1" | null(null = 这次写入不进预算,不记行)
|
|
172
257
|
* topics — 本次写入的确定性话题锚
|
|
173
258
|
* repeat — {topic, gapMs} | null(g2 的命中面)
|
|
174
259
|
* dup — {memory_id, archived, forgotten} | null(内容哈希的命中面)
|
|
175
260
|
* exempt — null | "pinned"
|
|
261
|
+
* deny — null | {reason, kind?, label?}(第 1 级命中面)
|
|
262
|
+
*
|
|
263
|
+
* deny 非空但 decision 仍是 allow = 仅告警档(config.writeAdmission.enforce 关):
|
|
264
|
+
* 判定照跑、审计照留,写入不拦。读审计时「deny 非空且 decision=allow」就是这一档的
|
|
265
|
+
* 指纹,不需要再读配置才能解释一行。
|
|
266
|
+
*
|
|
267
|
+
* 第 1 级判定放在 pinned 豁免之前:pinned 豁免的是**预算与冷却**(constraint /
|
|
268
|
+
* preference 是 #249 第一批立的逐字保真池,不该被会话预算拦),不是「这条能不能落库」。
|
|
269
|
+
* 一条写着私钥的 constraint 仍然是私钥。
|
|
176
270
|
* @param {{memory: object, sessionKey?: string|null}} input
|
|
177
271
|
*/
|
|
178
272
|
function evaluate({ memory, sessionKey } = {}) {
|
|
179
|
-
const verdict = {
|
|
273
|
+
const verdict = {
|
|
274
|
+
decision: ADMISSION_DECISION_ALLOW,
|
|
275
|
+
gate: null,
|
|
276
|
+
topics: [],
|
|
277
|
+
repeat: null,
|
|
278
|
+
dup: null,
|
|
279
|
+
exempt: null,
|
|
280
|
+
deny: null
|
|
281
|
+
};
|
|
180
282
|
// 无会话身份 = 系统写入(dream / summarize / import / organize),不进预算。
|
|
283
|
+
// 第 1 级判定同样在这里之外:#164 A2 定的判据面含 autoSummarize / dream 输出,
|
|
284
|
+
// 那三处的接线是 A2 的落地范围(本模块跑的是会话内的 memory_save 路径)。
|
|
181
285
|
if (!sessionKey) return verdict;
|
|
182
286
|
// 审计关掉时不记行、也不推进话题表(见文件头:那个开关连启动期清理一起关掉)。
|
|
183
287
|
if (config?.llmAudit?.enabled === false) return verdict;
|
|
288
|
+
|
|
289
|
+
const hit = config?.writeAdmission?.enabled === true ? firstLevelHit(memory) : null;
|
|
290
|
+
const judged = hit ? { ...verdict, deny: hit } : verdict;
|
|
291
|
+
|
|
292
|
+
// 命中且 enforce:决策已定,不再发候选集查询、不再比较话题(纯白花成本)。
|
|
293
|
+
if (hit && config?.writeAdmission?.enforce === true) {
|
|
294
|
+
return {
|
|
295
|
+
...judged,
|
|
296
|
+
decision: ADMISSION_DECISION_DENY,
|
|
297
|
+
gate: ADMISSION_GATE_SESSION_BUDGET,
|
|
298
|
+
topics: extractTopicKeys(memory)
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
184
302
|
const topics = extractTopicKeys(memory);
|
|
185
303
|
const pinned = PINNED_MEMORY_TYPES.has(String(memory?.type ?? ""));
|
|
186
|
-
if (pinned) return { ...
|
|
304
|
+
if (pinned) return { ...judged, gate: ADMISSION_GATE_SESSION_BUDGET, topics, exempt: "pinned" };
|
|
187
305
|
const dup = lookupContentDup(memory);
|
|
188
306
|
const table = topicTable(sessionKey);
|
|
189
307
|
const at = now();
|
|
@@ -194,11 +312,11 @@ export function createWriteAdmission({ store, config, logger, now = Date.now } =
|
|
|
194
312
|
// 同一行可能命中多个锚:取最近的那次(间隔最短=最有价值的那次重复)。
|
|
195
313
|
if (!repeat || gapMs < repeat.gapMs) repeat = { topic, gapMs };
|
|
196
314
|
}
|
|
197
|
-
return { ...
|
|
315
|
+
return { ...judged, gate: ADMISSION_GATE_SESSION_BUDGET, topics, repeat, dup };
|
|
198
316
|
}
|
|
199
317
|
|
|
200
318
|
/**
|
|
201
|
-
*
|
|
319
|
+
* 把测量点与拒绝面落成审计行(一行一个新建行 / 一次被拒的写入)。四个信号的读法:
|
|
202
320
|
* g1 会话写入预算:`GROUP BY session_key` 计数即得「会话内新建条数」分布;要剔
|
|
203
321
|
* 掉穿透行(pinned 不进预算)就加 `json_extract(metadata,'$.exempt') IS NULL`。
|
|
204
322
|
* g2 同话题冷却:`json_extract(metadata,'$.g2.gap_ms')` 即得「同话题新建行间隔」
|
|
@@ -207,6 +325,10 @@ export function createWriteAdmission({ store, config, logger, now = Date.now } =
|
|
|
207
325
|
* dup 内容哈希:`json_extract(metadata,'$.dup.memory_id')` 非空即「这条新行的内容
|
|
208
326
|
* 与某条既有行归一化后逐字节相同」;`$.dup.archived` 区分命中那条在活区还是
|
|
209
327
|
* 归档区——归档区命中就是去重候选集漏掉的那一类穿透。
|
|
328
|
+
* deny 第 1 级命中:#254 验收第 2 条要的「被拒的写入可解释、不静默丢弃」就落在
|
|
329
|
+
* 这一项。三个键分工——`$.deny.reason` 是稳定判据键(blank / noise / sensitive),
|
|
330
|
+
* `$.deny.kind` 是密钥/PII 的子类,`$.deny.enforced` 区分「真拦下了」与「仅告警」。
|
|
331
|
+
* enforced=false 时这一行同时也是一个正常的 g1 行(写入确实发生了)。
|
|
210
332
|
* 计量绝不影响写入:任何异常只 warn。
|
|
211
333
|
* @returns {object[]} 落下的审计行(无测量点或失败时为空数组)
|
|
212
334
|
*/
|
|
@@ -214,6 +336,7 @@ export function createWriteAdmission({ store, config, logger, now = Date.now } =
|
|
|
214
336
|
if (!sessionKey || !verdict || verdict.gate == null) return [];
|
|
215
337
|
const at = now();
|
|
216
338
|
const topics = Array.isArray(verdict.topics) ? verdict.topics : [];
|
|
339
|
+
const enforced = verdict.decision === ADMISSION_DECISION_DENY;
|
|
217
340
|
const rows = [];
|
|
218
341
|
try {
|
|
219
342
|
rows.push(store.saveLlmAudit({
|
|
@@ -239,6 +362,24 @@ export function createWriteAdmission({ store, config, logger, now = Date.now } =
|
|
|
239
362
|
archived: verdict.dup.archived === true,
|
|
240
363
|
forgotten: verdict.dup.forgotten === true
|
|
241
364
|
} }
|
|
365
|
+
: {}),
|
|
366
|
+
// enforced 由 decision 推出而不是另存一个入参:两者若各存一份,将来就会出现
|
|
367
|
+
// 「decision=deny 但 enforced=false」这种自相矛盾的行。
|
|
368
|
+
//
|
|
369
|
+
// decision 只在真有拒绝面时落盘:两个开关都关时,这条 metadata 必须与只计量
|
|
370
|
+
// 那一阶段逐字节一致(#254 验收第 1 条)。没有 deny 的行本来就默认是放行,
|
|
371
|
+
// 写一个恒为 "allow" 的键只是把「默认路径零行为变化」变成需要解释的事。
|
|
372
|
+
// 读法:有 deny 才有 decision,deny 非空且 decision=allow 就是仅告警档。
|
|
373
|
+
...(verdict.deny
|
|
374
|
+
? {
|
|
375
|
+
decision: verdict.decision,
|
|
376
|
+
deny: {
|
|
377
|
+
reason: verdict.deny.reason,
|
|
378
|
+
...(verdict.deny.kind !== undefined ? { kind: verdict.deny.kind } : {}),
|
|
379
|
+
...(verdict.deny.label !== undefined ? { label: verdict.deny.label } : {}),
|
|
380
|
+
enforced
|
|
381
|
+
}
|
|
382
|
+
}
|
|
242
383
|
: {})
|
|
243
384
|
}
|
|
244
385
|
}));
|
|
@@ -247,8 +388,10 @@ export function createWriteAdmission({ store, config, logger, now = Date.now } =
|
|
|
247
388
|
warn(`[dsh-mneme] write admission audit failed: ${String(e)}`);
|
|
248
389
|
}
|
|
249
390
|
// 只有真正落库、且不是穿透口的行才推进话题表:审计写失败时内存视图不能跑在审计
|
|
250
|
-
// 前面(重启回填会得到另一条时间线),pinned
|
|
251
|
-
|
|
391
|
+
// 前面(重启回填会得到另一条时间线),pinned 写入整个不在闸门里。被拦下的写入
|
|
392
|
+
// 同样不推进——它没进库,成为下一次 g2 的基准就会造出一条不存在的时间线。
|
|
393
|
+
// 仅告警档(deny 非空但 decision=allow)**要**推进:那条行确实落库了。
|
|
394
|
+
if (rows.length > 0 && !verdict.exempt && !enforced) {
|
|
252
395
|
const table = topicTable(sessionKey);
|
|
253
396
|
for (const topic of topics) table.set(topic, at);
|
|
254
397
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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.10",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -52,6 +52,11 @@ const SEED = [
|
|
|
52
52
|
// Standard query set: each case is a query plus the ids that MUST appear in
|
|
53
53
|
// the top-K for the case to count as a hit. Covers the three recall paths —
|
|
54
54
|
// multi-term lexical (BM25's home turf), identifier lookup, and semantic.
|
|
55
|
+
// Optional per case: `forbidden` — ids that must NOT appear in the top-K.
|
|
56
|
+
// Anti-update probes (E6, #280 链;StatemenBench anti-update probes):锁的
|
|
57
|
+
// 回归类是「supersede 后旧值复现」——被取代旧条靠 archived=1 从四路检索里
|
|
58
|
+
// **排除**(不是降权)。未来谁把任一默认检索路径改成 archived 宽容的降权、
|
|
59
|
+
// 或把归档行重新放回候选池,forbiddenHit 就会 >0,这里先红。
|
|
55
60
|
export const TEST_CASES = [
|
|
56
61
|
{ query: "rust 异步", expected: ["mem_async_pattern", "mem_rust_switch"], note: "scattered terms — BM25 territory" },
|
|
57
62
|
{ query: "ZFS-4421 checksum", expected: ["mem_zfs_bug"], note: "identifier + keyword" },
|
|
@@ -67,7 +72,12 @@ export const TEST_CASES = [
|
|
|
67
72
|
// leading token with a cross-topic memory that must rank below it) and an
|
|
68
73
|
// exact-token case that leans on the BM25 path.
|
|
69
74
|
{ query: "zfs 磁盘 替换", expected: ["mem_zfs_bug"], note: "shared-token distractor" },
|
|
70
|
-
{ query: "tokio spawn channel", expected: ["mem_async_pattern"], note: "exact async tokens" }
|
|
75
|
+
{ query: "tokio spawn channel", expected: ["mem_async_pattern"], note: "exact async tokens" },
|
|
76
|
+
// Anti-update probes(配 seedService 里的 supersede pair):第二条 query 的
|
|
77
|
+
// 措辞只与被取代的旧值(Go 选型)重叠——归档旧条若经任一默认路径复现,
|
|
78
|
+
// forbiddenHit > 0;同时新值必须照常命中(取代后话题有活答案)。
|
|
79
|
+
{ query: "编译模块 语言 选型", expected: ["mem_rust_switch"], forbidden: ["mem_rust_switch_old"], note: "anti-update: winner must answer the topic" },
|
|
80
|
+
{ query: "Go 部署 简单", expected: ["mem_rust_switch"], forbidden: ["mem_rust_switch_old"], note: "stale-value bait: wording matches only the superseded value" }
|
|
71
81
|
];
|
|
72
82
|
|
|
73
83
|
export function seedService(overrides = {}) {
|
|
@@ -95,6 +105,15 @@ export function seedService(overrides = {}) {
|
|
|
95
105
|
const row = store.save({ id: m.id, type: m.type, title: m.title, content: m.content, tags: m.tags, importance: m.importance, source: "seed" });
|
|
96
106
|
store.setEmbedding(row.id, hashVec(`${m.title} ${m.content}`));
|
|
97
107
|
}
|
|
108
|
+
// Supersede pair(anti-update 探针的种子):复刻 dream/decisions.js
|
|
109
|
+
// applySupersede 留下的确切落库状态——没有独立的取代列,取代关系完全由
|
|
110
|
+
// loser 的 archived=1 + 正文 (superseded by: …) 注记表达。winner 就是
|
|
111
|
+
// 上面的 mem_rust_switch 种子行,让「编译语言」话题始终有活答案,
|
|
112
|
+
// 归档的 Go 旧决策必须保持不可见。
|
|
113
|
+
const loserContent = "项目编译模块使用 Go 语言,选型理由是部署简单\n\n(superseded by: 语言迁移决策)";
|
|
114
|
+
const loser = store.save({ id: "mem_rust_switch_old", type: "decision", title: "编译模块语言(旧)", content: loserContent, importance: 4, tags: ["rust"], source: "seed" });
|
|
115
|
+
store.setEmbedding(loser.id, hashVec(`编译模块语言(旧) ${loserContent}`));
|
|
116
|
+
store.setArchived(loser.id, true);
|
|
98
117
|
return service;
|
|
99
118
|
}
|
|
100
119
|
|
|
@@ -109,18 +128,31 @@ export async function runBenchmark({ topK = 5, mode = "auto" } = {}) {
|
|
|
109
128
|
const rows = [];
|
|
110
129
|
let hits = 0;
|
|
111
130
|
let mrrSum = 0;
|
|
131
|
+
let leaks = 0;
|
|
112
132
|
for (const tc of TEST_CASES) {
|
|
113
133
|
const results = await service.searchMemories(tc.query, { mode, topK, useRerank: false });
|
|
114
134
|
const ids = results.map((r) => r.id);
|
|
115
135
|
const metrics = service.computeRetrievalMetrics(ids, tc.expected);
|
|
116
136
|
if (metrics.recall === 1) hits++;
|
|
117
137
|
mrrSum += metrics.mrr;
|
|
118
|
-
|
|
138
|
+
// forbidden 命中数:anti-update 探针的判定(见 TEST_CASES 头注释)。
|
|
139
|
+
const forbiddenHit = (tc.forbidden ?? []).filter((id) => ids.includes(id)).length;
|
|
140
|
+
if (forbiddenHit > 0) leaks++;
|
|
141
|
+
rows.push({
|
|
142
|
+
query: tc.query,
|
|
143
|
+
note: tc.note,
|
|
144
|
+
expected: tc.expected,
|
|
145
|
+
...(tc.forbidden ? { forbidden: tc.forbidden } : {}),
|
|
146
|
+
forbiddenHit,
|
|
147
|
+
got: ids,
|
|
148
|
+
...metrics
|
|
149
|
+
});
|
|
119
150
|
}
|
|
120
151
|
runs.push({
|
|
121
152
|
config: cfg.name,
|
|
122
153
|
recallAtK: +(hits / TEST_CASES.length).toFixed(3),
|
|
123
154
|
avgMrr: +(mrrSum / TEST_CASES.length).toFixed(3),
|
|
155
|
+
forbiddenHits: leaks,
|
|
124
156
|
rows
|
|
125
157
|
});
|
|
126
158
|
}
|
|
@@ -141,18 +173,30 @@ export async function runFusionBenchmark({ topK = 5, mode = "auto" } = {}) {
|
|
|
141
173
|
const rows = [];
|
|
142
174
|
let hits = 0;
|
|
143
175
|
let mrrSum = 0;
|
|
176
|
+
let leaks = 0;
|
|
144
177
|
for (const tc of TEST_CASES) {
|
|
145
178
|
const results = await service.searchMemories(tc.query, { mode, topK, useRerank: false });
|
|
146
179
|
const ids = results.map((r) => r.id);
|
|
147
180
|
const metrics = service.computeRetrievalMetrics(ids, tc.expected);
|
|
148
181
|
if (metrics.recall === 1) hits++;
|
|
149
182
|
mrrSum += metrics.mrr;
|
|
150
|
-
|
|
183
|
+
const forbiddenHit = (tc.forbidden ?? []).filter((id) => ids.includes(id)).length;
|
|
184
|
+
if (forbiddenHit > 0) leaks++;
|
|
185
|
+
rows.push({
|
|
186
|
+
query: tc.query,
|
|
187
|
+
note: tc.note,
|
|
188
|
+
expected: tc.expected,
|
|
189
|
+
...(tc.forbidden ? { forbidden: tc.forbidden } : {}),
|
|
190
|
+
forbiddenHit,
|
|
191
|
+
got: ids,
|
|
192
|
+
...metrics
|
|
193
|
+
});
|
|
151
194
|
}
|
|
152
195
|
runs.push({
|
|
153
196
|
config: recipe,
|
|
154
197
|
recallAtK: +(hits / TEST_CASES.length).toFixed(3),
|
|
155
198
|
avgMrr: +(mrrSum / TEST_CASES.length).toFixed(3),
|
|
199
|
+
forbiddenHits: leaks,
|
|
156
200
|
rows
|
|
157
201
|
});
|
|
158
202
|
}
|
|
@@ -164,10 +208,13 @@ function printFusionReport(report) {
|
|
|
164
208
|
console.log(`\n=== ${run.config} (topK=${report.topK}, mode=${report.mode}) ===`);
|
|
165
209
|
for (const r of run.rows) {
|
|
166
210
|
const ok = r.recall === 1 ? "PASS" : "MISS";
|
|
167
|
-
|
|
211
|
+
const leak = r.forbiddenHit > 0 ? " LEAK" : "";
|
|
212
|
+
console.log(` [${ok}${leak}] "${r.query}" (${r.note}) recall=${r.recall} mrr=${r.mrr}`);
|
|
168
213
|
if (r.recall < 1) console.log(` expected ⊇ ${r.expected.join(", ")} got: ${r.got.join(", ") || "—"}`);
|
|
214
|
+
if (r.forbiddenHit > 0) console.log(` forbidden hit ×${r.forbiddenHit}: ${r.got.filter((id) => r.forbidden.includes(id)).join(", ")}`);
|
|
169
215
|
}
|
|
170
|
-
console.log(` → Recall@${report.topK}: ${(run.recallAtK * 100).toFixed(1)}% avg MRR: ${run.avgMrr}`
|
|
216
|
+
console.log(` → Recall@${report.topK}: ${(run.recallAtK * 100).toFixed(1)}% avg MRR: ${run.avgMrr}` +
|
|
217
|
+
(run.forbiddenHits > 0 ? ` forbiddenHits: ${run.forbiddenHits}` : ""));
|
|
171
218
|
}
|
|
172
219
|
const summary = report.runs.map((r) => `${r.config}=${(r.recallAtK * 100).toFixed(1)}%`).join(" ");
|
|
173
220
|
console.log(`\n融合配方 A/B (Recall@${report.topK}, ${report.mode}): ${summary}`);
|
|
@@ -178,10 +225,13 @@ function printReport(report) {
|
|
|
178
225
|
console.log(`\n=== ${run.config} (topK=${report.topK}, mode=${report.mode}) ===`);
|
|
179
226
|
for (const r of run.rows) {
|
|
180
227
|
const ok = r.recall === 1 ? "PASS" : "MISS";
|
|
181
|
-
|
|
228
|
+
const leak = r.forbiddenHit > 0 ? " LEAK" : "";
|
|
229
|
+
console.log(` [${ok}${leak}] "${r.query}" (${r.note}) recall=${r.recall} mrr=${r.mrr}`);
|
|
182
230
|
if (r.recall < 1) console.log(` expected ⊇ ${r.expected.join(", ")} got: ${r.got.join(", ") || "—"}`);
|
|
231
|
+
if (r.forbiddenHit > 0) console.log(` forbidden hit ×${r.forbiddenHit}: ${r.got.filter((id) => r.forbidden.includes(id)).join(", ")}`);
|
|
183
232
|
}
|
|
184
|
-
console.log(` → Recall@${report.topK}: ${(run.recallAtK * 100).toFixed(1)}% avg MRR: ${run.avgMrr}`
|
|
233
|
+
console.log(` → Recall@${report.topK}: ${(run.recallAtK * 100).toFixed(1)}% avg MRR: ${run.avgMrr}` +
|
|
234
|
+
(run.forbiddenHits > 0 ? ` forbiddenHits: ${run.forbiddenHits}` : ""));
|
|
185
235
|
}
|
|
186
236
|
const [legacy, fused] = report.runs;
|
|
187
237
|
const lift = ((fused.recallAtK - legacy.recallAtK) * 100).toFixed(1);
|
package/src/api-standalone.js
CHANGED
|
@@ -386,7 +386,7 @@ export function createStandaloneApi({ service, store, config = {}, logger, setti
|
|
|
386
386
|
}
|
|
387
387
|
}
|
|
388
388
|
try {
|
|
389
|
-
const
|
|
389
|
+
const result = service.saveWithDedupe({
|
|
390
390
|
type: body.type,
|
|
391
391
|
title: body.title,
|
|
392
392
|
content: body.content,
|
|
@@ -398,6 +398,16 @@ export function createStandaloneApi({ service, store, config = {}, logger, setti
|
|
|
398
398
|
...(body.agent_scope !== undefined ? { agent_scope: body.agent_scope, agent_scope_source: "explicit" } : {}),
|
|
399
399
|
...(body.workspace_scope !== undefined ? { workspace_scope: body.workspace_scope, workspace_scope_source: "explicit" } : {})
|
|
400
400
|
});
|
|
401
|
+
const { action } = result;
|
|
402
|
+
// #254 写入准入:这条 HTTP 路径今天不带会话身份,evaluate 在无 sessionKey
|
|
403
|
+
// 时直接早退,所以拦不到;仍然显式分支——将来真接上会话身份时,落到
|
|
404
|
+
// `toApiList([null])` 上会变成 500,把「被拒」伪装成「服务器出错」,而
|
|
405
|
+
// 这两件事对调用方的处置完全不同(改内容 vs 重试)。
|
|
406
|
+
if (action === "denied") {
|
|
407
|
+
sendJson(res, 422, { error: "write-rejected", reason: result.reason });
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
const { memory } = result;
|
|
401
411
|
// action 随行透出(created/merged):memory_save 工具语义对齐所需,
|
|
402
412
|
// 附加键对既有消费方(CLI add 等)向后兼容。
|
|
403
413
|
sendJson(res, action === "created" ? 201 : 200, { ...service.toApiList([memory])[0], action });
|
package/src/api.js
CHANGED
|
@@ -135,9 +135,14 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
135
135
|
];
|
|
136
136
|
// 嵌套键在 kv 里按点号平铺("memoryQualityFilter.enabled"),运行时 cfg 里
|
|
137
137
|
// 是嵌套对象,effective 从对象子字段取值;其余键照旧从 cfg 顶层取。
|
|
138
|
+
// 这张表必须与 settings.js 白名单里的点号键一一对应:#254 加键时漏了这里,
|
|
139
|
+
// effective 会静默少两个键(configFlagValue 取不到 undefined 就不写进去),
|
|
140
|
+
// 面板上看不出来、PUT 也存不进去——test/api.test.js 的计数锁就是钉这件事的。
|
|
138
141
|
const NESTED_FLAG_PATHS = {
|
|
139
142
|
"memoryQualityFilter.enabled": ["memoryQualityFilter", "enabled"],
|
|
140
|
-
"llmAudit.enabled": ["llmAudit", "enabled"]
|
|
143
|
+
"llmAudit.enabled": ["llmAudit", "enabled"],
|
|
144
|
+
"writeAdmission.enabled": ["writeAdmission", "enabled"],
|
|
145
|
+
"writeAdmission.enforce": ["writeAdmission", "enforce"]
|
|
141
146
|
};
|
|
142
147
|
function configFlagValue(key) {
|
|
143
148
|
const path = NESTED_FLAG_PATHS[key];
|
package/src/config.js
CHANGED
|
@@ -71,6 +71,9 @@ export const Config = z.object({
|
|
|
71
71
|
// 提示:上限/原长/全文 memory_get 指引(BUDGET_EXCEEDED 原则——绝不静默)。
|
|
72
72
|
// 默认 300 = 与既有行为一致。
|
|
73
73
|
injectContentMaxChars: z.natural().min(60).max(4000).default(300),
|
|
74
|
+
// Issue #34(v0.7.2 引入;v0.7.11 误删后随 #333 恢复):对话开始注入一次当前
|
|
75
|
+
// 时间。默认关 = 与既有行为一致;格式与键名不变,存量 feature_flags 恢复即生效。
|
|
76
|
+
injectTimePrefix: z.boolean().default(false),
|
|
74
77
|
importanceThreshold: z.natural().min(1).max(5).default(3),
|
|
75
78
|
// Issue #239(第 5 项):注入条数的查询自适应(默认关)。确定性强的话题收缩注入
|
|
76
79
|
// 条数(减半、下限 1),模糊话题(回指/时间线索,或极短查询)维持
|
|
@@ -588,6 +591,29 @@ export const Config = z.object({
|
|
|
588
591
|
retentionDays: z.natural().min(1).max(3650).default(90)
|
|
589
592
|
}).default({}),
|
|
590
593
|
|
|
594
|
+
// --- write admission (#254) ------------------------------------------------
|
|
595
|
+
// 写入准入:把「这条该不该进库」前移到 LLM 之前。与 memoryQualityFilter 的分工
|
|
596
|
+
// 是时点不是判据——那个是**写入后**打分归档(扣分可以错,只影响排序与归档),
|
|
597
|
+
// 这个是**写入前**判定(硬拒不能错)。
|
|
598
|
+
//
|
|
599
|
+
// 两个开关都默认关(验收第 1 条「默认关零行为变化」):
|
|
600
|
+
// enabled — 跑第 1 级确定性判据。关时 evaluate 的返回与只计量那一阶段逐字段
|
|
601
|
+
// 一致,写入路径完全不碰。
|
|
602
|
+
// enforce — 命中时真的拒绝。关时判据照跑、审计照留,决策回落 allow(仅告警)。
|
|
603
|
+
// 读审计时「metadata.deny 非空且 metadata.decision=allow」就是这一档的指纹。
|
|
604
|
+
// 分层是为了能把「先观察」和「开始拦」分成两次上线:enforce 打开前先在真实流量
|
|
605
|
+
// 里看误杀面,而不是靠回滚开关当保险。
|
|
606
|
+
//
|
|
607
|
+
// 第 1 级的判据面刻意收窄到两类:密钥/PII(#164 A2 是判据来源,经
|
|
608
|
+
// createWriteAdmission 的 sensitiveScan 注入;本批只定义它怎么被消费)与空白/
|
|
609
|
+
// 纯噪声文本。去重键命中**不**进第 1 级——按维护者 09-22 的判定归 write-update
|
|
610
|
+
// 放行(2606.24535 的失效模式:同步近重复门跑在异步矛盾检测之前,把本该被裁决
|
|
611
|
+
// 的矛盾直接拒掉);G1/G2 的阈值继续只计量,等遥测有分布再单独提。
|
|
612
|
+
writeAdmission: z.object({
|
|
613
|
+
enabled: z.boolean().default(false),
|
|
614
|
+
enforce: z.boolean().default(false)
|
|
615
|
+
}).default({}),
|
|
616
|
+
|
|
591
617
|
// --- recall evaluation: test-result storage (v0.4.6, 方案 B) --------------
|
|
592
618
|
// Separate retrieval evaluation snapshots from the production recall audit.
|
|
593
619
|
// When false (default) evaluateRetrieval still computes precision/recall/mrr
|
package/src/index.js
CHANGED
|
@@ -272,10 +272,16 @@ export const apply = (ctx, config) => {
|
|
|
272
272
|
documentIndex.remove();
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
275
|
+
// 写入准入实例(#254)。两个开关 writeAdmission.enabled / .enforce 都默认关;
|
|
276
|
+
// 关时它的返回与只计量那一阶段逐字段一致,写入路径完全不碰。既有的
|
|
277
|
+
// llmAudit.enabled 关掉时它同样既不判也不写(那个开关连审计行的启动期清理一起
|
|
278
|
+
// 关掉,在无保留期的表里按写入频次增长是不能接受的)。
|
|
279
|
+
//
|
|
280
|
+
// sensitiveScan 是密钥 / PII 那一类判据的注入点。按 #254 验收第 4 条它是 #164 A2
|
|
281
|
+
// 的判据来源(A2 记在维护者排期里),所以本批不实现它,只把接口形状定在这里——
|
|
282
|
+
// 接上时只改这一行:
|
|
283
|
+
// createWriteAdmission({ ..., sensitiveScan: createSensitiveScan({ config: cfg }) })
|
|
284
|
+
// 缺省 null 时第 1 级只跑空白 / 噪声两类判据,其余一切照旧。
|
|
279
285
|
const writeAdmission = createWriteAdmission({ store, config: cfg, logger: ctx.logger });
|
|
280
286
|
const service = createService({ store, mirror, config: cfg, logger: ctx.logger, documentIndex, writeAdmission });
|
|
281
287
|
|