@modusensus/dsh-mneme 0.11.0 → 0.13.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 (174) hide show
  1. package/README.md +54 -21
  2. package/docs/CONFIGURATION.md +45 -4
  3. package/docs/DIRECTORIES.md +114 -0
  4. package/docs/DISTILL.md +64 -0
  5. package/docs/DSH-GO.md +97 -0
  6. package/docs/ENTITIES.md +5 -3
  7. package/docs/FORGET.md +77 -0
  8. package/docs/KB.md +110 -0
  9. package/docs/SLEEP.md +28 -3
  10. package/docs/STORAGE.md +23 -0
  11. package/lib/api-standalone.js +4 -1
  12. package/lib/api.js +744 -26
  13. package/lib/attachment-text.js +122 -0
  14. package/lib/choro-command.js +9 -2
  15. package/lib/client.js +3752 -819
  16. package/lib/config.js +171 -2
  17. package/lib/continuity.js +7 -6
  18. package/lib/diary/auto.js +190 -0
  19. package/lib/diary/compose.js +440 -0
  20. package/lib/diary/dir.js +59 -0
  21. package/lib/diary/export.js +97 -0
  22. package/lib/diary/index.js +26 -0
  23. package/lib/diary/library.js +189 -0
  24. package/lib/diary/qq-inbox.js +142 -0
  25. package/lib/diary/rollover.js +61 -0
  26. package/lib/diary/session-provider.js +122 -0
  27. package/lib/document.js +9 -3
  28. package/lib/dream/clustering.js +6 -1
  29. package/lib/dream/decisions.js +56 -5
  30. package/lib/dream/sleep.js +616 -15
  31. package/lib/dream.js +45 -13
  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 +8 -9
  44. package/lib/index.js +131 -5
  45. package/lib/inject.js +192 -19
  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 +115 -4
  50. package/lib/peak-hours.js +57 -2
  51. package/lib/profile-facts.js +356 -0
  52. package/lib/semantic.js +347 -60
  53. package/lib/service.js +302 -70
  54. package/lib/settings.js +117 -14
  55. package/lib/store.js +830 -55
  56. package/lib/summarize.js +38 -11
  57. package/lib/tools.js +173 -25
  58. package/lib/user-settings.js +175 -23
  59. package/lib/vector-index.js +76 -6
  60. package/lib/wiki/cli-hint.js +90 -0
  61. package/lib/wiki/queries.js +264 -10
  62. package/lib/wiki/t1-jobs.js +232 -0
  63. package/lib/wiki/t1.js +7 -1
  64. package/package.json +25 -1
  65. package/scripts/sync-test-badge.mjs +1 -1
  66. package/scripts/test-count-sync.mjs +9 -5
  67. package/src/api-standalone.js +4 -1
  68. package/src/api.js +744 -26
  69. package/src/attachment-text.js +122 -0
  70. package/src/choro-command.js +9 -2
  71. package/src/config.js +171 -2
  72. package/src/continuity.js +7 -6
  73. package/src/diary/auto.js +190 -0
  74. package/src/diary/compose.js +440 -0
  75. package/src/diary/dir.js +59 -0
  76. package/src/diary/export.js +97 -0
  77. package/src/diary/index.js +26 -0
  78. package/src/diary/library.js +189 -0
  79. package/src/diary/qq-inbox.js +142 -0
  80. package/src/diary/rollover.js +61 -0
  81. package/src/diary/session-provider.js +122 -0
  82. package/src/document.js +9 -3
  83. package/src/dream/clustering.js +6 -1
  84. package/src/dream/decisions.js +56 -5
  85. package/src/dream/sleep.js +616 -15
  86. package/src/dream.js +45 -13
  87. package/src/dsh-go/host.js +162 -0
  88. package/src/dsh-go/panel.js +2091 -0
  89. package/src/dsh-go/text.js +342 -0
  90. package/src/dsh-go/views/common.js +68 -0
  91. package/src/dsh-go/views/entity.js +48 -0
  92. package/src/dsh-go/views/memory.js +198 -0
  93. package/src/dsh-go/views/root.js +30 -0
  94. package/src/dsh-go/views/settings.js +398 -0
  95. package/src/dsh-go/views/status.js +51 -0
  96. package/src/embedding.js +42 -8
  97. package/src/entities/extractor.js +36 -7
  98. package/src/hot-memory.js +8 -9
  99. package/src/index.js +131 -5
  100. package/src/inject.js +192 -19
  101. package/src/kb/index.js +309 -0
  102. package/src/kb/routes.js +220 -0
  103. package/src/kb/tools.js +148 -0
  104. package/src/lang.js +115 -4
  105. package/src/peak-hours.js +57 -2
  106. package/src/profile-facts.js +356 -0
  107. package/src/semantic.js +347 -60
  108. package/src/service.js +302 -70
  109. package/src/settings.js +117 -14
  110. package/src/store.js +830 -55
  111. package/src/summarize.js +38 -11
  112. package/src/tools.js +173 -25
  113. package/src/user-settings.js +175 -23
  114. package/src/vector-index.js +76 -6
  115. package/src/wiki/cli-hint.js +90 -0
  116. package/src/wiki/queries.js +264 -10
  117. package/src/wiki/t1-jobs.js +232 -0
  118. package/src/wiki/t1.js +7 -1
  119. package/test/api-routes-gaps.test.js +63 -0
  120. package/test/api.test.js +377 -4
  121. package/test/attachment-text.test.js +140 -0
  122. package/test/bm25-corpus-cache.test.js +145 -0
  123. package/test/capacity-warning.test.js +262 -0
  124. package/test/client.test.js +908 -138
  125. package/test/config.test.js +79 -0
  126. package/test/conflict-freeze.test.js +45 -1
  127. package/test/continuity.test.js +8 -1
  128. package/test/diary-library.test.js +187 -0
  129. package/test/diary.test.js +576 -0
  130. package/test/document.test.js +32 -0
  131. package/test/dream-maxtokens-fallback.test.js +3 -3
  132. package/test/dream.test.js +62 -1
  133. package/test/dsh-go-host.test.js +134 -0
  134. package/test/dsh-go-panel-actions.test.js +854 -0
  135. package/test/dsh-go-panel.test.js +251 -0
  136. package/test/embedding-cache-f32.test.js +276 -0
  137. package/test/embedding-ready.test.js +33 -3
  138. package/test/entities.test.js +73 -0
  139. package/test/forget-corrections.test.js +70 -0
  140. package/test/image-memory.test.js +171 -0
  141. package/test/inject-pin-explicit.test.js +300 -0
  142. package/test/inject-pin-vector-crowd.test.js +225 -0
  143. package/test/issue-617-step1-query-and-fusion.test.js +361 -0
  144. package/test/kb-api-wiring.test.js +128 -0
  145. package/test/kb-collections.test.js +272 -0
  146. package/test/kb-routes.test.js +379 -0
  147. package/test/mint-source.test.js +26 -0
  148. package/test/peak-hours-observability.test.js +70 -0
  149. package/test/persona-api.test.js +3 -3
  150. package/test/persona-by-session.test.js +568 -0
  151. package/test/persona-settings.test.js +38 -7
  152. package/test/profile-facts.test.js +492 -0
  153. package/test/reflection-update-gate.test.js +129 -0
  154. package/test/reindex-backfill.test.js +334 -0
  155. package/test/reindex-scheduler.test.js +369 -0
  156. package/test/scope-candidates.test.js +249 -0
  157. package/test/session-gate.test.js +1 -1
  158. package/test/sleep-action-set.test.js +14 -0
  159. package/test/sleep-heat.test.js +49 -0
  160. package/test/sleep.test.js +598 -0
  161. package/test/standalone-api.test.js +24 -0
  162. package/test/store.test.js +20 -0
  163. package/test/summarize.test.js +26 -0
  164. package/test/test-count-sync.test.js +14 -3
  165. package/test/tool-state-ignore.test.js +65 -0
  166. package/test/tools-dto-schema-matrix.test.js +23 -2
  167. package/test/tools.test.js +144 -7
  168. package/test/user-settings-segments.test.js +528 -0
  169. package/test/vector-index.test.js +120 -6
  170. package/test/wiki-api.test.js +476 -1
  171. package/test/wiki-cli-hint.test.js +118 -0
  172. package/test/wiki-queries.test.js +91 -1
  173. package/test/wiki-t1-jobs.test.js +125 -0
  174. package/test/wiki-tools.test.js +131 -76
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 存储生命周期第一批:无损回收(手动入口,不挂启动路径)。
@@ -25,13 +28,25 @@ import { createChoroQueries } from "./wiki/queries.js";
25
28
  // chorography M3:开局注入块与 /choro 只读回执。两者都只吃 queries 读侧,不写任何东西。
26
29
  import { createChoroInject } from "./wiki/inject.js";
27
30
  import { registerChoroCommand } from "./choro-command.js";
31
+ // 收尾档:面板重建按钮走 runChoroT0(P34;T1 不上 HTTP)。
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";
28
36
  import { createSettings } from "./settings.js";
37
+ // 「Agent 眼中的你」自动事实(2026-11-06):逐卡事实清单 + dream 后自动维护轮。
38
+ import { createProfileFacts } from "./profile-facts.js";
39
+ // 知识库第二层(契约 §3.4):装配与开关无关(createKb 只建对象,读侧零 IO),
40
+ // 闸在路由与工具两层——这样「没开」与「没装配」在 HTTP 面上是两个可区分答案。
41
+ import { createKb } from "./kb/index.js";
29
42
  import { createCommandManager } from "./commands.js";
30
43
  import { createExecutionRecorder } from "./pipeline-runner.js";
31
44
  import { registerMoRunCommand } from "./pipeline-command.js";
32
45
  // M.O. 自动沉淀的触发侧(冷却 / 排队 / 兜底)。挖掘与落盘在 pipeline-nudge.js,
33
46
  // 这里只管「什么时候跑」。两者分开的理由见 src/pipeline-nudge-auto.js 文件头。
34
47
  import { createNudgeAutoRun } from "./pipeline-nudge-auto.js";
48
+ // QQ 日记本:合成管线(compose)与触发侧(auto)分开,理由见 src/diary/index.js 文件头。
49
+ import { createDiaryComposer, createDiaryAutoRun } from "./diary/index.js";
35
50
  import { createVectorIndex } from "./vector-index.js";
36
51
  // semantic(embedder/reranker/boot 回填)纯搬移至 src/semantic.js(PR2):宿主与
37
52
  // daemon(dsh-mneme-serve,#363)共用同一套装配,原文件保留 barrel 出口。
@@ -239,6 +254,19 @@ export const apply = (ctx, config) => {
239
254
  for (const [objKey, sub] of Object.entries(nestedFlags)) {
240
255
  cfg[objKey] = { ...(cfg[objKey] ?? {}), ...sub };
241
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
+ }
242
270
  const sessionGate = createSessionGate(cfg);
243
271
 
244
272
  // 失败表的启动期清理也放在装配之后(此前它在 createStore 之后、装配之前,写死
@@ -437,6 +465,13 @@ export const apply = (ctx, config) => {
437
465
  }
438
466
  }
439
467
 
468
+ // 「Agent 眼中的你」自动事实(2026-11-06,opt-in:profileFactsEnabled):dream 巩固
469
+ // 轮成功后的逐卡维护 + 面板手动轮(api.js)+ 注入事实段(factsProvider 传注入器与
470
+ // 预览路由)。装配与开关无关——闸在消费侧(注入渲染 / 自动轮判空 / 路由 409),
471
+ // 与 profileExtractor 同款:503 只该指装配缺失,不该被读成「用户没开」。
472
+ // 位置必须在 dream 调度器之前:onRun 的 fire-and-forget 收尾要引用它。
473
+ const profileFacts = createProfileFacts({ ctx, service, store, config: cfg, logger: ctx.logger });
474
+
440
475
  // Dream scheduler: automatic consolidation + summary runs, triggered by
441
476
  // store growth. Writes through the service fire the dream hook, which asks
442
477
  // the scheduler to (re)schedule a run once absolute and since-last-run
@@ -486,7 +521,26 @@ export const apply = (ctx, config) => {
486
521
  metadata: JSON.stringify({ count, chars })
487
522
  });
488
523
  },
489
- onRun: () => (dream ? dream.runDream(ctx, service, cfg) : Promise.resolve({ ok: true, skipped: true }))
524
+ onRun: () => {
525
+ const run = dream
526
+ ? dream.runDream(ctx, service, cfg)
527
+ : Promise.resolve({ ok: true, skipped: true });
528
+ // 画像事实自动维护:挂在巩固轮之后 fire-and-forget。刻意不并进 onRun 的
529
+ // promise——profileFacts 的失败不能被计入 dream 的失败退避
530
+ // (autoDreamFailureBackoff 的口径是巩固轮自身);逐卡经 service.enqueue
531
+ // 串行,不与 sleep/下一轮巩固抢模型。单卡失败只 warn,账本已有 failed 回执。
532
+ run.then(async (rep) => {
533
+ if (cfg.profileFactsEnabled !== true || (rep && rep.ok === false)) return;
534
+ for (const key of profileFacts.autoPassCandidates()) {
535
+ try {
536
+ await service.enqueue(() => profileFacts.runFor(key, { trigger: "auto" }));
537
+ } catch (error) {
538
+ ctx.logger?.warn?.(`[dsh-mneme] profile-facts auto pass failed for card "${key || "default"}": ${error?.message ?? error}`);
539
+ }
540
+ }
541
+ }).catch(() => { /* dream 自身失败走调度器退避,这里不做第二遍日志 */ });
542
+ return run;
543
+ }
490
544
  });
491
545
  service.setDreamHook(() => dream.maybeSchedule(service));
492
546
  }
@@ -585,7 +639,7 @@ export const apply = (ctx, config) => {
585
639
 
586
640
  ctx.inject(["systemPrompt"], (promptCtx) => {
587
641
  if (cfg.autoInject) {
588
- disposers.push(createInjector(promptCtx, service, settings, cfg, preInjectGate, sessionGate, choroInject));
642
+ disposers.push(createInjector(promptCtx, service, settings, cfg, preInjectGate, sessionGate, choroInject, profileFacts.factsProvider));
589
643
  }
590
644
  });
591
645
 
@@ -596,10 +650,17 @@ export const apply = (ctx, config) => {
596
650
  disposers.push(createContinuityRescue(ctx, store, sessionGate));
597
651
  }
598
652
 
653
+ // 知识库第二层(契约 §3.4):装配与开关无关(createKb 只建对象,读侧零 IO),闸在
654
+ // 路由与工具两层——这样「没开」(409)与「没装配」(503)在对外两面都是可区分的。
655
+ // 位置必须在 tools/api 两处消费点之前:cordis 的 inject 回调可能在任一之后才触发,
656
+ // 在消费点里现建就会造出第二份实例(两份缓存、两套 dispose)。
657
+ const kb = createKb({ store, embedder, cfg });
658
+ disposers.push(() => kb.dispose?.());
659
+
599
660
  ctx.inject(["tools"], (toolsCtx) => {
600
661
  // choro(M1):只读查询对象,chorographyEnabled 关着时工具压根不注册(闸在
601
662
  // createTools 的 hiddenTools,与 organizeEnabled 同款反向语义)。
602
- disposers.push(createTools(toolsCtx, service, cfg, embedder, sessionGate, createChoroQueries({ store })));
663
+ disposers.push(createTools(toolsCtx, service, cfg, embedder, sessionGate, createChoroQueries({ store }), kb));
603
664
  });
604
665
 
605
666
  // chorography M3:/choro 只读回执。与 /mo-run 同形(ctx.commands.register),
@@ -621,6 +682,15 @@ export const apply = (ctx, config) => {
621
682
  const summarizer = createSummarizer(ctx, service, cfg, {}, sessionGate);
622
683
  disposers.push(summarizer.dispose);
623
684
 
685
+ // QQ 日记本(v0.13,diaryEnabled):构造期零副作用(只闭包住依赖,不建目录、不读盘、
686
+ // 不调模型),所以位置可以放在这里——它必须早于下面的 createApi,面板的
687
+ // 「写一篇」按钮要转调同一个 composer;而自动触发侧在 webServer 块之后挂(同
688
+ // actionNudge 的 TDZ 理由)。关闸时 composer 是 null:路由回 409、座位不注册,
689
+ // 与「装配缺失」的 503 严格区分(#497 先例)。
690
+ const diary = cfg.diaryEnabled === true
691
+ ? createDiaryComposer({ ctx, config: cfg, logger: ctx.logger, service, settings, memoryDir })
692
+ : null;
693
+
624
694
  // #275 存储生命周期第一批:无损回收的手动入口。刻意不挂启动路径、不接定时器——
625
695
  // 这一步是不可逆的内容丢弃,只由人显式触发(面板的维护卡、`dsh-mneme reclaim`)。
626
696
  // 实例建在插件进程内,是为了让 CLI 也能走这条路:VACUUM 要排他锁,跟宿主抢锁的
@@ -644,13 +714,41 @@ export const apply = (ctx, config) => {
644
714
  const extractProfileDraft = createProfileExtractor({ ctx, service, config: cfg, logger: ctx.logger });
645
715
  // chorography 读侧(M1):与 service 刻意分开——所有权模型相反(spec §3)。
646
716
  // 闸在路由/工具两层(关着 409 / 不注册),queries 本体装配与开关无关。
647
- const choro = createChoroQueries({ store });
717
+ // 收尾档:choro 对象加两件 host 侧能力——embedder(向量惰性物化,P37)与
718
+ // runT0Apply(面板重建按钮的写路径,P34;T1 不上 HTTP,CLI 仍是它的入口)。
719
+ // T1 任务账随 choro 装配建立(与开关无关:开关在路由那侧判,没开时这些方法一次
720
+ // 都不会被调到,符合「503=装配缺件 / 409=用户没开」的分叉)。
721
+ const t1Jobs = createT1Jobs({ store, dbPath: join(memoryDir, "memory.db") });
722
+ disposers.push(() => t1Jobs.dispose());
723
+ const choro = {
724
+ ...createChoroQueries({ store }),
725
+ embedder,
726
+ runT0Apply: ({ repoRoot }) => runChoroT0({ repoRoot, store, apply: true }),
727
+ startT1Job: (args) => t1Jobs.start(args),
728
+ t1JobStatus: (jobId) => t1Jobs.status(jobId),
729
+ };
730
+ // The semantic surface handed to the API is createSemantic()'s own return
731
+ // value, plus the vectorIndex it does not carry. Passing the bare literal
732
+ // `{ vectorIndex, reranker }` used to drop embeddingDiagnostics, so the
733
+ // panel's `data.embeddings` was permanently null on a real install — the
734
+ // typeof guard in api.js fell through every time, and no test noticed
735
+ // because none asserted on `data.embeddings`.
648
736
  const api = createApi(ctx, service, settings, commands ?? {
649
737
  add: () => { throw new Error("commands unavailable"); },
650
738
  remove: () => false,
651
739
  list: () => []
652
- }, embedder, { vectorIndex, reranker }, cfg.apiToken, cfg, maintenance, extractProfileDraft, choro);
740
+ }, embedder, { ...semantic, vectorIndex, reranker }, cfg.apiToken, cfg, maintenance, extractProfileDraft, choro, profileFacts, kb, diary);
653
741
  disposers.push(api.dispose);
742
+
743
+ // dsh-go 原生面板:只有宿主提供 dshGo 服务(dsh-go 手机 App)时才挂载。
744
+ // 动态 import 是刻意的——静态导入会让「没有桥就整块不加载」这句承诺落空。
745
+ // cfg.apiToken 一并交给面板:createApi 给 token / token-write 路由包了鉴权,
746
+ // 进程内调用不带令牌会被自家路由拦成 401,不为此在服务端开白名单。
747
+ ctx.inject(["dshGo"], (dshCtx) => {
748
+ import("./dsh-go/panel.js")
749
+ .then((panel) => panel.mountDshGoPanel(dshCtx, { apiToken: cfg.apiToken }))
750
+ .catch((error) => ctx.logger?.warn?.(`[dsh-mneme] dsh-go 面板未加载:${error?.message ?? error}`));
751
+ });
654
752
  }
655
753
 
656
754
  // M.O. 自动沉淀(v0.9.3):项目里重复走的那套动作,自动变成 memoryDir 里的一条
@@ -665,6 +763,34 @@ export const apply = (ctx, config) => {
665
763
  disposers.push(createNudgeAutoRun({ ctx, memoryDir, logger: ctx.logger, sessionGate }));
666
764
  }
667
765
 
766
+ // 日记的触发侧(v0.13):turn/end 跨天判定 + 启动补写。
767
+ //
768
+ // 启动补写为什么在这里而不是 webServer 块里:它不依赖面板,headless 宿主也该补。
769
+ // 补哪个项目只能靠状态文件里记过的项目根——宿主刚起来时没有任何会话,
770
+ // 而「上次是哪个项目、哪天写的」就在那儿。补写不 await:启动路径上吊住一次
771
+ // 模型调用,等于让整个插件加载等模型回来(本机 p90 五十秒级)。
772
+ if (diary) {
773
+ // diaryWriteEveryMinutes > 0 时额外起一根定时器(见 auto.js 的 tick):日记不该
774
+ // 取决于「谁先开口」,整天不说话也要有一篇。listProjects 用 knownProjects ——
775
+ // 定时器唤醒时没有会话,只能靠状态文件里记过的项目根。
776
+ const everyMinutes = Number(cfg.diaryWriteEveryMinutes) || 0;
777
+ const diaryAuto = createDiaryAutoRun({
778
+ ctx,
779
+ composer: diary,
780
+ logger: ctx.logger,
781
+ sessionGate,
782
+ everyMs: everyMinutes > 0 ? everyMinutes * 60 * 1000 : 0,
783
+ listProjects: () => diary.knownProjects()
784
+ });
785
+ disposers.push(diaryAuto);
786
+ const projects = diary.knownProjects();
787
+ if (projects.length) {
788
+ Promise.resolve(diaryAuto.catchUp(projects)).catch((error) => {
789
+ ctx.logger?.warn?.("[dsh-mneme] diary startup catch-up failed: " + String(error));
790
+ });
791
+ }
792
+ }
793
+
668
794
  // Standalone external API (v0.7.12): plain node:http server for ecosystem
669
795
  // integrations outside the DSH host. Persisted external_api settings win
670
796
  // 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
@@ -93,7 +125,7 @@ function extractRounds(ctx, maxRounds) {
93
125
  }
94
126
  }
95
127
 
96
- export function createInjector(ctx, service, settings, config, gate = null, sessionGate = null, choroInject = null) {
128
+ export function createInjector(ctx, service, settings, config, gate = null, sessionGate = null, choroInject = null, factsInject = null) {
97
129
  const language = langOf(config);
98
130
  const baseMaxItems = config.maxInjectedItems ?? 5;
99
131
  const threshold = config.importanceThreshold ?? 3;
@@ -291,21 +323,117 @@ export function createInjector(ctx, service, settings, config, gate = null, sess
291
323
  // first render after a new user message may still fall back to the rule-based
292
324
  // pick; later assemblies in the same session reuse the cached vector. Bounded
293
325
  // cache (cap 8, drop oldest) so a long session never grows it unbounded.
326
+ //
327
+ // #617 根因 B:旧实现用一个 lastPrefetched 字符串做去重,且它在 await 之前
328
+ // 就置位——同一条查询第一次失败(模型没下好、超时)之后**永不重试**,而那
329
+ // 恰好是最需要重试的一次。这里换成三件套:inflight Map 去重(渲染同一步会
330
+ // 被求值两次:assemble 一次、refreshContext 一次,同一条查询只该嵌一次)、
331
+ // 失败冷却 15s(失败可重试,但不许每次渲染都拿一次失败去撞模型)、返回
332
+ // Promise(调用方/测试能等到它,而不是靠魔法延时)。
294
333
  const QUERY_VECTOR_CACHE_MAX = 8;
334
+ const PREFETCH_FAIL_COOLDOWN_MS = 15000;
295
335
  const queryVectorCache = new Map();
296
- let lastPrefetched = "";
336
+ const inflightPrefetches = new Map(); // query -> Promise<vector|null>
337
+ const prefetchFailedAt = new Map(); // query -> ms
297
338
 
298
339
  function prefetchQueryVector(query) {
299
- if (!query || query === lastPrefetched || queryVectorCache.has(query)) return;
300
- lastPrefetched = query;
301
- service.embedQuery(query).then((vec) => {
340
+ if (!query) return null;
341
+ const cached = queryVectorCache.get(query);
342
+ if (cached) return Promise.resolve(cached);
343
+ const inflight = inflightPrefetches.get(query);
344
+ if (inflight) return inflight;
345
+ const failedAt = prefetchFailedAt.get(query);
346
+ if (failedAt !== undefined && Date.now() - failedAt < PREFETCH_FAIL_COOLDOWN_MS) return null;
347
+ const pending = service.embedQuery(query).then((vec) => {
302
348
  if (Array.isArray(vec) && vec.length) {
303
349
  queryVectorCache.set(query, vec);
304
350
  if (queryVectorCache.size > QUERY_VECTOR_CACHE_MAX) {
305
351
  queryVectorCache.delete(queryVectorCache.keys().next().value);
306
352
  }
353
+ prefetchFailedAt.delete(query);
354
+ return vec;
307
355
  }
308
- }).catch(() => { /* prefetch is best-effort */ });
356
+ prefetchFailedAt.set(query, Date.now());
357
+ return null;
358
+ }).catch(() => {
359
+ prefetchFailedAt.set(query, Date.now());
360
+ return null; /* prefetch is best-effort */
361
+ }).finally(() => {
362
+ inflightPrefetches.delete(query);
363
+ });
364
+ inflightPrefetches.set(query, pending);
365
+ return pending;
366
+ }
367
+
368
+ // #617 根因 A 的补位票据:宿主(agent-loop 0.2.x)在每个 step 的 pre-step 里
369
+ // 先 inbox.claim()(同步 emit agent/inbox/claimed),再 assemble(),而
370
+ // user/message 要到 step() 末尾(:865)才 append——注入的 text 回调在
371
+ // assemble / refreshContext 里求值,**永远比当轮用户输入早一拍**:Step 1 拿到
372
+ // "",第 N 轮的 Step 1 只能拿到第 N-1 轮的旧文本(实测 46 个渲染点错 3 次)。
373
+ // 票据在 claim 那一刻记下原文;渲染时若事件流里最后一条用户消息还不是它,
374
+ // 说明本轮 user/message 尚未落盘,用票据;一旦落盘,事件流才是权威(它对
375
+ // rewind / compact 的可见面判定更准,票据不看 surface)。
376
+ //
377
+ // 为什么只在 claimed 上记、不从 agent/inbox/spliced 推:claim 自己的两次
378
+ // mutate 都落 outcome:"canceled" 的 spliced(inserted 为空),拿 removedCount
379
+ // 当撤回信号会把每次正常认领误判成取消;claimed 只在真有消息被认领时触发,
380
+ // 是唯一无歧义的信号源。Cancel/Rewind 穿透因此也不存在——它们不产生 claimed。
381
+ const PENDING_QUERY_MAX = 32;
382
+ // 票据超过这个岁数就不再采信:一个 step 的 assemble 不可能跑这么久,更久说明
383
+ // 那条消息压根没落盘(认领后整轮被取消),票据已是过期证据。
384
+ const PENDING_QUERY_TTL_MS = 60000;
385
+ const pendingQueries = new Map(); // sessionId -> { text, at }
386
+ const offClaimed = typeof ctx?.on === "function"
387
+ ? ctx.on("agent/inbox/claimed", (payload) => {
388
+ try {
389
+ const message = payload?.message;
390
+ const text = inboxMessageQuery(message);
391
+ if (!text) return;
392
+ const session = payload?.agent?.session;
393
+ const sessionId = session?.id;
394
+ if (typeof sessionId !== "string" || !sessionId) return;
395
+ // #539 无痕闸门必须挡在这里,不能只挡渲染侧:claim 在 pre-step 里同步触发,
396
+ // 早于 assemble;这条路径既写票据(票据会变成注入查询文本)又预取向量
397
+ // (embedQuery 直接 POST 外部 /embeddings),闸门命中时两者都是该会话数据的
398
+ // 外流。嵌入层自身没有任何会话维度判断,这里是唯一的拦截点。
399
+ if (sessionGate?.isOff(session)) return;
400
+ pendingQueries.delete(sessionId); // 重新插入 = 移到队尾
401
+ pendingQueries.set(sessionId, { text, at: Date.now() });
402
+ if (pendingQueries.size > PENDING_QUERY_MAX) {
403
+ pendingQueries.delete(pendingQueries.keys().next().value);
404
+ }
405
+ // 认领即预取:embed 在 assemble→step 之间就已经在跑,Step 1 的第二次
406
+ // 求值(refreshContext)有机会拿到向量。拿不到也无妨——查询文本已经在
407
+ // 手上,BM25 词法路(#198)能顶上。
408
+ prefetchQueryVector(text);
409
+ } catch { /* the ticket is best-effort; injection must never break */ }
410
+ })
411
+ : null;
412
+
413
+ // 当轮查询:票据优先于事件流(见上)。判据是**文本比对**而不是事件条数——
414
+ // 从 claim 到 assemble 之间还夹着 await tools.resolve(...),这期间任何插件
415
+ // 追加一个事件都会让条数增长,按条数判会把「消息还没落盘」误判成「已落盘」,
416
+ // 票据提前退休、Step 1 又退回空串(正是本 issue 要修的那一拍)。文本相等才是
417
+ // 「这条消息真的进事件流了」的直接证据;连续两轮同一句话时两者相等,取哪个都对。
418
+ function liveStepQuery(ctx) {
419
+ const info = lastUserMessageInfo(ctx);
420
+ let sessionId = null;
421
+ try {
422
+ const id = ctx?.agent?.session?.id;
423
+ if (typeof id === "string" && id) sessionId = id;
424
+ } catch { /* session internals unavailable */ }
425
+ if (!sessionId) return info.text;
426
+ const ticket = pendingQueries.get(sessionId);
427
+ if (!ticket) return info.text;
428
+ if (info.text === ticket.text) {
429
+ pendingQueries.delete(sessionId); // 已落盘,票据退休(顺带保证 Map 不被历史会话撑大)
430
+ return info.text;
431
+ }
432
+ if (Date.now() - ticket.at > PENDING_QUERY_TTL_MS) {
433
+ pendingQueries.delete(sessionId); // 过期证据,不采信
434
+ return info.text;
435
+ }
436
+ return ticket.text;
309
437
  }
310
438
 
311
439
  // 会话身份解析(v1.2 人格寻址用):读 session.header.agentPreset——冻结的
@@ -323,6 +451,39 @@ export function createInjector(ctx, service, settings, config, gate = null, sess
323
451
  }
324
452
  }
325
453
 
454
+ // 会话 id 解析(#606 绑会话卡用):session.id 在宿主里稳定可用(scope.js:97-103
455
+ // 就是靠它反查 workspace 的)。与 agentPreset 一样各自护住——两者来自同一个
456
+ // session 对象,但 accessor 抛错时不该互相拖累(一个取不到不影响另一个)。
457
+ // 为什么需要第二个键:DS Bot 里 agentPreset 恒为 "standard",按 bot 建卡分不开,
458
+ // 只有会话 id 能分开(实测见 issue #606)。
459
+ function sessionIdOf(ctx) {
460
+ try {
461
+ const id = ctx?.agent?.session?.id;
462
+ return typeof id === "string" && id.trim() ? id.trim() : null;
463
+ } catch {
464
+ return null;
465
+ }
466
+ }
467
+
468
+ // 父会话 id 解析(子 agent 继承人格卡用):子代理会话的 header 里有 `parentSession`
469
+ // (实测 518 个子代理头恒带此字段,主会话没有)。⚠️ 与 `session-gate.js` 的口径不同:
470
+ // 那里刻意不用它判「是不是子代理」(宿主 fork 出的主会话分支同样带 parentSession),
471
+ // 这里只把它当「自己没建卡时该继承谁」的线索,fork 出的分支继承父卡同样是正确语义。
472
+ function parentSessionIdOf(ctx) {
473
+ try {
474
+ const session = ctx?.agent?.session;
475
+ const parent = session?.header?.parentSession;
476
+ return typeof parent === "string" && parent.trim() ? parent.trim() : null;
477
+ } catch {
478
+ return null;
479
+ }
480
+ }
481
+
482
+ // 一次取全三个寻址键。分开取值、各自兜错:一个失败不该让另一个也丢。
483
+ function sessionIdentity(ctx) {
484
+ return { agentId: sessionAgentId(ctx), sessionId: sessionIdOf(ctx), parentSessionId: parentSessionIdOf(ctx) };
485
+ }
486
+
326
487
  // User profile + rules: injected ahead of the memory block because they are
327
488
  // always-relevant instructions the agent should follow every turn.
328
489
  // 渲染整体搬到 src/user-settings.js(与面板预览共用一份,见那里的模块头注释)。
@@ -331,7 +492,11 @@ export function createInjector(ctx, service, settings, config, gate = null, sess
331
492
  // 一致(老用户零变化)。personaStrictAddressing 打开时,「有身份但没建卡」与
332
493
  // 「卡被显式关掉」都不注入人格——default 只兜「这一帧解析不到身份」的会话。
333
494
  function renderUserSettings(ctx) {
334
- return renderUserSettingsBlock({ settings, config, agentId: sessionAgentId(ctx) }).text;
495
+ // factsInject 是 profile-facts 域的读口(index.js 接线,面板预览路由传同一份):
496
+ // 开关与事实有无的判定都在 renderUserSettingsBlock 内部,「关」等于零读数。
497
+ const { agentId, sessionId, parentSessionId } = sessionIdentity(ctx);
498
+ return renderUserSettingsBlock({ settings, config, agentId, sessionId, parentSessionId, factsProvider: factsInject })
499
+ .text;
335
500
  }
336
501
 
337
502
  // #249 能力说明的系统提示段。order 150 = 插件指引段的既有惯例
@@ -358,10 +523,12 @@ export function createInjector(ctx, service, settings, config, gate = null, sess
358
523
  text: (ctx) => {
359
524
  if (sessionGate?.isOff(sessionOf(ctx))) return "";
360
525
  // Bug4: pass the latest user query so injection prefers semantically
361
- // relevant memories; lastUserQuery is best-effort (empty → legacy).
526
+ // relevant memories; liveStepQuery is best-effort (empty → legacy).
362
527
  // The query vector is prefetched asynchronously (cached) because the
363
528
  // render itself must stay synchronous.
364
- const query = lastUserQuery(ctx);
529
+ // #617:liveStepQuery 而不是 lastUserQuery——宿主在 step 末尾才 append
530
+ // user/message,而注入在这之前求值,只看事件流会拿到上一轮的输入。
531
+ const query = liveStepQuery(ctx);
365
532
  if (query) prefetchQueryVector(query);
366
533
  const queryVector = queryVectorCache.get(query);
367
534
  // v0.8.0 A3:scope 随请求解析(解析器恒返回对象,身份取不到时两维为
@@ -456,6 +623,12 @@ export function createInjector(ctx, service, settings, config, gate = null, sess
456
623
  ];
457
624
 
458
625
  return () => {
626
+ // #617:认领票据是监听宿主事件的活句柄,卸载时必须成对摘掉,否则注入器重装
627
+ // (面板开关、宿主热重载)会留下一个继续写 Map 的孤儿监听。
628
+ offClaimed?.();
629
+ pendingQueries.clear();
630
+ inflightPrefetches.clear();
631
+ prefetchFailedAt.clear();
459
632
  queryVectorCache.clear();
460
633
  rotationHistory.clear();
461
634
  injectionSnapshot = null; // 注入器卸载即失效:快照不得跨生命周期存留