@modusensus/dsh-mneme 0.9.5 → 0.10.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 (81) hide show
  1. package/README.md +9 -5
  2. package/docs/CONFIGURATION.md +21 -0
  3. package/docs/DAEMON.md +6 -2
  4. package/docs/DISTILL.md +90 -6
  5. package/docs/SLEEP.md +20 -6
  6. package/docs/STORAGE.md +1 -0
  7. package/lib/api-standalone.js +11 -3
  8. package/lib/api.js +25 -5
  9. package/lib/client.js +137 -10
  10. package/lib/config.js +33 -1
  11. package/lib/continuity.js +11 -3
  12. package/lib/dream/decisions.js +88 -27
  13. package/lib/dream/sleep.js +467 -81
  14. package/lib/dream.js +110 -13
  15. package/lib/fence.js +24 -0
  16. package/lib/index.js +15 -6
  17. package/lib/inject.js +9 -4
  18. package/lib/lang.js +17 -12
  19. package/lib/pipeline-nudge-auto.js +3 -0
  20. package/lib/pipeline-nudge.js +41 -8
  21. package/lib/pipeline-runner.js +15 -0
  22. package/lib/pre-inject-gate.js +14 -0
  23. package/lib/profile-extract.js +4 -1
  24. package/lib/provenance-http.js +162 -37
  25. package/lib/service.js +30 -0
  26. package/lib/session-gate.js +91 -0
  27. package/lib/settings.js +13 -1
  28. package/lib/store.js +526 -18
  29. package/lib/summarize.js +152 -15
  30. package/lib/tools.js +26 -6
  31. package/package.json +4 -2
  32. package/src/api-standalone.js +11 -3
  33. package/src/api.js +25 -5
  34. package/src/config.js +33 -1
  35. package/src/continuity.js +11 -3
  36. package/src/dream/decisions.js +88 -27
  37. package/src/dream/sleep.js +467 -81
  38. package/src/dream.js +110 -13
  39. package/src/fence.js +24 -0
  40. package/src/index.js +15 -6
  41. package/src/inject.js +9 -4
  42. package/src/lang.js +17 -12
  43. package/src/pipeline-nudge-auto.js +3 -0
  44. package/src/pipeline-nudge.js +41 -8
  45. package/src/pipeline-runner.js +15 -0
  46. package/src/pre-inject-gate.js +14 -0
  47. package/src/profile-extract.js +4 -1
  48. package/src/provenance-http.js +162 -37
  49. package/src/service.js +30 -0
  50. package/src/session-gate.js +91 -0
  51. package/src/settings.js +13 -1
  52. package/src/store.js +526 -18
  53. package/src/summarize.js +152 -15
  54. package/src/tools.js +26 -6
  55. package/test/api.test.js +153 -4
  56. package/test/client.test.js +215 -0
  57. package/test/config.test.js +8 -0
  58. package/test/conflict-reviewed.test.js +79 -0
  59. package/test/continuity.test.js +29 -1
  60. package/test/dependency-overrides.test.js +78 -0
  61. package/test/differentiate-feedback.test.js +248 -0
  62. package/test/dream-decision-evidence.test.js +314 -0
  63. package/test/dream-fence.test.js +43 -0
  64. package/test/inject-pools.test.js +2 -1
  65. package/test/mint-source.test.js +83 -0
  66. package/test/pipeline-nudge-auto.test.js +26 -2
  67. package/test/pipeline-nudge.test.js +87 -0
  68. package/test/pipeline-runner.test.js +26 -0
  69. package/test/prompt-stance.test.js +128 -0
  70. package/test/provenance-http.test.js +200 -17
  71. package/test/release-docs.test.js +7 -1
  72. package/test/security-doc.test.js +43 -4
  73. package/test/session-gate.test.js +264 -0
  74. package/test/settings.test.js +4 -0
  75. package/test/sleep-action-set.test.js +16 -11
  76. package/test/sleep-catchup.test.js +315 -0
  77. package/test/sleep-failure-backoff.test.js +156 -0
  78. package/test/sleep.test.js +293 -0
  79. package/test/standalone-api.test.js +50 -0
  80. package/test/store.test.js +220 -0
  81. package/test/summarize.test.js +172 -0
package/lib/summarize.js CHANGED
@@ -6,6 +6,12 @@ import { STR, langOf } from "./lang.js";
6
6
  import { withEffortFallback, describeStreamFailure, EFFORT_REJECT_RE } from "./dream.js";
7
7
  // v0.9.0(M.O. 项目记忆):蒸馏写入复用工具路径那把作用域解析器,见 createSummarizer。
8
8
  import { createScopeResolver } from "./scope.js";
9
+ // #493 补充 §③:断链与巩固侧同一份实现(src/fence.js)。转录里出现闭合标记时
10
+ // 原先能提前闭合围栏——那是把用户消息升格成指令最短的一条路。
11
+ import { breakFenceMarkers } from "./fence.js";
12
+ // #512:每轮蒸馏一个 run id(归因主体)。store 侧也能铸造,但这里必须知道它——
13
+ // 写记忆时要把它一路带到 saveMemoryProvenance / 落 run 行,拿回来再写就是两次往返。
14
+ import { randomUUID } from "node:crypto";
9
15
 
10
16
  // 编码记忆蒸馏 prompt(codingRetrospect 开启时启用):在通用记忆之外,额外提取
11
17
  // 三类编码专属记忆,专治重复踩坑 / 遗忘被否决方案 / 丢失工程约束。字段仍沿用
@@ -18,6 +24,12 @@ import { createScopeResolver } from "./scope.js";
18
24
  // occurred_from 过滤。未知/无法换算的相对时间必须整条省略字段,不能落 null。
19
25
  // 纯函数:唯一外部输入是显式入参 nowMs(测试注入假时钟即可确定性复现)。
20
26
  const OCCURRED_AT_FUTURE_TOLERANCE_MS = 24 * 60 * 60 * 1000;
27
+ // #493 补充 §②:持久化的解析失败计数超过这么久没更新就按 0 算(读侧失效,见
28
+ // store.getDistillParseFailureStreak)。7 天:够长,能跨一次长假/一次停机;够短,
29
+ // 不至于让「停了很久的会话」拿几周前的 streak 直接熔断一个其实健康的窗口。
30
+ // 刻意不做成配置键:护栏本身默认关(summarizeParseFailureMaxRetries=0),一个只为
31
+ // 护栏服务的失效窗口不该再占一档用户可见配置(AGENTS.md 旗标计数锁也要跟着动)。
32
+ const PARSE_FAILURE_STREAK_STALE_MS = 7 * 24 * 60 * 60 * 1000;
21
33
  function normalizeDistilledOccurredAt(raw, nowMs = Date.now()) {
22
34
  if (typeof raw !== "string") return undefined;
23
35
  const text = raw.trim();
@@ -466,7 +478,7 @@ function collectMessages(session, maxChars = 8000, language = "zh", afterSeq, th
466
478
  // 角色重申把「这段文本是被提炼的素材」写进**它所在的那条消息**,而不是
467
479
  // 只写在 system 里——实测同段转录只加 system 约束仍 0/2 出 JSON。
468
480
  // 围栏是确定性的:重试收到的转录与首次逐字节相同(既有回归用例依赖)。
469
- text: `${STR.distillRoleReminder[language]}\n\n${STR.transcriptFenceOpen[language]}\n${transcriptBody}\n${STR.transcriptFenceClose[language]}`
481
+ text: `${STR.distillRoleReminder[language]}\n\n${STR.transcriptFenceOpen[language]}\n${breakFenceMarkers(transcriptBody)}\n${STR.transcriptFenceClose[language]}`
470
482
  }],
471
483
  source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" }
472
484
  })]
@@ -572,7 +584,7 @@ function enqueueDistill(task, intervalMs = 0) {
572
584
  // import + re-export 两行都要。
573
585
  import { parsePeakSpec, isInPeakWindow, nextOffPeakAt } from "./peak-hours.js";
574
586
  export { parsePeakSpec, isInPeakWindow, nextOffPeakAt };
575
- export function createSummarizer(ctx, service, config, deps = {}) {
587
+ export function createSummarizer(ctx, service, config, deps = {}, sessionGate = null) {
576
588
  if (!config.autoSummarize) return { dispose: () => {} };
577
589
 
578
590
  // Issue #239(第 4 项):时钟与定时器可注入——排程不绑死真实时钟,测试才能确定性
@@ -603,9 +615,54 @@ export function createSummarizer(ctx, service, config, deps = {}) {
603
615
  // Issue #210:只在一次蒸馏完整成功后记录已消费的最后事件 seq。
604
616
  const lastDistilledSeq = new Map();
605
617
  // Issue #493:per-session 连续解析失败计数(失败护栏用)。同一窗口在确定性
606
- // 失败下会被每个 turn/end 重试一次、且窗口只会越攒越大——没有出口。成功即
607
- // 清零;dispose 一并清理(与 runsUsed / lastRunAt 同生命周期)。
608
- const parseFailureStreak = new Map();
618
+ // 失败下会被每个 turn/end 重试一次、且窗口只会越攒越大——没有出口。成功即清零。
619
+ //
620
+ // #493 补充 §②:**落库**,不再是进程内 Map。原先的 Map 有两个洞:重启即清零
621
+ // (护栏可被重启绕过,而它防的正是「确定性失败反复重试」),以及进程内无法跨
622
+ // 两个宿主共享同一个库时保持一致。库里读出来的值带读侧失效窗口(见
623
+ // PARSE_FAILURE_STREAK_STALE_MS),停了很久的会话不会被旧 streak 误伤。
624
+ // service 没这一步(旧装配 / 测试替身)时退回进程内 Map,行为与历史版本一致。
625
+ const parseFailureStreakFallback = new Map();
626
+ const persistentStreak = () => typeof service?.getDistillParseFailureStreak === "function"
627
+ && typeof service?.bumpDistillParseFailureStreak === "function"
628
+ && typeof service?.clearDistillParseFailureStreak === "function";
629
+ function readParseFailureStreak(sessionId) {
630
+ if (!persistentStreak()) return parseFailureStreakFallback.get(sessionId) ?? 0;
631
+ try {
632
+ return Number(service.getDistillParseFailureStreak(sessionId, { staleAfterMs: PARSE_FAILURE_STREAK_STALE_MS })) || 0;
633
+ } catch (error) {
634
+ // 读数坏了不许把蒸馏本身拖下水:退回进程内计数(最坏是多试一次)。
635
+ ctx.logger?.warn?.(`dsh-mneme: parse failure streak read failed: ${String(error)}`);
636
+ return parseFailureStreakFallback.get(sessionId) ?? 0;
637
+ }
638
+ }
639
+ function bumpParseFailureStreak(sessionId) {
640
+ if (!persistentStreak()) {
641
+ const next = (parseFailureStreakFallback.get(sessionId) ?? 0) + 1;
642
+ parseFailureStreakFallback.set(sessionId, next);
643
+ return next;
644
+ }
645
+ try {
646
+ // 失效判断由 store 那一侧负责(同一个 staleAfterMs),这里不再自己算一遍——
647
+ // 算两遍就等于有两份失效口径,迟早分叉。
648
+ const written = Number(service.bumpDistillParseFailureStreak(sessionId, { staleAfterMs: PARSE_FAILURE_STREAK_STALE_MS }));
649
+ if (Number.isFinite(written) && written > 0) return written;
650
+ } catch (error) {
651
+ ctx.logger?.warn?.(`dsh-mneme: parse failure streak write failed: ${String(error)}`);
652
+ }
653
+ const next = readParseFailureStreak(sessionId) + 1;
654
+ parseFailureStreakFallback.set(sessionId, next);
655
+ return next;
656
+ }
657
+ function clearParseFailureStreak(sessionId) {
658
+ parseFailureStreakFallback.delete(sessionId);
659
+ if (!persistentStreak()) return;
660
+ try {
661
+ service.clearDistillParseFailureStreak(sessionId);
662
+ } catch (error) {
663
+ ctx.logger?.warn?.(`dsh-mneme: parse failure streak clear failed: ${String(error)}`);
664
+ }
665
+ }
609
666
  let disposed = false;
610
667
 
611
668
  /** 写一条 autoSummarize 的 LLM 审计行(审计失败绝不阻塞蒸馏本身)。 */
@@ -670,6 +727,7 @@ export function createSummarizer(ctx, service, config, deps = {}) {
670
727
 
671
728
  async function summarize(session, triggerEvent, opts = {}) {
672
729
  if (disposed || inFlight.has(session.id)) return;
730
+ if (sessionGate?.isOff(session)) return;
673
731
 
674
732
  const header = session.requestHeader?.()?.config;
675
733
  // Config override takes priority, then session header, then nothing.
@@ -734,6 +792,14 @@ export function createSummarizer(ctx, service, config, deps = {}) {
734
792
  // ever blocking the summarization itself.
735
793
  let audit = null;
736
794
  let abortedRun = false;
795
+ // #512:run 的三个事实必须活在 try 之外——finally 要落 run 行,而 finally 看不见
796
+ // try 块内声明的 let/const(会 ReferenceError,且那一下会把 inFlight 的清理一起跳过)。
797
+ let distillRunId = null;
798
+ let distillRunOpenedAt = null;
799
+ let distilledMemoryCount = 0;
800
+ let distilledWindow = null;
801
+ let distillRunStatus = null;
802
+ let distillRunError = null;
737
803
  try {
738
804
  if (!route) return;
739
805
  const persistedCursor = typeof service.getDistillCursor === "function"
@@ -800,6 +866,19 @@ export function createSummarizer(ctx, service, config, deps = {}) {
800
866
  lastRunAt.set(session.id, Date.now());
801
867
  runsUsed.set(session.id, (prevRuns ?? 0) + 1);
802
868
 
869
+ // #512:一轮蒸馏 = 一个 run。落点选在这里而不是更早——上面所有 return(间隔门、
870
+ // 高峰门、run 预算、空窗口、窗口过小)都不是一轮真蒸馏,各有自己的审计口径;
871
+ // 从这里往下就是「调用确实发生过」,与 lastRunAt 那两行的边界完全重合。
872
+ // run 的落盘在 finally 里独立 try/catch:归因是审计主体,写不成只该 warn,
873
+ // 不该把已经落盘的记忆连坐。所以本函数在 store 没有该能力时整体降级为无归因。
874
+ // 归因用的三件事都从**真正发生的事**里取,不从推断里取:区间取实际写进
875
+ // memory_provenance 的那个窗口(transcript 关了就没有),条数取实际写成功的
876
+ // 记忆数(含 merged——被并入的既有行确实由这段原文改动)。
877
+ if (typeof service?.saveDistillRun === "function") {
878
+ distillRunId = randomUUID();
879
+ distillRunOpenedAt = new Date(now()).toISOString();
880
+ }
881
+
803
882
  if (config?.llmAudit?.enabled !== false && typeof service.saveLlmAudit === "function") {
804
883
  audit = {
805
884
  route,
@@ -941,6 +1020,13 @@ export function createSummarizer(ctx, service, config, deps = {}) {
941
1020
  const { text, assembledText, aborted } = result ?? { text: "", assembledText: "", aborted: true };
942
1021
  if (aborted) {
943
1022
  abortedRun = true;
1023
+ // #512:aborted 不等于「没发生」——LLM 调用真的发出去了,窗口也因此没被消费。
1024
+ // 只有 dispose / 会话关闭才是「这一轮被主动打断」;模型侧流失败是一次失败的轮次,
1025
+ // 两者在 run 台账上必须分得开,否则「这个窗口为什么一直没记忆」查不到原因。
1026
+ distillRunStatus = controller.signal.aborted ? "aborted" : "failed";
1027
+ distillRunError = controller.signal.aborted
1028
+ ? "disposed"
1029
+ : (streamFailure || "llm stream aborted");
944
1030
  return;
945
1031
  }
946
1032
  const parsedResult = parseSummaryJsonResult(text || assembledText);
@@ -950,8 +1036,7 @@ export function createSummarizer(ctx, service, config, deps = {}) {
950
1036
  // null —— 事后无法回答「模型到底吐了什么形状」。这里只加计量、不做任何
951
1037
  // 跳过决策(raw_head 截断到 300 字符,避免把审计表撑大)。
952
1038
  const raw = text || assembledText || "";
953
- const streak = (parseFailureStreak.get(session.id) ?? 0) + 1;
954
- parseFailureStreak.set(session.id, streak);
1039
+ const streak = bumpParseFailureStreak(session.id);
955
1040
  // Issue #493 失败护栏:同一窗口连续失败到阈值后强制消费游标跳过它。
956
1041
  // 确定性失败(finish_reason=stop、同文同温)重试收益为 0,而窗口只会随
957
1042
  // 新事件继续变大——没有出口时它会一直失败下去。默认 0 = 关闭,行为与
@@ -965,13 +1050,17 @@ export function createSummarizer(ctx, service, config, deps = {}) {
965
1050
  if (skipWindow) {
966
1051
  // 先提交游标再复位计数:窗口已放弃,下一窗从全新状态起算。
967
1052
  commitCursor(session.id, nextSeq);
968
- parseFailureStreak.delete(session.id);
1053
+ clearParseFailureStreak(session.id);
969
1054
  }
1055
+ // 读数与错误文案由同一个纯函数产出(#502):三种失败形态各有各的说法,
1056
+ // 「模型在说话」与「分类词不在枚举内」不能都写成 invalid summary JSON。
1057
+ const failureAudit = summarizeParseFailureAudit(raw, parsedResult?.failure);
1058
+ // #512:解析失败也是失败轮,run 要和 audit 记同一句原因(两个口径不一致
1059
+ // 等于让人去猜哪一边是对的)。
1060
+ distillRunStatus = "failed";
1061
+ distillRunError = failureAudit.errorMessage;
970
1062
  if (audit) {
971
1063
  audit.status = "error";
972
- // 读数与错误文案由同一个纯函数产出(#502):三种失败形态各有各的说法,
973
- // 「模型在说话」与「分类词不在枚举内」不能都写成 invalid summary JSON。
974
- const failureAudit = summarizeParseFailureAudit(raw, parsedResult?.failure);
975
1064
  audit.errorMessage = failureAudit.errorMessage;
976
1065
  audit.metadata = {
977
1066
  distillChars,
@@ -983,7 +1072,7 @@ export function createSummarizer(ctx, service, config, deps = {}) {
983
1072
  }
984
1073
  return;
985
1074
  }
986
- parseFailureStreak.delete(session.id);
1075
+ clearParseFailureStreak(session.id);
987
1076
  const parsed = parsedResult.entries;
988
1077
  // Issue #127 条数上限:0 = 不限(现状)。被截断的条数与去重命中数一并进
989
1078
  // 审计 metadata——此前产出条数完全由模型输出决定,用户无从自查。
@@ -1062,8 +1151,14 @@ export function createSummarizer(ctx, service, config, deps = {}) {
1062
1151
  // 被并入的既有记忆——本次窗口的原文确实贡献了它,链接它是诚实的。
1063
1152
  if (outcome?.memory?.id) savedIds.push(outcome.memory.id);
1064
1153
  }
1154
+ // #512:run 的条数口径是「本次写成功的记忆行数」,与 savedIds 同源——
1155
+ // 另起一个计数器会在 merged 分支上与它对不上(那一路也 push 了 id)。
1156
+ distilledMemoryCount = savedIds.length;
1065
1157
  if (transcript && transcript.records.length) {
1066
1158
  service.saveDistillRecords(transcript.records);
1159
+ if (transcript.seqStart !== undefined && transcript.seqEnd !== undefined) {
1160
+ distilledWindow = { seqStart: transcript.seqStart, seqEnd: transcript.seqEnd };
1161
+ }
1067
1162
  if (transcript.seqStart !== undefined && transcript.seqEnd !== undefined
1068
1163
  && typeof service.saveMemoryProvenance === "function") {
1069
1164
  for (const memoryId of savedIds) {
@@ -1072,7 +1167,11 @@ export function createSummarizer(ctx, service, config, deps = {}) {
1072
1167
  sessionId: session.id,
1073
1168
  seqStart: transcript.seqStart,
1074
1169
  seqEnd: transcript.seqEnd,
1075
- createdAt: distilledAt
1170
+ createdAt: distilledAt,
1171
+ // #512:链接指回产出它的那一轮。distillRecords 关着时这里本就不跑,
1172
+ // 于是那一轮不会留下任何窗口级归因——这是诚实的(没存原文就没有
1173
+ // 「这段原文」可指),run 行本身照落,条数与状态仍然可查。
1174
+ distillRunId: distillRunId ?? undefined
1076
1175
  });
1077
1176
  }
1078
1177
  }
@@ -1095,6 +1194,12 @@ export function createSummarizer(ctx, service, config, deps = {}) {
1095
1194
  // Issue #239 定标:窗口字符数无条件随成功行落进 metadata。原先只有
1096
1195
  // capped / deduped / salvaged 其中一项发生时才建 metadata,默认档位下
1097
1196
  // 一条都不留——下一级预判规则的阈值就无从定标。
1197
+ // #512:走到这里说明整轮真的落地了(记忆写入 + 游标提交都没抛)。状态按
1198
+ // 实际产出分档,与 dream_runs 的 status 词表对齐:没有可写的记忆是 noop,
1199
+ // 有记忆就是 ok。degraded 留给「写了但解析有损」那一路(下面按 salvaged 判)。
1200
+ distillRunStatus = distilledMemoryCount > 0
1201
+ ? (parsedResult.salvaged ? "degraded" : "ok")
1202
+ : "noop";
1098
1203
  if (audit) {
1099
1204
  audit.metadata = {
1100
1205
  distillChars,
@@ -1108,6 +1213,8 @@ export function createSummarizer(ctx, service, config, deps = {}) {
1108
1213
  // 都会在此之前退出,从而保留窗口供下一次重试。
1109
1214
  if (Number.isFinite(nextSeq)) lastDistilledSeq.set(session.id, nextSeq);
1110
1215
  } catch (error) {
1216
+ distillRunStatus = abortedRun || controller.signal.aborted ? "aborted" : "failed";
1217
+ distillRunError = String(error?.message ?? error);
1111
1218
  if (audit) {
1112
1219
  audit.status = "error";
1113
1220
  audit.errorMessage = String(error?.message ?? error);
@@ -1124,9 +1231,14 @@ export function createSummarizer(ctx, service, config, deps = {}) {
1124
1231
  if (prevRuns === undefined) runsUsed.delete(session.id);
1125
1232
  else runsUsed.set(session.id, prevRuns);
1126
1233
  }
1234
+ // #512:run 与它的开销读数(audit 行)分开落,且都不许把已经写好的记忆连坐——
1235
+ // 归因是审计,写不成只该 warn。顺序刻意是「先 audit 后 run」:run.audit_id 要
1236
+ // 引用刚插进去的那一行,而 saveLlmAudit 是按 lastInsertRowid 回读的
1237
+ // (#534/#535 那条注释),拿到的就是自己那行,不会被并发写串。
1238
+ let auditRow = null;
1127
1239
  if (audit) {
1128
1240
  try {
1129
- service.saveLlmAudit({
1241
+ auditRow = service.saveLlmAudit({
1130
1242
  timestamp: audit.timestamp,
1131
1243
  trigger_source: "autoSummarize",
1132
1244
  operation_type: "summarize_compress",
@@ -1147,6 +1259,31 @@ export function createSummarizer(ctx, service, config, deps = {}) {
1147
1259
  ctx.logger?.warn?.(`dsh-mneme: llm audit write failed: ${String(auditError)}`);
1148
1260
  }
1149
1261
  }
1262
+ // #512:一轮真蒸馏对应一行 distill_runs,落点必须在这里(finally)而不是
1263
+ // 事务里:run 是审计主体,事务只负责「记忆 + 原文 + 链接」同生共死;把 run
1264
+ // 塞进事务会让一个审计写失败回滚掉已经算出来的记忆。
1265
+ // status 为 null 说明在开轮之前就退出了(间隔门/高峰门/短窗口),那不是一轮。
1266
+ if (distillRunId && distillRunStatus) {
1267
+ try {
1268
+ service.saveDistillRun({
1269
+ id: distillRunId,
1270
+ created_at: distillRunOpenedAt,
1271
+ status: distillRunStatus,
1272
+ error: distillRunError ?? undefined,
1273
+ run_type: "autoSummarize",
1274
+ trigger_source: "autoSummarize",
1275
+ session_id: session.id,
1276
+ seq_start: distilledWindow?.seqStart,
1277
+ seq_end: distilledWindow?.seqEnd,
1278
+ memory_count: distilledMemoryCount,
1279
+ provider: audit?.route?.provider ?? route?.provider,
1280
+ model: audit?.route?.model ?? route?.model,
1281
+ audit_id: auditRow?.id
1282
+ });
1283
+ } catch (runError) {
1284
+ ctx.logger?.warn?.(`dsh-mneme: distill run write failed: ${String(runError)}`);
1285
+ }
1286
+ }
1150
1287
  inFlight.delete(session.id);
1151
1288
  }
1152
1289
  }
@@ -1173,7 +1310,7 @@ export function createSummarizer(ctx, service, config, deps = {}) {
1173
1310
  inFlight.clear();
1174
1311
  lastRunAt.clear();
1175
1312
  lastDistilledSeq.clear();
1176
- parseFailureStreak.clear();
1313
+ parseFailureStreakFallback.clear();
1177
1314
  }
1178
1315
  };
1179
1316
  }
package/lib/tools.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { defineTool } from "@deepseek-ai/dsh-tools";
2
- import { createScopeResolver, normalizeExplicitScope, sessionKeyOf } from "./scope.js";
2
+ import { createScopeResolver, normalizeExplicitScope, sessionKeyOf, sessionOf } from "./scope.js";
3
3
  import { describeLocalRuntime, resolveRuntimeEntry } from "./runtime/loader.js";
4
4
  import { defaultRuntimeDir } from "./runtime/layout.js";
5
5
  import { hostModulesDir, loadRuntimeManifest, provisionRuntime } from "./runtime/provision.js";
@@ -76,13 +76,15 @@ const MEMORY_PROVENANCE = (m) => {
76
76
  return parts.length ? ` | ${parts.join(" | ")}` : "";
77
77
  };
78
78
 
79
- export function createTools(ctx, service, config, embedder) {
79
+ export function createTools(ctx, service, config, embedder, sessionGate = null) {
80
80
  const toolsRegistry = ctx.tools;
81
- let registeredTools = REGISTERED_TOOLS.get(toolsRegistry);
82
- if (!registeredTools) {
83
- registeredTools = new Set();
84
- REGISTERED_TOOLS.set(toolsRegistry, registeredTools);
81
+ let registration = REGISTERED_TOOLS.get(toolsRegistry);
82
+ if (!registration) {
83
+ registration = { names: new Set(), sessionGate: null };
84
+ REGISTERED_TOOLS.set(toolsRegistry, registration);
85
85
  }
86
+ registration.sessionGate = sessionGate;
87
+ const registeredTools = registration.names;
86
88
  // v0.8.0 A1/A2(issue #17):会话 scope 解析(agentPreset + workspace 反查)。
87
89
  // flag 关闭时恒返回 null——写入不标注,检索不加权,行为与 A1 前完全一致。
88
90
  // logger 透传:registry 反查失败时 warnOnce 才有出口(否则静默降级无观测)。
@@ -1054,6 +1056,24 @@ export function createTools(ctx, service, config, embedder) {
1054
1056
  ctx.logger?.warn?.(`[dsh-mneme] tool "${tool.name}" already registered, skipping duplicate`);
1055
1057
  continue;
1056
1058
  }
1059
+ // #539:闸门包在**注册期**,而不是在 execute 内部各写一遍。已注册的执行器
1060
+ // 不会被宿主反注册(同上面 disableMemorySearch 那段 v0.8.5 的定性),所以
1061
+ // 包一层的意义在于:热重载后 registration.sessionGate 换成新实例,旧的执行器
1062
+ // 也跟着读新的闸门,不会拿着过期 config 继续放行。
1063
+ //
1064
+ // 取会话走 sessionOf(exec) = exec.agent.session。ToolExecutionInput.agent 在
1065
+ // 宿主类型里是**可选**的,PTC 内层派发可能压根不带 agent;这种调用会拿到
1066
+ // null → isOff(null) 为 false → 放行。这是有意的:不认识调用者的工具调用
1067
+ // 不能一律拒绝(那会把正常会话也打死),闸门只对「能证明自己是谁」的调用生效。
1068
+ if (typeof tool.execute === "function") {
1069
+ const execute = tool.execute.bind(tool);
1070
+ tool.execute = async (args, exec) => {
1071
+ if (registration.sessionGate?.isOff(sessionOf(exec))) {
1072
+ throw new Error("dsh-mneme: memory tools are disabled for this incognito session");
1073
+ }
1074
+ return execute(args, exec);
1075
+ };
1076
+ }
1057
1077
  registeredTools.add(tool.name);
1058
1078
  ctx.tools.register(tool);
1059
1079
  }
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, 10 model tools, automatic injection, session summarization, user profile/rules, custom slash commands, vector (semantic) search, and a Web GUI panel",
4
- "version": "0.9.5",
4
+ "version": "0.10.0",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -110,7 +110,9 @@
110
110
  },
111
111
  "overrides": {
112
112
  "adm-zip": "0.6.1",
113
- "brace-expansion": "5.0.12"
113
+ "brace-expansion": "5.0.12",
114
+ "glob": "14.0.3",
115
+ "minimatch": "11.0.3"
114
116
  },
115
117
  "allowScripts": {
116
118
  "onnxruntime-node@1.30.0": true
@@ -21,7 +21,7 @@ import { bootstrapFromDirectory } from "./bootstrap.js";
21
21
  import { PACKAGE_VERSION } from "./version-check.js";
22
22
  import { parseReclaimJson, readReclaimFields, reclaimReply, RECLAIM_UNAVAILABLE } from "./reclaim-http.js";
23
23
  import { parseShareBody, shareReply, parseReceiveBody, receiveReply, parseUndoBody, undoReply } from "./share-http.js";
24
- import { parseProvenanceQuery, provenanceReply } from "./provenance-http.js";
24
+ import { parseProvenanceSelector, provenanceReply } from "./provenance-http.js";
25
25
 
26
26
  const DEFAULT_PORT = 8790;
27
27
  const DEFAULT_HOST = "127.0.0.1";
@@ -487,6 +487,10 @@ export function createStandaloneApi({ service, store, config = {}, logger, setti
487
487
  // 一个链接一个窗口(区间 + 区间内原文行,按 seq 升序);没有链接的记忆返回
488
488
  // 空 windows——「不是蒸馏来的」是有效答案,不是错误。
489
489
  //
490
+ // 两个读方向共一条路径(#524):?id=<memoryId> 走方向 1(记忆 → 窗口),
491
+ // ?sessionId=<sid> 走方向 2(会话 → 窗口)。互斥校验在共用模块里(都缺/都给
492
+ // 都是 400),本壳只把三个 query 参数原样递进去——拆分与择一的判断只准写一份。
493
+ //
490
494
  // 这张面本来就要 Bearer token(除 /health 外无例外),所以不涉及宿主侧那次
491
495
  // open → token 的归类调整;daemon 与宿主共用 service,口径天然一致。
492
496
  //
@@ -497,12 +501,16 @@ export function createStandaloneApi({ service, store, config = {}, logger, setti
497
501
  // 方向,别当成 bug 修掉。阶梯全貌(401 → 400 → 409 → 503 → 读)见
498
502
  // provenance-http.js 文件头。
499
503
  if (req.method === "GET" && pathname === "/memory/provenance") {
500
- const parsed = parseProvenanceQuery(url.searchParams.get("id"));
504
+ const parsed = parseProvenanceSelector({
505
+ memoryId: url.searchParams.get("id"),
506
+ sessionId: url.searchParams.get("sessionId"),
507
+ limit: url.searchParams.get("limit")
508
+ });
501
509
  if (!parsed.ok) {
502
510
  sendJson(res, parsed.status, { error: parsed.error });
503
511
  return;
504
512
  }
505
- const { status, body } = provenanceReply({ service, id: parsed.id, config, warn: (m) => logger?.warn?.(m) });
513
+ const { status, body } = provenanceReply({ service, selector: parsed.selector, config, warn: (m) => logger?.warn?.(m) });
506
514
  sendJson(res, status, body);
507
515
  return;
508
516
  }
package/src/api.js CHANGED
@@ -14,7 +14,7 @@ import { EFFORT_REJECT_RE, describeStreamFailure, resolveRoute, withEffortFallba
14
14
  import { summarizeSkipped } from "./dream/decisions.js";
15
15
  import { getInjectionSnapshot } from "./inject.js";
16
16
  import { renderUserSettingsBlock } from "./user-settings.js";
17
- import { parseProvenanceQuery, provenanceReply } from "./provenance-http.js";
17
+ import { parseProvenanceSelector, provenanceReply } from "./provenance-http.js";
18
18
  import { describeLocalRuntime, publicRuntimeStatus } from "./runtime/loader.js";
19
19
  import { hostModulesDir, provisionRuntime } from "./runtime/provision.js";
20
20
  import { classify, fetchLatestVersion, PACKAGE_VERSION } from "./version-check.js";
@@ -1314,6 +1314,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1314
1314
  // 能查,外部(面板 / standalone API / 脚本)拿不到,「这条记忆是从哪几句蒸出来
1315
1315
  // 的」只能开 sqlite 手写 JOIN。本端点补最后一段接线。
1316
1316
  //
1317
+ // 两个读方向共一条路径(#524):?id=<memoryId> 是「这条记忆从哪几句蒸出来的」,
1318
+ // ?sessionId=<sid> 是「这个会话蒸出了哪几段」。两个主键互斥——都缺 400、都给
1319
+ // 400(择一意味着调用方发一个自己也不知道会被读哪个主键的请求,而两边结果形状
1320
+ // 不同)。共用路径是有意的:多一条路径就多一处要登记的地方(SECURITY.md 两张
1321
+ // 表、README 路由表、security-doc 的清单锁),而两者读的是同一批数据。
1322
+ //
1317
1323
  // 一个链接一个窗口:区间 + 区间里的原文行(按 seq 升序)。没有链接的记忆
1318
1324
  // (memory_save / 工具写入 / 冷启动)返回空 windows——「不是蒸馏来的」本身是
1319
1325
  // 有效答案,不是错误,所以不 404。
@@ -1341,12 +1347,16 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1341
1347
  handler(req, res) {
1342
1348
  try {
1343
1349
  const url = new URL(req.url, "http://localhost");
1344
- const parsed = parseProvenanceQuery(url.searchParams.get("id"));
1350
+ const parsed = parseProvenanceSelector({
1351
+ memoryId: url.searchParams.get("id"),
1352
+ sessionId: url.searchParams.get("sessionId"),
1353
+ limit: url.searchParams.get("limit")
1354
+ });
1345
1355
  if (!parsed.ok) {
1346
1356
  sendJson(res, parsed.status, { error: parsed.error });
1347
1357
  return;
1348
1358
  }
1349
- const { status, body } = provenanceReply({ service, id: parsed.id, config, warn: (m) => ctx.logger?.warn?.(m) });
1359
+ const { status, body } = provenanceReply({ service, selector: parsed.selector, config, warn: (m) => ctx.logger?.warn?.(m) });
1350
1360
  sendJson(res, status, body);
1351
1361
  } catch {
1352
1362
  sendJson(res, 500, { error: "internal" });
@@ -1759,7 +1769,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1759
1769
  policy: "open",
1760
1770
  handler(req, res) {
1761
1771
  try {
1762
- const runs = (service.listDreamRuns?.({ limit: 5 }) ?? []).map((r) => ({
1772
+ const toRun = (r) => ({
1763
1773
  created_at: r.created_at,
1764
1774
  status: r.status,
1765
1775
  provider: r.provider ?? null,
@@ -1777,7 +1787,16 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1777
1787
  // 被发现。这里只回聚合(原因 + 动作 + 条数),明细仍留在库里。
1778
1788
  skippedCount: Array.isArray(r.skipped) ? r.skipped.length : 0,
1779
1789
  skippedReasons: summarizeSkipped(r.skipped)
1780
- }));
1790
+ });
1791
+ // Issue #495:organize 上线后 runs 会混进 run_type="organize",而面板那张
1792
+ // 卡的标题是「最近巩固」——一次 agent 主动整理就会把它顶成整理回执。
1793
+ // 两条路里选了「标注」而不是过滤:runs 仍是全类型 5 条(审计面是证据,
1794
+ // 不因显示面积省略),另给一个只含 auto/sleep 的 lastConsolidation 供那张
1795
+ // 卡取数;面板再用 lastRun 是否为 organize 决定要不要标一句脚注。
1796
+ // 这里不能只靠 runs[0] 的 run_type 过滤——一次整理写两行(dryRun 报告 +
1797
+ // apply 回执),挤空 5 个名额时 runs 里可能一条巩固都没有。
1798
+ const runs = (service.listDreamRuns?.({ limit: 5 }) ?? []).map(toRun);
1799
+ const lastConsolidation = service.listDreamRuns?.({ limit: 1, excludeRunType: "organize" })?.[0];
1781
1800
  const pendingConflicts = service.countConflictPending?.() ?? 0;
1782
1801
  const ids = new Set();
1783
1802
  for (const row of service.listConflictPending?.({ limit: 200, includeResolved: false }) ?? []) {
@@ -1788,6 +1807,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1788
1807
  }
1789
1808
  sendJson(res, 200, {
1790
1809
  lastRun: runs[0] ?? null,
1810
+ lastConsolidation: lastConsolidation ? toRun(lastConsolidation) : null,
1791
1811
  runs,
1792
1812
  pendingConflicts,
1793
1813
  pendingMemoryIds: [...ids].slice(0, 200)
package/src/config.js CHANGED
@@ -586,6 +586,16 @@ export const Config = z.object({
586
586
  // Minimum gap between two sleep runs (hours) — a second idle window within
587
587
  // this interval does not retrigger.
588
588
  sleepMinIntervalHours: z.natural().min(1).max(168).default(8),
589
+ // Issue #529(C 项的替代实现):sleep 连续失败退避(默认关 = 行为逐字节不变)。
590
+ // 基数是 sleepMinIntervalHours,有效间隔 = 基数 × 2^连续失败数(成功清零),
591
+ // 封顶 max(基数, 24 小时)——不是硬 24 小时:这个键允许配到 168,用户配 48 就是
592
+ // 明说「两天跑一次」,退避只能放宽间隔、不该把用户自己的基数压小。
593
+ // 为什么不按 issue 原案用 snapshot_hash 判据:sleep 的
594
+ // snapshot_hash 是**跑完之后**对 phases 结果算的哈希(dream/sleep.js 的
595
+ // createHash(JSON.stringify(phases))),不是输入快照;同一批输入跑两次结果不同
596
+ // 就会得到不同哈希,用它当「同一输入又在失败」的判据不成立。退避与 #292 的
597
+ // autoDreamFailureBackoff 同构:只放大既有的最小间隔闸,不新增状态面。
598
+ sleepFailureBackoff: z.boolean().default(false),
589
599
  // Conflict adjudication strictness:
590
600
  // gentle only high-confidence conflicts (threshold 0.92) are resolved
591
601
  // normal standard dream-level (threshold 0.85)
@@ -653,6 +663,22 @@ export const Config = z.object({
653
663
  // 增长);流式计费按实际用量,不按上限。
654
664
  sleepMaxTokens: z.natural().min(256).max(131072).default(8192).description("sleep 冲突消解与模式发现阶段的 LLM 输出预算上限(token)。原为硬编码 2048,sleepActionSet=full 实测需约 7000 导致裁决被截断而整轮失败;流式计费按实际用量,调大不增加成本。"),
655
665
 
666
+ // Issue #529:冲突阶段此前没有任何输入侧预算——同 scope 的全部相似对被一次性
667
+ // 拼进一个 prompt,实测单请求 163,054 输入 token(服务端 --ctx-size 163,840,
668
+ // 占 99.5%),449 对的池子必然撞墙;而失败的轮次不落 pairs、判 keep 又是
669
+ // no-op,下一轮冷却结束再送**完全相同的输入**,于是 23 轮连续 llm failed。
670
+ // 输出侧早有 sleepMaxTokens(#257),输入侧这一半一直是空的。
671
+ //
672
+ // 每批上限:0 = 不设上限(严格回退到旧的一次性全送,用于排查"是不是分批
673
+ // 引入的差异")。取 12 是因为实测单对约 112~168 输入 token、12 对落在
674
+ // 1.5K~2K 区间,远低于任何服务商的上下文窗口,也不至于让 LLM 在长清单里
675
+ // 走神。
676
+ sleepConflictMaxPairs: z.natural().min(0).max(500).default(12).description("sleep 冲突阶段每批送给 LLM 的候选对上限(0 = 不设上限,回退旧的一次性全送)。实测单对约 112~168 输入 token,默认 12 对约 1.5K~2K。"),
677
+ // 每轮总上限:分批之后仍要有个总闸,否则一个 449 对的池子会拆成 38 批、
678
+ // 每批一次 LLM 往返,单轮跑上几小时(本身也没比撞墙好)。0 = 不设上限。
679
+ // 未入选的对不是"被丢弃"——它们留在 conflict_pending 与下一轮的候选池里。
680
+ sleepConflictMaxPairsPerRun: z.natural().min(0).max(5000).default(60).description("sleep 冲突阶段单轮累计裁定的候选对上限(0 = 不设上限)。默认 60 对 ≈ 5 批,约 10K 输入 token。"),
681
+
656
682
  // --- epistemic trust: memory source credibility (v0.4.5) -----------------
657
683
  // Distinguish memories by source: observation (measured / witnessed),
658
684
  // subjective (opinion / guess) and inferred (derived from other evidence).
@@ -966,7 +992,13 @@ export const Config = z.object({
966
992
  // 显式保存,画像正文必须是用户手写的原文,不给模型改写权。
967
993
  // 进 LIGHT_MODE_OFF:一次调用要读上百行记忆 + 一轮模型生成,与低资源档互斥(同
968
994
  // preInjectGate.enabled 的定性);是预设不是强制,用户显式勾选仍然赢。
969
- profileExtractEnabled: z.boolean().default(false)
995
+ profileExtractEnabled: z.boolean().default(false),
996
+
997
+ // #539 逐会话无痕:总开关默认关,保持旧用户行为;子代理选择器默认开,但总开关
998
+ // 未开时不生效。手动名单是精确的 session id 匹配。
999
+ sessionGateEnabled: z.boolean().default(false),
1000
+ sessionGateSubagents: z.boolean().default(true),
1001
+ sessionGateSessions: z.array(z.string()).default([])
970
1002
  });
971
1003
 
972
1004
  // Fields forced to false by the light-mode preset. Everything not listed here
package/src/continuity.js CHANGED
@@ -173,10 +173,10 @@ function noticeAlreadyInSurface(session, text) {
173
173
  * 挂两条监听(#249 §7 的 `agent/pre-step` 触发位 + 压缩事件面),返回 { dispose }。
174
174
  *
175
175
  * `store` 直接注入:本模块只写提案表、不碰检索/嵌入,没有用到 service 的任何能力。
176
- * 调用方负责父/子闸门(`autoInject` + `continuityRescueEnabled`);本函数不做开关判定,
177
- * 保持"挂上就是开"的单一口径。
176
+ * 调用方负责父/子开关(`autoInject` + `continuityRescueEnabled`),本函数另检查逐会话
177
+ * 无痕闸门;保持"挂上就是开、命中会话就不处理"的单一口径。
178
178
  */
179
- export function createContinuityRescue(ctx, store) {
179
+ export function createContinuityRescue(ctx, store, sessionGate = null) {
180
180
  // 待处理的边缘:sessionId → 压缩事件序号。**真落地**的压缩事件到达时置位,下一次 pre-step 消费后
181
181
  // 删除(同一个边缘绝不注入第二次)。只留序号、不留 Session:插件在宿主进程里只 apply
182
182
  // 一次、服务所有会话,握着 Session 就是握着它的全量日志不放——压缩完就收工、不再有
@@ -184,6 +184,10 @@ export function createContinuityRescue(ctx, store) {
184
184
  const edge = new Map();
185
185
 
186
186
  const unsubscribe = ctx.on("session/event", (session, event) => {
187
+ if (sessionGate?.isOff(session)) {
188
+ if (session?.id) edge.delete(session.id);
189
+ return;
190
+ }
187
191
  if (!isCompactionEdge(event)) return;
188
192
  if (!session?.id) return;
189
193
  if (!edge.has(session.id) && edge.size >= MAX_TRACKED_EDGES) {
@@ -200,6 +204,10 @@ export function createContinuityRescue(ctx, store) {
200
204
  // reject 是"这一步不该发生",往里面塞消息没有意义;未知形状原样放行。
201
205
  if (decision?.kind !== "enter") return decision;
202
206
  const session = payload?.agent?.session;
207
+ if (sessionGate?.isOff(session)) {
208
+ if (session?.id) edge.delete(session.id);
209
+ return decision;
210
+ }
203
211
  const sessionId = session?.id;
204
212
  if (!sessionId) return decision;
205
213
  if (!edge.has(sessionId)) return decision;