@modusensus/dsh-mneme 0.12.0 → 0.14.0-beta.1

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 (182) hide show
  1. package/README.md +51 -20
  2. package/bin/dsh-mneme-choro.mjs +9 -1
  3. package/docs/CONFIGURATION.md +47 -6
  4. package/docs/DIRECTORIES.md +114 -0
  5. package/docs/DISTILL.md +64 -0
  6. package/docs/DSH-GO.md +97 -0
  7. package/docs/ENTITIES.md +5 -3
  8. package/docs/FORGET.md +77 -0
  9. package/docs/KB.md +110 -0
  10. package/docs/SLEEP.md +28 -3
  11. package/docs/STORAGE.md +23 -0
  12. package/lib/api-standalone.js +4 -1
  13. package/lib/api.js +788 -30
  14. package/lib/attachment-text.js +122 -0
  15. package/lib/choro-command.js +9 -2
  16. package/lib/client.js +4627 -1409
  17. package/lib/config.js +166 -1
  18. package/lib/continuity.js +7 -6
  19. package/lib/diary/auto.js +190 -0
  20. package/lib/diary/compose.js +440 -0
  21. package/lib/diary/dir.js +59 -0
  22. package/lib/diary/export.js +97 -0
  23. package/lib/diary/index.js +26 -0
  24. package/lib/diary/library.js +189 -0
  25. package/lib/diary/qq-inbox.js +142 -0
  26. package/lib/diary/rollover.js +61 -0
  27. package/lib/diary/session-provider.js +122 -0
  28. package/lib/dream/decisions.js +95 -6
  29. package/lib/dream/sleep.js +760 -19
  30. package/lib/dream/triage.js +139 -0
  31. package/lib/dream.js +168 -21
  32. package/lib/dsh-go/host.js +162 -0
  33. package/lib/dsh-go/panel.js +2091 -0
  34. package/lib/dsh-go/text.js +342 -0
  35. package/lib/dsh-go/views/common.js +68 -0
  36. package/lib/dsh-go/views/entity.js +48 -0
  37. package/lib/dsh-go/views/memory.js +198 -0
  38. package/lib/dsh-go/views/root.js +30 -0
  39. package/lib/dsh-go/views/settings.js +398 -0
  40. package/lib/dsh-go/views/status.js +51 -0
  41. package/lib/embedding.js +42 -8
  42. package/lib/entities/extractor.js +36 -7
  43. package/lib/hot-memory.js +22 -11
  44. package/lib/index.js +123 -3
  45. package/lib/inject.js +203 -20
  46. package/lib/kb/index.js +309 -0
  47. package/lib/kb/routes.js +220 -0
  48. package/lib/kb/tools.js +148 -0
  49. package/lib/lang.js +98 -4
  50. package/lib/peak-hours.js +57 -2
  51. package/lib/semantic.js +347 -60
  52. package/lib/service.js +268 -69
  53. package/lib/settings.js +128 -15
  54. package/lib/store.js +585 -26
  55. package/lib/summarize.js +38 -11
  56. package/lib/tools.js +35 -4
  57. package/lib/user-settings.js +128 -32
  58. package/lib/vector-index.js +76 -6
  59. package/lib/wiki/claims.js +267 -0
  60. package/lib/wiki/cli-hint.js +90 -0
  61. package/lib/wiki/queries.js +282 -2
  62. package/lib/wiki/render.js +74 -0
  63. package/lib/wiki/t0.js +43 -6
  64. package/lib/wiki/t1-jobs.js +232 -0
  65. package/lib/wiki/t1.js +145 -32
  66. package/package.json +39 -1
  67. package/scripts/release-docs.mjs +7 -1
  68. package/scripts/release-notes.mjs +4 -1
  69. package/src/api-standalone.js +4 -1
  70. package/src/api.js +788 -30
  71. package/src/attachment-text.js +122 -0
  72. package/src/choro-command.js +9 -2
  73. package/src/config.js +166 -1
  74. package/src/continuity.js +7 -6
  75. package/src/diary/auto.js +190 -0
  76. package/src/diary/compose.js +440 -0
  77. package/src/diary/dir.js +59 -0
  78. package/src/diary/export.js +97 -0
  79. package/src/diary/index.js +26 -0
  80. package/src/diary/library.js +189 -0
  81. package/src/diary/qq-inbox.js +142 -0
  82. package/src/diary/rollover.js +61 -0
  83. package/src/diary/session-provider.js +122 -0
  84. package/src/dream/decisions.js +95 -6
  85. package/src/dream/sleep.js +760 -19
  86. package/src/dream/triage.js +139 -0
  87. package/src/dream.js +168 -21
  88. package/src/dsh-go/host.js +162 -0
  89. package/src/dsh-go/panel.js +2091 -0
  90. package/src/dsh-go/text.js +342 -0
  91. package/src/dsh-go/views/common.js +68 -0
  92. package/src/dsh-go/views/entity.js +48 -0
  93. package/src/dsh-go/views/memory.js +198 -0
  94. package/src/dsh-go/views/root.js +30 -0
  95. package/src/dsh-go/views/settings.js +398 -0
  96. package/src/dsh-go/views/status.js +51 -0
  97. package/src/embedding.js +42 -8
  98. package/src/entities/extractor.js +36 -7
  99. package/src/hot-memory.js +22 -11
  100. package/src/index.js +123 -3
  101. package/src/inject.js +203 -20
  102. package/src/kb/index.js +309 -0
  103. package/src/kb/routes.js +220 -0
  104. package/src/kb/tools.js +148 -0
  105. package/src/lang.js +98 -4
  106. package/src/peak-hours.js +57 -2
  107. package/src/semantic.js +347 -60
  108. package/src/service.js +268 -69
  109. package/src/settings.js +128 -15
  110. package/src/store.js +585 -26
  111. package/src/summarize.js +38 -11
  112. package/src/tools.js +35 -4
  113. package/src/user-settings.js +128 -32
  114. package/src/vector-index.js +76 -6
  115. package/src/wiki/claims.js +267 -0
  116. package/src/wiki/cli-hint.js +90 -0
  117. package/src/wiki/queries.js +282 -2
  118. package/src/wiki/render.js +74 -0
  119. package/src/wiki/t0.js +43 -6
  120. package/src/wiki/t1-jobs.js +232 -0
  121. package/src/wiki/t1.js +145 -32
  122. package/test/api-routes-gaps.test.js +63 -0
  123. package/test/api.test.js +385 -4
  124. package/test/attachment-text.test.js +140 -0
  125. package/test/bm25-corpus-cache.test.js +145 -0
  126. package/test/capacity-warning.test.js +262 -0
  127. package/test/client.test.js +1051 -185
  128. package/test/conflict-freeze.test.js +45 -1
  129. package/test/conflict-ledger-enforce.test.js +452 -0
  130. package/test/conflict-ledger.test.js +242 -0
  131. package/test/continuity.test.js +8 -1
  132. package/test/contrast.test.js +124 -12
  133. package/test/diary-library.test.js +187 -0
  134. package/test/diary.test.js +576 -0
  135. package/test/dream-baseline-clamp.test.js +174 -0
  136. package/test/dream-maxtokens-fallback.test.js +3 -3
  137. package/test/dream.test.js +62 -1
  138. package/test/dsh-go-host.test.js +134 -0
  139. package/test/dsh-go-panel-actions.test.js +854 -0
  140. package/test/dsh-go-panel.test.js +251 -0
  141. package/test/embedding-ready.test.js +33 -3
  142. package/test/entities.test.js +73 -0
  143. package/test/forget-corrections.test.js +70 -0
  144. package/test/graph-api.test.js +124 -0
  145. package/test/hot-memory.test.js +48 -0
  146. package/test/image-memory.test.js +171 -0
  147. package/test/inject-parent-gate.test.js +6 -3
  148. package/test/inject.test.js +59 -0
  149. package/test/issue-617-step1-query-and-fusion.test.js +361 -0
  150. package/test/kb-api-wiring.test.js +128 -0
  151. package/test/kb-collections.test.js +272 -0
  152. package/test/kb-routes.test.js +379 -0
  153. package/test/mint-source.test.js +26 -0
  154. package/test/panel-chart-scale.test.js +90 -0
  155. package/test/panel-tokens.test.js +245 -0
  156. package/test/peak-hours-observability.test.js +70 -0
  157. package/test/persona-api.test.js +3 -3
  158. package/test/persona-by-session.test.js +568 -0
  159. package/test/persona-settings.test.js +7 -7
  160. package/test/reflection-update-gate.test.js +129 -0
  161. package/test/reindex-backfill.test.js +334 -0
  162. package/test/reindex-scheduler.test.js +369 -0
  163. package/test/release-docs.test.js +20 -0
  164. package/test/release-notes.test.js +25 -0
  165. package/test/scope-candidates.test.js +249 -0
  166. package/test/security-doc.test.js +10 -0
  167. package/test/sleep-action-set.test.js +14 -0
  168. package/test/sleep-heat.test.js +49 -0
  169. package/test/sleep.test.js +635 -0
  170. package/test/standalone-api.test.js +24 -0
  171. package/test/store.test.js +20 -0
  172. package/test/summarize.test.js +26 -0
  173. package/test/user-settings-segments.test.js +528 -0
  174. package/test/vector-index.test.js +120 -6
  175. package/test/wiki-api.test.js +463 -1
  176. package/test/wiki-claims.test.js +194 -0
  177. package/test/wiki-cli-hint.test.js +118 -0
  178. package/test/wiki-provenance.test.js +202 -0
  179. package/test/wiki-queries.test.js +128 -0
  180. package/test/wiki-t0.test.js +77 -1
  181. package/test/wiki-t1-jobs.test.js +125 -0
  182. package/test/wiki-t1.test.js +226 -1
package/lib/index.js CHANGED
@@ -17,6 +17,9 @@ import { createSummarizer } from "./summarize.js";
17
17
  import { createSessionGate } from "./session-gate.js";
18
18
  import { createDreamScheduler, describeStreamFailure } from "./dream.js";
19
19
  import { createSleepScheduler, runSleep } from "./dream/sleep.js";
20
+ // #622 可观测性:错峰时段串解析失败是「静默未配置」(parsePeakSpec 契约不救错),
21
+ // 启动装配后对非空且解析失败的键出一条 warn——只补可见性,不改语义。
22
+ import { invalidPeakSpecs } from "./peak-hours.js";
20
23
  import { createApi } from "./api.js";
21
24
  import { createStandaloneApi } from "./api-standalone.js";
22
25
  // #275 存储生命周期第一批:无损回收(手动入口,不挂启动路径)。
@@ -27,15 +30,23 @@ import { createChoroInject } from "./wiki/inject.js";
27
30
  import { registerChoroCommand } from "./choro-command.js";
28
31
  // 收尾档:面板重建按钮走 runChoroT0(P34;T1 不上 HTTP)。
29
32
  import { runChoroT0 } from "./wiki/t0.js";
33
+ // T1 后台任务账(契约 §3.2):面板点「补语义正文」走 HTTP 起一个 spawn 子进程,
34
+ // 进程账不能住在 api.js(HTTP 面刻意不摸 child_process),装配点在这里。
35
+ import { createT1Jobs } from "./wiki/t1-jobs.js";
30
36
  import { createSettings } from "./settings.js";
31
37
  // 「Agent 眼中的你」自动事实(2026-11-06):逐卡事实清单 + dream 后自动维护轮。
32
38
  import { createProfileFacts } from "./profile-facts.js";
39
+ // 知识库第二层(契约 §3.4):装配与开关无关(createKb 只建对象,读侧零 IO),
40
+ // 闸在路由与工具两层——这样「没开」与「没装配」在 HTTP 面上是两个可区分答案。
41
+ import { createKb } from "./kb/index.js";
33
42
  import { createCommandManager } from "./commands.js";
34
43
  import { createExecutionRecorder } from "./pipeline-runner.js";
35
44
  import { registerMoRunCommand } from "./pipeline-command.js";
36
45
  // M.O. 自动沉淀的触发侧(冷却 / 排队 / 兜底)。挖掘与落盘在 pipeline-nudge.js,
37
46
  // 这里只管「什么时候跑」。两者分开的理由见 src/pipeline-nudge-auto.js 文件头。
38
47
  import { createNudgeAutoRun } from "./pipeline-nudge-auto.js";
48
+ // QQ 日记本:合成管线(compose)与触发侧(auto)分开,理由见 src/diary/index.js 文件头。
49
+ import { createDiaryComposer, createDiaryAutoRun } from "./diary/index.js";
39
50
  import { createVectorIndex } from "./vector-index.js";
40
51
  // semantic(embedder/reranker/boot 回填)纯搬移至 src/semantic.js(PR2):宿主与
41
52
  // daemon(dsh-mneme-serve,#363)共用同一套装配,原文件保留 barrel 出口。
@@ -243,6 +254,19 @@ export const apply = (ctx, config) => {
243
254
  for (const [objKey, sub] of Object.entries(nestedFlags)) {
244
255
  cfg[objKey] = { ...(cfg[objKey] ?? {}), ...sub };
245
256
  }
257
+ // #622:错峰时段串(summarizePeakHours / dreamPeakHours)解析失败 = 静默未配置
258
+ // (parsePeakSpec 的契约:排程是省钱手段,绝不该因写错格式停掉蒸馏/巩固)。但
259
+ // 「不救错」不等于「不吭声」——配置写错的唯一症状是功能悄悄没生效(#424 的排查
260
+ // 只能靠「高峰期还在巩固」倒推)。这里对**非空**且解析为 null 的键各出一条 warn:
261
+ // 空串是合法的未配置,不告警(否则每个没配错峰的用户开机都被警告一遍)。helper
262
+ // 只转述 parsePeakSpec:等价类归一(PR #621,评审中)落地后,救得了的写法自然
263
+ // 不再走到这。行为零变化:只 log,不改 cfg。
264
+ for (const [key, spec] of Object.entries(invalidPeakSpecs(cfg))) {
265
+ ctx.logger?.warn?.(
266
+ `[dsh-mneme] ${key} "${spec}" 不是合法的高峰时段串,将按未配置处理(错峰不生效、全天候运行)。` +
267
+ `写法:09:00-18:00,多段用逗号分隔,可带星期前缀(mon-fri 08:00-12:00),支持跨零点;24:00 越界请写 00:00`
268
+ );
269
+ }
246
270
  const sessionGate = createSessionGate(cfg);
247
271
 
248
272
  // 失败表的启动期清理也放在装配之后(此前它在 createStore 之后、装配之前,写死
@@ -497,6 +521,34 @@ export const apply = (ctx, config) => {
497
521
  metadata: JSON.stringify({ count, chars })
498
522
  });
499
523
  },
524
+ // Issue #634:归档/删除/合并把活跃量压到上次成功轮的峰值(= 阈值基线)之下
525
+ // 时,调度器在判定口把基线夹回当前活跃量。这里把「夹回发生过」落进审计——
526
+ // 修复前这类收缩会变成永久静默停摆(overBase 恒为假且无任何账面),这条行
527
+ // 就是「为什么 X 天没巩固」的答案。status 与 peak-hours 同用
528
+ // "skipped":按状态聚合(store.auditSummary)只做分组计数,不会把这行算进
529
+ // 成功率的分母口径之外的新桶;成败口径仍以 dream_runs 为准。门禁与写失败的
530
+ // fail-safe 同 auditPeakSkip(调度器侧还有一层 try/catch 兜底)。
531
+ auditBaselineClamp: ({ previous, count, chars }) => {
532
+ if (cfg?.llmAudit?.enabled === false || typeof service?.saveLlmAudit !== "function") return;
533
+ const modelId = cfg.dreamProvider && cfg.dreamModel ? `${cfg.dreamProvider}:${cfg.dreamModel}` : "";
534
+ service.saveLlmAudit({
535
+ timestamp: new Date().toISOString(),
536
+ trigger_source: "autoDream",
537
+ operation_type: "dream_consolidate",
538
+ model_id: modelId,
539
+ input_tokens: 0,
540
+ output_tokens: 0,
541
+ total_tokens: 0,
542
+ cost_usd: 0,
543
+ duration_ms: 0,
544
+ status: "skipped",
545
+ error_message: "baseline-clamp",
546
+ related_memory_ids: [],
547
+ // 观测用:previous = 夹回前的基线(旧峰值),count/chars = 夹回后的
548
+ // 活跃量(即新基线)——两份放一起,收缩幅度一眼可读。
549
+ metadata: JSON.stringify({ previous, count, chars })
550
+ });
551
+ },
500
552
  onRun: () => {
501
553
  const run = dream
502
554
  ? dream.runDream(ctx, service, cfg)
@@ -626,10 +678,17 @@ export const apply = (ctx, config) => {
626
678
  disposers.push(createContinuityRescue(ctx, store, sessionGate));
627
679
  }
628
680
 
681
+ // 知识库第二层(契约 §3.4):装配与开关无关(createKb 只建对象,读侧零 IO),闸在
682
+ // 路由与工具两层——这样「没开」(409)与「没装配」(503)在对外两面都是可区分的。
683
+ // 位置必须在 tools/api 两处消费点之前:cordis 的 inject 回调可能在任一之后才触发,
684
+ // 在消费点里现建就会造出第二份实例(两份缓存、两套 dispose)。
685
+ const kb = createKb({ store, embedder, cfg });
686
+ disposers.push(() => kb.dispose?.());
687
+
629
688
  ctx.inject(["tools"], (toolsCtx) => {
630
689
  // choro(M1):只读查询对象,chorographyEnabled 关着时工具压根不注册(闸在
631
690
  // createTools 的 hiddenTools,与 organizeEnabled 同款反向语义)。
632
- disposers.push(createTools(toolsCtx, service, cfg, embedder, sessionGate, createChoroQueries({ store })));
691
+ disposers.push(createTools(toolsCtx, service, cfg, embedder, sessionGate, createChoroQueries({ store }), kb));
633
692
  });
634
693
 
635
694
  // chorography M3:/choro 只读回执。与 /mo-run 同形(ctx.commands.register),
@@ -651,6 +710,15 @@ export const apply = (ctx, config) => {
651
710
  const summarizer = createSummarizer(ctx, service, cfg, {}, sessionGate);
652
711
  disposers.push(summarizer.dispose);
653
712
 
713
+ // QQ 日记本(v0.13,diaryEnabled):构造期零副作用(只闭包住依赖,不建目录、不读盘、
714
+ // 不调模型),所以位置可以放在这里——它必须早于下面的 createApi,面板的
715
+ // 「写一篇」按钮要转调同一个 composer;而自动触发侧在 webServer 块之后挂(同
716
+ // actionNudge 的 TDZ 理由)。关闸时 composer 是 null:路由回 409、座位不注册,
717
+ // 与「装配缺失」的 503 严格区分(#497 先例)。
718
+ const diary = cfg.diaryEnabled === true
719
+ ? createDiaryComposer({ ctx, config: cfg, logger: ctx.logger, service, settings, memoryDir })
720
+ : null;
721
+
654
722
  // #275 存储生命周期第一批:无损回收的手动入口。刻意不挂启动路径、不接定时器——
655
723
  // 这一步是不可逆的内容丢弃,只由人显式触发(面板的维护卡、`dsh-mneme reclaim`)。
656
724
  // 实例建在插件进程内,是为了让 CLI 也能走这条路:VACUUM 要排他锁,跟宿主抢锁的
@@ -676,17 +744,41 @@ export const apply = (ctx, config) => {
676
744
  // 闸在路由/工具两层(关着 409 / 不注册),queries 本体装配与开关无关。
677
745
  // 收尾档:choro 对象加两件 host 侧能力——embedder(向量惰性物化,P37)与
678
746
  // runT0Apply(面板重建按钮的写路径,P34;T1 不上 HTTP,CLI 仍是它的入口)。
747
+ // T1 任务账随 choro 装配建立(与开关无关:开关在路由那侧判,没开时这些方法一次
748
+ // 都不会被调到,符合「503=装配缺件 / 409=用户没开」的分叉)。
749
+ const t1Jobs = createT1Jobs({ store, dbPath: join(memoryDir, "memory.db") });
750
+ disposers.push(() => t1Jobs.dispose());
679
751
  const choro = {
680
752
  ...createChoroQueries({ store }),
681
753
  embedder,
682
- runT0Apply: ({ repoRoot }) => runChoroT0({ repoRoot, store, apply: true }),
754
+ // guardT1Loss 只给「读侧顺手刷新」用:那条路不是用户的明确指令,不能替他
755
+ // 丢掉花模型调用换来的语义段。手动重建(面板按钮 / CLI)不传,行为不变。
756
+ runT0Apply: ({ repoRoot, guardT1Loss = false }) => runChoroT0({ repoRoot, store, apply: true, guardT1Loss }),
757
+ startT1Job: (args) => t1Jobs.start(args),
758
+ t1JobStatus: (jobId) => t1Jobs.status(jobId),
683
759
  };
760
+ // The semantic surface handed to the API is createSemantic()'s own return
761
+ // value, plus the vectorIndex it does not carry. Passing the bare literal
762
+ // `{ vectorIndex, reranker }` used to drop embeddingDiagnostics, so the
763
+ // panel's `data.embeddings` was permanently null on a real install — the
764
+ // typeof guard in api.js fell through every time, and no test noticed
765
+ // because none asserted on `data.embeddings`.
684
766
  const api = createApi(ctx, service, settings, commands ?? {
685
767
  add: () => { throw new Error("commands unavailable"); },
686
768
  remove: () => false,
687
769
  list: () => []
688
- }, embedder, { vectorIndex, reranker }, cfg.apiToken, cfg, maintenance, extractProfileDraft, choro, profileFacts);
770
+ }, embedder, { ...semantic, vectorIndex, reranker }, cfg.apiToken, cfg, maintenance, extractProfileDraft, choro, profileFacts, kb, diary);
689
771
  disposers.push(api.dispose);
772
+
773
+ // dsh-go 原生面板:只有宿主提供 dshGo 服务(dsh-go 手机 App)时才挂载。
774
+ // 动态 import 是刻意的——静态导入会让「没有桥就整块不加载」这句承诺落空。
775
+ // cfg.apiToken 一并交给面板:createApi 给 token / token-write 路由包了鉴权,
776
+ // 进程内调用不带令牌会被自家路由拦成 401,不为此在服务端开白名单。
777
+ ctx.inject(["dshGo"], (dshCtx) => {
778
+ import("./dsh-go/panel.js")
779
+ .then((panel) => panel.mountDshGoPanel(dshCtx, { apiToken: cfg.apiToken }))
780
+ .catch((error) => ctx.logger?.warn?.(`[dsh-mneme] dsh-go 面板未加载:${error?.message ?? error}`));
781
+ });
690
782
  }
691
783
 
692
784
  // M.O. 自动沉淀(v0.9.3):项目里重复走的那套动作,自动变成 memoryDir 里的一条
@@ -701,6 +793,34 @@ export const apply = (ctx, config) => {
701
793
  disposers.push(createNudgeAutoRun({ ctx, memoryDir, logger: ctx.logger, sessionGate }));
702
794
  }
703
795
 
796
+ // 日记的触发侧(v0.13):turn/end 跨天判定 + 启动补写。
797
+ //
798
+ // 启动补写为什么在这里而不是 webServer 块里:它不依赖面板,headless 宿主也该补。
799
+ // 补哪个项目只能靠状态文件里记过的项目根——宿主刚起来时没有任何会话,
800
+ // 而「上次是哪个项目、哪天写的」就在那儿。补写不 await:启动路径上吊住一次
801
+ // 模型调用,等于让整个插件加载等模型回来(本机 p90 五十秒级)。
802
+ if (diary) {
803
+ // diaryWriteEveryMinutes > 0 时额外起一根定时器(见 auto.js 的 tick):日记不该
804
+ // 取决于「谁先开口」,整天不说话也要有一篇。listProjects 用 knownProjects ——
805
+ // 定时器唤醒时没有会话,只能靠状态文件里记过的项目根。
806
+ const everyMinutes = Number(cfg.diaryWriteEveryMinutes) || 0;
807
+ const diaryAuto = createDiaryAutoRun({
808
+ ctx,
809
+ composer: diary,
810
+ logger: ctx.logger,
811
+ sessionGate,
812
+ everyMs: everyMinutes > 0 ? everyMinutes * 60 * 1000 : 0,
813
+ listProjects: () => diary.knownProjects()
814
+ });
815
+ disposers.push(diaryAuto);
816
+ const projects = diary.knownProjects();
817
+ if (projects.length) {
818
+ Promise.resolve(diaryAuto.catchUp(projects)).catch((error) => {
819
+ ctx.logger?.warn?.("[dsh-mneme] diary startup catch-up failed: " + String(error));
820
+ });
821
+ }
822
+ }
823
+
704
824
  // Standalone external API (v0.7.12): plain node:http server for ecosystem
705
825
  // integrations outside the DSH host. Persisted external_api settings win
706
826
  // over the bundle config (enabled/port); the Bearer token lives in the same
package/lib/inject.js CHANGED
@@ -44,13 +44,17 @@ function visibleSeqsOf(session) {
44
44
  // 候选问的是同一件事——「当时到底想干什么」;这条取原话的路只该有一份实现。
45
45
  // 复制一份的代价本仓库付过几次(src/lib 平价、snapshotEvents 兼容层),所以
46
46
  // 这里只加 export,不改行为。
47
- export function lastUserQuery(ctx) {
47
+ //
48
+ // #617:多带一个 seq——注入器要拿它判断「待认领票据是否比事件流更新」(见
49
+ // createInjector 的 pendingQueries)。seq 是宿主事件序号,取不到时为 null
50
+ // (老版本宿主 / 测试夹具),调用方按 null 参与相等比较,不做真假推断。
51
+ export function lastUserMessageInfo(ctx) {
48
52
  try {
49
53
  const session = ctx?.agent?.session;
50
54
  // DSH ≥0.1.2-rc only exposes events via snapshotEvents(); older builds
51
55
  // still have the .events property, so fall back to it.
52
56
  const events = session?.snapshotEvents?.() ?? session?.events;
53
- if (!Array.isArray(events) || events.length === 0) return "";
57
+ if (!Array.isArray(events) || events.length === 0) return { text: "", seq: null };
54
58
  const visibleSeqs = visibleSeqsOf(session);
55
59
  for (let i = events.length - 1; i >= 0; i--) {
56
60
  const event = events[i];
@@ -61,16 +65,44 @@ export function lastUserQuery(ctx) {
61
65
  if (kind !== undefined && kind !== "user") continue;
62
66
  const parts = event.data?.content;
63
67
  if (!Array.isArray(parts) || parts.length === 0) continue;
64
- return safeSlice(
65
- parts
66
- .map((p) => (typeof p === "string" ? p : p?.text ?? ""))
67
- .filter(Boolean)
68
- .join("\n"),
69
- 500
70
- );
68
+ return {
69
+ text: safeSlice(
70
+ parts
71
+ .map((p) => (typeof p === "string" ? p : p?.text ?? ""))
72
+ .filter(Boolean)
73
+ .join("\n"),
74
+ 500
75
+ ),
76
+ seq: event?.seq ?? null
77
+ };
71
78
  }
72
79
  } catch { /* session internals unavailable: degrade to no query */ }
73
- return "";
80
+ return { text: "", seq: null };
81
+ }
82
+
83
+ export function lastUserQuery(ctx) {
84
+ return lastUserMessageInfo(ctx).text;
85
+ }
86
+
87
+ // 认领消息 → 查询文本。#617 的补位票据就是这里算出来的字符串:判定口径与
88
+ // user/message 事件同源(content parts + source.kind),于是「插件注入的上下文」
89
+ // 这类非人类消息照样被跳过,票据不会拿插件自己的话当用户问题。
90
+ function inboxMessageQuery(message) {
91
+ try {
92
+ const parts = message?.content ?? message?.data?.content;
93
+ if (!Array.isArray(parts) || parts.length === 0) return "";
94
+ const kind = message?.source?.kind ?? message?.data?.source?.kind;
95
+ if (kind !== undefined && kind !== "user") return "";
96
+ return safeSlice(
97
+ parts
98
+ .map((p) => (typeof p === "string" ? p : p?.text ?? ""))
99
+ .filter(Boolean)
100
+ .join("\n"),
101
+ 500
102
+ );
103
+ } catch {
104
+ return "";
105
+ }
74
106
  }
75
107
 
76
108
  // Hot-memory round extraction (v0.5.0 1.3): pairs each user/message with the
@@ -80,6 +112,14 @@ export function lastUserQuery(ctx) {
80
112
  // visibleSeqsOf). Rewind-shadowed / compacted events are thereby excluded:
81
113
  // hot memory and the model see the same conversation. Best-effort: returns []
82
114
  // on any failure, and the hot block simply does not render.
115
+ //
116
+ // omitPendingRound: true(Issue #637):prompt 组装发生的时刻,尾部 pending 轮
117
+ // 就是「正在组装的这一轮」——它的提问已经作为真实 user message 在本条 prompt
118
+ // 里,热记忆里再渲染一份空 `A:` 行是纯重复,且回答落地后该行会从空 `A:` 抖成
119
+ // 正文:宿主「文本变了就追加快照」的契机会让同一轮的注入块被追加多份 runtime-
120
+ // context,旧份永久留在会话历史(#637 报告:4 轮 → 10 份、75% 重复)。省略之后
121
+ // 一轮内热块文本恒定、回答落地时恰好变一次——快照数降到每轮一份。直调方
122
+ // (tests / 其他消费者)不传这个参数,保持完整配对。
83
123
  function extractRounds(ctx, maxRounds) {
84
124
  try {
85
125
  const session = ctx?.agent?.session;
@@ -87,7 +127,7 @@ function extractRounds(ctx, maxRounds) {
87
127
  // still have the .events property, so fall back to it.
88
128
  const events = session?.snapshotEvents?.() ?? session?.events;
89
129
  if (!Array.isArray(events) || events.length === 0) return [];
90
- return roundsFromEvents(events, maxRounds, visibleSeqsOf(session));
130
+ return roundsFromEvents(events, maxRounds, visibleSeqsOf(session), { omitPendingRound: true });
91
131
  } catch {
92
132
  return [];
93
133
  }
@@ -259,7 +299,11 @@ export function createInjector(ctx, service, settings, config, gate = null, sess
259
299
  ? STR.verified[language]
260
300
  : "";
261
301
  const title = STR.entryTitle[language](m.title, m.importance);
262
- const content = injectMemory(m, pinned ? PINNED_CONTENT_MAX : maxContent);
302
+ // Issue #641(T4 注入标注):conflictNote 由 injectCandidates 在 enforce
303
+ // 档下挂上(矛盾对同批时的机械排位说明)。#537 的教训:标注必须进注入
304
+ // 文本本体,不能只留在账本里——消费方看不见的标注等于没标。
305
+ const content = injectMemory(m, pinned ? PINNED_CONTENT_MAX : maxContent)
306
+ + (m.conflictNote ? "\n" + m.conflictNote : "");
263
307
  const full = m.graphHint === true
264
308
  ? STR.graphHintLine[language](m.type, title, content)
265
309
  : STR.entryLine[language](m.type, verified, title, content);
@@ -291,21 +335,117 @@ export function createInjector(ctx, service, settings, config, gate = null, sess
291
335
  // first render after a new user message may still fall back to the rule-based
292
336
  // pick; later assemblies in the same session reuse the cached vector. Bounded
293
337
  // cache (cap 8, drop oldest) so a long session never grows it unbounded.
338
+ //
339
+ // #617 根因 B:旧实现用一个 lastPrefetched 字符串做去重,且它在 await 之前
340
+ // 就置位——同一条查询第一次失败(模型没下好、超时)之后**永不重试**,而那
341
+ // 恰好是最需要重试的一次。这里换成三件套:inflight Map 去重(渲染同一步会
342
+ // 被求值两次:assemble 一次、refreshContext 一次,同一条查询只该嵌一次)、
343
+ // 失败冷却 15s(失败可重试,但不许每次渲染都拿一次失败去撞模型)、返回
344
+ // Promise(调用方/测试能等到它,而不是靠魔法延时)。
294
345
  const QUERY_VECTOR_CACHE_MAX = 8;
346
+ const PREFETCH_FAIL_COOLDOWN_MS = 15000;
295
347
  const queryVectorCache = new Map();
296
- let lastPrefetched = "";
348
+ const inflightPrefetches = new Map(); // query -> Promise<vector|null>
349
+ const prefetchFailedAt = new Map(); // query -> ms
297
350
 
298
351
  function prefetchQueryVector(query) {
299
- if (!query || query === lastPrefetched || queryVectorCache.has(query)) return;
300
- lastPrefetched = query;
301
- service.embedQuery(query).then((vec) => {
352
+ if (!query) return null;
353
+ const cached = queryVectorCache.get(query);
354
+ if (cached) return Promise.resolve(cached);
355
+ const inflight = inflightPrefetches.get(query);
356
+ if (inflight) return inflight;
357
+ const failedAt = prefetchFailedAt.get(query);
358
+ if (failedAt !== undefined && Date.now() - failedAt < PREFETCH_FAIL_COOLDOWN_MS) return null;
359
+ const pending = service.embedQuery(query).then((vec) => {
302
360
  if (Array.isArray(vec) && vec.length) {
303
361
  queryVectorCache.set(query, vec);
304
362
  if (queryVectorCache.size > QUERY_VECTOR_CACHE_MAX) {
305
363
  queryVectorCache.delete(queryVectorCache.keys().next().value);
306
364
  }
365
+ prefetchFailedAt.delete(query);
366
+ return vec;
307
367
  }
308
- }).catch(() => { /* prefetch is best-effort */ });
368
+ prefetchFailedAt.set(query, Date.now());
369
+ return null;
370
+ }).catch(() => {
371
+ prefetchFailedAt.set(query, Date.now());
372
+ return null; /* prefetch is best-effort */
373
+ }).finally(() => {
374
+ inflightPrefetches.delete(query);
375
+ });
376
+ inflightPrefetches.set(query, pending);
377
+ return pending;
378
+ }
379
+
380
+ // #617 根因 A 的补位票据:宿主(agent-loop 0.2.x)在每个 step 的 pre-step 里
381
+ // 先 inbox.claim()(同步 emit agent/inbox/claimed),再 assemble(),而
382
+ // user/message 要到 step() 末尾(:865)才 append——注入的 text 回调在
383
+ // assemble / refreshContext 里求值,**永远比当轮用户输入早一拍**:Step 1 拿到
384
+ // "",第 N 轮的 Step 1 只能拿到第 N-1 轮的旧文本(实测 46 个渲染点错 3 次)。
385
+ // 票据在 claim 那一刻记下原文;渲染时若事件流里最后一条用户消息还不是它,
386
+ // 说明本轮 user/message 尚未落盘,用票据;一旦落盘,事件流才是权威(它对
387
+ // rewind / compact 的可见面判定更准,票据不看 surface)。
388
+ //
389
+ // 为什么只在 claimed 上记、不从 agent/inbox/spliced 推:claim 自己的两次
390
+ // mutate 都落 outcome:"canceled" 的 spliced(inserted 为空),拿 removedCount
391
+ // 当撤回信号会把每次正常认领误判成取消;claimed 只在真有消息被认领时触发,
392
+ // 是唯一无歧义的信号源。Cancel/Rewind 穿透因此也不存在——它们不产生 claimed。
393
+ const PENDING_QUERY_MAX = 32;
394
+ // 票据超过这个岁数就不再采信:一个 step 的 assemble 不可能跑这么久,更久说明
395
+ // 那条消息压根没落盘(认领后整轮被取消),票据已是过期证据。
396
+ const PENDING_QUERY_TTL_MS = 60000;
397
+ const pendingQueries = new Map(); // sessionId -> { text, at }
398
+ const offClaimed = typeof ctx?.on === "function"
399
+ ? ctx.on("agent/inbox/claimed", (payload) => {
400
+ try {
401
+ const message = payload?.message;
402
+ const text = inboxMessageQuery(message);
403
+ if (!text) return;
404
+ const session = payload?.agent?.session;
405
+ const sessionId = session?.id;
406
+ if (typeof sessionId !== "string" || !sessionId) return;
407
+ // #539 无痕闸门必须挡在这里,不能只挡渲染侧:claim 在 pre-step 里同步触发,
408
+ // 早于 assemble;这条路径既写票据(票据会变成注入查询文本)又预取向量
409
+ // (embedQuery 直接 POST 外部 /embeddings),闸门命中时两者都是该会话数据的
410
+ // 外流。嵌入层自身没有任何会话维度判断,这里是唯一的拦截点。
411
+ if (sessionGate?.isOff(session)) return;
412
+ pendingQueries.delete(sessionId); // 重新插入 = 移到队尾
413
+ pendingQueries.set(sessionId, { text, at: Date.now() });
414
+ if (pendingQueries.size > PENDING_QUERY_MAX) {
415
+ pendingQueries.delete(pendingQueries.keys().next().value);
416
+ }
417
+ // 认领即预取:embed 在 assemble→step 之间就已经在跑,Step 1 的第二次
418
+ // 求值(refreshContext)有机会拿到向量。拿不到也无妨——查询文本已经在
419
+ // 手上,BM25 词法路(#198)能顶上。
420
+ prefetchQueryVector(text);
421
+ } catch { /* the ticket is best-effort; injection must never break */ }
422
+ })
423
+ : null;
424
+
425
+ // 当轮查询:票据优先于事件流(见上)。判据是**文本比对**而不是事件条数——
426
+ // 从 claim 到 assemble 之间还夹着 await tools.resolve(...),这期间任何插件
427
+ // 追加一个事件都会让条数增长,按条数判会把「消息还没落盘」误判成「已落盘」,
428
+ // 票据提前退休、Step 1 又退回空串(正是本 issue 要修的那一拍)。文本相等才是
429
+ // 「这条消息真的进事件流了」的直接证据;连续两轮同一句话时两者相等,取哪个都对。
430
+ function liveStepQuery(ctx) {
431
+ const info = lastUserMessageInfo(ctx);
432
+ let sessionId = null;
433
+ try {
434
+ const id = ctx?.agent?.session?.id;
435
+ if (typeof id === "string" && id) sessionId = id;
436
+ } catch { /* session internals unavailable */ }
437
+ if (!sessionId) return info.text;
438
+ const ticket = pendingQueries.get(sessionId);
439
+ if (!ticket) return info.text;
440
+ if (info.text === ticket.text) {
441
+ pendingQueries.delete(sessionId); // 已落盘,票据退休(顺带保证 Map 不被历史会话撑大)
442
+ return info.text;
443
+ }
444
+ if (Date.now() - ticket.at > PENDING_QUERY_TTL_MS) {
445
+ pendingQueries.delete(sessionId); // 过期证据,不采信
446
+ return info.text;
447
+ }
448
+ return ticket.text;
309
449
  }
310
450
 
311
451
  // 会话身份解析(v1.2 人格寻址用):读 session.header.agentPreset——冻结的
@@ -323,6 +463,39 @@ export function createInjector(ctx, service, settings, config, gate = null, sess
323
463
  }
324
464
  }
325
465
 
466
+ // 会话 id 解析(#606 绑会话卡用):session.id 在宿主里稳定可用(scope.js:97-103
467
+ // 就是靠它反查 workspace 的)。与 agentPreset 一样各自护住——两者来自同一个
468
+ // session 对象,但 accessor 抛错时不该互相拖累(一个取不到不影响另一个)。
469
+ // 为什么需要第二个键:DS Bot 里 agentPreset 恒为 "standard",按 bot 建卡分不开,
470
+ // 只有会话 id 能分开(实测见 issue #606)。
471
+ function sessionIdOf(ctx) {
472
+ try {
473
+ const id = ctx?.agent?.session?.id;
474
+ return typeof id === "string" && id.trim() ? id.trim() : null;
475
+ } catch {
476
+ return null;
477
+ }
478
+ }
479
+
480
+ // 父会话 id 解析(子 agent 继承人格卡用):子代理会话的 header 里有 `parentSession`
481
+ // (实测 518 个子代理头恒带此字段,主会话没有)。⚠️ 与 `session-gate.js` 的口径不同:
482
+ // 那里刻意不用它判「是不是子代理」(宿主 fork 出的主会话分支同样带 parentSession),
483
+ // 这里只把它当「自己没建卡时该继承谁」的线索,fork 出的分支继承父卡同样是正确语义。
484
+ function parentSessionIdOf(ctx) {
485
+ try {
486
+ const session = ctx?.agent?.session;
487
+ const parent = session?.header?.parentSession;
488
+ return typeof parent === "string" && parent.trim() ? parent.trim() : null;
489
+ } catch {
490
+ return null;
491
+ }
492
+ }
493
+
494
+ // 一次取全三个寻址键。分开取值、各自兜错:一个失败不该让另一个也丢。
495
+ function sessionIdentity(ctx) {
496
+ return { agentId: sessionAgentId(ctx), sessionId: sessionIdOf(ctx), parentSessionId: parentSessionIdOf(ctx) };
497
+ }
498
+
326
499
  // User profile + rules: injected ahead of the memory block because they are
327
500
  // always-relevant instructions the agent should follow every turn.
328
501
  // 渲染整体搬到 src/user-settings.js(与面板预览共用一份,见那里的模块头注释)。
@@ -333,7 +506,9 @@ export function createInjector(ctx, service, settings, config, gate = null, sess
333
506
  function renderUserSettings(ctx) {
334
507
  // factsInject 是 profile-facts 域的读口(index.js 接线,面板预览路由传同一份):
335
508
  // 开关与事实有无的判定都在 renderUserSettingsBlock 内部,「关」等于零读数。
336
- return renderUserSettingsBlock({ settings, config, agentId: sessionAgentId(ctx), factsProvider: factsInject }).text;
509
+ const { agentId, sessionId, parentSessionId } = sessionIdentity(ctx);
510
+ return renderUserSettingsBlock({ settings, config, agentId, sessionId, parentSessionId, factsProvider: factsInject })
511
+ .text;
337
512
  }
338
513
 
339
514
  // #249 能力说明的系统提示段。order 150 = 插件指引段的既有惯例
@@ -360,10 +535,12 @@ export function createInjector(ctx, service, settings, config, gate = null, sess
360
535
  text: (ctx) => {
361
536
  if (sessionGate?.isOff(sessionOf(ctx))) return "";
362
537
  // Bug4: pass the latest user query so injection prefers semantically
363
- // relevant memories; lastUserQuery is best-effort (empty → legacy).
538
+ // relevant memories; liveStepQuery is best-effort (empty → legacy).
364
539
  // The query vector is prefetched asynchronously (cached) because the
365
540
  // render itself must stay synchronous.
366
- const query = lastUserQuery(ctx);
541
+ // #617:liveStepQuery 而不是 lastUserQuery——宿主在 step 末尾才 append
542
+ // user/message,而注入在这之前求值,只看事件流会拿到上一轮的输入。
543
+ const query = liveStepQuery(ctx);
367
544
  if (query) prefetchQueryVector(query);
368
545
  const queryVector = queryVectorCache.get(query);
369
546
  // v0.8.0 A3:scope 随请求解析(解析器恒返回对象,身份取不到时两维为
@@ -458,6 +635,12 @@ export function createInjector(ctx, service, settings, config, gate = null, sess
458
635
  ];
459
636
 
460
637
  return () => {
638
+ // #617:认领票据是监听宿主事件的活句柄,卸载时必须成对摘掉,否则注入器重装
639
+ // (面板开关、宿主热重载)会留下一个继续写 Map 的孤儿监听。
640
+ offClaimed?.();
641
+ pendingQueries.clear();
642
+ inflightPrefetches.clear();
643
+ prefetchFailedAt.clear();
461
644
  queryVectorCache.clear();
462
645
  rotationHistory.clear();
463
646
  injectionSnapshot = null; // 注入器卸载即失效:快照不得跨生命周期存留