@modusensus/dsh-mneme 0.8.15 → 0.9.0

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.
Files changed (100) hide show
  1. package/README.md +21 -3
  2. package/assets/icon.svg +2 -2
  3. package/bin/cli.mjs +4 -2
  4. package/docs/CONFIGURATION.md +16 -3
  5. package/docs/CONTINUITY.md +18 -3
  6. package/docs/DISTILL.md +162 -0
  7. package/docs/STORAGE.md +11 -2
  8. package/docs/WORKFLOWS.md +299 -0
  9. package/lib/api-standalone.js +22 -39
  10. package/lib/api.js +294 -10
  11. package/lib/candidates.js +454 -0
  12. package/lib/capabilities.js +819 -0
  13. package/lib/client.js +2767 -337
  14. package/lib/config.js +99 -6
  15. package/lib/continuity.js +35 -6
  16. package/lib/dream/decisions.js +37 -1
  17. package/lib/dream/sleep.js +10 -2
  18. package/lib/dream.js +7 -1
  19. package/lib/entities/extractor.js +4 -0
  20. package/lib/hot-memory.js +62 -0
  21. package/lib/index.js +72 -18
  22. package/lib/inject.js +33 -64
  23. package/lib/maintenance.js +25 -5
  24. package/lib/pipeline.js +534 -0
  25. package/lib/recall-stats.js +22 -7
  26. package/lib/reclaim-http.js +150 -0
  27. package/lib/safe-slice.js +18 -0
  28. package/lib/scope.js +16 -4
  29. package/lib/serve.js +1 -1
  30. package/lib/service.js +83 -17
  31. package/lib/session-usage.js +350 -0
  32. package/lib/settings.js +19 -2
  33. package/lib/store.js +251 -10
  34. package/lib/summarize.js +239 -60
  35. package/lib/tools.js +11 -2
  36. package/package.json +1 -1
  37. package/scripts/publish-gate.mjs +197 -0
  38. package/src/api-standalone.js +22 -39
  39. package/src/api.js +294 -10
  40. package/src/candidates.js +454 -0
  41. package/src/capabilities.js +819 -0
  42. package/src/config.js +99 -6
  43. package/src/continuity.js +35 -6
  44. package/src/dream/decisions.js +37 -1
  45. package/src/dream/sleep.js +10 -2
  46. package/src/dream.js +7 -1
  47. package/src/entities/extractor.js +4 -0
  48. package/src/hot-memory.js +62 -0
  49. package/src/index.js +72 -18
  50. package/src/inject.js +33 -64
  51. package/src/maintenance.js +25 -5
  52. package/src/pipeline.js +534 -0
  53. package/src/recall-stats.js +22 -7
  54. package/src/reclaim-http.js +150 -0
  55. package/src/safe-slice.js +18 -0
  56. package/src/scope.js +16 -4
  57. package/src/serve.js +1 -1
  58. package/src/service.js +83 -17
  59. package/src/session-usage.js +350 -0
  60. package/src/settings.js +19 -2
  61. package/src/store.js +251 -10
  62. package/src/summarize.js +239 -60
  63. package/src/tools.js +11 -2
  64. package/test/api-routes-gaps.test.js +681 -0
  65. package/test/api.test.js +171 -8
  66. package/test/candidates.test.js +345 -0
  67. package/test/capabilities.test.js +577 -0
  68. package/test/client.test.js +715 -4
  69. package/test/continuity.test.js +84 -0
  70. package/test/contrast.test.js +314 -0
  71. package/test/dream-merge-guardrail.test.js +219 -0
  72. package/test/embed-ready-poll.test.js +94 -0
  73. package/test/entities.test.js +13 -2
  74. package/test/failure-query.test.js +147 -0
  75. package/test/failure-retention.test.js +105 -0
  76. package/test/graph-api.test.js +36 -2
  77. package/test/hot-memory.test.js +67 -1
  78. package/test/inject-recall.test.js +68 -1
  79. package/test/inject.test.js +23 -0
  80. package/test/llm-audit.test.js +190 -1
  81. package/test/maintenance.test.js +112 -2
  82. package/test/mint-source.test.js +202 -0
  83. package/test/pipeline.test.js +505 -0
  84. package/test/placement.test.js +1 -1
  85. package/test/pre-inject-gate.test.js +10 -0
  86. package/test/publish-gate.test.js +272 -0
  87. package/test/recall-retention.test.js +128 -0
  88. package/test/recall-stats.test.js +89 -4
  89. package/test/reclaim-http.test.js +209 -0
  90. package/test/release-workflow.test.js +77 -0
  91. package/test/scope-distill-stamp.test.js +135 -0
  92. package/test/serve.test.js +1 -1
  93. package/test/service.test.js +7 -2
  94. package/test/session-usage.test.js +308 -0
  95. package/test/settings.test.js +8 -0
  96. package/test/store-write-path-columns.test.js +191 -0
  97. package/test/store.test.js +108 -0
  98. package/test/summarize.test.js +350 -0
  99. package/test/supersede-state-tracking.test.js +4 -4
  100. package/test/tools.test.js +16 -1
package/lib/summarize.js CHANGED
@@ -4,6 +4,8 @@ import { STR, langOf } from "./lang.js";
4
4
  // 重试一次不带 effort 字段)与 describeStreamFailure(流失败原因归一)——
5
5
  // 三条链路同一降级语义,不另造第二份实现。
6
6
  import { withEffortFallback, describeStreamFailure, EFFORT_REJECT_RE } from "./dream.js";
7
+ // v0.9.0(M.O. 项目记忆):蒸馏写入复用工具路径那把作用域解析器,见 createSummarizer。
8
+ import { createScopeResolver } from "./scope.js";
7
9
 
8
10
  // 编码记忆蒸馏 prompt(codingRetrospect 开启时启用):在通用记忆之外,额外提取
9
11
  // 三类编码专属记忆,专治重复踩坑 / 遗忘被否决方案 / 丢失工程约束。字段仍沿用
@@ -225,6 +227,83 @@ function eventSeq(event) {
225
227
 
226
228
  const SUBAGENT_MESSAGE_KINDS = new Set(["agent-message", "subagent-settled"]);
227
229
 
230
+ // 兼容严格形状 [{type:"text",text}] 与宽松形状 ["字符串", ...](lib-smoke 用例
231
+ // 直接传字符串数组)。只取公开文本块;reasoning 私有推理块不进蒸馏上下文。
232
+ function textOf(content) {
233
+ if (typeof content === "string") return content;
234
+ if (!Array.isArray(content)) return "";
235
+ return content
236
+ .map((block) => (typeof block === "string" ? block : (block && block.type === "text" && typeof block.text === "string" ? block.text : "")))
237
+ .filter((s) => s)
238
+ .join("\n");
239
+ }
240
+
241
+ /**
242
+ * Issue #399:公开会话正文的**单一隐私谓词**。
243
+ *
244
+ * 渲染(喂给 LLM 的转录)与落盘(distill_records 原始记录)必须走同一份判定,
245
+ * 各写一遍必然漂移——而漂移方向是「存下来的比 LLM 看过的更多」,也就是把从未
246
+ * 交给模型的内容留在盘上,是最糟的那个方向。所以这里只回答一个问题:这个事件
247
+ * 里有没有**可公开的会话正文**,是什么角色、什么原文;裁剪与格式化交给调用方。
248
+ *
249
+ * 返回 `[]` 表示不含可公开正文,包含三种情形:
250
+ * - 机器注入内容(AGENTS.md、skill 正文、文件变更通知):source.kind 不是
251
+ * user / agent-message / subagent-settled,整个事件被挡在门外;
252
+ * - 私有推理(assistant 的 reasoning 块):textOf 只认 `type:"text"` 块,
253
+ * 思考链在两条路径上都到不了;
254
+ * - 工具调用与结果、code 输出:它们是**渲染专属**内容(codingRetrospect 需要
255
+ * 模型看见工具报错才能沉淀踩坑根因),但 #399 明确不落盘(已知边界 3),
256
+ * 因此由 collectMessages 的渲染分支单独处理,不进这个函数。
257
+ *
258
+ * @returns {Array<{role: "user"|"assistant"|"agent", text: string, key?: string}>}
259
+ * text 是**未截断**原文;key 仅子会话消息用于跨事件去重。
260
+ */
261
+ function publicTranscriptParts(event) {
262
+ const data = event?.data ?? {};
263
+ const kind = data?.source?.kind;
264
+ const parts = [];
265
+ const pushAgentMessage = (message) => {
266
+ const messageKind = message?.source?.kind;
267
+ if (!SUBAGENT_MESSAGE_KINDS.has(messageKind)) return;
268
+ const text = textOf(message?.content);
269
+ if (!text.trim()) return;
270
+ parts.push({
271
+ role: "agent",
272
+ text,
273
+ // 同一条子会话消息可能同时出现在 user/message 与 agent/inbox/spliced
274
+ // 里(投递路径重叠),按 id(无 id 时按内容)去重,否则转录会重复。
275
+ key: typeof message?.id === "string" && message.id
276
+ ? `id:${message.id}`
277
+ : `content:${messageKind}:${text}`
278
+ });
279
+ };
280
+ switch (event?.type) {
281
+ case "user/message": {
282
+ if (kind !== undefined && kind !== "user" && !SUBAGENT_MESSAGE_KINDS.has(kind)) break;
283
+ const text = textOf(data?.content);
284
+ if (!text.trim()) break;
285
+ if (SUBAGENT_MESSAGE_KINDS.has(kind)) pushAgentMessage(data);
286
+ else parts.push({ role: "user", text });
287
+ break;
288
+ }
289
+ case "agent/inbox/spliced": {
290
+ const inserted = Array.isArray(data?.inserted) ? data.inserted : [];
291
+ for (const message of inserted) pushAgentMessage(message);
292
+ break;
293
+ }
294
+ case "assistant/message": {
295
+ const blocks = Array.isArray(data?.message?.content) ? data.message.content : [];
296
+ const text = textOf(blocks);
297
+ // 私有推理块(reasoning)刻意不采集:蒸馏记忆不得沉淀模型私有思考链。
298
+ if (text.trim()) parts.push({ role: "assistant", text });
299
+ break;
300
+ }
301
+ default:
302
+ break;
303
+ }
304
+ return parts;
305
+ }
306
+
228
307
  // DSH 0.1.2-rc.1 起 Session 改用 snapshotEvents(),兼容旧版 .events。
229
308
  // snapshotEvents 的范围参数按 seq 传入;再做一次事件级过滤,是为了兼容
230
309
  // 旧实现忽略参数、或把边界解释为闭区间的情况,同时避免用数组下标充当游标。
@@ -253,16 +332,6 @@ function collectMessages(session, maxChars = 8000, language = "zh", afterSeq, th
253
332
  const events = snapshotSessionEvents(session, afterSeq, throughSeq);
254
333
  const lines = [];
255
334
  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
335
  const trim = (s, n) => (typeof s === "string" && s.length > n ? `${s.slice(0, n)}…` : s);
267
336
  const trimTranscript = (s, n) => {
268
337
  if (typeof s !== "string" || s.length <= n) return s;
@@ -283,29 +352,22 @@ function collectMessages(session, maxChars = 8000, language = "zh", afterSeq, th
283
352
  };
284
353
  for (const event of events) {
285
354
  const data = event?.data ?? {};
286
- const kind = data?.source?.kind;
287
- switch (event.type) {
288
- case "user/message": {
289
- if (kind !== undefined && kind !== "user" && !SUBAGENT_MESSAGE_KINDS.has(kind)) break;
290
- const text = textOf(data?.content);
291
- if (!text.trim()) break;
292
- if (SUBAGENT_MESSAGE_KINDS.has(kind)) appendSubagentMessage(data);
293
- else lines.push(STR.transcriptUser[language](text));
294
- break;
295
- }
296
- case "agent/inbox/spliced": {
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;
355
+ // #399:用户提示 / 助手公开正文 / 子会话交付三种可公开内容由共享谓词产出,
356
+ // 渲染与落盘因此不可能对「什么算公开」产生分歧(见 publicTranscriptParts)。
357
+ for (const part of publicTranscriptParts(event)) {
358
+ if (part.role === "agent") {
359
+ if (part.key && seenSubagentMessages.has(part.key)) continue;
360
+ if (part.key) seenSubagentMessages.add(part.key);
361
+ lines.push(STR.transcriptAgent[language](part.text));
362
+ } else if (part.role === "user") {
363
+ lines.push(STR.transcriptUser[language](part.text));
364
+ } else {
365
+ lines.push(STR.transcriptAssistant[language](part.text));
308
366
  }
367
+ }
368
+ // 渲染专属内容:工具调用/结果与 code 输出进 LLM 转录(codingRetrospect 靠
369
+ // 它们沉淀踩坑根因),但不进 distill_records——#399 已知边界 3 明说不覆盖。
370
+ switch (event.type) {
309
371
  case "tool/call": {
310
372
  const args = typeof data.arguments === "string"
311
373
  ? data.arguments
@@ -351,6 +413,56 @@ function collectMessages(session, maxChars = 8000, language = "zh", afterSeq, th
351
413
  };
352
414
  }
353
415
 
416
+ /**
417
+ * Issue #399:把当前蒸馏窗口里**可公开的会话正文**收集成待落盘记录。
418
+ *
419
+ * 与 collectMessages 共用 publicTranscriptParts,但**不截断**:留存的意义就是
420
+ * 整段原文(超长用户提示在记录里必须全文,而喂给 LLM 的转录仍受 distillMaxChars
421
+ * 约束)——两条路径在裁剪上的差异是设计的一部分,测试锁 3 显式覆盖。
422
+ *
423
+ * 只收有 seq 的事件:distill_records 的主键是 (session_id, seq),seq 也是溯源
424
+ * 区间与幂等的唯一依据;没有 seq 就无法表达「这条记录属于哪个窗口」。
425
+ */
426
+ function collectTranscriptRecords(session, afterSeq, throughSeq, distilledAt) {
427
+ const events = snapshotSessionEvents(session, afterSeq, throughSeq);
428
+ const records = [];
429
+ const seenKeys = new Set();
430
+ // 窗口的 seq 区间取窗口内**全部**事件的 min/max(不只是公开正文那些):这是
431
+ // LLM 实际看到的那个窗口,溯源链接要回答的是「这条记忆出自哪个窗口」。
432
+ let seqStart;
433
+ let seqEnd;
434
+ for (const event of events) {
435
+ const seq = eventSeq(event);
436
+ if (seq !== undefined) {
437
+ if (seqStart === undefined || seq < seqStart) seqStart = seq;
438
+ if (seqEnd === undefined || seq > seqEnd) seqEnd = seq;
439
+ }
440
+ if (seq === undefined) continue;
441
+ const parts = [];
442
+ for (const part of publicTranscriptParts(event)) {
443
+ if (part.key) {
444
+ if (seenKeys.has(part.key)) continue;
445
+ seenKeys.add(part.key);
446
+ }
447
+ parts.push(part);
448
+ }
449
+ if (!parts.length) continue;
450
+ // 主键是 (session_id, seq):一个事件一行。agent/inbox/spliced 一次可投递多条
451
+ // 子会话消息(同 seq、同 role),各自成行会被 INSERT OR IGNORE 静默丢掉后几条;
452
+ // 按事件合并成一行才是这套主键的真实语义(角色同事件内不会混:user/message 与
453
+ // assistant/message 各只产出一条公开 part)。
454
+ records.push({
455
+ sessionId: session.id,
456
+ seq,
457
+ role: parts[0].role,
458
+ text: parts.map((part) => part.text).join("\n"),
459
+ windowId: `session:${session.id}:${seq}`,
460
+ distilledAt
461
+ });
462
+ }
463
+ return { records, seqStart, seqEnd };
464
+ }
465
+
354
466
  // ── 智能调速器(429 保护)─────────────────────────────────────────────
355
467
  // 对话一多时 turn/end 会批量触发蒸馏,多个 LLM 请求"一拥而上"正是 429 的
356
468
  // 来源。这里借鉴机场安检的思路:所有蒸馏调用进同一个全局串行队列,按间隔
@@ -401,6 +513,16 @@ export function createSummarizer(ctx, service, config, deps = {}) {
401
513
  const setTimer = typeof deps.setTimeoutFn === "function" ? deps.setTimeoutFn : setTimeout;
402
514
  const clearTimer = typeof deps.clearTimeoutFn === "function" ? deps.clearTimeoutFn : clearTimeout;
403
515
 
516
+ // v0.9.0(M.O. 项目记忆):蒸馏写入也要盖作用域章。此前只有 memory_save 那条工具
517
+ // 路径盖章(tools.js 的 memory_save 里 resolveSessionScope 到 workspaceLabel 那一段,
518
+ // 2026-10-13 在 :173-193),而机器提炼是写入量的绝大多数——于是「项目记忆」
519
+ // 在库里根本不存在(全库带 scope 的行不足 3%,且带 scope 的全部来自工具路径)。
520
+ // 这里不另造口径:同一把解析器(scope.js 的 createScopeResolver,2026-10-13 在 :24)、同一把闸门
521
+ // (config.scopeEnabled === true)、同一对列(*_scope + *_scope_source='auto'),
522
+ // 与工具路径逐字对齐。解析器的入参形状是「工具 exec」(内部读 exec.agent.session),
523
+ // 蒸馏路径手里直接有会话对象,所以包一层壳喂进去——不改解析器的公共契约。
524
+ const resolveSessionScope = createScopeResolver({ ctx, config, logger: ctx.logger });
525
+
404
526
  const inFlight = new Map();
405
527
  // Issue #127:per-session 上一次实际开跑时刻(最小间隔闸门用)。与 inFlight
406
528
  // 同生命周期,dispose 时一并清空。
@@ -574,23 +696,28 @@ export function createSummarizer(ctx, service, config, deps = {}) {
574
696
  // 判定纯规则(字符数),无模型参与;被挡下的窗口照常消费游标,否则每个
575
697
  // turn/end 都会重新评估同一段短文本。skip 原因进审计,作为后续按真实负载
576
698
  // 决定下一级规则的依据。
699
+ //
700
+ // 字符数改为无条件计算并写进审计 metadata:它是「下一级预判该不该开、阈值
701
+ // 该给多少」的唯一依据,而原先开关一关就一个数都不留(维护者本机 1240 条
702
+ // summarize_compress 成功行的 metadata 里窗口字符数为 0 条),验收 4「默认配置
703
+ // 下调用量可量化下降」因此无法对账。本改动只加计量,不动任何跳过决策——
704
+ // summarizeMinWindowChars 仍为 0 时行为逐字节不变。
705
+ const distillChars = messages.reduce(
706
+ (total, message) => total + (Array.isArray(message.content) ? message.content : [])
707
+ .reduce((n, part) => n + (typeof part?.text === "string" ? part.text.length : 0), 0),
708
+ 0
709
+ );
577
710
  const minWindowChars = config.summarizeMinWindowChars ?? 0;
578
- if (minWindowChars > 0) {
579
- const distillChars = messages.reduce(
580
- (total, message) => total + (Array.isArray(message.content) ? message.content : [])
581
- .reduce((n, part) => n + (typeof part?.text === "string" ? part.text.length : 0), 0),
582
- 0
583
- );
584
- if (distillChars < minWindowChars) {
585
- commitCursor(session.id, nextSeq);
586
- writeAudit({
587
- timestamp: new Date().toISOString(),
588
- model_id: route ? `${route.provider}:${route.model}` : "unknown",
589
- status: "skipped",
590
- error_message: "window-too-small"
591
- });
592
- return;
593
- }
711
+ if (minWindowChars > 0 && distillChars < minWindowChars) {
712
+ commitCursor(session.id, nextSeq);
713
+ writeAudit({
714
+ timestamp: new Date().toISOString(),
715
+ model_id: route ? `${route.provider}:${route.model}` : "unknown",
716
+ status: "skipped",
717
+ error_message: "window-too-small",
718
+ metadata: { distillChars, minWindowChars }
719
+ });
720
+ return;
594
721
  }
595
722
  // 只有真正准备发起一次 LLM 蒸馏时才占用最小间隔与 run 预算;失败路径仍按
596
723
  // 原语义占用(调用确实发生过),aborted 则由 finally 回滚。
@@ -758,6 +885,13 @@ export function createSummarizer(ctx, service, config, deps = {}) {
758
885
  const dedupeMode = config.summarizeDedupeMode ?? "off";
759
886
  let deduped = 0;
760
887
  let dedupeMaxSim = 0;
888
+ // v0.9.0(M.O. 项目记忆):一次窗口解析一次——同一蒸馏窗口里所有条目同属一个
889
+ // 工作区、一个 agent 身份,逐条解析是白花钱。解析不到就落 NULL(NULL = 未标注 =
890
+ // 全局可见),且解析器绝不抛错(scope.js 的硬约束:记忆写入不该被作用域拖挂)。
891
+ // 闸门与 tools.js 的 autoStamping(2026-10-13 在 :180)同一把:关着就一个字段都不加,老行为不变。
892
+ const writeScope = config?.scopeEnabled === true
893
+ ? resolveSessionScope({ agent: { session } })
894
+ : null;
761
895
  const writes = [];
762
896
  for (const entry of entries) {
763
897
  // Provenance: the summarizer runs on a real session (turn/end hook), so
@@ -787,7 +921,57 @@ export function createSummarizer(ctx, service, config, deps = {}) {
787
921
  }
788
922
  writes.push({ entry, source, dup });
789
923
  }
790
- if (writes.length) {
924
+ // #399:蒸馏窗口的原始公开记录 + 记忆级溯源链接,与记忆写入同事务落盘。
925
+ // 凭证与它的派生记忆必须同生共死:记忆写了而记录没写,溯源就断在「不知道
926
+ // 出自哪段原文」;记录写了而记忆没写,盘上留一份没有索引的原文。默认关
927
+ // (enabled !== true)时连收集都不做,一次 SQL 都不发(测试锁 5)。
928
+ const distilledAt = new Date(now()).toISOString();
929
+ const transcript = config?.distillRecords?.enabled === true
930
+ && typeof service.saveDistillRecords === "function"
931
+ ? collectTranscriptRecords(session, previousSeq, triggerSeq, distilledAt)
932
+ : null;
933
+ const writeWindow = () => {
934
+ const savedIds = [];
935
+ for (const { entry, source, dup } of writes) {
936
+ const outcome = service.saveWithDedupe({
937
+ ...entry,
938
+ source,
939
+ // 合并进既有行时不盖章,但这件事由 saveWithDedupe 的合并分支自己保证
940
+ // (它只认 explicit 来源升级,从不采纳这里传的 auto 字段)——调用方不能
941
+ // 靠「省略字段」来表达这个意图,因为省略会反向把合并也挡掉:
942
+ // findSessionDuplicate 只按 type+source+时间窗找候选,不看 scope;找到
943
+ // 候选后由 saveWithDedupe 的 scopeMatches 门决定并或不并。这里一省略,
944
+ // 新条目的 scope 就是 null,与已盖过章的候选不等,门必拒 → 回落新建,
945
+ // 而那一行同样是新行、同样该带章,却没有章可带。实测两轮蒸馏正是如此:
946
+ // 第二轮命中第一轮、门拒后新建,留下同题两行一行 auto 一行 NULL——
947
+ // 作用域隔离没做成,重复倒留下了。传字段、让合并分支按自己的口径处理,
948
+ // 才是「不重贴」与「新行必贴」两个要求的唯一相容解。
949
+ ...(writeScope ? { agent_scope: writeScope.agent_scope, agent_scope_source: "auto" } : {}),
950
+ ...(writeScope ? { workspace_scope: writeScope.workspace_scope, workspace_scope_source: "auto" } : {}),
951
+ ...(dup ? { _mergeInto: dup.memory.id } : {})
952
+ });
953
+ // created 与 merged 两条成功分支都返回 memory(含 id);merged 时 id 是
954
+ // 被并入的既有记忆——本次窗口的原文确实贡献了它,链接它是诚实的。
955
+ if (outcome?.memory?.id) savedIds.push(outcome.memory.id);
956
+ }
957
+ if (transcript && transcript.records.length) {
958
+ service.saveDistillRecords(transcript.records);
959
+ if (transcript.seqStart !== undefined && transcript.seqEnd !== undefined
960
+ && typeof service.saveMemoryProvenance === "function") {
961
+ for (const memoryId of savedIds) {
962
+ service.saveMemoryProvenance({
963
+ memoryId,
964
+ sessionId: session.id,
965
+ seqStart: transcript.seqStart,
966
+ seqEnd: transcript.seqEnd,
967
+ createdAt: distilledAt
968
+ });
969
+ }
970
+ }
971
+ }
972
+ persistCursor(session.id, nextSeq);
973
+ };
974
+ if (writes.length || (transcript && transcript.records.length)) {
791
975
  // 查询去重目标可以异步执行,但记忆写入必须在同一个事务中完成。
792
976
  // 否则第 N 条写入失败时,前 N-1 条会残留,而重试同一 seq 窗口会重复
793
977
  // 追加它们。createService 始终提供 transaction;缺失时直接失败并保留
@@ -795,22 +979,17 @@ export function createSummarizer(ctx, service, config, deps = {}) {
795
979
  if (typeof service.transaction !== "function") {
796
980
  throw new Error("dsh-mneme: atomic summarization writes require service.transaction");
797
981
  }
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
- });
982
+ service.transaction(writeWindow);
808
983
  } else {
809
984
  // 空数组是合法成功:没有记忆写入,但本次事件窗口仍然应被持久消费。
810
985
  persistCursor(session.id, nextSeq);
811
986
  }
812
- if (audit && (capped > 0 || deduped > 0 || parsedResult.salvaged)) {
987
+ // Issue #239 定标:窗口字符数无条件随成功行落进 metadata。原先只有
988
+ // capped / deduped / salvaged 其中一项发生时才建 metadata,默认档位下
989
+ // 一条都不留——下一级预判规则的阈值就无从定标。
990
+ if (audit) {
813
991
  audit.metadata = {
992
+ distillChars,
814
993
  parsed: parsed.length,
815
994
  ...(parsedResult.salvaged ? { json_salvaged: true } : {}),
816
995
  ...(capped > 0 ? { capped } : {}),
package/lib/tools.js CHANGED
@@ -7,6 +7,7 @@ import { matchesPlatform } from "./runtime/closure.js";
7
7
  import { verifyPayload } from "./runtime/verify.js";
8
8
  import { TOOL_GUIDE, CONTINUITY_TOOL_RULE } from "./guide.js";
9
9
  import { injectChildEnabled } from "./config.js";
10
+ import { lastUserQuery } from "./inject.js";
10
11
 
11
12
  const TEXT_OUTPUT = (text) => [{ type: "text", text }];
12
13
  // Per-registry tool-name registry: guards against duplicate registration on
@@ -381,6 +382,14 @@ export function createTools(ctx, service, config, embedder) {
381
382
  render: (_args, value) => TEXT_OUTPUT(`Updated memory ${value.memory.id}: ${value.memory.title}`)
382
383
  },
383
384
  async execute(args) {
385
+ // v0.9.0:失败表的 query 是「当时到底想干什么」唯一的落点,而 service
386
+ // 层看不到会话(它只认 ctx.query,见 src/service.js 的 saveFailure 注释)。
387
+ // 工具层是唯一同时握着改写动作与本轮会话的入口:没给 reason 就退回当轮用户
388
+ // 原话,取法与注入侧共用同一份实现(src/inject.js 的 lastUserQuery),不复制
389
+ // 第二份。两边都取不到时仍然写 NULL——空串会假装「有意图」,NULL 才说实话。
390
+ const exec = arguments[1];
391
+ const reason = typeof args.reason === "string" ? args.reason.trim() : "";
392
+ const query = reason || lastUserQuery(exec) || undefined;
384
393
  const memory = service.update(args.id, {
385
394
  title: args.title,
386
395
  content: args.content,
@@ -389,10 +398,10 @@ export function createTools(ctx, service, config, embedder) {
389
398
  importance: args.importance,
390
399
  // 复核项 4(#170):strictScope 下他 scope 的行按「不存在」处理(与 get 对齐),
391
400
  // 展开恒为 undefined,仅承担写入前校验。
392
- ...(assertVisibleForTool(args.id, arguments[1])),
401
+ ...(assertVisibleForTool(args.id, exec)),
393
402
  ...(args.agent_scope !== undefined ? { agent_scope: args.agent_scope } : {}),
394
403
  ...(args.workspace_scope !== undefined ? { workspace_scope: args.workspace_scope } : {})
395
- }, { query: args.reason });
404
+ }, { query });
396
405
  return { memory: { id: memory.id, title: memory.title, content: memory.content } };
397
406
  }
398
407
  }),
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.15",
4
+ "version": "0.9.0",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -0,0 +1,197 @@
1
+ // 发布闸门:publish 步之后**必须**确认这个版本真的能被 registry 看见,看不见就整轮失败。
2
+ //
3
+ // 为什么需要它:原先这一步是
4
+ // echo "Published: $(npm view "@modusensus/dsh-mneme@${V}" version)"
5
+ // 命令替换里的失败**不会**让 `set -e` 退出。v0.8.16 那次日志白纸黑字写着
6
+ // `npm error code E404 / 404 No match found for version 0.8.16`,step 与 job 却都是绿的。
7
+ // 也就是说「真发不出去」(token 失效、权限不足、npm 侧拒绝)与「发出去了」在 CI 上是同一种
8
+ // 颜色,只有人翻日志才看得出来 —— 闸门的意义恰恰是没人翻日志的时候。
9
+ //
10
+ // 为什么是轮询而不是一次性查询:registry 有传播延迟,v0.8.16 实测 publish 到可见约 4.5 分钟
11
+ // (11:15:52Z → 11:20:22Z)。一次性查询只有两种结局:忽略失败(就是现在的假绿),或者拿传播
12
+ // 延迟当发布失败(假红)。幂等跳过那条路径(版本已存在)**首次探测即通过**,所以只有真发新版
13
+ // 才付等待成本。
14
+ //
15
+ // 为什么探测走 registry 公开 HTTP 面而不是 `npm view`:不依赖 .npmrc 里的 token、不依赖 npm CLI
16
+ // 的 E404 语义,而且能把「还没传播」与「网络异常」分开报出来 —— 两种都重试,但日志里要能区分,
17
+ // 否则排障时又得靠猜。
18
+ //
19
+ // 与 scripts/changelog-prep.mjs 同一个理由:逻辑收在可注入的模块里才测得到(probe/sleep/now 全
20
+ // 可注入,单测零网络零等待),入口只负责读参数与决定退出码。
21
+
22
+ import { pathToFileURL } from "node:url";
23
+
24
+ export const PACKAGE_NAME = "@modusensus/dsh-mneme";
25
+ export const REGISTRY_ORIGIN = "https://registry.npmjs.org";
26
+
27
+ /** 默认 10 分钟:实测传播 ~4.5 分钟,留一倍余量;超时=发布失败,宁可红也不许假绿。 */
28
+ export const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
29
+ /** 默认 15 秒:一次发布最多 40 次探测,日志行数与 registry 负载都可接受。 */
30
+ export const DEFAULT_INTERVAL_MS = 15 * 1000;
31
+ /** 单次探测的 HTTP 超时:挂死的请求必须自己失败,不能吃掉整段预算。 */
32
+ export const DEFAULT_PROBE_TIMEOUT_MS = 15 * 1000;
33
+
34
+ /**
35
+ * 单版本的 registry URL。scoped 包名必须整体 encodeURIComponent(`@scope/name` → `%40scope%2Fname`):
36
+ * 直接拼裸 `/` 会被当成路径分隔符,registry 回 404 —— 那正是「闸门自己造成的假红」。
37
+ */
38
+ export function versionUrl(name, version, origin = REGISTRY_ORIGIN) {
39
+ return `${origin.replace(/\/+$/, "")}/${encodeURIComponent(name)}/${encodeURIComponent(version)}`;
40
+ }
41
+
42
+ /**
43
+ * 探测一次「该版本是否已在 registry 上」。
44
+ *
45
+ * 永不抛错:任何异常都折成 `{ok:false, reason, detail}`,让上层的重试与超时逻辑保持单一形状。
46
+ * `reason` 取值:`missing`(404,多半是还没传播) / `network`(请求本身失败) / `http`(非 2xx/404) /
47
+ * `bad-json` / `mismatch`(200 但版本号对不上)。
48
+ *
49
+ * @param {string} version 如 "0.8.16"
50
+ * @param {{name?: string, origin?: string, fetchImpl?: typeof fetch, timeoutMs?: number}} [options]
51
+ * @returns {Promise<{ok: true, version: string, tarball: string} | {ok: false, reason: string, detail: string}>}
52
+ */
53
+ export async function probeVersion(version, {
54
+ name = PACKAGE_NAME,
55
+ origin = REGISTRY_ORIGIN,
56
+ fetchImpl = globalThis.fetch,
57
+ timeoutMs = DEFAULT_PROBE_TIMEOUT_MS
58
+ } = {}) {
59
+ const url = versionUrl(name, version, origin);
60
+ let response;
61
+ try {
62
+ response = await fetchImpl(url, {
63
+ headers: { accept: "application/json" },
64
+ signal: AbortSignal.timeout(timeoutMs)
65
+ });
66
+ } catch (error) {
67
+ return { ok: false, reason: "network", detail: `请求失败:${error?.message ?? error}(${url})` };
68
+ }
69
+ if (response.status === 404) {
70
+ return { ok: false, reason: "missing", detail: `HTTP 404,registry 上还看不到(${url})` };
71
+ }
72
+ if (!response.ok) {
73
+ return { ok: false, reason: "http", detail: `HTTP ${response.status}(${url})` };
74
+ }
75
+ let body;
76
+ try {
77
+ body = await response.json();
78
+ } catch (error) {
79
+ return { ok: false, reason: "bad-json", detail: `响应不是 JSON:${error?.message ?? error}` };
80
+ }
81
+ if (body?.version !== version) {
82
+ return { ok: false, reason: "mismatch", detail: `registry 返回的版本是 ${body?.version ?? "(缺失)"}` };
83
+ }
84
+ return { ok: true, version, tarball: body?.dist?.tarball ?? "" };
85
+ }
86
+
87
+ /**
88
+ * 轮询到版本可见为止;超时即 `{ok:false, reason:"timeout"}`。
89
+ *
90
+ * 契约(单测锁的就是这条):**看不见就绝不返回 ok:true**。任何把超时折叠成成功、或者把
91
+ * 「探测失败」当成「跳过校验」的改法,都等于退回 v0.8.16 那种假绿。
92
+ *
93
+ * @param {string} version
94
+ * @param {{name?: string, origin?: string, timeoutMs?: number, intervalMs?: number,
95
+ * now?: () => number, sleep?: (ms: number) => Promise<void>,
96
+ * probe?: () => Promise<{ok: boolean, detail?: string}>, log?: (line: string) => void}} [options]
97
+ */
98
+ export async function waitForPublished(version, {
99
+ name = PACKAGE_NAME,
100
+ origin = REGISTRY_ORIGIN,
101
+ timeoutMs = DEFAULT_TIMEOUT_MS,
102
+ intervalMs = DEFAULT_INTERVAL_MS,
103
+ now = () => Date.now(),
104
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
105
+ probe = () => probeVersion(version, { name, origin }),
106
+ log = () => {}
107
+ } = {}) {
108
+ const startedAt = now();
109
+ let attempts = 0;
110
+ let lastError = "";
111
+ for (;;) {
112
+ attempts += 1;
113
+ const result = await probe();
114
+ // 探测成功即算通过,**不再回头比期限**:预算是「等多久才放弃」,不是「多久之后就算没发布」。
115
+ // 单次探测自带 HTTP 超时(默认 15 秒),所以晚到的成功最多超出预算十几秒;而版本真的在
116
+ // registry 上就等于发布成功,因为晚到几秒把它判红才是假红——这个闸门存在的理由正是消灭
117
+ // 假绿,不该顺手制造另一种失真。反过来,探测失败仍严格按预算收口(见下)。
118
+ if (result?.ok) {
119
+ return { ok: true, version, attempts, waitedMs: now() - startedAt, tarball: result.tarball ?? "" };
120
+ }
121
+ lastError = result?.detail ?? "未知原因";
122
+ const waitedMs = now() - startedAt;
123
+ log(`第 ${attempts} 次探测(已等 ${Math.round(waitedMs / 1000)}s):${lastError}`);
124
+ if (waitedMs >= timeoutMs) {
125
+ return { ok: false, reason: "timeout", attempts, waitedMs, lastError };
126
+ }
127
+ // 只睡到预算耗尽为止:最后一次探测落在超时点上,不额外多睡一整轮。
128
+ await sleep(Math.min(intervalMs, timeoutMs - waitedMs));
129
+ }
130
+ }
131
+
132
+ /**
133
+ * 入口参数:位置参数是版本号,另可覆盖 `--timeout-ms` / `--interval-ms`(排障与手动跑用)。
134
+ * 缺版本号如实回报 `missing-version`,由入口打印用法并退出 1。
135
+ */
136
+ export function parseArgs(argv = []) {
137
+ const out = { version: "", timeoutMs: DEFAULT_TIMEOUT_MS, intervalMs: DEFAULT_INTERVAL_MS };
138
+ const positional = [];
139
+ for (let i = 0; i < argv.length; i += 1) {
140
+ const arg = argv[i];
141
+ if (arg === "--timeout-ms" || arg === "--interval-ms") {
142
+ const key = arg === "--timeout-ms" ? "timeoutMs" : "intervalMs";
143
+ const value = Number(argv[i + 1]);
144
+ if (!Number.isFinite(value) || value <= 0) return { ok: false, reason: `${arg} 需要一个正数` };
145
+ out[key] = value;
146
+ i += 1;
147
+ continue;
148
+ }
149
+ positional.push(arg);
150
+ }
151
+ if (positional.length !== 1) return { ok: false, reason: "missing-version" };
152
+ out.version = positional[0];
153
+ return { ok: true, ...out };
154
+ }
155
+
156
+ /** 入口:CI 与手动都用它;失败一律 `process.exit(1)`,绝不吞掉。 */
157
+ export async function main(argv = process.argv.slice(2), {
158
+ log = console.log,
159
+ error = console.error,
160
+ wait = waitForPublished
161
+ } = {}) {
162
+ const parsed = parseArgs(argv);
163
+ if (!parsed.ok) {
164
+ error(`用法:node scripts/publish-gate.mjs <版本号> [--timeout-ms 600000] [--interval-ms 15000]`);
165
+ error(`参数有误:${parsed.reason}`);
166
+ return 1;
167
+ }
168
+ log(`等待 ${PACKAGE_NAME}@${parsed.version} 在 registry 上可见(最多 ${parsed.timeoutMs / 1000}s)…`);
169
+ const result = await wait(parsed.version, {
170
+ timeoutMs: parsed.timeoutMs,
171
+ intervalMs: parsed.intervalMs,
172
+ log
173
+ });
174
+ if (!result.ok) {
175
+ error(
176
+ `发布闸门未通过:${PACKAGE_NAME}@${parsed.version} 在 ${Math.round(result.waitedMs / 1000)}s 内` +
177
+ `(${result.attempts} 次探测)始终看不到 —— 最后一次:${result.lastError}`
178
+ );
179
+ error(`publish 步很可能失败了(token 失效 / 权限不足 / npm 侧拒绝),请翻上面 publish 步的日志。`);
180
+ return 1;
181
+ }
182
+ const waitedSec = Math.round(result.waitedMs / 1000);
183
+ log(`✓ ${PACKAGE_NAME}@${result.version} 已在 registry 上可见(第 ${result.attempts} 次探测,等了 ${waitedSec}s)`);
184
+ if (result.tarball) log(` tarball: ${result.tarball}`);
185
+ return 0;
186
+ }
187
+
188
+ // import 即执行会让单测没法只 import 纯函数(changelog-prep.mjs 的同一教训),故用主模块判别。
189
+ // 比较用 pathToFileURL:手拼 `file://${argv[1]}` 在本机(Windows 盘符 + 非 ASCII 路径)拼不出合法 URL,
190
+ // 判别会恒假 —— 那样的失败是静默的:脚本什么也不做,还退出 0。
191
+ //
192
+ // 用 `process.exitCode` 而不是 `process.exit()`:刚做完 HTTP 请求就硬退,会在 Windows 上撞到
193
+ // libuv 的 `Assertion failed: !(handle->flags & UV_HANDLE_CLOSING)`(实测 0.8.16 成功路径复现),
194
+ // 退出码变成 0xC0000409 而不是 0 —— 一个把成功报成崩溃的退出路径,正好与本次要修的假绿同类。
195
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
196
+ process.exitCode = await main();
197
+ }