@modusensus/dsh-mneme 0.8.8 → 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 -4
- package/lib/api-standalone.js +11 -1
- package/lib/api.js +7 -2
- package/lib/client.js +371 -164
- package/lib/config.js +26 -0
- package/lib/continuity.js +1 -1
- package/lib/dream/sleep.js +4 -4
- package/lib/dream.js +6 -6
- package/lib/entities/extractor.js +2 -2
- package/lib/index.js +10 -4
- package/lib/inject.js +68 -9
- package/lib/peak-hours.js +117 -117
- package/lib/service.js +28 -3
- package/lib/settings.js +9 -1
- package/lib/summarize.js +2 -2
- 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 +7 -2
- package/src/config.js +26 -0
- package/src/continuity.js +1 -1
- package/src/dream/sleep.js +4 -4
- package/src/dream.js +6 -6
- package/src/entities/extractor.js +2 -2
- package/src/index.js +10 -4
- package/src/inject.js +68 -9
- package/src/peak-hours.js +117 -117
- package/src/service.js +28 -3
- package/src/settings.js +9 -1
- package/src/summarize.js +2 -2
- package/src/tools.js +17 -8
- package/src/write-admission.js +165 -22
- package/test/api.test.js +19 -3
- package/test/client.test.js +17 -6
- package/test/continuity.test.js +1 -1
- 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/llm-message-source.test.js +32 -2
- package/test/recall-anti-update.test.js +82 -0
- package/test/write-admission.test.js +208 -0
package/src/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/test/api.test.js
CHANGED
|
@@ -667,8 +667,10 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
|
|
|
667
667
|
// v0.8.5 新增 disableMemorySearch/disableMemoryArchive,
|
|
668
668
|
// 本地嵌入池化新增 localEmbedPooling,issue #315 新增 summarizeReasoningEffort,
|
|
669
669
|
// issue #239 第 4 项镜像到巩固新增 dreamPeakHours/dreamPeakMaxDeferMinutes,
|
|
670
|
-
// issue #292 新增 autoDreamFailureBackoff
|
|
671
|
-
|
|
670
|
+
// issue #292 新增 autoDreamFailureBackoff、
|
|
671
|
+
// issue #254 新增 writeAdmission.enabled/writeAdmission.enforce,
|
|
672
|
+
// issue #34 恢复(#333)新增 injectTimePrefix)
|
|
673
|
+
assert.equal(Object.keys(data.effective).length, 59 + 3 + 2 + 1 + 2 + 2 + 2 + 1 + 1 + 1 + 2 + 1 + 2 + 1);
|
|
672
674
|
assert.equal(data.effective.dreamSkipInvalid, true);
|
|
673
675
|
assert.equal(data.effective.allowCrossTypeMerge, false);
|
|
674
676
|
assert.equal(data.effective.dreamMinIntervalMinutes, 0);
|
|
@@ -687,6 +689,9 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
|
|
|
687
689
|
assert.equal(data.effective.reflectionFailureTracking, true);
|
|
688
690
|
assert.equal(data.effective["memoryQualityFilter.enabled"], true);
|
|
689
691
|
assert.equal(data.effective["llmAudit.enabled"], true);
|
|
692
|
+
// #254:写入准入两个键都默认关(默认路径零行为变化),面板可逐项启停。
|
|
693
|
+
assert.equal(data.effective["writeAdmission.enabled"], false);
|
|
694
|
+
assert.equal(data.effective["writeAdmission.enforce"], false);
|
|
690
695
|
// 新增字符串 / URL / 枚举键
|
|
691
696
|
assert.equal(data.effective.localEmbedModel, "Xenova/bge-small-zh-v1.5");
|
|
692
697
|
assert.equal(data.effective.ollamaBaseUrl, "http://localhost:11434");
|
|
@@ -735,6 +740,12 @@ test("PUT /api/dsh-mneme/features round-trips nested, string, url and enum keys"
|
|
|
735
740
|
const patch = {
|
|
736
741
|
"memoryQualityFilter.enabled": false,
|
|
737
742
|
"llmAudit.enabled": false,
|
|
743
|
+
// #254:新点号键必须同时进 settings 白名单**和** api.js 的 NESTED_FLAG_PATHS。
|
|
744
|
+
// 只加白名单时 configFlagValue("writeAdmission.enabled") 取的是 cfg 顶层的
|
|
745
|
+
// 同名字符串(不存在)→ effective 静默少键、PUT 存了也读不回来,且面板无感。
|
|
746
|
+
// 这里往返一次就是钉这件事的(计数锁只钉数量,钉不住值)。
|
|
747
|
+
"writeAdmission.enabled": true,
|
|
748
|
+
"writeAdmission.enforce": true,
|
|
738
749
|
embedProvider: "local",
|
|
739
750
|
ollamaBaseUrl: "http://127.0.0.1:11434",
|
|
740
751
|
dreamProvider: " siliconflow ",
|
|
@@ -747,6 +758,8 @@ test("PUT /api/dsh-mneme/features round-trips nested, string, url and enum keys"
|
|
|
747
758
|
assert.deepEqual(data.overrides, {
|
|
748
759
|
"memoryQualityFilter.enabled": false,
|
|
749
760
|
"llmAudit.enabled": false,
|
|
761
|
+
"writeAdmission.enabled": true,
|
|
762
|
+
"writeAdmission.enforce": true,
|
|
750
763
|
embedProvider: "local",
|
|
751
764
|
ollamaBaseUrl: "http://127.0.0.1:11434",
|
|
752
765
|
dreamProvider: "siliconflow",
|
|
@@ -755,6 +768,8 @@ test("PUT /api/dsh-mneme/features round-trips nested, string, url and enum keys"
|
|
|
755
768
|
// 嵌套键的覆盖值压过 bundle 配置的对象子字段
|
|
756
769
|
assert.equal(data.effective["memoryQualityFilter.enabled"], false);
|
|
757
770
|
assert.equal(data.effective["llmAudit.enabled"], false);
|
|
771
|
+
assert.equal(data.effective["writeAdmission.enabled"], true);
|
|
772
|
+
assert.equal(data.effective["writeAdmission.enforce"], true);
|
|
758
773
|
assert.equal(data.effective.embedProvider, "local");
|
|
759
774
|
assert.equal(data.effective.ollamaBaseUrl, "http://127.0.0.1:11434");
|
|
760
775
|
assert.equal(data.effective.dreamProvider, "siliconflow");
|
|
@@ -1427,7 +1442,8 @@ test("POST /api/dsh-mneme/test-model succeeds and reports reply + latency", asyn
|
|
|
1427
1442
|
// 0.1.6-alpha.1 起被官方 API 整单拒绝(「messages: at least one message
|
|
1428
1443
|
// is required」),探测按钮会 502。
|
|
1429
1444
|
assert.equal(MOCK_LLM.lastOptions.messages[0].role, "user");
|
|
1430
|
-
|
|
1445
|
+
// issue #326:kind 必须是生产者自有值,裸 "plugin" 被 DSH 0.1.7 的 V4 写入准入拒绝。
|
|
1446
|
+
assert.equal(MOCK_LLM.lastOptions.messages[0].source?.kind, "plugin:dsh-mneme");
|
|
1431
1447
|
assert.equal("reasoningEffort" in MOCK_LLM.lastOptions, false, "no effort configured -> field omitted");
|
|
1432
1448
|
});
|
|
1433
1449
|
|
package/test/client.test.js
CHANGED
|
@@ -234,9 +234,12 @@ test("entry tracks native class, inherits native sizing, and lifts the toolbar d
|
|
|
234
234
|
|
|
235
235
|
// Importance renders as Lucide star glyphs (the morphicons-paired data set;
|
|
236
236
|
// the runtime cannot require the ESM-only morphicons engine, so the path
|
|
237
|
-
// ships inline like the other stroke icons)
|
|
238
|
-
// drawer's edit-mode <option>
|
|
239
|
-
// inside <option
|
|
237
|
+
// ships inline like the other stroke icons). Raw text stars are fully
|
|
238
|
+
// retired: the drawer's edit-mode <option> select used to keep "★".repeat
|
|
239
|
+
// labels (SVG cannot render inside <option>) — it is now the SVG star
|
|
240
|
+
// button row, and the inline indicators (importance chips / entity meta /
|
|
241
|
+
// inject preview) share the same StarGlyph, so every star in the panel has
|
|
242
|
+
// one shape. (用户反馈:文本 ★ 过尖、与卡片 SVG 星不统一。)
|
|
240
243
|
test("importance renders as star glyphs, not raw text stars", () => {
|
|
241
244
|
assert.ok(
|
|
242
245
|
clientSource.includes("STAR_PATH_D"),
|
|
@@ -247,9 +250,9 @@ test("importance renders as star glyphs, not raw text stars", () => {
|
|
|
247
250
|
"the star-row component must exist"
|
|
248
251
|
);
|
|
249
252
|
assert.equal(
|
|
250
|
-
(clientSource.match(
|
|
251
|
-
|
|
252
|
-
"
|
|
253
|
+
(clientSource.match(/★/g) || []).length,
|
|
254
|
+
0,
|
|
255
|
+
"no raw text star may remain anywhere — every star renders through the shared SVG path"
|
|
253
256
|
);
|
|
254
257
|
assert.ok(
|
|
255
258
|
/h\(ImportanceStars, \{ className: "mneme-dmetaval"/.test(clientSource),
|
|
@@ -259,6 +262,14 @@ test("importance renders as star glyphs, not raw text stars", () => {
|
|
|
259
262
|
/h\(ImportanceStars, \{ value: m\.importance/.test(clientSource),
|
|
260
263
|
"the card foot must render the star row"
|
|
261
264
|
);
|
|
265
|
+
assert.ok(
|
|
266
|
+
/className: "mneme-staredit"/.test(clientSource),
|
|
267
|
+
"the drawer edit importance must be the SVG star button row, not a text-star <select>"
|
|
268
|
+
);
|
|
269
|
+
assert.ok(
|
|
270
|
+
/h\(StarGlyph, \{ size: 11 \}\)/.test(clientSource),
|
|
271
|
+
"inline star indicators must go through the shared StarGlyph"
|
|
272
|
+
);
|
|
262
273
|
});
|
|
263
274
|
|
|
264
275
|
// better-sidebar ecosystem integration is an optional capability (official
|
package/test/continuity.test.js
CHANGED
|
@@ -147,7 +147,7 @@ test("#249 N3:pre-step 双落点——压缩边缘落提案并往末尾追加
|
|
|
147
147
|
assert.equal(decision.messages.length, 1, "压缩边缘 → 追加一条");
|
|
148
148
|
const message = decision.messages[0];
|
|
149
149
|
assert.equal(message.source.plugin, "dsh-mneme");
|
|
150
|
-
assert.equal(message.source.kind, "plugin", "
|
|
150
|
+
assert.equal(message.source.kind, "plugin:dsh-mneme", "插件来源会被蒸馏管线跳过,注入物不会变成记忆(也不会自我蒸馏);裸 kind: \"plugin\" 被 V4 写入准入拒收(issue #326)");
|
|
151
151
|
assert.ok(message.content[0].text.startsWith(CONTINUITY_NOTICE_PREFIX));
|
|
152
152
|
assert.ok(message.content[0].text.includes("current_work: 把 N3 实现完"));
|
|
153
153
|
const proposal = store.getContinuityProposal("s1", KIND);
|
package/test/epistemic.test.js
CHANGED
|
@@ -296,3 +296,30 @@ test("conflict resolution keeps the LLM winner when disabled", () => {
|
|
|
296
296
|
assert.equal(store.getById(w.memory.id).archived, false);
|
|
297
297
|
assert.equal(store.getById(l.memory.id).archived, true);
|
|
298
298
|
});
|
|
299
|
+
|
|
300
|
+
// E2 四臂实验(Kaggle v11)当场抓到的回归:saveWithDedupe 此前丢弃显式
|
|
301
|
+
// epistemic_status,store 回退内容推断,英文/无标记内容一律 subjective——
|
|
302
|
+
// EPISTEMIC_WEIGHTS 对整池均匀 ×0.7,trustEpistemicWeighting 的重排整体空转
|
|
303
|
+
// (R+ 臂与 A 臂注入序逐条相同)。显式值必须穿透创建路径;undefined 保持推断。
|
|
304
|
+
test("saveWithDedupe forwards explicit epistemic_status (rerank needs non-uniform weights)", async () => {
|
|
305
|
+
const store = createStore(":memory:");
|
|
306
|
+
const builder = createService({ store, mirror: null, config: {} });
|
|
307
|
+
const bait = builder.saveWithDedupe({ type: "preference", title: "m#0", content: "User feels that daily stand-up meetings are micromanagement theater", importance: 3, epistemic_status: "subjective" });
|
|
308
|
+
const fact = builder.saveWithDedupe({ type: "preference", title: "m#1", content: "User is a Lead Software Engineer with 15 years of experience", importance: 3, epistemic_status: "observation" });
|
|
309
|
+
assert.equal(store.getById(bait.memory.id).epistemic_status, "subjective", "explicit subjective stored");
|
|
310
|
+
assert.equal(store.getById(fact.memory.id).epistemic_status, "observation", "explicit field must survive saveWithDedupe (English content, inference cannot classify)");
|
|
311
|
+
|
|
312
|
+
const plain = createService({ store, mirror: null, config: { signalTransparency: true } });
|
|
313
|
+
const weighted = createService({ store, mirror: null, config: { signalTransparency: true, trustEpistemicWeighting: true } });
|
|
314
|
+
for (const s of [plain, weighted]) s.setVectorIndex(createVectorIndex({ store }));
|
|
315
|
+
const opts = { mode: "auto", topK: 5, useRerank: false };
|
|
316
|
+
const pRows = await plain.searchMemories("software engineer", opts);
|
|
317
|
+
const wRows = await weighted.searchMemories("software engineer", opts);
|
|
318
|
+
const pScore = Object.fromEntries(pRows.map((r) => [r.id, r.signals?.final]));
|
|
319
|
+
const wScore = Object.fromEntries(wRows.map((r) => [r.id, r.signals?.final]));
|
|
320
|
+
assert.ok(pScore[fact.memory.id] > 0, "observation entry is a candidate");
|
|
321
|
+
assert.ok(
|
|
322
|
+
Math.abs(wScore[fact.memory.id] - pScore[fact.memory.id]) < 1e-9,
|
|
323
|
+
"observation keeps weight 1.0 — if scaled, the explicit field was dropped (all rows uniform subjective)"
|
|
324
|
+
);
|
|
325
|
+
});
|