@modusensus/dsh-mneme 0.8.9 → 0.8.11
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/assets/icon.svg +3 -0
- package/lib/api-standalone.js +11 -1
- package/lib/api.js +6 -1
- package/lib/client.js +208 -59
- 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/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +6 -2
- 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/client.test.js +245 -45
- 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/inject.js
CHANGED
|
@@ -16,6 +16,23 @@ export function getInjectionSnapshot() {
|
|
|
16
16
|
return injectionSnapshot;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
// Issue #334:UTF-16 截断切开 emoji 代理对会留下孤立代理项(如 🔴 = D83D DD34
|
|
20
|
+
// 在 800 处切,D83D 留下、DD34 被切走)。DSH 把它序列化成非法 UTF-8,DeepSeek
|
|
21
|
+
// API 对每个请求回 400 INVALID_REQUEST——而且畸形文本随注入消息被永久写进会话
|
|
22
|
+
// 历史,此后**每一轮**都带着它,表现为会话从某一轮起连续 400、换话题/重启都不
|
|
23
|
+
// 恢复(会话级不可逆损坏)。防法是「少一个字符都行,绝不留半个」:尾字符是
|
|
24
|
+
// 高位代理(0xD800–0xDBFF)说明它的低位搭档被切走了,把这个高位代理整个丢掉。
|
|
25
|
+
// (反过来尾字符是低位代理不可能孤立——它的搭档必然紧邻在前、同在切点之内。)
|
|
26
|
+
// ASCII-only 的文本零开销(length 相等直接短路)。
|
|
27
|
+
function safeSlice(text, limit) {
|
|
28
|
+
const s = String(text);
|
|
29
|
+
if (s.length <= limit) return s;
|
|
30
|
+
let cut = s.slice(0, limit);
|
|
31
|
+
const tail = cut.charCodeAt(cut.length - 1);
|
|
32
|
+
if (tail >= 0xd800 && tail <= 0xdbff) cut = cut.slice(0, -1);
|
|
33
|
+
return cut;
|
|
34
|
+
}
|
|
35
|
+
|
|
19
36
|
// Best-effort extraction of the current user's latest message text from the
|
|
20
37
|
// live session, for semantic-first injection (Bug4). The system-prompt
|
|
21
38
|
// interpolator renders synchronously, so this walks the already-materialized
|
|
@@ -36,11 +53,13 @@ function lastUserQuery(ctx) {
|
|
|
36
53
|
if (kind !== undefined && kind !== "user") continue;
|
|
37
54
|
const parts = event.data?.content;
|
|
38
55
|
if (!Array.isArray(parts) || parts.length === 0) continue;
|
|
39
|
-
return
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
56
|
+
return safeSlice(
|
|
57
|
+
parts
|
|
58
|
+
.map((p) => (typeof p === "string" ? p : p?.text ?? ""))
|
|
59
|
+
.filter(Boolean)
|
|
60
|
+
.join("\n"),
|
|
61
|
+
500
|
|
62
|
+
);
|
|
44
63
|
}
|
|
45
64
|
} catch { /* session internals unavailable: degrade to no query */ }
|
|
46
65
|
return "";
|
|
@@ -85,7 +104,7 @@ function extractRounds(ctx, maxRounds) {
|
|
|
85
104
|
const isUser = event?.type === "user/message" && (kind === undefined || kind === "user");
|
|
86
105
|
if (isUser) {
|
|
87
106
|
if (pendingQuery) rounds.push({ query: pendingQuery, response: "" });
|
|
88
|
-
pendingQuery = textOf(event)
|
|
107
|
+
pendingQuery = safeSlice(textOf(event), 500);
|
|
89
108
|
continue;
|
|
90
109
|
}
|
|
91
110
|
// Only assistant-originated events close a round; tool/system events
|
|
@@ -94,7 +113,7 @@ function extractRounds(ctx, maxRounds) {
|
|
|
94
113
|
|| kind === "assistant";
|
|
95
114
|
const body = isAssistant ? textOf(event) : "";
|
|
96
115
|
if (!body || !pendingQuery) continue;
|
|
97
|
-
rounds.push({ query: pendingQuery, response: body
|
|
116
|
+
rounds.push({ query: pendingQuery, response: safeSlice(body, 800) });
|
|
98
117
|
pendingQuery = null;
|
|
99
118
|
}
|
|
100
119
|
if (pendingQuery) rounds.push({ query: pendingQuery, response: "" });
|
|
@@ -171,7 +190,7 @@ export function createInjector(ctx, service, settings, config) {
|
|
|
171
190
|
function injectMemory(m, maxLength = maxContent) {
|
|
172
191
|
const text = String(m?.content ?? "");
|
|
173
192
|
if (text.length <= maxLength) return text;
|
|
174
|
-
return `${text
|
|
193
|
+
return `${safeSlice(text, maxLength)}…${STR.truncatedHint[language](maxLength, text.length, m.id)}`;
|
|
175
194
|
}
|
|
176
195
|
|
|
177
196
|
// Prompt-variable brace escaping (issue #162, restored from v0.7.4 #40):
|
|
@@ -192,6 +211,41 @@ export function createInjector(ctx, service, settings, config) {
|
|
|
192
211
|
return String(text).replace(/[{}]{2,}/g, (run) => run.split("").join("\\"));
|
|
193
212
|
}
|
|
194
213
|
|
|
214
|
+
// Time-prefix injection (issue #34,恢复于 #333): opt-in, off by default.
|
|
215
|
+
// When enabled the current date/time is injected once per conversation — at
|
|
216
|
+
// the first prompt assembly of a new session — so the model can sense what
|
|
217
|
+
// time/day it is. A per-session latch means later assemblies in the same
|
|
218
|
+
// session never re-inject; a bare render ctx (no session id) falls back to
|
|
219
|
+
// once per injector lifetime.
|
|
220
|
+
// v0.7.11 面板大改版(ce4658e)时这段随一次批量删除被静默移除,CHANGELOG 无
|
|
221
|
+
// 登记(#162 的 escapePromptVars 同刀误删、后来恢复,本函数漏了)——用户按
|
|
222
|
+
// CHANGELOG 开着开关,实际什么都不会发生。本次按 v0.7.2 语义原样恢复:键名、
|
|
223
|
+
// 默认值(false)、格式 `[当前时间: YYYY-MM-DD 周X HH:MM]` 全部不变,存量
|
|
224
|
+
// feature_flags 里的配置恢复即生效。
|
|
225
|
+
// 已注入过时间前缀的会话集合(#335 review:单值闩锁在 A→B→A 交替渲染下会
|
|
226
|
+
// 重复注入)。上限 500 防长驻进程无界增长,超限逐出最早记录。
|
|
227
|
+
const timePrefixDone = new Set();
|
|
228
|
+
let timePrefixBare = false;
|
|
229
|
+
|
|
230
|
+
function renderTimePrefix(ctx) {
|
|
231
|
+
if (config.injectTimePrefix !== true) return "";
|
|
232
|
+
const sessionId = ctx?.agent?.session?.id;
|
|
233
|
+
if (sessionId === undefined) {
|
|
234
|
+
if (timePrefixBare) return "";
|
|
235
|
+
timePrefixBare = true;
|
|
236
|
+
} else {
|
|
237
|
+
if (timePrefixDone.has(sessionId)) return "";
|
|
238
|
+
if (timePrefixDone.size >= 500) timePrefixDone.delete(timePrefixDone.keys().next().value);
|
|
239
|
+
timePrefixDone.add(sessionId);
|
|
240
|
+
}
|
|
241
|
+
const WEEKDAYS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
|
|
242
|
+
const now = new Date();
|
|
243
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
244
|
+
const date = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
|
245
|
+
const time = `${pad(now.getHours())}:${pad(now.getMinutes())}`;
|
|
246
|
+
return escapePromptVars(`[当前时间: ${date} ${WEEKDAYS[now.getDay()]} ${time}]`);
|
|
247
|
+
}
|
|
248
|
+
|
|
195
249
|
// Hot memory (v0.5.0 1.3): the latest rounds of THIS session, rebuilt from
|
|
196
250
|
// the materialized event log on every render — stateless, so it survives
|
|
197
251
|
// session switches and never persists anywhere.
|
|
@@ -335,7 +389,12 @@ export function createInjector(ctx, service, settings, config) {
|
|
|
335
389
|
// separate context) keeps the prompt assembly stable at two blocks.
|
|
336
390
|
const hotText = renderHotContext(ctx);
|
|
337
391
|
const body = render(candidates, pinnedStats);
|
|
338
|
-
|
|
392
|
+
// #333(issue #34 恢复):时间前缀只在会话首轮出现(per-session 闩锁),
|
|
393
|
+
// 排在整个记忆块的最前——模型先知道「今天几号」,再看热上下文与长期记忆。
|
|
394
|
+
const timePrefix = renderTimePrefix(ctx);
|
|
395
|
+
// 拼接次序:时间 → 热上下文 → 长期记忆(三者都可能为空,逐级判空拼接)。
|
|
396
|
+
const withHot = !hotText ? body : body ? `${hotText}\n\n${body}` : hotText;
|
|
397
|
+
const finalBody = !timePrefix ? withHot : withHot ? `${timePrefix}\n\n${withHot}` : timePrefix;
|
|
339
398
|
// Issue #179:旁路缓存一帧——面板「注入预览」卡据此展示构成与体积。
|
|
340
399
|
// chars 为条目内容的截断后近似值(与 render 同一函数计长),totalChars
|
|
341
400
|
// 是本次实际返回块的精确长度。maxItems 反映自适应收缩后的生效值。
|
package/lib/service.js
CHANGED
|
@@ -1250,9 +1250,12 @@ export function createService({ store, mirror, config, onWrite, logger, document
|
|
|
1250
1250
|
scheduleEmbed(result);
|
|
1251
1251
|
return { action: "merged", memory: result };
|
|
1252
1252
|
}
|
|
1253
|
-
// #254
|
|
1254
|
-
//
|
|
1255
|
-
//
|
|
1253
|
+
// #254 写入准入:决策形状由 write-admission.js 一次定死(阶段一定死、之后只加
|
|
1254
|
+
// 分支),这里只做两件事——先问决策,再在 enforce 命中时**在 store.save 之前**
|
|
1255
|
+
// 返回。放在 store.save 之前是这条闸门的意义所在:写进去再删等于没拦,而且中间
|
|
1256
|
+
// 那一瞬的注入/检索面已经暴露了。
|
|
1257
|
+
// 计量是旁路,拒绝不是:evaluate 抛异常按「不判定」处理(宁可漏拦也不能让判据
|
|
1258
|
+
// 故障把写入变成不可用),record 抛异常同样只 warn(审计失败不能反噬写入)。
|
|
1256
1259
|
let admission = null;
|
|
1257
1260
|
if (writeAdmission) {
|
|
1258
1261
|
try {
|
|
@@ -1261,6 +1264,23 @@ export function createService({ store, mirror, config, onWrite, logger, document
|
|
|
1261
1264
|
try { logger?.warn?.(`[dsh-mneme] write admission evaluate failed: ${String(e)}`); } catch { /* 不反噬 */ }
|
|
1262
1265
|
}
|
|
1263
1266
|
}
|
|
1267
|
+
// 第 1 级硬拒(enforce 打开且判据命中):这次写入不落库、不通知、不排嵌入、
|
|
1268
|
+
// 不抽实体。审计行仍然要落——拒绝面是这条闸门唯一可解释性的来源(验收第 2 条
|
|
1269
|
+
// 「被拒写入有审计与明确原因、可解释不静默丢弃」)。memoryId 传 null:这次写入
|
|
1270
|
+
// 没有产生任何行,related_memory_ids 必须空着,挂一个不存在的 id 就是造虚账。
|
|
1271
|
+
// reason 随返回值透出,工具层据此给模型一句可行动的拒绝理由。
|
|
1272
|
+
if (admission?.decision === "deny") {
|
|
1273
|
+
try {
|
|
1274
|
+
writeAdmission.record({
|
|
1275
|
+
sessionKey: memory._sessionKey,
|
|
1276
|
+
verdict: admission,
|
|
1277
|
+
memoryId: null
|
|
1278
|
+
});
|
|
1279
|
+
} catch (e) {
|
|
1280
|
+
try { logger?.warn?.(`[dsh-mneme] write admission record failed: ${String(e)}`); } catch { /* 不反噬 */ }
|
|
1281
|
+
}
|
|
1282
|
+
return { action: "denied", reason: admission.deny?.reason ?? "denied", deny: admission.deny ?? null, memory: null };
|
|
1283
|
+
}
|
|
1264
1284
|
const created = store.save({
|
|
1265
1285
|
type: memory.type,
|
|
1266
1286
|
title: memory.title,
|
|
@@ -1268,6 +1288,11 @@ export function createService({ store, mirror, config, onWrite, logger, document
|
|
|
1268
1288
|
tags: memory.tags ?? [],
|
|
1269
1289
|
importance: memory.importance ?? 3,
|
|
1270
1290
|
source: memory.source ?? "manual",
|
|
1291
|
+
// epistemic_status 必须显式透传:省略时 store 会回退到内容标记推断
|
|
1292
|
+
// (中文正则),英文/无标记内容一律落 subjective——E2 四臂实验当场抓到
|
|
1293
|
+
// (trustEpistemicWeighting 的重排对 saveWithDedupe 写入的记忆整体空转,
|
|
1294
|
+
// R+ 臂与 A 臂注入序逐条相同)。undefined 保持推断行为,显式值优先。
|
|
1295
|
+
epistemic_status: memory.epistemic_status,
|
|
1271
1296
|
...(memory.evidence !== undefined ? { evidence: memory.evidence } : {}),
|
|
1272
1297
|
// v0.8.0 A1:scope 标注透传(store 端归一化,未标注落 NULL)。
|
|
1273
1298
|
// v0.8.1 底座:来源(auto/explicit)与决策时间随行透传。
|
package/lib/settings.js
CHANGED
|
@@ -43,6 +43,9 @@ const FEATURE_FLAG_BOOLEANS = [
|
|
|
43
43
|
"autoInject",
|
|
44
44
|
"autoSummarize",
|
|
45
45
|
"hotMemoryEnabled",
|
|
46
|
+
// Issue #34(v0.7.2 引入;v0.7.11 误删后随 #333 恢复):对话开始注入当前时间。
|
|
47
|
+
// 顶层扁平键,随白名单回面板(FEATURE_GROUPS 的 group.core)。
|
|
48
|
+
"injectTimePrefix",
|
|
46
49
|
// Issue #239 第 5 项:注入条数的查询自适应(确定性强则收缩注入条数,默认关)。
|
|
47
50
|
"injectUncertaintyAdaptive",
|
|
48
51
|
// Issue #249:能力说明(工具描述判断指引 + order 150 总则段)。第二批起它是
|
|
@@ -100,7 +103,12 @@ const FEATURE_FLAG_BOOLEANS = [
|
|
|
100
103
|
// 子字段。kv 按点号键平铺存("memoryQualityFilter.enabled": false),index.js
|
|
101
104
|
// 合并时展开回嵌套对象,api.js 的 effective 从对象子字段取值。
|
|
102
105
|
"memoryQualityFilter.enabled",
|
|
103
|
-
"llmAudit.enabled"
|
|
106
|
+
"llmAudit.enabled",
|
|
107
|
+
// Issue #254:写入准入(第 1 级确定性拒绝)。两个键分层——enabled 跑判据、
|
|
108
|
+
// enforce 真拦;都默认关。同上,点号键平铺存、合并时展开回
|
|
109
|
+
// writeAdmission 对象。
|
|
110
|
+
"writeAdmission.enabled",
|
|
111
|
+
"writeAdmission.enforce"
|
|
104
112
|
];
|
|
105
113
|
// 整数开关的闭区间,与 config.js 里 z.natural().min().max() 对齐。
|
|
106
114
|
const FEATURE_FLAG_INT_RANGES = {
|
package/lib/tools.js
CHANGED
|
@@ -149,11 +149,17 @@ export function createTools(ctx, service, config, embedder) {
|
|
|
149
149
|
type: "object",
|
|
150
150
|
additionalProperties: false,
|
|
151
151
|
properties: {
|
|
152
|
-
action: { type: "string", required: true, enum: ["created", "merged"] },
|
|
153
|
-
|
|
152
|
+
action: { type: "string", required: true, enum: ["created", "merged", "denied"] },
|
|
153
|
+
// denied 时压根没有行可指,所以 id 缺省而不是空串:空串会让「写成功但
|
|
154
|
+
// id 为空」与「根本没写」在读回执时长得一样,与 #254 验收第 2 条
|
|
155
|
+
// (被拒写入可解释、不静默丢弃)相悖。
|
|
156
|
+
id: { type: "string", description: "Row id; absent when action=denied." },
|
|
157
|
+
reason: { type: "string", description: "Rejection reason when action=denied: blank | noise | sensitive." }
|
|
154
158
|
}
|
|
155
159
|
},
|
|
156
|
-
render: (_args, value) => TEXT_OUTPUT(
|
|
160
|
+
render: (_args, value) => TEXT_OUTPUT(value.action === "denied"
|
|
161
|
+
? `memory save rejected (${value.reason ?? "denied"}): nothing was written`
|
|
162
|
+
: `memory ${value.action}: ${value.id}`)
|
|
157
163
|
},
|
|
158
164
|
async execute(args, exec) {
|
|
159
165
|
// scope 标注(v0.8.1 底座,issue #170):默认由会话身份解析(载体自动
|
|
@@ -172,10 +178,10 @@ export function createTools(ctx, service, config, embedder) {
|
|
|
172
178
|
const workspaceLabel = args.workspace_scope !== undefined
|
|
173
179
|
? { value: normalizeExplicitScope(args.workspace_scope), source: "explicit" }
|
|
174
180
|
: autoStamping && scope ? { value: scope.workspace_scope, source: "auto" } : null;
|
|
175
|
-
const
|
|
176
|
-
// #254
|
|
177
|
-
//
|
|
178
|
-
//
|
|
181
|
+
const result = service.saveWithDedupe({
|
|
182
|
+
// #254 写入准入的计数单位:会话键作为瞬时字段随写入传递(与 _mergeInto /
|
|
183
|
+
// _overwrite 同款约定,不落库)。缺会话身份的宿主返回 null = 这次写入不进
|
|
184
|
+
// 预算;系统写入(dream / summarize / organize)从不带这个字段。
|
|
179
185
|
_sessionKey: sessionKeyOf(exec),
|
|
180
186
|
type: args.type,
|
|
181
187
|
title: args.title,
|
|
@@ -188,7 +194,10 @@ export function createTools(ctx, service, config, embedder) {
|
|
|
188
194
|
...(args.sensitivity !== undefined ? { sensitivity: args.sensitivity } : {}),
|
|
189
195
|
...(args.occurred_at !== undefined ? { occurred_at: args.occurred_at } : {})
|
|
190
196
|
});
|
|
191
|
-
|
|
197
|
+
// #254 第 1 级硬拒:工具层是模型唯一能看到这次失败的地方,必须显式回一句
|
|
198
|
+
// 可行动的拒绝理由。静默返回成功会让模型以为记住了,然后永远不再重写。
|
|
199
|
+
if (result.action === "denied") return { action: "denied", reason: result.reason };
|
|
200
|
+
return { action: result.action, id: result.memory.id };
|
|
192
201
|
}
|
|
193
202
|
}),
|
|
194
203
|
|
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/locale/en.json
ADDED
package/locale/zh.json
ADDED
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.11",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
},
|
|
14
14
|
"type": "module",
|
|
15
15
|
"main": "lib/index.js",
|
|
16
|
+
"icon": "assets/icon.svg",
|
|
16
17
|
"bin": {
|
|
17
18
|
"dsh-mneme": "bin/cli.mjs",
|
|
18
19
|
"dsh-mneme-mcp": "bin/dsh-mneme-mcp.mjs"
|
|
@@ -24,11 +25,14 @@
|
|
|
24
25
|
"./client": {
|
|
25
26
|
"default": "./lib/client.js"
|
|
26
27
|
},
|
|
27
|
-
"./package.json": "./package.json"
|
|
28
|
+
"./package.json": "./package.json",
|
|
29
|
+
"./locale/*": "./locale/*"
|
|
28
30
|
},
|
|
29
31
|
"files": [
|
|
32
|
+
"assets",
|
|
30
33
|
"bin",
|
|
31
34
|
"lib",
|
|
35
|
+
"locale",
|
|
32
36
|
"src",
|
|
33
37
|
"scripts",
|
|
34
38
|
"test",
|