@modusensus/dsh-mneme 0.12.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 (154) hide show
  1. package/README.md +48 -19
  2. package/docs/CONFIGURATION.md +43 -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 +581 -29
  13. package/lib/attachment-text.js +122 -0
  14. package/lib/choro-command.js +9 -2
  15. package/lib/client.js +3537 -1205
  16. package/lib/config.js +139 -1
  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/dream/decisions.js +56 -5
  28. package/lib/dream/sleep.js +616 -15
  29. package/lib/dream.js +45 -13
  30. package/lib/dsh-go/host.js +162 -0
  31. package/lib/dsh-go/panel.js +2091 -0
  32. package/lib/dsh-go/text.js +342 -0
  33. package/lib/dsh-go/views/common.js +68 -0
  34. package/lib/dsh-go/views/entity.js +48 -0
  35. package/lib/dsh-go/views/memory.js +198 -0
  36. package/lib/dsh-go/views/root.js +30 -0
  37. package/lib/dsh-go/views/settings.js +398 -0
  38. package/lib/dsh-go/views/status.js +51 -0
  39. package/lib/embedding.js +42 -8
  40. package/lib/entities/extractor.js +36 -7
  41. package/lib/hot-memory.js +8 -9
  42. package/lib/index.js +92 -2
  43. package/lib/inject.js +189 -18
  44. package/lib/kb/index.js +309 -0
  45. package/lib/kb/routes.js +220 -0
  46. package/lib/kb/tools.js +148 -0
  47. package/lib/lang.js +87 -4
  48. package/lib/peak-hours.js +57 -2
  49. package/lib/semantic.js +347 -60
  50. package/lib/service.js +193 -66
  51. package/lib/settings.js +112 -14
  52. package/lib/store.js +347 -21
  53. package/lib/summarize.js +38 -11
  54. package/lib/tools.js +35 -4
  55. package/lib/user-settings.js +128 -32
  56. package/lib/vector-index.js +76 -6
  57. package/lib/wiki/cli-hint.js +90 -0
  58. package/lib/wiki/queries.js +116 -2
  59. package/lib/wiki/t1-jobs.js +232 -0
  60. package/lib/wiki/t1.js +7 -1
  61. package/package.json +25 -1
  62. package/src/api-standalone.js +4 -1
  63. package/src/api.js +581 -29
  64. package/src/attachment-text.js +122 -0
  65. package/src/choro-command.js +9 -2
  66. package/src/config.js +139 -1
  67. package/src/continuity.js +7 -6
  68. package/src/diary/auto.js +190 -0
  69. package/src/diary/compose.js +440 -0
  70. package/src/diary/dir.js +59 -0
  71. package/src/diary/export.js +97 -0
  72. package/src/diary/index.js +26 -0
  73. package/src/diary/library.js +189 -0
  74. package/src/diary/qq-inbox.js +142 -0
  75. package/src/diary/rollover.js +61 -0
  76. package/src/diary/session-provider.js +122 -0
  77. package/src/dream/decisions.js +56 -5
  78. package/src/dream/sleep.js +616 -15
  79. package/src/dream.js +45 -13
  80. package/src/dsh-go/host.js +162 -0
  81. package/src/dsh-go/panel.js +2091 -0
  82. package/src/dsh-go/text.js +342 -0
  83. package/src/dsh-go/views/common.js +68 -0
  84. package/src/dsh-go/views/entity.js +48 -0
  85. package/src/dsh-go/views/memory.js +198 -0
  86. package/src/dsh-go/views/root.js +30 -0
  87. package/src/dsh-go/views/settings.js +398 -0
  88. package/src/dsh-go/views/status.js +51 -0
  89. package/src/embedding.js +42 -8
  90. package/src/entities/extractor.js +36 -7
  91. package/src/hot-memory.js +8 -9
  92. package/src/index.js +92 -2
  93. package/src/inject.js +189 -18
  94. package/src/kb/index.js +309 -0
  95. package/src/kb/routes.js +220 -0
  96. package/src/kb/tools.js +148 -0
  97. package/src/lang.js +87 -4
  98. package/src/peak-hours.js +57 -2
  99. package/src/semantic.js +347 -60
  100. package/src/service.js +193 -66
  101. package/src/settings.js +112 -14
  102. package/src/store.js +347 -21
  103. package/src/summarize.js +38 -11
  104. package/src/tools.js +35 -4
  105. package/src/user-settings.js +128 -32
  106. package/src/vector-index.js +76 -6
  107. package/src/wiki/cli-hint.js +90 -0
  108. package/src/wiki/queries.js +116 -2
  109. package/src/wiki/t1-jobs.js +232 -0
  110. package/src/wiki/t1.js +7 -1
  111. package/test/api-routes-gaps.test.js +63 -0
  112. package/test/api.test.js +375 -4
  113. package/test/attachment-text.test.js +140 -0
  114. package/test/bm25-corpus-cache.test.js +145 -0
  115. package/test/capacity-warning.test.js +262 -0
  116. package/test/client.test.js +779 -154
  117. package/test/conflict-freeze.test.js +45 -1
  118. package/test/continuity.test.js +8 -1
  119. package/test/diary-library.test.js +187 -0
  120. package/test/diary.test.js +576 -0
  121. package/test/dream-maxtokens-fallback.test.js +3 -3
  122. package/test/dream.test.js +62 -1
  123. package/test/dsh-go-host.test.js +134 -0
  124. package/test/dsh-go-panel-actions.test.js +854 -0
  125. package/test/dsh-go-panel.test.js +251 -0
  126. package/test/embedding-ready.test.js +33 -3
  127. package/test/entities.test.js +73 -0
  128. package/test/forget-corrections.test.js +70 -0
  129. package/test/image-memory.test.js +171 -0
  130. package/test/issue-617-step1-query-and-fusion.test.js +361 -0
  131. package/test/kb-api-wiring.test.js +128 -0
  132. package/test/kb-collections.test.js +272 -0
  133. package/test/kb-routes.test.js +379 -0
  134. package/test/mint-source.test.js +26 -0
  135. package/test/peak-hours-observability.test.js +70 -0
  136. package/test/persona-api.test.js +3 -3
  137. package/test/persona-by-session.test.js +568 -0
  138. package/test/persona-settings.test.js +7 -7
  139. package/test/reflection-update-gate.test.js +129 -0
  140. package/test/reindex-backfill.test.js +334 -0
  141. package/test/reindex-scheduler.test.js +369 -0
  142. package/test/scope-candidates.test.js +249 -0
  143. package/test/sleep-action-set.test.js +14 -0
  144. package/test/sleep-heat.test.js +49 -0
  145. package/test/sleep.test.js +598 -0
  146. package/test/standalone-api.test.js +24 -0
  147. package/test/store.test.js +20 -0
  148. package/test/summarize.test.js +26 -0
  149. package/test/user-settings-segments.test.js +528 -0
  150. package/test/vector-index.test.js +120 -6
  151. package/test/wiki-api.test.js +355 -0
  152. package/test/wiki-cli-hint.test.js +118 -0
  153. package/test/wiki-queries.test.js +7 -0
  154. package/test/wiki-t1-jobs.test.js +125 -0
package/lib/config.js CHANGED
@@ -1,6 +1,16 @@
1
1
  import z from "@deepseek-ai/schemastery";
2
2
  import { TYPE_DECAY_DEFAULTS } from "./heat.js";
3
3
 
4
+ // #275 容量预警的阈值默认值,单独导出:service.getCapacityHealth 在 config 缺席
5
+ // (直连调用方 / 测试传裸对象)时也要用同一套数字判级,不能另立一份——schema 的
6
+ // .default({}) 只在「Config() 解析过」的路径上补值,裸 config 走不到。
7
+ // 数字锚定 #275 议题里写的形态区间(活跃数百条、归档数千条),依据见 schema 处注释。
8
+ export const CAPACITY_WARNING_DEFAULTS = Object.freeze({
9
+ dbSizeWarnMb: 256,
10
+ archivedRowsWarn: 5000,
11
+ archivedRatioWarn: 0.95
12
+ });
13
+
4
14
  export const Config = z.object({
5
15
  // 记忆语言:生成记忆、注入标题与后台 LLM 提示词所用语言;'zh'(默认,
6
16
  // 行为不变)/ 'en'。启动时读入,切换后重启生效。
@@ -180,6 +190,18 @@ export const Config = z.object({
180
190
  // dreamMinIntervalMinutes 再开本键)。与 dreamPeakHours / dreamMinIntervalMinutes
181
191
  // 同族(节流阀,不新增任何 LLM 调用),故不进 LIGHT_MODE_OFF。
182
192
  autoDreamFailureBackoff: z.boolean().default(false),
193
+ // Issue #557(建议 2,opt-in 默认关 = 审计行逐字节一致):dream 审计行的输入
194
+ // 快照瘦身。dream_runs.input 单行体积随记忆量涨(#557 实测平均 40-79 KB/轮,
195
+ // 绝大部分是 content),轮数又随天数涨,两者相乘超线性(报告库占 43% 的页)。
196
+ // 开启后 auto 轮的快照只留骨架(id/type/title/importance/updated_at,不含
197
+ // content)。巩固判定不回读审计行:LLM 输入由记忆库现算(prompt 不变),
198
+ // snapshot_hash 在内存里对含 content 的快照计算(receipt 完整性不变);变的
199
+ // 只是离线回放——原文不在库里就重算不出 hash,#512「每一行都能回放一次巩固」
200
+ // 对瘦身轮降级为骨架级(#275 reclaim 清过 input 的库本就丢过这层,本键把同一
201
+ // 权衡前移到写入期)。organize 轮不经过这里(重放载荷走 organize.js 自己的
202
+ // saveDreamRun),写入期与 reclaim(run_type != 'organize')同一条边界。
203
+ // storage-only、不新增任何 LLM 调用,不进 LIGHT_MODE_OFF(同上定性)。
204
+ dreamInputSummaryEnabled: z.boolean().default(false),
183
205
  // Issue #239(第 4 项,错峰队列)镜像到巩固:高峰期不做梦。与
184
206
  // summarizePeakHours 同一份时段语法(复用 src/summarize.js 的 parsePeakSpec /
185
207
  // isInPeakWindow / nextOffPeakAt,不另写解析器):逗号分隔、可带星期前缀、支持
@@ -335,6 +357,26 @@ export const Config = z.object({
335
357
  // (= #230 的「管线对正文零读零写」);与 memoryDir 一样是路径配置,不是
336
358
  // 行为开关,故不进 settings.js 的 feature flags 白名单、也不上面板。
337
359
  documentDir: z.string().default(""),
360
+ // 日记本(QQ 机器人写日记):跨天惰性判定 + 启动补写,正文落
361
+ // `<memoryDir>/diaries/<项目键>/YYYY-MM-DD.md`,记忆库里留一条 document 指针行。
362
+ // 默认关=行为与此前一致;lightMode 强制关闭(它要调模型,与低资源档互斥)。
363
+ // 与 documentMemoryEnabled **成对但不相依**:关掉文档记忆只影响「能不能被检索到」,
364
+ // 正文照样写——那是用户自己的日记,不该被一个检索开关收走。
365
+ diaryEnabled: z.boolean().default(false),
366
+ // 日记根目录:空串 = 跟随 `<memoryDir>/diaries/`。与 documentDir 同一套路径规则
367
+ // (`~` 展开、绝对路径原样、相对路径落 memoryDir 下),同样是**路径配置不是行为
368
+ // 开关**,故不进 settings.js 白名单、也不上面板。
369
+ diaryDir: z.string().default(""),
370
+ // QQ 收件箱目录:空串 = 从 memoryDir 推(`<memoryDir>/../media/onebot`,本机即
371
+ // `D:\DSH\.dsh\media\onebot`)。推出不来就报 no-inbox-dir,绝不猜别的盘。
372
+ diaryInboxDir: z.string().default(""),
373
+ // 定时写(v0.13,桉桉 2026-11-09「日记本按理来说应该需要定时写,这个要存设置中」):
374
+ // 每隔 N 分钟主动检查一次「今天是否还没写」,0 = 关(只在 turn/end 与启动时判,
375
+ // 即此前行为)。为什么要这一项:auto.js 原来只在回合结束时判跨天,用户整天不说话
376
+ // 就整天不写——日记不该取决于谁先开口。它仍然**不是 cron**:定时器只负责唤醒判定,
377
+ // 写不写在 compose 里按落盘日期去重,所以一天最多一篇,失败也只是 warn。
378
+ // 上限 1440(一天):比一天更长的间隔没有意义,因为判定单位就是「天」。
379
+ diaryWriteEveryMinutes: z.natural().min(0).max(1440).default(0),
338
380
  // 隐式 keep(v0.4.4):LLM 未提及的 snapshot 记忆自动补 {action:"keep"},
339
381
  // 避免"未覆盖即全拒"白白浪费整轮 run。设为 false 时保留旧的严格校验
340
382
  // (未覆盖即拒绝整单)。
@@ -525,6 +567,33 @@ export const Config = z.object({
525
567
  conflictFreezeEnabled: z.boolean().default(false),
526
568
  // Maximum number of frozen conflicts to keep pending for manual review.
527
569
  conflictFreezeMaxPending: z.natural().min(1).max(1000).default(100),
570
+ // Issue #603 第 4 层(自动裁决档):freeze 模式里相似度极高的同 scope 对直接
571
+ // 裁决(保留信息量更大的那条、归档另一条),不进人工队列——人只看模糊地带。
572
+ // 默认关:这是会归档记忆的自动动作,必须显式打开。
573
+ //
574
+ // ⚠️ Issue #616:这一档有三个已知边界,打开前先读,别当成「开了就清净」。
575
+ //
576
+ // 1) 阈值论证用的是早期快照,当前库上 0.95 等于不生效。
577
+ // 注释此前写「345 条有相似度、>=0.95 有 20 条(5.8%)」——2026-11-09 在
578
+ // live 库重跑同口径:conflict_pending 全表 748 行,similarity 非 NULL 只有
579
+ // 11 行,其中 >=0.95 是 0 行,最高一对 0.914。这不是说阈值错了,是说
580
+ // 按 0.95 打开今晚一对都不会被判,用户会以为功能坏了。真要开,先按当前
581
+ // 分布重定阈值(配置下限 0.85),别沿用这两个数。
582
+ //
583
+ // 2) 它不是独立开关,是 freeze 分支的子节点。整段自动裁决都在
584
+ // sleep.js 的 `if (config.conflictFreezeEnabled === true)` 里;关掉 freeze
585
+ // 会连它一起静默消失。面板上两个键看着平行,实际是父子。
586
+ //
587
+ // 3) 未标注 scope 会被判成同 scope。isCrossScopePair 走 scopeKeyOf,NULL 归一
588
+ // 成 null,null !== null 为 false,于是「归属未知」被当成「归属相同」。
589
+ // live 库活跃 8259 行里 2710 行(32.8%)两个 scope 全 NULL,这批因此绕过了
590
+ // 「归属是用户的决定、永不自动」(v0.8.1 #170)这条守卫。改判据前不要打开。
591
+ //
592
+ // 另外 autoResolved/autoFailed 目前没有任何消费者(dream.js 只取 frozen),
593
+ // conflict_reviewed 台账也不记赢家、无 API 暴露——自动裁决了什么是看不见的。
594
+ // 可见性补齐之前,这一档只适合在能盯日志的前提下短时打开。
595
+ conflictAutoResolveEnabled: z.boolean().default(false),
596
+ conflictAutoResolveMinSimilarity: z.number().min(0.85).max(1).default(0.95),
528
597
 
529
598
  // --- entity gene (v0.3.0) -----------------------------------------------
530
599
  // Opt-in: when false (default) nothing in the pipeline extracts entities.
@@ -638,6 +707,22 @@ export const Config = z.object({
638
707
  sleepPatternLookbackDays: z.natural().min(1).max(90).default(30),
639
708
  // Max pattern memories minted per run (0 = disabled).
640
709
  sleepMaxPatternPerRun: z.natural().min(0).max(10).default(3),
710
+ // --- affective attribution (v0.13.0, issue #619) --------------------------
711
+ // Opt-in: when false (default) the affective phase does not run at all — no
712
+ // LLM call, no relations written. It exists so that a situational emotion
713
+ // ("she was anxious on defence day") can be stored as an occasion-bound
714
+ // relation instead of being generalised into "she is an anxious person".
715
+ sleepAffectiveEnabled: z.boolean().default(false),
716
+ // How many **independent sessions** a candidate must span before it may be
717
+ // promoted to a type=pattern trait. Below this it stays an occasion-bound
718
+ // affective_context relation. The floor is 2 on purpose: N=1 means a single
719
+ // occasion, and a single occasion is exactly what must never become a trait
720
+ // (that is the failure mode this phase exists to prevent).
721
+ sleepAffectiveTraitMinSessions: z.natural().min(2).max(10).default(3),
722
+ // Max affective_context relations written per run. Same role as
723
+ // sleepMaxPatternPerRun for the relation channel: a runaway model that
724
+ // "finds" an emotion in every memory must be capped, not trusted.
725
+ sleepAffectiveMaxRelationsPerRun: z.natural().min(0).max(50).default(10),
641
726
  // Optional LLM route override for sleep's bulk passes (empty = use dream
642
727
  // route / agent default model).
643
728
  sleepProvider: z.string().default("").description("sleep 深维护专用模型服务商,留空用巩固模型或当前模型。"),
@@ -760,6 +845,33 @@ export const Config = z.object({
760
845
  retentionDays: z.natural().min(1).max(3650).default(90)
761
846
  }).default({}),
762
847
 
848
+ // --- capacity warning (#275: 空间维度) ---------------------------------------
849
+ // 容量预警:库体积 / 归档区规模过线时在 /health 与面板上报(维护者 2026-10-21
850
+ // 接手口径:有 retentionDays 的时间维度,缺空间维度)。三个阈值都是**观察口**,
851
+ // 不是动作触发:没有任何周期性消费者(/health 被读到才算一次),预警也永不
852
+ // 自动归档 / 删除(#275 拍板:真删由人显式确认)——所以不设 enabled 总开关,
853
+ // 数字恒在 /health,阈值 0 = 显式关掉那一条检查。
854
+ //
855
+ // 默认值锚定议题里的形态区间,不是拍脑袋:
856
+ // dbSizeWarnMb 256 —— 议题实测 55 MB 库 VACUUM 0.8s;按同议题的归档增速
857
+ // (+143 行/天 ≈ 2.8 MB/天)留约 70 天余量,正落在「现在便宜、以后贵」
858
+ // 该开始看数字的量级;
859
+ // archivedRowsWarn 5000 —— 「归档是数千条这个量级」的上沿(议题实测 2,400
860
+ // 时还没坏);
861
+ // archivedRatioWarn 0.95 —— 健康形态本身就落在 0.8~0.9(活跃数百 / 归档
862
+ // 数千,议题实测 0.90),0.9 会对着健康库天天报警;0.95 起才是「归档区
863
+ // 把活跃区吞掉」的形态。
864
+ // 判级实现在 service.getCapacityHealth,答复挂 /health 的 capacity 块;出口是
865
+ // 维护卡的无损回收(dsh-mneme reclaim,见 docs/STORAGE.md)与后续批次的归档区
866
+ // 周期处理。增速指标(归档净增速率)在 recall-stats 的 archive 块,不在这里重复。
867
+ capacityWarning: z.object({
868
+ dbSizeWarnMb: z.natural().min(0).max(1048576).default(CAPACITY_WARNING_DEFAULTS.dbSizeWarnMb),
869
+ archivedRowsWarn: z.natural().min(0).max(10000000).default(CAPACITY_WARNING_DEFAULTS.archivedRowsWarn),
870
+ // 浮点:与 conflictAutoResolveMinSimilarity 同款(面板整数控件吃不下小数,
871
+ // 走 FEATURE_FLAG_NUMBER_RANGES)。
872
+ archivedRatioWarn: z.number().min(0).max(1).default(CAPACITY_WARNING_DEFAULTS.archivedRatioWarn)
873
+ }).default({}),
874
+
763
875
  // --- write admission (#254) ------------------------------------------------
764
876
  // 写入准入:把「这条该不该进库」前移到 LLM 之前。与 memoryQualityFilter 的分工
765
877
  // 是时点不是判据——那个是**写入后**打分归档(扣分可以错,只影响排序与归档),
@@ -1049,6 +1161,25 @@ export const Config = z.object({
1049
1161
  // 占用轻量档预算的考量见键注释末句。
1050
1162
  profileFactsEnabled: z.boolean().default(false),
1051
1163
 
1164
+ // chorography 面板闭环(2026-11-08 kb-qoder parity,契约见
1165
+ // docs/plans/2026-11-08-kb-qoder-parity.md §3.1)。三个键都是「面板能不能替人
1166
+ // 动手」的授权闸,与上面的 chorographyEnabled(子系统总闸)分层:总闸关着时
1167
+ // 它们一律不生效,所以不必再进 LIGHT_MODE_OFF —— 轻量档要压的是「每轮付多少」,
1168
+ // 而这三个键管的是「谁按下了重跑按钮」,关了也省不下一次调用,反倒让面板在
1169
+ // 轻量档里静默少一块可操作区(同 chorographyEnabled 的定性与理由)。
1170
+ //
1171
+ // 过期的仓库在面板上直接刷新(POST /choro/run 的 refresh)。默认关:一次 T0 重跑
1172
+ // 会扫整个仓库并重写 .chorography/ 下的页面,属于「改动工作区」的动作,不该在
1173
+ // 没人要求时被面板顺手触发。
1174
+ chorographyAutoRefresh: z.boolean().default(false),
1175
+ // 面板发起 T1(POST /choro/t1)。默认**开**:这是面板闭环的主路径,关掉等于面板
1176
+ // 上那个按钮永远是死的;真花钱的那一步仍由用户点按钮触发,且有
1177
+ // chorographyEnabled 总闸在前面。需要「只读面板」的部署可以单独关它。
1178
+ chorographyT1FromPanel: z.boolean().default(true),
1179
+ // kb 子系统(知识库问答,B 线实现)的总闸。本车道只登记键与白名单,接线由后续
1180
+ // 车道做——先有键才能让 /features 稳定暴露 false,客户端不至于静默少键。
1181
+ kbEnabled: z.boolean().default(false),
1182
+
1052
1183
  // #539 逐会话无痕:总开关默认关,保持旧用户行为;子代理选择器默认开,但总开关
1053
1184
  // 未开时不生效。手动名单是精确的 session id 匹配。
1054
1185
  sessionGateEnabled: z.boolean().default(false),
@@ -1063,6 +1194,9 @@ const LIGHT_MODE_OFF = [
1063
1194
  "entityExtractionEnabled",
1064
1195
  "autoDream",
1065
1196
  "sleepModeEnabled",
1197
+ // #619:情感归因相位要调模型、且依赖实体抽取产出(抽取关掉就没有实体可挂边),
1198
+ // 与低资源档双重互斥。默认本就是关,这里只是轻量预设再确认一次。
1199
+ "sleepAffectiveEnabled",
1066
1200
  "rerankEnabled",
1067
1201
  "autoReindexOnBoot",
1068
1202
  "hybridInject",
@@ -1120,7 +1254,11 @@ const LIGHT_MODE_OFF = [
1120
1254
  // M.O. 自动沉淀:同样挂在 session/event 热路径上,而且解压会话日志是同步 I/O。
1121
1255
  // 轻量档的取向(小模型 / 小上下文 / 少常驻路径)与它直接冲突,预设里关掉;
1122
1256
  // 用户显式勾选仍然赢(合并顺序同上面几条)。
1123
- "actionNudgeEnabled"
1257
+ "actionNudgeEnabled",
1258
+ // 日记本:一次要调模型写一整段散文,且挂 session/event 热路径做跨天判定。
1259
+ // 与低资源档(小模型 / 小上下文 / 少常驻路径)直接冲突,预设里关掉;
1260
+ // 用户显式勾选仍然赢(合并顺序同上面几条)。
1261
+ "diaryEnabled"
1124
1262
  ];
1125
1263
 
1126
1264
  /**
package/lib/continuity.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { createUserMessage } from "@deepseek-ai/dsh-llm";
2
+ // Issue #611:图片块的文本口径(占位,不是空串),与 summarize.js 共用一份。
3
+ import { contentToText } from "./attachment-text.js";
2
4
 
3
5
  // #249 N3(压缩边缘双落点):上下文即将大幅精简前,抢救「正在做什么」。
4
6
  //
@@ -72,13 +74,12 @@ function isCompactionEdge(event) {
72
74
  // 与 summarize.js 的 collectMessages 同一个口径:只取公开文本块,reasoning 私有推理
73
75
  // 不进快照(快照可能被宿主重建进摘要)。刻意副本而不是导出——那边是会话蒸馏的内部
74
76
  // 细节,两边取文本的理由不同。
77
+ //
78
+ // Issue #611:图片块不再返回空串,改走 attachment-text 的占位口径。快照的全部意义
79
+ // 就是「压缩后还能看出刚才在干什么」,而一张没被记下的截图恰恰是最容易丢的那部分
80
+ // 上下文——用户在等你看图,压缩后却只剩一句「看这个」,这种快照比没有更误导。
75
81
  function textOf(content) {
76
- if (typeof content === "string") return content;
77
- if (!Array.isArray(content)) return "";
78
- return content
79
- .map((block) => (typeof block === "string" ? block : (block?.type === "text" && typeof block.text === "string" ? block.text : "")))
80
- .filter((s) => s)
81
- .join("\n");
82
+ return contentToText(content);
82
83
  }
83
84
 
84
85
  // DSH 0.1.2-rc.1 起 Session 用 snapshotEvents(),兼容旧版 .events。这是**全量日志**
@@ -0,0 +1,190 @@
1
+ /**
2
+ * 日记的**触发侧**:让「跨天了该写一篇」变成自动发生,而不是等人想起来点按钮。
3
+ *
4
+ * 为什么单独一个文件:compose.js 是纯逻辑(采集/生成/落盘),触发时机、去重、
5
+ * 错误兜底是**宿主接线**问题。混在一起会让那层不再能被测试直接驱动。
6
+ *
7
+ * 三条执行侧边界(与 pipeline-nudge-auto.js 同一套口径):
8
+ * - **不 cron,但可选定时唤醒**:判定发生在三处——turn/end、插件启动、以及
9
+ * diaryWriteEveryMinutes > 0 时的定时器。定时器只负责「醒来问一句今天写了没」,
10
+ * 写不写仍由 compose 按落盘日期去重,所以一天最多一篇;缺省 0 = 关,只有前两处
11
+ * (=此前行为,整日不说话就不写);
12
+ * - **一次只补一篇**:睡得久一点回来了也就补一篇,不许醒来一次补十篇烧配额;
13
+ * - **失败不冒泡**:session/event 那条链上还挂着摘要与续期,日记写砸了只能 warn。
14
+ */
15
+ import { projectRootOf } from "../pipeline-runner.js";
16
+ import { localDateString } from "./rollover.js";
17
+ import { sessionAgentId } from "./session-provider.js";
18
+
19
+ /** 同一项目两次判定的最短间隔:turn/end 密度很高,而一天只需写一篇。 */
20
+ export const DIARY_MIN_INTERVAL_MS = 60 * 1000;
21
+
22
+ export function createDiaryAutoRun({
23
+ ctx = null,
24
+ composer = null,
25
+ logger = null,
26
+ sessionGate = null,
27
+ now = () => Date.now(),
28
+ minIntervalMs = DIARY_MIN_INTERVAL_MS,
29
+ everyMs = 0,
30
+ listProjects = null
31
+ } = {}) {
32
+ const log = logger || (ctx && ctx.logger) || null;
33
+ let disposed = false;
34
+ let timer = null;
35
+ /** 定时写的钟。与 timer(setTimeout(0) 的派发器)是两回事:那个只把待判项目挪到下一帧。 */
36
+ let ticker = null;
37
+ /** 每个项目最近一次判定时刻。注意是「判定」不是「写过」——写过与否在 compose 里按落盘判断。 */
38
+ const checkedAt = new Map();
39
+ /**
40
+ * 待判项目 → { session, trigger }。带会话是因为素材主来源就是它(见 compose 的
41
+ * session 参数);带 trigger 是因为重扫要如实记账「这篇是谁叫醒的」。
42
+ */
43
+ const pending = new Map();
44
+
45
+ const normalize = (p) => {
46
+ let s = String(p).replace(/\\/g, "/").replace(/\/+$/, "");
47
+ if (/^[a-zA-Z]:\//.test(s)) s = s.toLowerCase();
48
+ return s;
49
+ };
50
+
51
+ const runOne = (projectRoot, trigger, session = null) => {
52
+ pending.delete(projectRoot);
53
+ if (disposed || !composer) return null;
54
+ checkedAt.set(normalize(projectRoot), now());
55
+ // session 与 agentId 一起传:素材取会话转录,声音取这个会话的人格卡。
56
+ // 两者必须来自同一个会话,否则会写出「A 的人格 + B 的素材」那种缝合怪。
57
+ const task = composer.compose({ trigger, projectRoot, session, agentId: sessionAgentId(session) });
58
+ // compose 是异步的(要等模型),绝不能返回给 session/event 的派发方去 await——
59
+ // 那会把回合收尾吊在模型延迟上(本机实测 p90 五十秒级)。
60
+ return Promise.resolve(task).catch((error) => {
61
+ log?.warn?.("[dsh-mneme] diary compose failed (" + projectRoot + "): " + String((error && error.message) || error));
62
+ return null;
63
+ });
64
+ };
65
+
66
+ const flush = () => {
67
+ timer = null;
68
+ if (disposed) return;
69
+ const first = pending.entries().next();
70
+ if (first.done) return;
71
+ const [projectRoot, queued] = first.value;
72
+ runOne(projectRoot, queued.trigger, queued.session);
73
+ if (pending.size > 0 && !disposed) defer();
74
+ };
75
+
76
+ const defer = () => {
77
+ if (disposed || timer) return;
78
+ // 当前这一帧是宿主的 turn/end 处理链。setTimeout(0) 挪到下一个宏任务,
79
+ // 宿主先收尾;unref 让退出不被这根定时器吊住。
80
+ timer = setTimeout(flush, 0);
81
+ if (typeof timer.unref === "function") timer.unref();
82
+ };
83
+
84
+ const schedule = (projectRoot, session = null, trigger = "rollover") => {
85
+ const key = normalize(projectRoot);
86
+ const last = checkedAt.get(key);
87
+ if (last !== undefined && now() - last < minIntervalMs) return;
88
+ if (pending.has(projectRoot)) return;
89
+ pending.set(projectRoot, { session, trigger });
90
+ defer();
91
+ };
92
+
93
+ const onSessionEvent = (session, event) => {
94
+ if (disposed) return;
95
+ if (sessionGate?.isOff(session)) return;
96
+ if (!event || event.type !== "turn/end") return;
97
+ const projectRoot = projectRootOf(session);
98
+ if (!projectRoot) return;
99
+ schedule(projectRoot, session);
100
+ };
101
+
102
+ const unsubscribe = ctx && typeof ctx.on === "function" ? ctx.on("session/event", onSessionEvent) : null;
103
+
104
+ /**
105
+ * 定时写:每隔 everyMs 醒来问一次「今天写了没」。
106
+ *
107
+ * 为什么需要它:原来的判定全挂在 turn/end 上,等于「你不说话它就不写」。桉桉
108
+ * 2026-11-09 说的「日记本按理来说应该需要定时写」就是这件事。唤醒后走的是同一条
109
+ * schedule → compose 路径,去重仍在 compose 里按落盘日期做,所以:
110
+ * - 一天最多一篇(跟手点「写一篇」共用那个去重);
111
+ * - 失败只 warn,不打断宿主(session/event 那条链上还挂着摘要与续期);
112
+ * - 醒来时不知道该扫哪个项目就什么都不做——宁可漏一次,不猜别人的项目根。
113
+ *
114
+ * 不用 setInterval 的理由:写完一篇要等模型(本机 p90 五十秒级),固定节拍会在
115
+ * 慢的那次之后立刻再来一次。这里改成「跑完再排下一次」,天然不重叠。
116
+ */
117
+ const tick = async () => {
118
+ if (disposed) return;
119
+ ticker = null;
120
+ if (!composer || everyMs <= 0) return;
121
+ try {
122
+ const projects = typeof listProjects === "function" ? listProjects() : [];
123
+ const list = Array.isArray(projects) ? projects.filter(Boolean) : [];
124
+ for (const projectRoot of list) {
125
+ if (disposed) break;
126
+ // 同一个项目由 turn/end 刚判过就不必再判(minIntervalMs 已经在 schedule 里
127
+ // 拦了一道,这里只是省掉一次 map 写入)。
128
+ schedule(projectRoot, null, "timer");
129
+ }
130
+ } catch (error) {
131
+ log?.warn?.("[dsh-mneme] diary timer scan failed: " + String((error && error.message) || error));
132
+ }
133
+ arm();
134
+ };
135
+
136
+ const arm = () => {
137
+ if (disposed || everyMs <= 0 || ticker) return;
138
+ ticker = setTimeout(() => {
139
+ Promise.resolve(tick()).catch((error) => {
140
+ log?.warn?.("[dsh-mneme] diary timer tick failed: " + String((error && error.message) || error));
141
+ });
142
+ }, everyMs);
143
+ // unref:不因为一根日记的钟让宿主退不出去。
144
+ if (typeof ticker.unref === "function") ticker.unref();
145
+ };
146
+
147
+ // 只有明确配了间隔才起钟。缺省 0 = 完全不起,行为与 v0.13 之前一致。
148
+ arm();
149
+
150
+ /**
151
+ * 启动补写:只补上次关宿主那段时间留下的那一篇。
152
+ *
153
+ * 为什么是启动而不是「每隔几小时」:宿主退出期间没有任何东西在跑,唯一能知道
154
+ * 「昨天没写」的时刻就是下次启动或下次说话。两处都判,效果对用户是一样的。
155
+ * 不知道该补哪个项目(启动时没有会话)→ 什么都不做,等第一次 turn/end。
156
+ */
157
+ const catchUp = (projects = []) => {
158
+ if (disposed || !composer) return Promise.resolve([]);
159
+ const list = Array.isArray(projects) ? projects.filter(Boolean) : [];
160
+ return Promise.all(
161
+ list.map((projectRoot) =>
162
+ Promise.resolve(composer.compose({ trigger: "startup", projectRoot })).catch((error) => {
163
+ log?.warn?.("[dsh-mneme] diary startup catch-up failed (" + projectRoot + "): " + String((error && error.message) || error));
164
+ return null;
165
+ })
166
+ )
167
+ );
168
+ };
169
+
170
+ const dispose = () => {
171
+ disposed = true;
172
+ pending.clear();
173
+ if (timer) {
174
+ clearTimeout(timer);
175
+ timer = null;
176
+ }
177
+ if (ticker) {
178
+ clearTimeout(ticker);
179
+ ticker = null;
180
+ }
181
+ if (typeof unsubscribe === "function") unsubscribe();
182
+ };
183
+ dispose.runNow = (projectRoot, trigger = "manual", session = null) => (disposed || !projectRoot ? null : runOne(projectRoot, trigger, session));
184
+ dispose.pendingCount = () => pending.size;
185
+ dispose.timerArmed = () => ticker !== null;
186
+ dispose.tickNow = () => (disposed ? Promise.resolve() : Promise.resolve(tick()));
187
+ dispose.catchUp = catchUp;
188
+ dispose.today = () => localDateString(new Date(now()));
189
+ return dispose;
190
+ }