@modusensus/dsh-mneme 0.8.15 → 0.8.16
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 +8 -3
- package/bin/cli.mjs +4 -2
- package/docs/CONFIGURATION.md +14 -2
- package/docs/DISTILL.md +162 -0
- package/docs/STORAGE.md +11 -2
- package/lib/api-standalone.js +22 -39
- package/lib/api.js +171 -3
- package/lib/client.js +1065 -156
- package/lib/config.js +68 -1
- package/lib/dream/decisions.js +20 -0
- package/lib/dream.js +2 -0
- package/lib/hot-memory.js +62 -0
- package/lib/index.js +24 -7
- package/lib/inject.js +27 -63
- package/lib/maintenance.js +25 -5
- package/lib/recall-stats.js +22 -7
- package/lib/reclaim-http.js +150 -0
- package/lib/safe-slice.js +18 -0
- package/lib/service.js +27 -2
- package/lib/settings.js +13 -2
- package/lib/store.js +196 -7
- package/lib/summarize.js +208 -60
- package/package.json +1 -1
- package/src/api-standalone.js +22 -39
- package/src/api.js +171 -3
- package/src/config.js +68 -1
- package/src/dream/decisions.js +20 -0
- package/src/dream.js +2 -0
- package/src/hot-memory.js +62 -0
- package/src/index.js +24 -7
- package/src/inject.js +27 -63
- package/src/maintenance.js +25 -5
- package/src/recall-stats.js +22 -7
- package/src/reclaim-http.js +150 -0
- package/src/safe-slice.js +18 -0
- package/src/service.js +27 -2
- package/src/settings.js +13 -2
- package/src/store.js +196 -7
- package/src/summarize.js +208 -60
- package/test/api-routes-gaps.test.js +681 -0
- package/test/api.test.js +25 -2
- package/test/client.test.js +354 -2
- package/test/contrast.test.js +314 -0
- package/test/dream-merge-guardrail.test.js +219 -0
- package/test/hot-memory.test.js +67 -1
- package/test/inject-recall.test.js +39 -0
- package/test/inject.test.js +23 -0
- package/test/llm-audit.test.js +98 -0
- package/test/maintenance.test.js +112 -2
- package/test/pre-inject-gate.test.js +10 -0
- package/test/recall-stats.test.js +89 -4
- package/test/reclaim-http.test.js +209 -0
- package/test/service.test.js +7 -2
- package/test/settings.test.js +8 -0
- package/test/store.test.js +108 -0
- package/test/summarize.test.js +174 -0
package/src/summarize.js
CHANGED
|
@@ -225,6 +225,83 @@ function eventSeq(event) {
|
|
|
225
225
|
|
|
226
226
|
const SUBAGENT_MESSAGE_KINDS = new Set(["agent-message", "subagent-settled"]);
|
|
227
227
|
|
|
228
|
+
// 兼容严格形状 [{type:"text",text}] 与宽松形状 ["字符串", ...](lib-smoke 用例
|
|
229
|
+
// 直接传字符串数组)。只取公开文本块;reasoning 私有推理块不进蒸馏上下文。
|
|
230
|
+
function textOf(content) {
|
|
231
|
+
if (typeof content === "string") return content;
|
|
232
|
+
if (!Array.isArray(content)) return "";
|
|
233
|
+
return content
|
|
234
|
+
.map((block) => (typeof block === "string" ? block : (block && block.type === "text" && typeof block.text === "string" ? block.text : "")))
|
|
235
|
+
.filter((s) => s)
|
|
236
|
+
.join("\n");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Issue #399:公开会话正文的**单一隐私谓词**。
|
|
241
|
+
*
|
|
242
|
+
* 渲染(喂给 LLM 的转录)与落盘(distill_records 原始记录)必须走同一份判定,
|
|
243
|
+
* 各写一遍必然漂移——而漂移方向是「存下来的比 LLM 看过的更多」,也就是把从未
|
|
244
|
+
* 交给模型的内容留在盘上,是最糟的那个方向。所以这里只回答一个问题:这个事件
|
|
245
|
+
* 里有没有**可公开的会话正文**,是什么角色、什么原文;裁剪与格式化交给调用方。
|
|
246
|
+
*
|
|
247
|
+
* 返回 `[]` 表示不含可公开正文,包含三种情形:
|
|
248
|
+
* - 机器注入内容(AGENTS.md、skill 正文、文件变更通知):source.kind 不是
|
|
249
|
+
* user / agent-message / subagent-settled,整个事件被挡在门外;
|
|
250
|
+
* - 私有推理(assistant 的 reasoning 块):textOf 只认 `type:"text"` 块,
|
|
251
|
+
* 思考链在两条路径上都到不了;
|
|
252
|
+
* - 工具调用与结果、code 输出:它们是**渲染专属**内容(codingRetrospect 需要
|
|
253
|
+
* 模型看见工具报错才能沉淀踩坑根因),但 #399 明确不落盘(已知边界 3),
|
|
254
|
+
* 因此由 collectMessages 的渲染分支单独处理,不进这个函数。
|
|
255
|
+
*
|
|
256
|
+
* @returns {Array<{role: "user"|"assistant"|"agent", text: string, key?: string}>}
|
|
257
|
+
* text 是**未截断**原文;key 仅子会话消息用于跨事件去重。
|
|
258
|
+
*/
|
|
259
|
+
function publicTranscriptParts(event) {
|
|
260
|
+
const data = event?.data ?? {};
|
|
261
|
+
const kind = data?.source?.kind;
|
|
262
|
+
const parts = [];
|
|
263
|
+
const pushAgentMessage = (message) => {
|
|
264
|
+
const messageKind = message?.source?.kind;
|
|
265
|
+
if (!SUBAGENT_MESSAGE_KINDS.has(messageKind)) return;
|
|
266
|
+
const text = textOf(message?.content);
|
|
267
|
+
if (!text.trim()) return;
|
|
268
|
+
parts.push({
|
|
269
|
+
role: "agent",
|
|
270
|
+
text,
|
|
271
|
+
// 同一条子会话消息可能同时出现在 user/message 与 agent/inbox/spliced
|
|
272
|
+
// 里(投递路径重叠),按 id(无 id 时按内容)去重,否则转录会重复。
|
|
273
|
+
key: typeof message?.id === "string" && message.id
|
|
274
|
+
? `id:${message.id}`
|
|
275
|
+
: `content:${messageKind}:${text}`
|
|
276
|
+
});
|
|
277
|
+
};
|
|
278
|
+
switch (event?.type) {
|
|
279
|
+
case "user/message": {
|
|
280
|
+
if (kind !== undefined && kind !== "user" && !SUBAGENT_MESSAGE_KINDS.has(kind)) break;
|
|
281
|
+
const text = textOf(data?.content);
|
|
282
|
+
if (!text.trim()) break;
|
|
283
|
+
if (SUBAGENT_MESSAGE_KINDS.has(kind)) pushAgentMessage(data);
|
|
284
|
+
else parts.push({ role: "user", text });
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
case "agent/inbox/spliced": {
|
|
288
|
+
const inserted = Array.isArray(data?.inserted) ? data.inserted : [];
|
|
289
|
+
for (const message of inserted) pushAgentMessage(message);
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
case "assistant/message": {
|
|
293
|
+
const blocks = Array.isArray(data?.message?.content) ? data.message.content : [];
|
|
294
|
+
const text = textOf(blocks);
|
|
295
|
+
// 私有推理块(reasoning)刻意不采集:蒸馏记忆不得沉淀模型私有思考链。
|
|
296
|
+
if (text.trim()) parts.push({ role: "assistant", text });
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
default:
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
return parts;
|
|
303
|
+
}
|
|
304
|
+
|
|
228
305
|
// DSH 0.1.2-rc.1 起 Session 改用 snapshotEvents(),兼容旧版 .events。
|
|
229
306
|
// snapshotEvents 的范围参数按 seq 传入;再做一次事件级过滤,是为了兼容
|
|
230
307
|
// 旧实现忽略参数、或把边界解释为闭区间的情况,同时避免用数组下标充当游标。
|
|
@@ -253,16 +330,6 @@ function collectMessages(session, maxChars = 8000, language = "zh", afterSeq, th
|
|
|
253
330
|
const events = snapshotSessionEvents(session, afterSeq, throughSeq);
|
|
254
331
|
const lines = [];
|
|
255
332
|
const seenSubagentMessages = new Set();
|
|
256
|
-
// 兼容严格形状 [{type:"text",text}] 与宽松形状 ["字符串", ...](lib-smoke 用例
|
|
257
|
-
// 直接传字符串数组)。只取公开文本块;reasoning 私有推理块不进蒸馏上下文。
|
|
258
|
-
const textOf = (content) => {
|
|
259
|
-
if (typeof content === "string") return content;
|
|
260
|
-
if (!Array.isArray(content)) return "";
|
|
261
|
-
return content
|
|
262
|
-
.map((block) => (typeof block === "string" ? block : (block && block.type === "text" && typeof block.text === "string" ? block.text : "")))
|
|
263
|
-
.filter((s) => s)
|
|
264
|
-
.join("\n");
|
|
265
|
-
};
|
|
266
333
|
const trim = (s, n) => (typeof s === "string" && s.length > n ? `${s.slice(0, n)}…` : s);
|
|
267
334
|
const trimTranscript = (s, n) => {
|
|
268
335
|
if (typeof s !== "string" || s.length <= n) return s;
|
|
@@ -283,29 +350,22 @@ function collectMessages(session, maxChars = 8000, language = "zh", afterSeq, th
|
|
|
283
350
|
};
|
|
284
351
|
for (const event of events) {
|
|
285
352
|
const data = event?.data ?? {};
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
if (
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
const inserted = Array.isArray(data?.inserted) ? data.inserted : [];
|
|
298
|
-
for (const message of inserted) appendSubagentMessage(message);
|
|
299
|
-
break;
|
|
300
|
-
}
|
|
301
|
-
case "assistant/message": {
|
|
302
|
-
const msg = data?.message;
|
|
303
|
-
const blocks = Array.isArray(msg?.content) ? msg.content : [];
|
|
304
|
-
const text = textOf(blocks);
|
|
305
|
-
if (text.trim()) lines.push(STR.transcriptAssistant[language](text));
|
|
306
|
-
// 私有推理块(reasoning)刻意不采集:蒸馏记忆不得沉淀模型私有思考链。
|
|
307
|
-
break;
|
|
353
|
+
// #399:用户提示 / 助手公开正文 / 子会话交付三种可公开内容由共享谓词产出,
|
|
354
|
+
// 渲染与落盘因此不可能对「什么算公开」产生分歧(见 publicTranscriptParts)。
|
|
355
|
+
for (const part of publicTranscriptParts(event)) {
|
|
356
|
+
if (part.role === "agent") {
|
|
357
|
+
if (part.key && seenSubagentMessages.has(part.key)) continue;
|
|
358
|
+
if (part.key) seenSubagentMessages.add(part.key);
|
|
359
|
+
lines.push(STR.transcriptAgent[language](part.text));
|
|
360
|
+
} else if (part.role === "user") {
|
|
361
|
+
lines.push(STR.transcriptUser[language](part.text));
|
|
362
|
+
} else {
|
|
363
|
+
lines.push(STR.transcriptAssistant[language](part.text));
|
|
308
364
|
}
|
|
365
|
+
}
|
|
366
|
+
// 渲染专属内容:工具调用/结果与 code 输出进 LLM 转录(codingRetrospect 靠
|
|
367
|
+
// 它们沉淀踩坑根因),但不进 distill_records——#399 已知边界 3 明说不覆盖。
|
|
368
|
+
switch (event.type) {
|
|
309
369
|
case "tool/call": {
|
|
310
370
|
const args = typeof data.arguments === "string"
|
|
311
371
|
? data.arguments
|
|
@@ -351,6 +411,56 @@ function collectMessages(session, maxChars = 8000, language = "zh", afterSeq, th
|
|
|
351
411
|
};
|
|
352
412
|
}
|
|
353
413
|
|
|
414
|
+
/**
|
|
415
|
+
* Issue #399:把当前蒸馏窗口里**可公开的会话正文**收集成待落盘记录。
|
|
416
|
+
*
|
|
417
|
+
* 与 collectMessages 共用 publicTranscriptParts,但**不截断**:留存的意义就是
|
|
418
|
+
* 整段原文(超长用户提示在记录里必须全文,而喂给 LLM 的转录仍受 distillMaxChars
|
|
419
|
+
* 约束)——两条路径在裁剪上的差异是设计的一部分,测试锁 3 显式覆盖。
|
|
420
|
+
*
|
|
421
|
+
* 只收有 seq 的事件:distill_records 的主键是 (session_id, seq),seq 也是溯源
|
|
422
|
+
* 区间与幂等的唯一依据;没有 seq 就无法表达「这条记录属于哪个窗口」。
|
|
423
|
+
*/
|
|
424
|
+
function collectTranscriptRecords(session, afterSeq, throughSeq, distilledAt) {
|
|
425
|
+
const events = snapshotSessionEvents(session, afterSeq, throughSeq);
|
|
426
|
+
const records = [];
|
|
427
|
+
const seenKeys = new Set();
|
|
428
|
+
// 窗口的 seq 区间取窗口内**全部**事件的 min/max(不只是公开正文那些):这是
|
|
429
|
+
// LLM 实际看到的那个窗口,溯源链接要回答的是「这条记忆出自哪个窗口」。
|
|
430
|
+
let seqStart;
|
|
431
|
+
let seqEnd;
|
|
432
|
+
for (const event of events) {
|
|
433
|
+
const seq = eventSeq(event);
|
|
434
|
+
if (seq !== undefined) {
|
|
435
|
+
if (seqStart === undefined || seq < seqStart) seqStart = seq;
|
|
436
|
+
if (seqEnd === undefined || seq > seqEnd) seqEnd = seq;
|
|
437
|
+
}
|
|
438
|
+
if (seq === undefined) continue;
|
|
439
|
+
const parts = [];
|
|
440
|
+
for (const part of publicTranscriptParts(event)) {
|
|
441
|
+
if (part.key) {
|
|
442
|
+
if (seenKeys.has(part.key)) continue;
|
|
443
|
+
seenKeys.add(part.key);
|
|
444
|
+
}
|
|
445
|
+
parts.push(part);
|
|
446
|
+
}
|
|
447
|
+
if (!parts.length) continue;
|
|
448
|
+
// 主键是 (session_id, seq):一个事件一行。agent/inbox/spliced 一次可投递多条
|
|
449
|
+
// 子会话消息(同 seq、同 role),各自成行会被 INSERT OR IGNORE 静默丢掉后几条;
|
|
450
|
+
// 按事件合并成一行才是这套主键的真实语义(角色同事件内不会混:user/message 与
|
|
451
|
+
// assistant/message 各只产出一条公开 part)。
|
|
452
|
+
records.push({
|
|
453
|
+
sessionId: session.id,
|
|
454
|
+
seq,
|
|
455
|
+
role: parts[0].role,
|
|
456
|
+
text: parts.map((part) => part.text).join("\n"),
|
|
457
|
+
windowId: `session:${session.id}:${seq}`,
|
|
458
|
+
distilledAt
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
return { records, seqStart, seqEnd };
|
|
462
|
+
}
|
|
463
|
+
|
|
354
464
|
// ── 智能调速器(429 保护)─────────────────────────────────────────────
|
|
355
465
|
// 对话一多时 turn/end 会批量触发蒸馏,多个 LLM 请求"一拥而上"正是 429 的
|
|
356
466
|
// 来源。这里借鉴机场安检的思路:所有蒸馏调用进同一个全局串行队列,按间隔
|
|
@@ -574,23 +684,28 @@ export function createSummarizer(ctx, service, config, deps = {}) {
|
|
|
574
684
|
// 判定纯规则(字符数),无模型参与;被挡下的窗口照常消费游标,否则每个
|
|
575
685
|
// turn/end 都会重新评估同一段短文本。skip 原因进审计,作为后续按真实负载
|
|
576
686
|
// 决定下一级规则的依据。
|
|
687
|
+
//
|
|
688
|
+
// 字符数改为无条件计算并写进审计 metadata:它是「下一级预判该不该开、阈值
|
|
689
|
+
// 该给多少」的唯一依据,而原先开关一关就一个数都不留(维护者本机 1240 条
|
|
690
|
+
// summarize_compress 成功行的 metadata 里窗口字符数为 0 条),验收 4「默认配置
|
|
691
|
+
// 下调用量可量化下降」因此无法对账。本改动只加计量,不动任何跳过决策——
|
|
692
|
+
// summarizeMinWindowChars 仍为 0 时行为逐字节不变。
|
|
693
|
+
const distillChars = messages.reduce(
|
|
694
|
+
(total, message) => total + (Array.isArray(message.content) ? message.content : [])
|
|
695
|
+
.reduce((n, part) => n + (typeof part?.text === "string" ? part.text.length : 0), 0),
|
|
696
|
+
0
|
|
697
|
+
);
|
|
577
698
|
const minWindowChars = config.summarizeMinWindowChars ?? 0;
|
|
578
|
-
if (minWindowChars > 0) {
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
model_id: route ? `${route.provider}:${route.model}` : "unknown",
|
|
589
|
-
status: "skipped",
|
|
590
|
-
error_message: "window-too-small"
|
|
591
|
-
});
|
|
592
|
-
return;
|
|
593
|
-
}
|
|
699
|
+
if (minWindowChars > 0 && distillChars < minWindowChars) {
|
|
700
|
+
commitCursor(session.id, nextSeq);
|
|
701
|
+
writeAudit({
|
|
702
|
+
timestamp: new Date().toISOString(),
|
|
703
|
+
model_id: route ? `${route.provider}:${route.model}` : "unknown",
|
|
704
|
+
status: "skipped",
|
|
705
|
+
error_message: "window-too-small",
|
|
706
|
+
metadata: { distillChars, minWindowChars }
|
|
707
|
+
});
|
|
708
|
+
return;
|
|
594
709
|
}
|
|
595
710
|
// 只有真正准备发起一次 LLM 蒸馏时才占用最小间隔与 run 预算;失败路径仍按
|
|
596
711
|
// 原语义占用(调用确实发生过),aborted 则由 finally 回滚。
|
|
@@ -787,7 +902,45 @@ export function createSummarizer(ctx, service, config, deps = {}) {
|
|
|
787
902
|
}
|
|
788
903
|
writes.push({ entry, source, dup });
|
|
789
904
|
}
|
|
790
|
-
|
|
905
|
+
// #399:蒸馏窗口的原始公开记录 + 记忆级溯源链接,与记忆写入同事务落盘。
|
|
906
|
+
// 凭证与它的派生记忆必须同生共死:记忆写了而记录没写,溯源就断在「不知道
|
|
907
|
+
// 出自哪段原文」;记录写了而记忆没写,盘上留一份没有索引的原文。默认关
|
|
908
|
+
// (enabled !== true)时连收集都不做,一次 SQL 都不发(测试锁 5)。
|
|
909
|
+
const distilledAt = new Date(now()).toISOString();
|
|
910
|
+
const transcript = config?.distillRecords?.enabled === true
|
|
911
|
+
&& typeof service.saveDistillRecords === "function"
|
|
912
|
+
? collectTranscriptRecords(session, previousSeq, triggerSeq, distilledAt)
|
|
913
|
+
: null;
|
|
914
|
+
const writeWindow = () => {
|
|
915
|
+
const savedIds = [];
|
|
916
|
+
for (const { entry, source, dup } of writes) {
|
|
917
|
+
const outcome = service.saveWithDedupe({
|
|
918
|
+
...entry,
|
|
919
|
+
source,
|
|
920
|
+
...(dup ? { _mergeInto: dup.memory.id } : {})
|
|
921
|
+
});
|
|
922
|
+
// created 与 merged 两条成功分支都返回 memory(含 id);merged 时 id 是
|
|
923
|
+
// 被并入的既有记忆——本次窗口的原文确实贡献了它,链接它是诚实的。
|
|
924
|
+
if (outcome?.memory?.id) savedIds.push(outcome.memory.id);
|
|
925
|
+
}
|
|
926
|
+
if (transcript && transcript.records.length) {
|
|
927
|
+
service.saveDistillRecords(transcript.records);
|
|
928
|
+
if (transcript.seqStart !== undefined && transcript.seqEnd !== undefined
|
|
929
|
+
&& typeof service.saveMemoryProvenance === "function") {
|
|
930
|
+
for (const memoryId of savedIds) {
|
|
931
|
+
service.saveMemoryProvenance({
|
|
932
|
+
memoryId,
|
|
933
|
+
sessionId: session.id,
|
|
934
|
+
seqStart: transcript.seqStart,
|
|
935
|
+
seqEnd: transcript.seqEnd,
|
|
936
|
+
createdAt: distilledAt
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
persistCursor(session.id, nextSeq);
|
|
942
|
+
};
|
|
943
|
+
if (writes.length || (transcript && transcript.records.length)) {
|
|
791
944
|
// 查询去重目标可以异步执行,但记忆写入必须在同一个事务中完成。
|
|
792
945
|
// 否则第 N 条写入失败时,前 N-1 条会残留,而重试同一 seq 窗口会重复
|
|
793
946
|
// 追加它们。createService 始终提供 transaction;缺失时直接失败并保留
|
|
@@ -795,22 +948,17 @@ export function createSummarizer(ctx, service, config, deps = {}) {
|
|
|
795
948
|
if (typeof service.transaction !== "function") {
|
|
796
949
|
throw new Error("dsh-mneme: atomic summarization writes require service.transaction");
|
|
797
950
|
}
|
|
798
|
-
service.transaction(
|
|
799
|
-
for (const { entry, source, dup } of writes) {
|
|
800
|
-
service.saveWithDedupe({
|
|
801
|
-
...entry,
|
|
802
|
-
source,
|
|
803
|
-
...(dup ? { _mergeInto: dup.memory.id } : {})
|
|
804
|
-
});
|
|
805
|
-
}
|
|
806
|
-
persistCursor(session.id, nextSeq);
|
|
807
|
-
});
|
|
951
|
+
service.transaction(writeWindow);
|
|
808
952
|
} else {
|
|
809
953
|
// 空数组是合法成功:没有记忆写入,但本次事件窗口仍然应被持久消费。
|
|
810
954
|
persistCursor(session.id, nextSeq);
|
|
811
955
|
}
|
|
812
|
-
|
|
956
|
+
// Issue #239 定标:窗口字符数无条件随成功行落进 metadata。原先只有
|
|
957
|
+
// capped / deduped / salvaged 其中一项发生时才建 metadata,默认档位下
|
|
958
|
+
// 一条都不留——下一级预判规则的阈值就无从定标。
|
|
959
|
+
if (audit) {
|
|
813
960
|
audit.metadata = {
|
|
961
|
+
distillChars,
|
|
814
962
|
parsed: parsed.length,
|
|
815
963
|
...(parsedResult.salvaged ? { json_salvaged: true } : {}),
|
|
816
964
|
...(capped > 0 ? { capped } : {}),
|