@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/src/dream.js CHANGED
@@ -14,6 +14,9 @@ import { STR, langOf } from "./lang.js";
14
14
  // service.setArchived(dream/decisions.js),所以「不许改写外来行」这条契约要在
15
15
  // 每个池子各挡一遍。清单与静态锁见 share-imported-guard.test.js。
16
16
  import { isImportedRow } from "./memory-source.js";
17
+ // 断链是围栏的共同底子:三处围栏(巩固/睡眠清单、蒸馏转录、画像条目)共用这一份,
18
+ // 不许各写一条(#493 补充 §③)。
19
+ import { breakFenceMarkers } from "./fence.js";
17
20
  export { validateDecisions, applyDecisions, withEffortFallback, withMaxTokensFallback, parseMaxTokensCap, describeStreamFailure, resolveDreamEffort, resolveRoute, streamText, runAuditedLlm };
18
21
 
19
22
  // Extract the first JSON array from LLM output, tolerating markdown fences,
@@ -247,7 +250,8 @@ function buildRecordReceipts({ runId, committed, snapshot, policyEpoch }) {
247
250
  });
248
251
  } else if (c.action === "differentiate") {
249
252
  // Issue #126:互补型裁决——两条都保留,各记一条 receipt。数量没有变化,
250
- // count_before/after 固定 1/1(该记录只是被追加了差异注记)。
253
+ // count_before/after 固定 1/1。Issue #533 起正文不再追加差异注记、一个字节
254
+ // 都不动,这里记的是「这一对被裁定为互补」,配对关系另见 differentiated_pairs。
251
255
  for (const id of c.ids ?? []) {
252
256
  receipts.push({
253
257
  ...base,
@@ -332,7 +336,7 @@ export function fenceListBody(language, text, shape = "json") {
332
336
  const close = STR.memoryFenceClose[language] ?? STR.memoryFenceClose.en;
333
337
  const table = shape === "text" ? STR.dreamTextRoleReminder : STR.dreamRoleReminder;
334
338
  const reminder = table[language] ?? table.en;
335
- const body = String(text ?? "").replace(/<<<|>>>/g, (run) => `${run[0]}\u200B${run.slice(1)}`);
339
+ const body = breakFenceMarkers(text);
336
340
  return `${reminder}\n\n${open}\n${body}\n${close}`;
337
341
  }
338
342
 
@@ -352,6 +356,55 @@ export function parseFailureMeta(text) {
352
356
  };
353
357
  }
354
358
 
359
+ /**
360
+ * #534 建议 3(分账读数):一轮巩固在「校验层」判了什么。
361
+ *
362
+ * 调用层的审计行在 LLM 返回时就写死了 status=success,而这一轮可能整单被校验
363
+ * 拒绝、也可能只跳过几条(degraded)。把这三个数字补进那一行的 metadata,
364
+ * 「调用成功率」与「整轮有效率」就有了同一份可对齐的分母,不必再拿两张表硬拼。
365
+ *
366
+ * 只记计数与动作名,不记原文——原文已经在 raw_output 列(失败轮)与 skipped
367
+ * (逐条 error)里了,这里再抄一遍等于同一份数据存三处。
368
+ */
369
+ export function decisionValidationMeta({ verdict, errors, skipped, survivorCount } = {}) {
370
+ const skips = Array.isArray(skipped) ? skipped : [];
371
+ const actions = [...new Set(skips.map((s) => s?.action).filter((a) => typeof a === "string" && a.length > 0))];
372
+ // verdict 由调用方给:它知道这一轮是「没解析出数组」「整单拒绝」还是「合法子集」,
373
+ // 判据复用校验器的返回值而不是在这里再推一遍布尔组合。
374
+ return {
375
+ decision_validation: {
376
+ verdict: typeof verdict === "string" ? verdict : "unknown",
377
+ error_count: Array.isArray(errors) ? errors.length : 0,
378
+ skipped_count: skips.length,
379
+ survivor_count: Number.isFinite(survivorCount) ? survivorCount : 0,
380
+ invalid_actions: actions
381
+ }
382
+ };
383
+ }
384
+
385
+ /**
386
+ * #534 建议 2:失败轮把模型原文留在行上。此前失败只有一条 warn,日志一滚现场
387
+ * 就没了——作者实测 19 轮里 17 轮整轮无效,事后连模型是怎么坏的都复不了盘。
388
+ *
389
+ * 截断口径沿用 #502/#504(summarize.js 的 summarizeParseFailureAudit):头部
390
+ * 300 字,仅当原文 >500 字时补末 200 字。只留头尾是刻意的——模型跑飞时中段是
391
+ * 重复噪声,头尾才分别在「它怎么起手」「它怎么收尾」上给线索;整段原文既不必要
392
+ * 也不该进库(一轮可有数十 KB)。
393
+ *
394
+ * 空文本返回 undefined,调用方据此整键不写(与 saveDreamRun 的 undefined→NULL
395
+ * 约定一致,不落一个空壳对象冒充「有原文」)。
396
+ */
397
+ export function rawOutputPayload(text) {
398
+ if (typeof text !== "string" || text.length === 0) return undefined;
399
+ const tail = text.length > 500 ? text.slice(-200) : "";
400
+ return {
401
+ raw_len: text.length,
402
+ raw_head: text.slice(0, 300),
403
+ ...(tail ? { raw_tail: tail } : {}),
404
+ raw_has_bracket: text.includes("[")
405
+ };
406
+ }
407
+
355
408
  async function runAuditedLlm(ctx, service, config, spec, body) {
356
409
  const audit = config?.llmAudit;
357
410
  if (audit?.enabled === false || typeof service?.saveLlmAudit !== "function") return body(() => {});
@@ -406,7 +459,7 @@ async function runAuditedLlm(ctx, service, config, spec, body) {
406
459
  }
407
460
  }
408
461
  try {
409
- service.saveLlmAudit({
462
+ const row = service.saveLlmAudit({
410
463
  timestamp,
411
464
  trigger_source: spec.triggerSource,
412
465
  operation_type: spec.operationType,
@@ -421,6 +474,10 @@ async function runAuditedLlm(ctx, service, config, spec, body) {
421
474
  related_memory_ids: spec.relatedMemoryIds ?? [],
422
475
  ...(metadata ? { metadata } : {})
423
476
  });
477
+ // #534 建议 3:把刚落库这一行的 id 交回调用方。校验判决发生在调用**之后**,
478
+ // 只有拿到 id 才补得回去(见 store 的 annotateLlmAudit)。这里只做交接,
479
+ // 不改写任何调用层事实——status/token/耗时仍然是调用那一刻的真相。
480
+ if (spec.auditRef && typeof spec.auditRef === "object") spec.auditRef.id = row?.id;
424
481
  } catch (auditError) {
425
482
  ctx.logger?.warn?.(`dsh-mneme: llm audit write failed: ${String(auditError)}`);
426
483
  }
@@ -870,13 +927,9 @@ export async function maintainIndexAfterDream(decisions, service, semantic) {
870
927
  // loser 同等处置)。winner 正文未变化,向量保持有效。
871
928
  vectorIndex.deleteEmbedding(d.loser);
872
929
  } else if (d.action === "differentiate") {
873
- // Issue #126 review(Copilot):双方正文都被追加了差异注记,而 update 在事务内
874
- // 会因 txDepth>0 跳过 scheduleEmbed —— 不在这里重嵌,注记就进不了 embedding,
875
- // 注释里承诺的"下一轮不再判成重复"就不成立。
876
- for (const id of d.ids ?? []) {
877
- const mem = service.getById(id);
878
- if (mem) rebuild.set(id, [mem.title, mem.content].filter(Boolean).join("\n"));
879
- }
930
+ // Issue #533:differentiate 现在一个字节都不改正文(只落裁决账本),两侧向量
931
+ // 因此保持有效,不需要重嵌。旧版在这里为「注记进 embedding 防再判重复」兜底
932
+ // ——那个假设已被复现证伪,重嵌反而是替它续命。
880
933
  } else if (d.action === "update") {
881
934
  const id = d.ids[0];
882
935
  const mem = service.getById(id);
@@ -1187,6 +1240,12 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
1187
1240
  // 数组」消费,内联标记会污染其它读者(失败轮的 _validationFailed 是整单
1188
1241
  // 拒绝哨兵,语义与「部分应用」不同,也不复用)。
1189
1242
  skipped: result.skipped,
1243
+ // #534 建议 2:拿到文本却解析/校验失败的轮,把模型原文的头尾留在行上。
1244
+ // 单独一列而不是内联进 decisions——decisions 的语义是「可重放决策数组」
1245
+ // (applyDecisions 直接吃它),塞非决策对象会污染重放者与所有按决策数组
1246
+ // 消费的下游;失败轮哨兵 _validationFailed 也只表达「整单拒绝」这一件事。
1247
+ // 流错误(result===undefined)没有文本可留,落 NULL。
1248
+ raw_output: result.raw_output,
1190
1249
  // Issue #89(基线半边):成功的 run 才带这两个值(见上方 baselineSnapshot
1191
1250
  // 的注释)。两列要么同时有值、要么同时为 NULL,读侧以「同时有值」认账。
1192
1251
  store_count: baselineSnapshot?.count,
@@ -1250,14 +1309,20 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
1250
1309
  const effort = await resolveDreamEffort(ctx, route, config.dreamReasoningEffort, logger);
1251
1310
  let decisions = null;
1252
1311
  let streamFailure = "";
1312
+ // #534 建议 3:这一轮巩固的审计行 id(runAuditedLlm 落库后回填)。校验判决
1313
+ // 出来时用它把 result 补回调用层那一行,两套账才对得上。每次重试重置,只认
1314
+ // 最终生效的那一次调用。
1315
+ let auditRef = {};
1253
1316
  const runConsolidation = (withEffort, tokens) => {
1254
1317
  streamFailure = "";
1318
+ auditRef = {};
1255
1319
  return runAuditedLlm(ctx, service, config, {
1256
1320
  triggerSource: "autoDream",
1257
1321
  operationType: "dream_consolidate",
1258
1322
  modelId: `${route.provider}:${route.model}`,
1259
1323
  relatedMemoryIds: [...snapshot.keys()],
1260
1324
  streamError: () => streamFailure,
1325
+ auditRef,
1261
1326
  auditError: (text) => {
1262
1327
  decisions = extractJsonArray(text);
1263
1328
  return Array.isArray(decisions) ? null : "no json array in llm output";
@@ -1288,10 +1353,21 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
1288
1353
  logger?.warn?.(`dsh-mneme dream: consolidation llm stream aborted or errored${streamFailure ? ` (${streamFailure})` : ""}`);
1289
1354
  return finish({ ok: false, error: "llm failed", summary: false });
1290
1355
  }
1356
+ // #534 建议 3 的接线点:审计行已由 runAuditedLlm 落库(status=error 也只说明
1357
+ // 「调用层这轮不算成功」),把校验层的判决补回去,两张表才说同一件事。
1358
+ const annotateAudit = (patch) => {
1359
+ if (!auditRef.id || typeof service?.annotateLlmAudit !== "function") return;
1360
+ try {
1361
+ service.annotateLlmAudit(auditRef.id, patch);
1362
+ } catch (error) {
1363
+ logger?.warn?.(`dsh-mneme dream: failed to annotate llm audit row: ${String(error)}`);
1364
+ }
1365
+ };
1291
1366
  if (!Array.isArray(decisions)) {
1292
1367
  const head = (decisionText ?? "").slice(0, 300).replace(/\s+/g, " ").trim();
1293
1368
  logger?.warn?.(`dsh-mneme dream: no json array in llm output (raw length ${decisionText?.length ?? 0}; head: ${head})`);
1294
- return finish({ ok: false, error: "no json array in llm output", summary: false });
1369
+ annotateAudit(decisionValidationMeta({ verdict: "unparsed", errors: [], skipped: [], survivorCount: 0 }));
1370
+ return finish({ ok: false, error: "no json array in llm output", summary: false, raw_output: rawOutputPayload(decisionText) });
1295
1371
  }
1296
1372
  const { ok, errors, skipped, resolvedShortIds, coverageShortfall } = validateDecisions(decisions, snapshot, {
1297
1373
  maxUpdatePerRun: config.reflectionUpdateMaxPerRun,
@@ -1327,11 +1403,15 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
1327
1403
  if (skipped.length > 0) {
1328
1404
  logger?.warn?.(`dsh-mneme dream: ${skipped.length} invalid decision(s) skipped: ${skipped.map((s) => `decision[${s.index}]: ${s.error}`).join("; ")}`);
1329
1405
  }
1406
+ annotateAudit(decisionValidationMeta({ verdict: "rejected", errors, skipped, survivorCount: 0 }));
1330
1407
  return finish({
1331
1408
  ok: false,
1332
1409
  error: `invalid decisions: ${errors.length} errors`,
1333
1410
  summary: false,
1334
- decisions: [{ _validationFailed: true, errors, skipped }]
1411
+ decisions: [{ _validationFailed: true, errors, skipped }],
1412
+ // #534 建议 2:整轮被拒时最该留的就是模型原文——否则事后只能看到
1413
+ // 「N errors」这个结论,看不到它到底吐了什么。
1414
+ raw_output: rawOutputPayload(decisionText)
1335
1415
  });
1336
1416
  }
1337
1417
  const skippedInvalid = skipped.length > 0;
@@ -1344,6 +1424,22 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
1344
1424
  if (coverageDegraded) {
1345
1425
  logger?.warn?.(`dsh-mneme dream: ${coverageShortfall} (run degrades: valid subset applied, unclaimed ids kept implicitly)`);
1346
1426
  }
1427
+ // 校验通过(可能带若干跳过、也可能只是覆盖率不足 → degraded)。判决补回审计行:
1428
+ // 这一步之前没有任何地方能回答「调用成功的那一轮,决策层到底认不认」。
1429
+ //
1430
+ // verdict 必须与下方状态判定同源(CodeRabbit on #535):coverageShortfall 同样是
1431
+ // 校验层的降级,若这里只认 skippedInvalid,覆盖率不足的轮会被 dream_runs 记成
1432
+ // degraded、审计 metadata 却写 ok——两套账在最该对齐的一格上又对不上,正是本
1433
+ // PR 要消灭的病。
1434
+ annotateAudit(decisionValidationMeta({
1435
+ verdict: skippedInvalid || coverageDegraded ? "degraded" : "ok",
1436
+ errors,
1437
+ skipped,
1438
+ // 校验器已就地 splice:这个长度就是下游 apply/audit 真正会消费的条目数
1439
+ // (含隐式补齐的 keep)。不能再减 skipped.length——被跳过的那些早被剪掉了,
1440
+ // 再减一次会把「跳 1 条、存活 1 条」算成 0(本分支自测当场抓到过)。
1441
+ survivorCount: decisions.length
1442
+ }));
1347
1443
 
1348
1444
  // Capture pre-update snapshots so the audit records what each update changed.
1349
1445
  const updateSnapshots = {};
@@ -1436,7 +1532,8 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
1436
1532
  // mint 来源:主链路的 create 落 source=dream。来源从管道给而非从决策对象取——
1437
1533
  // 见 applyDecisions 的 @param config。
1438
1534
  const { applied, conflicts, failures, committed } = applyDecisions(
1439
- applyList, service, logger, snapshot, { ...config, mintSource: "dream" }
1535
+ // runId 让 differentiate 的账本行能追到是哪一轮 dream 判的(issue #533)。
1536
+ applyList, service, logger, snapshot, { ...config, mintSource: "dream", runId }
1440
1537
  );
1441
1538
  // Per-record receipt chain: one row per actually-committed merge/conflict/
1442
1539
  // update verdict, stamped with the decision-basis digest + idempotency
package/src/fence.js ADDED
@@ -0,0 +1,24 @@
1
+ // dsh-mneme/src/fence.js
2
+ // 围栏正文的「断链」——把内容里自带的 `<<<` / `>>>` 拆散,使它无法提前闭合我们围出来的围栏。
3
+ //
4
+ // 为什么单独成模块(#493 的补充,报告者 §③):断链原先只长在 dream.js 的 fenceListBody
5
+ // 里,于是同一件事在三处各有一条命:dream/sleep 的清单(有断链)、蒸馏转录(没有)、
6
+ // 画像提取的条目(也没有)。两处一旦分叉,下次出问题就得分别查一遍——#494 与 #501/PR
7
+ // #503 的重复劳动就是这么来的。判定逻辑只能有一份实现,这条纪律在本仓已经付过学费
8
+ // (scopeStateGate 注释里「过滤点漂移」那条)。
9
+ //
10
+ // 三个刻意的性质:
11
+ // - **只中和标记,不删内容**:被断链的文本仍逐字可读(零宽空格不占位),只是不再是
12
+ // 一个能匹配围栏串的连续字符序列。吞掉正文会让「这段记忆说了什么」变成不可复原的。
13
+ // - **纯函数、不带 nonce**:重试收到同输入时提示词必须逐字节相同,否则「同输入同错」
14
+ // 这条诊断依据就没了(#493 的重试 20 次现场全靠它)。同一份文本调两次结果相同。
15
+ // - **不认围栏名字**:三对围栏(转录 / 记忆清单 / 画像条目)都以 `<<<` 开头、`>>>` 收尾,
16
+ // 中和这两个前缀/后缀就够了;认名字反而会在新增围栏时漏一处。
17
+ /**
18
+ * 断链:`<<<` / `>>>` 中间插一个零宽空格。
19
+ * @param {string} text 任意正文
20
+ * @returns {string} 断链后的正文(未触及的文本逐字节不变)
21
+ */
22
+ export function breakFenceMarkers(text) {
23
+ return String(text ?? "").replace(/<<<|>>>/g, (run) => `${run[0]}\u200B${run.slice(1)}`);
24
+ }
package/src/index.js CHANGED
@@ -14,6 +14,7 @@ import { createInjector } from "./inject.js";
14
14
  import { createPreInjectGate } from "./pre-inject-gate.js";
15
15
  import { createContinuityRescue } from "./continuity.js";
16
16
  import { createSummarizer } from "./summarize.js";
17
+ import { createSessionGate } from "./session-gate.js";
17
18
  import { createDreamScheduler, describeStreamFailure } from "./dream.js";
18
19
  import { createSleepScheduler, runSleep } from "./dream/sleep.js";
19
20
  import { createApi } from "./api.js";
@@ -231,6 +232,7 @@ export const apply = (ctx, config) => {
231
232
  for (const [objKey, sub] of Object.entries(nestedFlags)) {
232
233
  cfg[objKey] = { ...(cfg[objKey] ?? {}), ...sub };
233
234
  }
235
+ const sessionGate = createSessionGate(cfg);
234
236
 
235
237
  // 失败表的启动期清理也放在装配之后(此前它在 createStore 之后、装配之前,写死
236
238
  // 90 天)。保留期现在是显式配置键 reflectionFailureRetentionDays;等它进面板
@@ -411,7 +413,7 @@ export const apply = (ctx, config) => {
411
413
  //
412
414
  // 门控:默认关(actionRunEnabled),并且它挂热路径,轻量档也关。
413
415
  if (cfg.actionRunEnabled === true) {
414
- recorder = createExecutionRecorder({ ctx, logger: ctx.logger });
416
+ recorder = createExecutionRecorder({ ctx, logger: ctx.logger, sessionGate });
415
417
  const disposeMoRun = registerMoRunCommand({
416
418
  ctx,
417
419
  recorder,
@@ -535,6 +537,13 @@ export const apply = (ctx, config) => {
535
537
 
536
538
  const disposers = [];
537
539
 
540
+ try {
541
+ const disposeSessionGate = ctx.on?.("session/event", (session) => sessionGate.observe(session));
542
+ if (typeof disposeSessionGate === "function") disposers.push(disposeSessionGate);
543
+ } catch {
544
+ // 宿主没有该事件钩子时,注入、工具与蒸馏消费点仍直接检查当前 Session。
545
+ }
546
+
538
547
  // Issue #380:注入前判定闸门。判定 LLM 走 entity adapter 同款装配(ctx.llm +
539
548
  // agentDefaultModel 回退),gate 自持 enabled/usable 判定(未启用/llm 缺席/
540
549
  // llmAudit 关闭时内部恒 off,注入行为与现状逐字节一致)。
@@ -552,21 +561,21 @@ export const apply = (ctx, config) => {
552
561
  disposers.push(() => semantic.dispose()); // #118 重试计时器 + boot 回填计时器(搬入 semantic.js 后由它自持)
553
562
 
554
563
  ctx.inject(["systemPrompt"], (promptCtx) => {
555
- if (cfg.autoInject) disposers.push(createInjector(promptCtx, service, settings, cfg, preInjectGate));
564
+ if (cfg.autoInject) disposers.push(createInjector(promptCtx, service, settings, cfg, preInjectGate, sessionGate));
556
565
  });
557
566
 
558
567
  // #249 N3:压缩边缘双落点。触发靠宿主自己落的压缩事件(订阅 + pre-step 追加),
559
568
  // 不需要 systemPrompt / tools 的任何能力,所以不塞进上面的 inject 回调;父/子
560
569
  // 闸门在挂载点判一次,与 inject.js 共用同一个 injectChildEnabled 判据。
561
570
  if (cfg.autoInject && injectChildEnabled(cfg, "continuityRescueEnabled")) {
562
- disposers.push(createContinuityRescue(ctx, store));
571
+ disposers.push(createContinuityRescue(ctx, store, sessionGate));
563
572
  }
564
573
 
565
574
  ctx.inject(["tools"], (toolsCtx) => {
566
- disposers.push(createTools(toolsCtx, service, cfg, embedder));
575
+ disposers.push(createTools(toolsCtx, service, cfg, embedder, sessionGate));
567
576
  });
568
577
 
569
- const summarizer = createSummarizer(ctx, service, cfg);
578
+ const summarizer = createSummarizer(ctx, service, cfg, {}, sessionGate);
570
579
  disposers.push(summarizer.dispose);
571
580
 
572
581
  // #275 存储生命周期第一批:无损回收的手动入口。刻意不挂启动路径、不接定时器——
@@ -607,7 +616,7 @@ export const apply = (ctx, config) => {
607
616
  // 为什么挂 session/event 却又要进轻量档黑名单:它读的是 zstd 会话日志(同步解压),
608
617
  // 是本插件里最重的一条后台路径;且它只产「提议」,关掉不影响记忆本身的正确性。
609
618
  if (cfg.actionNudgeEnabled === true) {
610
- disposers.push(createNudgeAutoRun({ ctx, memoryDir, logger: ctx.logger }));
619
+ disposers.push(createNudgeAutoRun({ ctx, memoryDir, logger: ctx.logger, sessionGate }));
611
620
  }
612
621
 
613
622
  // Standalone external API (v0.7.12): plain node:http server for ecosystem
package/src/inject.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createScopeResolver } from "./scope.js";
1
+ import { createScopeResolver, sessionOf } from "./scope.js";
2
2
  import { createHotMemory, roundsFromEvents } from "./hot-memory.js";
3
3
  import { STR, langOf } from "./lang.js";
4
4
  import { escapePromptVars as escapePromptVarsWithConfig, renderUserSettingsBlock } from "./user-settings.js";
@@ -93,7 +93,7 @@ function extractRounds(ctx, maxRounds) {
93
93
  }
94
94
  }
95
95
 
96
- export function createInjector(ctx, service, settings, config, gate = null) {
96
+ export function createInjector(ctx, service, settings, config, gate = null, sessionGate = null) {
97
97
  const language = langOf(config);
98
98
  const baseMaxItems = config.maxInjectedItems ?? 5;
99
99
  const threshold = config.importanceThreshold ?? 3;
@@ -343,7 +343,11 @@ export function createInjector(ctx, service, settings, config, gate = null) {
343
343
  // 第二道、也是同一个判据的显式落点——两处消费点共用 injectChildEnabled,
344
344
  // 不在各自的地方重写「父关则子不生效」。
345
345
  const guideSection = injectChildEnabled(config, "injectGuidanceEnabled") && typeof ctx.systemPrompt?.section === "function"
346
- ? ctx.systemPrompt.section({ name: "memory-guide", order: 150, text: MEMORY_GUIDE_SECTION })
346
+ ? ctx.systemPrompt.section({
347
+ name: "memory-guide",
348
+ order: 150,
349
+ text: (promptCtx) => (sessionGate?.isOff(sessionOf(promptCtx)) ? "" : MEMORY_GUIDE_SECTION)
350
+ })
347
351
  : null;
348
352
 
349
353
  const disposers = [
@@ -352,6 +356,7 @@ export function createInjector(ctx, service, settings, config, gate = null) {
352
356
  name: "memory",
353
357
  order: 90,
354
358
  text: (ctx) => {
359
+ if (sessionGate?.isOff(sessionOf(ctx))) return "";
355
360
  // Bug4: pass the latest user query so injection prefers semantically
356
361
  // relevant memories; lastUserQuery is best-effort (empty → legacy).
357
362
  // The query vector is prefetched asynchronously (cached) because the
@@ -434,7 +439,7 @@ export function createInjector(ctx, service, settings, config, gate = null) {
434
439
  ctx.systemPrompt.context({
435
440
  name: "user-settings",
436
441
  order: 85,
437
- text: (ctx) => renderUserSettings(ctx)
442
+ text: (ctx) => (sessionGate?.isOff(sessionOf(ctx)) ? "" : renderUserSettings(ctx))
438
443
  })
439
444
  ];
440
445
 
package/src/lang.js CHANGED
@@ -11,16 +11,16 @@ export function langOf(config) {
11
11
 
12
12
  const PROMPTS = {
13
13
  "summary": {
14
- "zh": "你是记忆库提炼助手。根据下面的会话内容,提炼值得跨会话记住的原子记忆。\n原子记忆原则:每条记忆只装一个独立事实/偏好/决策,短小、自带完整上下文(把数字、名字、路径、结论等原始细节保留在 content 里,不要抽象概括);宁可拆成多条也绝不合并丢细节。信息量一般提 2-4 条,信息密集的对话可提 4-8 条。\n只输出 JSON 数组,每项形如 {\"type\":\"preference|project|decision|history|rejected_solution|pitfall|constraint\",\"title\":\"简短标题\",\"content\":\"保留原始细节的一句话\",\"importance\":1-5,\"occurred_at\":\"可选:事件发生的绝对时间,ISO-8601\"}。\noccurred_at(可选):这条记忆所述事件**实际发生**的绝对时间,ISO-8601 带时区(如 2026-10-05T09:30:00+08:00)。会话里出现「昨天/前天/上周/上个月」这类相对时间时,按提示末尾给出的当前时间换算成绝对时间再填;没有任何时间依据、或换算不出来时,整条省略该字段——严禁编造,也不要把当前时间当成事件时间填进去。\n`type` 只能取上面枚举里的值,不要自造分类;每项只输出上面列出的键,不要附加 id、source 等额外字段。\n不要输出任何其他文字。",
15
- "en": "You are the memory curation assistant. From the conversation content below, distill atomic memories worth remembering across sessions.\nAtomic-memory principle: each memory holds exactly one standalone fact/preference/decision — short and self-contained (keep raw details like numbers, names, paths, and conclusions verbatim in content; do not abstract or summarize them away). Prefer splitting into multiple entries over merging and losing detail. Extract 2-4 entries normally, 4-8 for information-dense conversations.\nWrite every title and content in English.\nOutput only a JSON array, each item shaped {\"type\":\"preference|project|decision|history|rejected_solution|pitfall|constraint\",\"title\":\"short title\",\"content\":\"one sentence preserving original detail\",\"importance\":1-5,\"occurred_at\":\"optional: absolute time the event happened, ISO-8601\"}.\noccurred_at (optional): the absolute time the described event **actually happened**, in ISO-8601 with a timezone (e.g. 2026-10-05T09:30:00+08:00). When the conversation uses a relative time (yesterday / the day before / last week / last month), convert it into an absolute time against the current time given at the end of this prompt. Omit the field entirely when there is no temporal basis or it cannot be converted — never invent a value, and never pass the current time off as the event time.\n`type` must be one of the enumerated values above (do not invent categories); each item may contain only the listed keys - no id, source or other extra fields.\nOutput no other text."
14
+ "zh": "你是记忆库提炼助手。根据下面的会话内容,提炼值得跨会话记住的原子记忆。\n原子记忆原则:每条记忆只装一个独立事实/偏好/决策,短小、自带完整上下文(把数字、名字、路径、结论等原始细节保留在 content 里,不要抽象概括);宁可拆成多条也绝不合并丢细节。信息量一般提 2-4 条,信息密集的对话可提 4-8 条。\n立场纪律(与上条原则冲突时以本条为准):\n- 只记事实、偏好、决策与约束。偏好是用户对某件事的立场,照常记录;但不要记录对用户本人性格、能力、情绪、品行的评价,无论褒贬(「用户很聪明」「用户今天很沮丧」都不进记忆库)。\n- 转录里「用户:」「助手:」「子会话交付:」可信度不同:用户说的话是用户陈述;助手与子会话的结论、评价是推断,不是事实。除非用户明确采纳,否则不要把助手的结论或评价写成记忆;确需保留时,content 里要写明这是推断。工具输出、报错、命令结果直接给出的技术结论不受此限,照常记录。\n- 用户陈述与助手推断冲突时,事实归属以用户陈述为准(不要把助手推断记成事实);但 content 里要把两种说法如实并存,不要写成谁对谁错、也不要替用户裁决。\n- importance 只看这条事实对后续工作的影响面。用户表达满意、赞许或情绪,都不是提高 importance 的理由。\n只输出 JSON 数组,每项形如 {\"type\":\"preference|project|decision|history|rejected_solution|pitfall|constraint\",\"title\":\"简短标题\",\"content\":\"保留原始细节的一句话\",\"importance\":1-5,\"occurred_at\":\"可选:事件发生的绝对时间,ISO-8601\"}。\noccurred_at(可选):这条记忆所述事件**实际发生**的绝对时间,ISO-8601 带时区(如 2026-10-05T09:30:00+08:00)。会话里出现「昨天/前天/上周/上个月」这类相对时间时,按提示末尾给出的当前时间换算成绝对时间再填;没有任何时间依据、或换算不出来时,整条省略该字段——严禁编造,也不要把当前时间当成事件时间填进去。\n`type` 只能取上面枚举里的值,不要自造分类;每项只输出上面列出的键,不要附加 id、source 等额外字段。\n不要输出任何其他文字。",
15
+ "en": "You are the memory curation assistant. From the conversation content below, distill atomic memories worth remembering across sessions.\nAtomic-memory principle: each memory holds exactly one standalone fact/preference/decision — short and self-contained (keep raw details like numbers, names, paths, and conclusions verbatim in content; do not abstract or summarize them away). Prefer splitting into multiple entries over merging and losing detail. Extract 2-4 entries normally, 4-8 for information-dense conversations.\nStance discipline (this wins over the principle above):\n- Record only facts, preferences, decisions, constraints. A preference is the user's stance on a thing — keep recording it. Never record evaluations of the user's own personality, ability, mood or character, favourable or not (\"the user is very smart\", \"the user is frustrated today\" do not belong in memory).\n- The transcript's three roles differ in trustworthiness: \"User:\" is a user statement; \"Assistant:\" and \"Subagent delivery:\" conclusions or evaluations are inferences, not facts. Do not record one as a memory unless the user explicitly adopted it; if it must be kept, say in content that it is an inference. Technical conclusions that tool output, errors or command results state directly are exempt — record those as usual.\n- When a user statement conflicts with an assistant inference, the user statement wins on the facts (never record the assistant inference as fact); but keep both sides as they stand in content — do not write a verdict on who is right and do not adjudicate for the user.\n- importance reflects only how much this fact affects later work. The user's satisfaction, praise or emotion is never a reason to raise it.\nWrite every title and content in English.\nOutput only a JSON array, each item shaped {\"type\":\"preference|project|decision|history|rejected_solution|pitfall|constraint\",\"title\":\"short title\",\"content\":\"one sentence preserving original detail\",\"importance\":1-5,\"occurred_at\":\"optional: absolute time the event happened, ISO-8601\"}.\noccurred_at (optional): the absolute time the described event **actually happened**, in ISO-8601 with a timezone (e.g. 2026-10-05T09:30:00+08:00). When the conversation uses a relative time (yesterday / the day before / last week / last month), convert it into an absolute time against the current time given at the end of this prompt. Omit the field entirely when there is no temporal basis or it cannot be converted — never invent a value, and never pass the current time off as the event time.\n`type` must be one of the enumerated values above (do not invent categories); each item may contain only the listed keys - no id, source or other extra fields.\nOutput no other text."
16
16
  },
17
17
  "codingSummary": {
18
- "zh": "你是记忆库提炼助手。根据下面的会话内容(含用户输入、助手回答、工具调用与结果),提炼值得跨会话记住的原子记忆。\n原子记忆原则:每条记忆只装一个独立事实/偏好/决策,短小、自带完整上下文(把数字、报错信息、命令、路径、结论等原始细节保留在 content 里,不要抽象概括);宁可拆成多条也绝不合并丢细节。信息量一般提 2-4 条,信息密集的对话可提 4-8 条。\n只输出 JSON 数组,每项形如 {\"type\":\"preference|project|decision|history|rejected_solution|pitfall|constraint\",\"title\":\"简短标题\",\"content\":\"保留原始细节的一句话\",\"importance\":1-5,\"occurred_at\":\"可选:事件发生的绝对时间,ISO-8601\"}。\noccurred_at(可选):这条记忆所述事件**实际发生**的绝对时间,ISO-8601 带时区(如 2026-10-05T09:30:00+08:00)。会话里出现「昨天/前天/上周/上个月」这类相对时间时,按提示末尾给出的当前时间换算成绝对时间再填;没有任何时间依据、或换算不出来时,整条省略该字段——严禁编造,也不要把当前时间当成事件时间填进去。\n若对话涉及编码/调试,可额外提取编码类记忆:\n- rejected_solution:被否决/废弃的实现方案(content 含方案简述 + 被否决原因 + 最终采用方案)\n- pitfall:调试踩坑记录(content 含现象/报错 + 根因 + 解决/规避方法)\n- constraint:项目工程约束(content 含约束描述 + 来源)\n普通闲聊、临时无关对话一律不提取编码类记忆。不要输出任何其他文字。",
19
- "en": "You are the memory curation assistant. From the conversation content below (including user input, assistant replies, tool calls and results), distill atomic memories worth remembering across sessions.\nAtomic-memory principle: each memory holds exactly one standalone fact/preference/decision — short and self-contained (keep raw details like numbers, error messages, commands, paths, and conclusions verbatim in content; do not abstract them away). Prefer splitting into multiple entries over merging and losing detail. Extract 2-4 entries normally, 4-8 for information-dense conversations.\nWrite every title and content in English.\nOutput only a JSON array, each item shaped {\"type\":\"preference|project|decision|history|rejected_solution|pitfall|constraint\",\"title\":\"short title\",\"content\":\"one sentence preserving original detail\",\"importance\":1-5,\"occurred_at\":\"optional: absolute time the event happened, ISO-8601\"}.\noccurred_at (optional): the absolute time the described event **actually happened**, in ISO-8601 with a timezone (e.g. 2026-10-05T09:30:00+08:00). When the conversation uses a relative time (yesterday / the day before / last week / last month), convert it into an absolute time against the current time given at the end of this prompt. Omit the field entirely when there is no temporal basis or it cannot be converted — never invent a value, and never pass the current time off as the event time.\nIf the conversation involves coding/debugging, additionally extract coding-specific memories:\n- rejected_solution: implementation approaches that were rejected or abandoned (content: brief approach + why rejected + what was adopted instead)\n- pitfall: debugging pitfalls (content: symptom/error + root cause + fix or workaround)\n- constraint: project engineering constraints (content: constraint description + source)\nNever extract coding-specific memories from casual small talk or temporary unrelated conversations. Output no other text."
18
+ "zh": "你是记忆库提炼助手。根据下面的会话内容(含用户输入、助手回答、工具调用与结果),提炼值得跨会话记住的原子记忆。\n原子记忆原则:每条记忆只装一个独立事实/偏好/决策,短小、自带完整上下文(把数字、报错信息、命令、路径、结论等原始细节保留在 content 里,不要抽象概括);宁可拆成多条也绝不合并丢细节。信息量一般提 2-4 条,信息密集的对话可提 4-8 条。\n立场纪律(与上条原则冲突时以本条为准):\n- 只记事实、偏好、决策与约束。偏好是用户对某件事的立场,照常记录;但不要记录对用户本人性格、能力、情绪、品行的评价,无论褒贬(「用户很聪明」「用户今天很沮丧」都不进记忆库)。\n- 转录里「用户:」「助手:」「子会话交付:」可信度不同:用户说的话是用户陈述;助手与子会话的结论、评价是推断,不是事实。除非用户明确采纳,否则不要把助手的结论或评价写成记忆;确需保留时,content 里要写明这是推断。工具输出、报错、命令结果直接给出的技术结论不受此限,照常记录。\n- 用户陈述与助手推断冲突时,事实归属以用户陈述为准(不要把助手推断记成事实);但 content 里要把两种说法如实并存,不要写成谁对谁错、也不要替用户裁决。\n- importance 只看这条事实对后续工作的影响面。用户表达满意、赞许或情绪,都不是提高 importance 的理由。\n只输出 JSON 数组,每项形如 {\"type\":\"preference|project|decision|history|rejected_solution|pitfall|constraint\",\"title\":\"简短标题\",\"content\":\"保留原始细节的一句话\",\"importance\":1-5,\"occurred_at\":\"可选:事件发生的绝对时间,ISO-8601\"}。\noccurred_at(可选):这条记忆所述事件**实际发生**的绝对时间,ISO-8601 带时区(如 2026-10-05T09:30:00+08:00)。会话里出现「昨天/前天/上周/上个月」这类相对时间时,按提示末尾给出的当前时间换算成绝对时间再填;没有任何时间依据、或换算不出来时,整条省略该字段——严禁编造,也不要把当前时间当成事件时间填进去。\n若对话涉及编码/调试,可额外提取编码类记忆:\n- rejected_solution:被否决/废弃的实现方案(content 含方案简述 + 被否决原因 + 最终采用方案)\n- pitfall:调试踩坑记录(content 含现象/报错 + 根因 + 解决/规避方法)\n- constraint:项目工程约束(content 含约束描述 + 来源)\n普通闲聊、临时无关对话一律不提取编码类记忆。不要输出任何其他文字。",
19
+ "en": "You are the memory curation assistant. From the conversation content below (including user input, assistant replies, tool calls and results), distill atomic memories worth remembering across sessions.\nAtomic-memory principle: each memory holds exactly one standalone fact/preference/decision — short and self-contained (keep raw details like numbers, error messages, commands, paths, and conclusions verbatim in content; do not abstract them away). Prefer splitting into multiple entries over merging and losing detail. Extract 2-4 entries normally, 4-8 for information-dense conversations.\nStance discipline (this wins over the principle above):\n- Record only facts, preferences, decisions, constraints. A preference is the user's stance on a thing — keep recording it. Never record evaluations of the user's own personality, ability, mood or character, favourable or not (\"the user is very smart\", \"the user is frustrated today\" do not belong in memory).\n- The transcript's three roles differ in trustworthiness: \"User:\" is a user statement; \"Assistant:\" and \"Subagent delivery:\" conclusions or evaluations are inferences, not facts. Do not record one as a memory unless the user explicitly adopted it; if it must be kept, say in content that it is an inference. Technical conclusions that tool output, errors or command results state directly are exempt — record those as usual.\n- When a user statement conflicts with an assistant inference, the user statement wins on the facts (never record the assistant inference as fact); but keep both sides as they stand in content — do not write a verdict on who is right and do not adjudicate for the user.\n- importance reflects only how much this fact affects later work. The user's satisfaction, praise or emotion is never a reason to raise it.\nWrite every title and content in English.\nOutput only a JSON array, each item shaped {\"type\":\"preference|project|decision|history|rejected_solution|pitfall|constraint\",\"title\":\"short title\",\"content\":\"one sentence preserving original detail\",\"importance\":1-5,\"occurred_at\":\"optional: absolute time the event happened, ISO-8601\"}.\noccurred_at (optional): the absolute time the described event **actually happened**, in ISO-8601 with a timezone (e.g. 2026-10-05T09:30:00+08:00). When the conversation uses a relative time (yesterday / the day before / last week / last month), convert it into an absolute time against the current time given at the end of this prompt. Omit the field entirely when there is no temporal basis or it cannot be converted — never invent a value, and never pass the current time off as the event time.\nIf the conversation involves coding/debugging, additionally extract coding-specific memories:\n- rejected_solution: implementation approaches that were rejected or abandoned (content: brief approach + why rejected + what was adopted instead)\n- pitfall: debugging pitfalls (content: symptom/error + root cause + fix or workaround)\n- constraint: project engineering constraints (content: constraint description + source)\nNever extract coding-specific memories from casual small talk or temporary unrelated conversations. Output no other text."
20
20
  },
21
21
  "dreamSummary": {
22
- "zh": "你是记忆库状态叙述助手。根据整理后的记忆,写一段 150-200 字的「当前状态」叙述:现在在做什么(活跃主题)、最近完成了什么或发生了什么变化、接下来明显的走向。只陈述记忆中有依据的事实,不要发明细节;偏好、项目、决策自然融入叙述而不必逐项罗列。这段叙述会作为会话上下文常驻注入。只输出叙述文本,不要其他内容。",
23
- "en": "You are the memory status narrator. From the consolidated memories below, write a 150-200 word \"current state\" narrative: what is being worked on now (active themes), what was recently completed or changed, and the obvious next direction. State only facts grounded in the memories — never invent details; weave preferences, projects, and decisions into the narrative instead of listing them. This narrative is injected as standing conversation context. Output only the narrative text, nothing else."
22
+ "zh": "你是记忆库状态叙述助手。根据整理后的记忆,写一段 150-200 字的「当前状态」叙述:现在在做什么(活跃主题)、最近完成了什么或发生了什么变化、接下来明显的走向。只陈述记忆中有依据的事实,不要发明细节;偏好、项目、决策自然融入叙述而不必逐项罗列。叙述纪律:只写记忆里已写明的事实与偏好;不要给用户添上性格、能力、情绪方面的评价,也不要把助手自己的判断写成「用户认为」。某条记忆本身带褒贬口气时,只取它的事实内核,不要把那个口气复述进叙述。这段叙述会作为会话上下文常驻注入。只输出叙述文本,不要其他内容。",
23
+ "en": "You are the memory status narrator. From the consolidated memories below, write a 150-200 word \"current state\" narrative: what is being worked on now (active themes), what was recently completed or changed, and the obvious next direction. State only facts grounded in the memories — never invent details; weave preferences, projects, and decisions into the narrative instead of listing them. Narrative discipline: state only the facts and preferences the memories already give; add no characterisation of the user's personality, ability or mood, and never restate the assistant's own judgement as \"the user thinks\". When a memory itself carries praise or blame, take its factual core and leave the tone out. This narrative is injected as standing conversation context. Output only the narrative text, nothing else."
24
24
  },
25
25
  "consolidation": {
26
26
  "zh": "你是记忆库整理助手。下面是全部记忆条目(id、类型、标题、内容、重要性、更新时间)。\n请执行记忆巩固(consolidation),输出一个决策 JSON 数组。\n\n【决策格式(必须严格遵守)】\n每个决策必须是对象,字段固定:\n- \"action\":必填。取值只能是 \"keep\" / \"merge\" / \"archive\" / \"update\" / \"conflict\" 之一(字段名必须是 action,严禁写成 type)\n- \"ids\":必填,数组,本决策涉及的记忆 id 列表\n- \"reason\":可选,字符串,决策理由\n- \"importance\":可选,整数 1-5\n- merge 额外字段:\"keepSource\"(单个 id 字符串,必须是 ids 之一)+ 合并后的 \"title\"、\"content\"\n- conflict 额外字段:\"winner\" 与 \"loser\",都是【单个 id 字符串,不是数组】\n- update 额外字段:修正后的 \"title\" 和/或 \"content\";\"ids\" 只能包含一个 id\n\n【决策 JSON 示例】\n[\n { \"action\": \"merge\", \"ids\": [\"m1\", \"m2\"], \"keepSource\": \"m1\", \"title\": \"合并标题\", \"content\": \"合并后的摘要内容\", \"importance\": 4, \"reason\": \"重复(确属同一件事)\" },\n { \"action\": \"conflict\", \"winner\": \"m3\", \"loser\": \"m4\", \"reason\": \"内容矛盾,保留更新的信息\" },\n { \"action\": \"update\", \"ids\": [\"m5\"], \"content\": \"修正后的内容\", \"reason\": \"信息过时\" },\n { \"action\": \"archive\", \"ids\": [\"m6\"], \"reason\": \"重复或过时\" }\n]\n\n【任务】\n1. 识别主题相近的条目 → merge(合并为更精炼的摘要,保留信息最完整的 id 作为 keepSource)\n2. 识别重复/过时信息 → archive\n3. 识别内容矛盾的条目 → conflict(按时间新旧、来源完整性、信息具体程度判断 winner/loser)\n4. 发现单条记忆中的信息过时、错误或遗漏 → update(直接修正内容)\n - update 的 ids 只能包含一个 id\n - 必须提供修正后的 title 和/或 content\n - 仅当内容确实需要修正时才使用,不要滥用\n - 每次整理最多输出 2 个 update\n - 24 小时内新建的记忆不可 update\n5. 无问题的条目无需输出(未提及的条目将自动保留 keep)\n\n【硬性规则】\n- 字段名必须精确为 \"action\",严禁写成 \"type\";字段名统一用双引号\n- conflict 的 winner/loser、merge 的 keepSource 都是【单个 id 字符串,绝不是数组】\n- 每条记忆最多被 claim 一次:同一个 id 不能出现在多个决策中(同一 id 不能被 merge 和 conflict/archive 等重复占用)\n- 未在决策中提及的记忆将自动保留(keep),无需为每条记忆输出 keep\n- merge 的 keepSource 必须是 ids 之一\n- 仅合并同类型条目(type 相同)\n- merge 仅适用于非长保留类型。rejected_solution / constraint / pitfall / preference / pattern 属长保留类型:仅当确属「重复或已过时」时才可 merge,且 reason 必须写明这一点(如「重复」「过时」);只是主题相近请输出 keep,不要 merge\n- 不要编造 ids;只使用提供的 id\n- 重要性 1-5,合并后取最高\n- archive 仅用于确属「重复或已过时」的条目;rejected_solution / constraint / pitfall / preference / pattern 属长保留类型,除非确属重复或过时,否则不要归档\n- 每次整理最多输出 8 个 archive\n- 只输出 JSON 数组,不要其他文字",
@@ -248,12 +248,17 @@ export const STR = {
248
248
  zh: (content, winnerTitle) => `${content}\n\n(已被取代:${winnerTitle})`,
249
249
  en: (content, winnerTitle) => `${content}\n\n(superseded by: ${winnerTitle})`
250
250
  },
251
- // Issue #126:differentiate 的差异注记,双方都要追加(进正文 → 进注入与
252
- // embedding,下一轮就不会再被判成重复)。
253
- differentiatedMarker: {
254
- zh: (notes) => `\n\n(差异注记:${notes.join(";")})`,
255
- en: (notes) => `\n\n(differences: ${notes.join("; ")})`
256
- },
251
+ // Issue #533:differentiate 不再往正文写注记。原设计(#126)指望「注记进正文 →
252
+ // 进注入与 embedding → 下一轮就不会再被判成重复」,离线复现把它证伪了:两侧
253
+ // 共享同一段后缀时向量同向靠拢(5 轮 0.876→0.960,注记截到 0 字也一样),
254
+ // 于是同一对每轮再判一次、再追加一段,单条堆到 19 段。现在裁决只记在
255
+ // differentiated_pairs 账本里(src/store.js),正文一个字节都不动。
256
+ //
257
+ // 这里保留的是一句识别式,只用来兼容存量库:旧版本已经写进正文的注记块仍然
258
+ // 算「这一对已裁决」,免得升级后又把老行原样重判一遍。用户切过 language,两种
259
+ // 语言都可能留在库里,所以一条正则同时认 zh/en 开头。注记永远追加在正文末尾,
260
+ // 故锚到结尾;非贪婪 + `$` 也吃得住旧版本堆叠的多段。
261
+ differentiatedNoteRe: /\n\n(?:(差异注记:|\(differences: )[\s\S]*?[))]\s*$/,
257
262
 
258
263
  // --- service.js:三方合并冲突批注(写入记忆内容) -----------------------------
259
264
  serviceConflictMarker: {
@@ -24,6 +24,7 @@ export function createNudgeAutoRun({
24
24
  ctx = null,
25
25
  memoryDir = null,
26
26
  logger = null,
27
+ sessionGate = null,
27
28
  now = () => Date.now(),
28
29
  runNudge = nudgeForProject,
29
30
  minIntervalMs = NUDGE_AUTO_MIN_INTERVAL_MS,
@@ -55,6 +56,7 @@ export function createNudgeAutoRun({
55
56
  // 自动跑:永不落草稿。它只在记忆库的 proposals 分桶里留提议。
56
57
  materialize: false,
57
58
  persistProposals: true,
59
+ sessionGate,
58
60
  budgetMs,
59
61
  ...(maxFiles ? { maxFiles } : {})
60
62
  });
@@ -100,6 +102,7 @@ export function createNudgeAutoRun({
100
102
 
101
103
  const onSessionEvent = (session, event) => {
102
104
  if (disposed) return;
105
+ if (sessionGate?.isOff(session)) return;
103
106
  // 只认 turn/end:一轮结束才算一个观测窗口,中途记的是半截。
104
107
  if (!event || event.type !== "turn/end") return;
105
108
  const projectRoot = projectRootOf(session);
@@ -536,7 +536,8 @@ export function toolCallsInLine(line) {
536
536
  * 取不到就落 null——落 null 的观察只会让支持度算不出来,不会假装成「同一个会话」
537
537
  * 或「同一个项目」。
538
538
  */
539
- export function readSessionObservations({ sessionDir = resolveSessionDir(), limit = MAX_SESSION_FILES, deadline = null } = {}) {
539
+ export function readSessionObservations({ sessionDir = resolveSessionDir(), limit = MAX_SESSION_FILES, deadline = null, sessionGate = null } = {}) {
540
+ const filterSessions = sessionGate?.isEnabled?.() === true;
540
541
  const found = findSessionFiles(sessionDir, { limit });
541
542
  const files = found.files.slice(0, limit);
542
543
  const totalFiles = Number.isFinite(found.totalFiles) ? found.totalFiles : found.files.length;
@@ -568,6 +569,10 @@ export function readSessionObservations({ sessionDir = resolveSessionDir(), limi
568
569
  result.truncated = true;
569
570
  break;
570
571
  }
572
+ const parts = file.split(/[\\/]/);
573
+ const sessionId = parts.slice(-2, -1)[0] || null;
574
+ const bucket = parts.slice(-3, -2)[0] || null;
575
+ if (filterSessions && sessionGate.isOff(sessionId)) continue;
571
576
  let buf;
572
577
  try {
573
578
  buf = readFileSync(file);
@@ -576,11 +581,35 @@ export function readSessionObservations({ sessionDir = resolveSessionDir(), limi
576
581
  continue;
577
582
  }
578
583
  result.scannedFiles += 1;
579
- const parts = file.split(/[\\/]/);
580
- const sessionId = parts.slice(-2, -1)[0] || null;
581
- const bucket = parts.slice(-3, -2)[0] || null;
584
+ let headerChecked = false;
585
+ let excluded = false;
582
586
  const stat = forEachZstdFrame(buf, (text) => {
583
587
  for (const line of text.split("\n")) {
588
+ if (excluded) break;
589
+ if (!headerChecked && line.trim()) {
590
+ headerChecked = true;
591
+ if (filterSessions) {
592
+ try {
593
+ const row = JSON.parse(line);
594
+ // 会话日志首行是 {"type":"session", version, id, createdAt, cwd,
595
+ // isSeeded, delegationDepth, origin?, parentSession?}——**头字段就在
596
+ // 这一行的顶层**,没有 header/config 包一层(本机 641 个真实会话首行
597
+ // 全部如此)。所以先认 type,再退到 header/config 两种带壳形状。
598
+ // 旧写法末尾还有 `?? row?.data ?? row` 两级兜底,等于把**任何**首行
599
+ // 都当作头:一条 {"type":"permission/preset", data:{preset}} 会被读成
600
+ // 「没有 origin、没有 delegationDepth 的主会话」而放行,闸门静默失效;
601
+ // 认不出来时宁可返回 null(isOff 收到 null 只会按 id 判),也不假装
602
+ // 那是一条头。
603
+ const header = row?.type === "session" ? row : (row?.header ?? row?.config ?? null);
604
+ if (sessionGate.isOff({ id: sessionId, header })) {
605
+ excluded = true;
606
+ break;
607
+ }
608
+ } catch {
609
+ // 坏头无法可靠判定会话归属;后续坏行仍由既有解析路径跳过。
610
+ }
611
+ }
612
+ }
584
613
  if (!line.trim() || !line.includes('"tool-call"')) continue;
585
614
  result.lines += 1;
586
615
  const calls = toolCallsInLine(line);
@@ -592,6 +621,7 @@ export function readSessionObservations({ sessionDir = resolveSessionDir(), limi
592
621
  result.frames += stat.frames;
593
622
  result.failedFrames += stat.failed;
594
623
  buf.fill(0);
624
+ if (excluded) continue;
595
625
  }
596
626
 
597
627
  return result;
@@ -1429,7 +1459,8 @@ export function nudgeForProject({
1429
1459
  persistProposals = true,
1430
1460
  maxFiles = MAX_SESSION_FILES,
1431
1461
  kinds = DEFAULT_NUDGE_KINDS,
1432
- attribution = null
1462
+ attribution = null,
1463
+ sessionGate = null
1433
1464
  } = {}) {
1434
1465
  // 归属解析自己也可能很贵(同步扫桶 500 个),所以它也要吃同一个预算。调用方给了现成的
1435
1466
  // attribution 就不再解析(`/nudge` 路由就是这么做的:它先解析一次才能决定要不要回 400)。
@@ -1466,7 +1497,8 @@ export function nudgeForProject({
1466
1497
  kinds,
1467
1498
  materialize,
1468
1499
  persistProposals,
1469
- budgetMs
1500
+ budgetMs,
1501
+ sessionGate
1470
1502
  });
1471
1503
  return { ...doc, attribution: where, skipped: false };
1472
1504
  }
@@ -1501,7 +1533,8 @@ export function nudgeFromSessions({
1501
1533
  budgetMs = 0,
1502
1534
  // 提议落盘是**写**。预览(面板刷新一次就调一次)不能顺手往 memoryDir 里塞文件:
1503
1535
  // 落盘要从这里显式关掉,不能靠调用方「记得别调」。默认 true 保持既有语义。
1504
- persistProposals = true
1536
+ persistProposals = true,
1537
+ sessionGate = null
1505
1538
  } = {}) {
1506
1539
  const scoped = typeof sessionDir === "string" && sessionDir.trim() !== "";
1507
1540
  const dir = scoped ? sessionDir : resolveSessionDir({ dshHome });
@@ -1511,7 +1544,7 @@ export function nudgeFromSessions({
1511
1544
  const allowedProjectRoot = attributionRefused ? null : projectRoot;
1512
1545
  // budgetMs > 0 才设截止点:测试与离线批处理要完整结果,面板要「不卡」。
1513
1546
  const deadline = Number.isFinite(budgetMs) && budgetMs > 0 ? now + budgetMs : null;
1514
- const read = readSessionObservations({ sessionDir: dir, limit: maxFiles, deadline });
1547
+ const read = readSessionObservations({ sessionDir: dir, limit: maxFiles, deadline, sessionGate });
1515
1548
  const segments = segmentObservations(read.observations);
1516
1549
  const candidates = mineRepeatedPatterns(segments, { minSupport, minLength, maxLength, maxResults, kinds });
1517
1550
  const projectName = attributionRefused ? "" : project || (allowedProjectRoot ? projectLabel(allowedProjectRoot) : "");