dsh-prime-memory 0.11.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 (151) hide show
  1. package/CHANGELOG.en.md +28 -0
  2. package/CHANGELOG.ja.md +30 -0
  3. package/CHANGELOG.ko.md +30 -0
  4. package/CHANGELOG.md +1220 -0
  5. package/ENGINEERING-NOTES.md +452 -0
  6. package/INSTALL.en.md +92 -0
  7. package/INSTALL.ja.md +92 -0
  8. package/INSTALL.ko.md +92 -0
  9. package/INSTALL.md +92 -0
  10. package/LICENSE +21 -0
  11. package/README.en.md +458 -0
  12. package/README.ja.md +306 -0
  13. package/README.ko.md +306 -0
  14. package/README.md +425 -0
  15. package/assets/changelog/0.8.10/01-write-only-pill.png +0 -0
  16. package/assets/changelog/0.8.9/01-panel.png +0 -0
  17. package/assets/changelog/0.8.9/02-halo.png +0 -0
  18. package/assets/changelog/0.8.9/03-layer-segmented-panel.png +0 -0
  19. package/assets/changelog/0.8.9/04-layer-l1-panel.png +0 -0
  20. package/assets/img/EmbeddingSource.png +0 -0
  21. package/assets/img/Hero.png +0 -0
  22. package/assets/img/Layers.png +0 -0
  23. package/assets/img/MemoryTools.png +0 -0
  24. package/assets/img/Modes.png +0 -0
  25. package/assets/img/ToolTrajectory.png +0 -0
  26. package/assets/img/ui-dark.jpg +0 -0
  27. package/assets/img/ui-light.jpg +0 -0
  28. package/assets/readme/bench-dialog.svg +70 -0
  29. package/assets/readme/bench-workflow.svg +79 -0
  30. package/assets/readme/flow.svg +189 -0
  31. package/assets/readme/storage.svg +115 -0
  32. package/cordis.patch.yml +16 -0
  33. package/dist/bench-control.d.ts +34 -0
  34. package/dist/bench-control.js +16 -0
  35. package/dist/client.js +4293 -0
  36. package/dist/config.d.ts +683 -0
  37. package/dist/config.js +129 -0
  38. package/dist/contract.d.ts +820 -0
  39. package/dist/contract.js +1 -0
  40. package/dist/embedding-worker.cjs +176 -0
  41. package/dist/graph/apply.d.ts +37 -0
  42. package/dist/graph/apply.js +270 -0
  43. package/dist/graph/constraints.d.ts +47 -0
  44. package/dist/graph/constraints.js +38 -0
  45. package/dist/graph/search.d.ts +16 -0
  46. package/dist/graph/search.js +115 -0
  47. package/dist/graph/types.d.ts +142 -0
  48. package/dist/graph/types.js +14 -0
  49. package/dist/hooks/capture.d.ts +32 -0
  50. package/dist/hooks/capture.js +194 -0
  51. package/dist/hooks/recall.d.ts +63 -0
  52. package/dist/hooks/recall.js +429 -0
  53. package/dist/index.d.ts +534 -0
  54. package/dist/index.js +344 -0
  55. package/dist/llm-usage.d.ts +26 -0
  56. package/dist/llm-usage.js +37 -0
  57. package/dist/llm.d.ts +153 -0
  58. package/dist/llm.js +530 -0
  59. package/dist/pipeline/graph.d.ts +35 -0
  60. package/dist/pipeline/graph.js +104 -0
  61. package/dist/pipeline/l1.d.ts +19 -0
  62. package/dist/pipeline/l1.js +271 -0
  63. package/dist/pipeline/l2.d.ts +13 -0
  64. package/dist/pipeline/l2.js +83 -0
  65. package/dist/pipeline/l3.d.ts +15 -0
  66. package/dist/pipeline/l3.js +78 -0
  67. package/dist/pipeline/rebuild.d.ts +61 -0
  68. package/dist/pipeline/rebuild.js +307 -0
  69. package/dist/pipeline/ruminate.d.ts +89 -0
  70. package/dist/pipeline/ruminate.js +298 -0
  71. package/dist/pipeline/runner.d.ts +167 -0
  72. package/dist/pipeline/runner.js +638 -0
  73. package/dist/pipeline/trigger.d.ts +40 -0
  74. package/dist/pipeline/trigger.js +75 -0
  75. package/dist/prompts/graph-projection.d.ts +70 -0
  76. package/dist/prompts/graph-projection.js +167 -0
  77. package/dist/prompts/l1-dedup.d.ts +22 -0
  78. package/dist/prompts/l1-dedup.js +251 -0
  79. package/dist/prompts/l1-extraction.d.ts +22 -0
  80. package/dist/prompts/l1-extraction.js +457 -0
  81. package/dist/prompts/persona.d.ts +23 -0
  82. package/dist/prompts/persona.js +240 -0
  83. package/dist/prompts/scene.d.ts +32 -0
  84. package/dist/prompts/scene.js +414 -0
  85. package/dist/runtime-package-lock.json +982 -0
  86. package/dist/settings.d.ts +50 -0
  87. package/dist/settings.js +355 -0
  88. package/dist/stats.d.ts +109 -0
  89. package/dist/stats.js +929 -0
  90. package/dist/store/bm25.d.ts +19 -0
  91. package/dist/store/bm25.js +63 -0
  92. package/dist/store/cost-ledger.d.ts +75 -0
  93. package/dist/store/cost-ledger.js +171 -0
  94. package/dist/store/download-queue.d.ts +79 -0
  95. package/dist/store/download-queue.js +424 -0
  96. package/dist/store/embedding-source.d.ts +118 -0
  97. package/dist/store/embedding-source.js +443 -0
  98. package/dist/store/embedding.d.ts +90 -0
  99. package/dist/store/embedding.js +206 -0
  100. package/dist/store/graph-store.d.ts +94 -0
  101. package/dist/store/graph-store.js +641 -0
  102. package/dist/store/l0.d.ts +40 -0
  103. package/dist/store/l0.js +197 -0
  104. package/dist/store/l1.d.ts +93 -0
  105. package/dist/store/l1.js +297 -0
  106. package/dist/store/local-embedding.d.ts +89 -0
  107. package/dist/store/local-embedding.js +227 -0
  108. package/dist/store/model-catalog.d.ts +48 -0
  109. package/dist/store/model-catalog.js +81 -0
  110. package/dist/store/occupancy.d.ts +30 -0
  111. package/dist/store/occupancy.js +134 -0
  112. package/dist/store/pending.d.ts +36 -0
  113. package/dist/store/pending.js +103 -0
  114. package/dist/store/persona.d.ts +15 -0
  115. package/dist/store/persona.js +60 -0
  116. package/dist/store/recall-dedupe.d.ts +26 -0
  117. package/dist/store/recall-dedupe.js +138 -0
  118. package/dist/store/runtime-installer.d.ts +59 -0
  119. package/dist/store/runtime-installer.js +243 -0
  120. package/dist/store/scenes.d.ts +24 -0
  121. package/dist/store/scenes.js +160 -0
  122. package/dist/store/search-utils.d.ts +38 -0
  123. package/dist/store/search-utils.js +100 -0
  124. package/dist/store/session-modes.d.ts +35 -0
  125. package/dist/store/session-modes.js +144 -0
  126. package/dist/store/sqlite.d.ts +246 -0
  127. package/dist/store/sqlite.js +1491 -0
  128. package/dist/store/state.d.ts +41 -0
  129. package/dist/store/state.js +72 -0
  130. package/dist/token-cost.d.ts +23 -0
  131. package/dist/token-cost.js +185 -0
  132. package/dist/tools/index.d.ts +34 -0
  133. package/dist/tools/index.js +758 -0
  134. package/dist/types.d.ts +139 -0
  135. package/dist/types.js +38 -0
  136. package/dist/util/context-occupancy.d.ts +68 -0
  137. package/dist/util/context-occupancy.js +92 -0
  138. package/dist/util/filelog.d.ts +6 -0
  139. package/dist/util/filelog.js +108 -0
  140. package/dist/util/io.d.ts +18 -0
  141. package/dist/util/io.js +97 -0
  142. package/dist/util/recall-budget.d.ts +31 -0
  143. package/dist/util/recall-budget.js +84 -0
  144. package/dist/util/sanitize.d.ts +11 -0
  145. package/dist/util/sanitize.js +67 -0
  146. package/dist/util/text.d.ts +16 -0
  147. package/dist/util/text.js +61 -0
  148. package/dist/util/tokenizer.d.ts +9 -0
  149. package/dist/util/tokenizer.js +50 -0
  150. package/dsh.plugin.json +22 -0
  151. package/package.json +118 -0
@@ -0,0 +1,638 @@
1
+ import { resolveDataDir } from '../config.js';
2
+ import { GRAPH_PRIORITY_NEW } from '../graph/types.js';
3
+ import { emptyPending, freshWarmup, groupPendingBySession, loadPending, PENDING_MODES, pendingPathFor, savePending, } from '../store/pending.js';
4
+ import { errDetail } from '../util/filelog.js';
5
+ import { advanceWarmupThreshold, effectiveExtractThreshold, extractionBackoffMs, idleSessionsToFlush, modeSwitchAction, pickSessionBackground, } from './trigger.js';
6
+ import { runExtraction } from './l1.js';
7
+ import { runGraphProjection } from './graph.js';
8
+ import { runSceneConsolidation } from './l2.js';
9
+ import { runPersona } from './l3.js';
10
+ /**
11
+ * 选取下一个要执行的任务下标:最早的 live 优先,其次 graph(图谱投影单批短、
12
+ * 让位用户轮次但优先于重建分块),否则队首。
13
+ */
14
+ export function pickNextTaskIndex(tasks) {
15
+ for (let i = 0; i < tasks.length; i++) {
16
+ if (tasks[i].kind === 'live')
17
+ return i;
18
+ }
19
+ for (let i = 0; i < tasks.length; i++) {
20
+ if (tasks[i].kind === 'graph')
21
+ return i;
22
+ }
23
+ return 0;
24
+ }
25
+ /**
26
+ * 运行时调参视图:设置页运行时链(distillChain)与旧单路由/档位键、分层输出预算、
27
+ * 蒸馏通道覆盖、远程嵌入覆盖可临时覆盖静态 config。浅拷贝只覆盖 llm/embedding
28
+ * 相关键,其余键与原 cfg 共享只读引用;pipeline 全链继续收 cfg,无需感知。
29
+ *
30
+ * 优先级表(自上而下):
31
+ * - 蒸馏模型:部署静态 pin(provider+model 双字段齐)> 运行时统一链 distillChain
32
+ * [0]> 旧单路由键(distillProvider+distillModel 成对)> agentDefaultModel;
33
+ * pinned 时链与旧键一并失效(部署锁定路由)。
34
+ * - 思考档位:有 settings 服务时运行时值整体接管('' = 自动);链模式主路由档位走
35
+ * primaryEffort;非链模式旧键对静态回退条目盖章接管(存量兼容)。
36
+ * - 输出预算/输入预算/通道覆盖/远程嵌入覆盖:非零/非空运行时值直接注入对应子树。
37
+ */
38
+ export function effectiveCfg(cfg, live) {
39
+ const s = live?.get();
40
+ const eff = s?.reasoningEffort ?? '';
41
+ // 可选链防御:smoke/测试缝构造的最小 cfg 可能没有 llm 字段
42
+ const pinned = Boolean(cfg.llm?.provider && cfg.llm?.model);
43
+ // 统一路由链:只认显式 distillChain(非空即权威);未配置链时旧键走不变的旧路径;
44
+ // pinned 时链整体失效(部署锁定路由,链编辑器只读)
45
+ const chain = s?.distillChain?.length ? s.distillChain : [];
46
+ const chainMode = chain.length > 0 && !pinned;
47
+ const chainEffort = chainMode && chain[0].reasoningEffort ? chain[0].reasoningEffort : null;
48
+ // 链非空即整体接管:回退链以运行时链为准(slice(1)),单行链 = 显式无回退
49
+ // (空数组覆盖静态 cfg.llm.fallbacks——"UI 所见即所跑")
50
+ const chainFallbacks = chainMode
51
+ ? chain.slice(1).map((e) => ({ provider: e.provider, model: e.model, reasoningEffort: e.reasoningEffort || '' }))
52
+ : null;
53
+ const override = chainMode && chain[0].provider && chain[0].model
54
+ ? { provider: chain[0].provider, model: chain[0].model }
55
+ : !chain.length && s && !pinned && s.distillProvider && s.distillModel
56
+ ? { provider: s.distillProvider, model: s.distillModel }
57
+ : null;
58
+ const b = s?.distillBudgets;
59
+ // 守卫必须含 graph(只配图谱预算也要注入 budgets 子树——漏键 = 静默丢预算)
60
+ const budgets = b && (b.extract > 0 || b.dedup > 0 || b.l2 > 0 || b.l3 > 0 || b.graph > 0)
61
+ ? {
62
+ ...(b.extract > 0 ? { extract: b.extract } : {}),
63
+ ...(b.dedup > 0 ? { dedup: b.dedup } : {}),
64
+ ...(b.l2 > 0 ? { l2: b.l2 } : {}),
65
+ ...(b.l3 > 0 ? { l3: b.l3 } : {}),
66
+ ...(b.graph > 0 ? { graph: b.graph } : {}),
67
+ }
68
+ : null;
69
+ // 运行时按层路由链:非空层链逐层注入 layerChainsRuntime(解析侧层内第一优先级);
70
+ // pinned 一并失效;全部层为空 = 无注入。层键逐个 ?.length 容忍部分键缺失
71
+ const lc = s?.distillLayerChains;
72
+ const layerPick = lc && !pinned
73
+ ? {
74
+ ...(lc.l1?.length ? { l1: lc.l1 } : {}),
75
+ ...(lc.l2?.length ? { l2: lc.l2 } : {}),
76
+ ...(lc.l3?.length ? { l3: lc.l3 } : {}),
77
+ }
78
+ : null;
79
+ const layerChains = layerPick && Object.keys(layerPick).length ? layerPick : null;
80
+ const maxInput = s && s.distillMaxInputChars > 0 ? s.distillMaxInputChars : null;
81
+ // 旧档位键的全局接管(含给静态回退条目盖章):仅非链模式保留(旧存量值兼容)
82
+ const fallbacksTakeover = !chainMode && eff && cfg.llm?.fallbacks?.length
83
+ ? cfg.llm.fallbacks.map((f) => ({ ...f, reasoningEffort: eff }))
84
+ : null;
85
+ // 档位注入:链模式下不走全局接管(主路由档位走 primaryEffort,空档位条目回退
86
+ // 静态全局而非运行时旧键);非链模式保持旧整体接管语义
87
+ const effortInject = live && !chainMode && (eff !== '' || Boolean(cfg.llm?.reasoningEffort))
88
+ ? { reasoningEffort: eff }
89
+ : null;
90
+ // 蒸馏通道运行时覆盖:distillMode='host'|'direct' 锁定通道档,directBaseURL /
91
+ // directApiKey 覆盖直连端点与密钥(与路由 pin 正交:pin 锁宿主 provider 路由,
92
+ // direct 是独立传输层)
93
+ const dm = s?.distillMode;
94
+ const modeOverride = dm === 'host' || dm === 'direct' ? { mode: dm } : null;
95
+ const baseOverride = s && s.directBaseURL ? { baseURL: s.directBaseURL } : null;
96
+ const keyOverride = s && s.directApiKey ? { apiKey: s.directApiKey } : null;
97
+ const hasChannel = modeOverride !== null || baseOverride !== null || keyOverride !== null;
98
+ // 远程嵌入连接运行时覆盖(设置 UI 可编辑,替代部署 YAML):任一字段非空即视为
99
+ // 启用远程(覆盖 embedding.enabled 为 true,让 remoteCeiling 放行);注入到
100
+ // cfg.embedding 子树(与 llm 分属不同子树,独立注入,不进 channel 判定)
101
+ const erb = s?.embedRemoteBaseURL;
102
+ const erc = s?.embedRemoteApiKey;
103
+ const erm = s?.embedRemoteModel;
104
+ const erd = s?.embedRemoteDimensions;
105
+ const embedOverride = erb || erc || erm || (erd ?? 0) > 0
106
+ ? {
107
+ embedding: {
108
+ ...(cfg.embedding ?? {}),
109
+ enabled: true,
110
+ ...(erb ? { baseUrl: erb } : {}),
111
+ ...(erc ? { apiKey: erc } : {}),
112
+ ...(erm ? { model: erm } : {}),
113
+ ...((erd ?? 0) > 0 ? { dimensions: erd } : {}),
114
+ },
115
+ }
116
+ : null;
117
+ if (!override && !budgets && !maxInput && !fallbacksTakeover && !chainEffort && !chainFallbacks && !effortInject && !layerChains && !hasChannel && !embedOverride)
118
+ return cfg;
119
+ return {
120
+ ...cfg,
121
+ ...(embedOverride ?? {}),
122
+ llm: {
123
+ ...cfg.llm,
124
+ ...(effortInject ?? {}),
125
+ ...(override ?? {}),
126
+ ...(budgets ? { budgets } : {}),
127
+ ...(maxInput ? { maxInputChars: maxInput } : {}),
128
+ ...(fallbacksTakeover ? { fallbacks: fallbacksTakeover } : {}),
129
+ ...(chainEffort ? { primaryEffort: chainEffort } : {}),
130
+ ...(chainFallbacks ? { fallbacks: chainFallbacks } : {}),
131
+ ...(layerChains ? { layerChainsRuntime: layerChains } : {}),
132
+ ...(modeOverride ?? {}),
133
+ ...(baseOverride ?? {}),
134
+ ...(keyOverride ?? {}),
135
+ },
136
+ };
137
+ }
138
+ /** 单桶堆积上限(防无限堆积;重建分块不受限——历史会话需全量入桶蒸馏)。 */
139
+ const PENDING_BUCKET_CAP = 200;
140
+ /** 会话产出记账表容量上限(超出淘汰最久未蒸馏会话;悬浮卡信息区数据源防泄漏)。 */
141
+ const SESSION_PRODUCED_CAP = 400;
142
+ /** 启动补跑延迟:避开宿主启动期忙乱。 */
143
+ const STARTUP_RETRY_DELAY_MS = 20_000;
144
+ /** 闲置扫描 tick 粒度(实际落袋延迟 = idleSeconds + 至多一个 tick)。 */
145
+ const IDLE_TICK_MS = 30_000;
146
+ export class MemoryRunner {
147
+ ctx;
148
+ cfg;
149
+ stores;
150
+ logger;
151
+ live;
152
+ modes;
153
+ tasks = [];
154
+ draining = false;
155
+ /** 停止标志(dispose 序置位):不再取新任务;进行中任务自然收尾。 */
156
+ stopped = false;
157
+ pending = emptyPending();
158
+ /** 各档位桶渐进阈值(1 起步翻倍至稳态毕业;随 pending.json 持久化)。 */
159
+ warmup = freshWarmup();
160
+ /** 每会话最后活动时间(闲置兜底判定用)。 */
161
+ lastActivity = new Map();
162
+ /** 每会话累计产出 L1 条数与最近蒸馏时间(session-stats 数据源;LRU 上限防泄漏)。 */
163
+ sessionProduced = new Map();
164
+ /** 抽取连续失败退避(瞬态,不持久化:重启后允许首试再退避)。 */
165
+ extractFailures = new Map();
166
+ /** 图谱泵在队列中的占位标志(同一时刻至多一个 graph 任务;护栏四件套之一)。 */
167
+ graphPumpQueued = false;
168
+ pendingFile;
169
+ /** 分族 checkpoint(init 后可用;重建收尾也从这里读活引用)。 */
170
+ states;
171
+ afterRun;
172
+ constructor(ctx, cfg, stores, logger, live,
173
+ /** 会话档位只读句柄(闲置扫描跳过 off 档会话;挂起语义)。 */
174
+ modes) {
175
+ this.ctx = ctx;
176
+ this.cfg = cfg;
177
+ this.stores = stores;
178
+ this.logger = logger;
179
+ this.live = live;
180
+ this.modes = modes;
181
+ this.pendingFile = pendingPathFor(resolveDataDir(cfg));
182
+ }
183
+ async init() {
184
+ await this.stores.state.load();
185
+ this.states = {
186
+ chat: this.stores.state.forFamily('chat'),
187
+ work: this.stores.state.forFamily('work'),
188
+ };
189
+ if (this.stores.state.didMigrate) {
190
+ this.logger.info('[memory] state.json 已迁移为 v2 分族格式(旧数据归 chat 桶)');
191
+ await this.stores.state.save();
192
+ }
193
+ // 恢复未蒸馏缓冲(上次进程退出前未蒸馏的消息,含失败待重试与攒阈值中途的)
194
+ try {
195
+ const { buckets: loaded, warmup } = await loadPending(this.pendingFile, this.logger);
196
+ for (const key of PENDING_MODES) {
197
+ if (loaded[key].length > PENDING_BUCKET_CAP)
198
+ loaded[key] = loaded[key].slice(-PENDING_BUCKET_CAP);
199
+ }
200
+ this.pending = loaded;
201
+ this.warmup = warmup;
202
+ if (this.pendingCount > 0) {
203
+ this.logger.info(`[memory] 未蒸馏缓冲已恢复 ${this.pendingCount} 条(auto=${this.pending.auto.length}/chat=${this.pending.chat.length}/work=${this.pending.work.length}),${STARTUP_RETRY_DELAY_MS / 1000}s 后自动补跑`);
204
+ this.scheduleStartupRetry();
205
+ }
206
+ }
207
+ catch (err) {
208
+ this.logger.warn(`[memory] 未蒸馏缓冲恢复失败(空桶起步): ${errDetail(err)}`);
209
+ }
210
+ // 图谱:启动回收上次进程退出卡在 running 的投影任务(dispose 缝不永久卡批),
211
+ // 存量补投影延后单次限速入队(≤9000,由 20s 后的启动补跑与周期泵低速收敛)
212
+ const graphs = this.stores.graph;
213
+ if (graphs) {
214
+ graphs.recoverRunning();
215
+ if (this.cfg.graph?.enabled) {
216
+ this.ctx.effect(() => {
217
+ const timer = setTimeout(() => {
218
+ if (this.stopped)
219
+ return;
220
+ const jobs = graphs.queueMissing(9000);
221
+ if (jobs > 0) {
222
+ this.logger.info(`[memory] 图谱存量补投影:${jobs} 个任务入队`);
223
+ this.maybeQueueGraphTask();
224
+ }
225
+ }, STARTUP_RETRY_DELAY_MS);
226
+ return () => clearTimeout(timer);
227
+ });
228
+ }
229
+ }
230
+ }
231
+ /** 启动补跑:对每个非空桶的每个会话切片入队一次蒸馏尝试(受 live 开关与
232
+ * 生效阈值约束;不足阈值的消息等用户继续或闲置兜底,失败不无限重试)。 */
233
+ scheduleStartupRetry() {
234
+ const modes = PENDING_MODES.filter((m) => this.pending[m].length > 0);
235
+ this.ctx.effect(() => {
236
+ const timer = setTimeout(() => {
237
+ // 到点后按当前桶内容重新分组(期间可能有新轮次已消费切片)
238
+ for (const mode of modes) {
239
+ for (const g of groupPendingBySession(this.pending[mode])) {
240
+ this.enqueue(g.sessionId, [], mode);
241
+ }
242
+ }
243
+ }, STARTUP_RETRY_DELAY_MS);
244
+ return () => clearTimeout(timer);
245
+ });
246
+ }
247
+ /** L1 抽取待重试的消息条数(状态面板用)。 */
248
+ get pendingCount() {
249
+ return this.pending.auto.length + this.pending.chat.length + this.pending.work.length;
250
+ }
251
+ /**
252
+ * 会话级蒸馏视图(session-stats 端点数据源;纯内存读,零 I/O)。
253
+ * pendingSlice = 当前档位桶中该会话的攒批切片条数(threshold 为生效阈值,含 warmup 爬坡);
254
+ * parkedSlices = 其余档位桶中的残留切片(换档遗留 / off 档挂起)。
255
+ */
256
+ sessionView(sessionId, mode) {
257
+ const count = (bucket) => bucket.reduce((n, m) => (m.sessionId === sessionId ? n + 1 : n), 0);
258
+ const view = {
259
+ pendingSlice: 0,
260
+ parkedSlices: 0,
261
+ threshold: null,
262
+ producedRecords: 0,
263
+ lastDistillAt: null,
264
+ };
265
+ const prod = this.sessionProduced.get(sessionId);
266
+ if (prod) {
267
+ view.producedRecords = prod.count;
268
+ view.lastDistillAt = prod.lastAt;
269
+ }
270
+ const own = PENDING_MODES.includes(mode) ? mode : null;
271
+ if (own) {
272
+ view.pendingSlice = count(this.pending[own]);
273
+ view.threshold = effectiveExtractThreshold(this.warmup[own], this.cfg.extract?.minMessages ?? 0);
274
+ }
275
+ for (const m of PENDING_MODES) {
276
+ if (m !== own)
277
+ view.parkedSlices += count(this.pending[m]);
278
+ }
279
+ return view;
280
+ }
281
+ /** 会话产出记账(切片成功消费时调用;LRU 淘汰最久未蒸馏会话防 Map 无界增长)。 */
282
+ noteSessionDistill(sessionId, produced) {
283
+ const now = Date.now();
284
+ const cur = this.sessionProduced.get(sessionId);
285
+ if (cur) {
286
+ cur.count += produced;
287
+ cur.lastAt = now;
288
+ }
289
+ else {
290
+ this.sessionProduced.set(sessionId, { count: produced, lastAt: now });
291
+ }
292
+ if (this.sessionProduced.size > SESSION_PRODUCED_CAP) {
293
+ let oldestKey = null;
294
+ let oldestAt = Infinity;
295
+ for (const [sid, v] of this.sessionProduced) {
296
+ if (v.lastAt < oldestAt) {
297
+ oldestAt = v.lastAt;
298
+ oldestKey = sid;
299
+ }
300
+ }
301
+ if (oldestKey)
302
+ this.sessionProduced.delete(oldestKey);
303
+ }
304
+ }
305
+ /** 管线跑完一轮后的回调(用于召回缓存失效)。 */
306
+ setAfterRun(fn) {
307
+ this.afterRun = fn;
308
+ }
309
+ /** 一轮对话结束后入队(L0 落盘由 capture 在 turn/end 即时完成,不排蒸馏队列)。
310
+ * onTurnDone 在该任务真正跑完后回调,携带本轮新增记录数(供反刍统计真实产出)。 */
311
+ enqueue(sessionId, messages, mode, opts) {
312
+ this.pushTask({
313
+ kind: 'live',
314
+ run: () => this.runTurn(sessionId, messages, mode, opts),
315
+ ...(opts?.onTurnDone ? { onDone: (r) => opts.onTurnDone?.(typeof r === 'number' ? r : 0) } : {}),
316
+ });
317
+ }
318
+ /** 重建任务入队(低优先级:让位于正常轮次;由 RebuildController 分块驱动)。 */
319
+ enqueueRebuildTask(run) {
320
+ this.pushTask({ kind: 'rebuild', run });
321
+ }
322
+ /** 重建蒸馏轮:统一 auto 档,全量强制蒸馏(不受阈值约束)、不受缓冲 200 上限。 */
323
+ runRebuildTurn(sessionId, messages) {
324
+ return this.runTurn(sessionId, messages, 'auto', { noBufferCap: true, force: true });
325
+ }
326
+ /** 停止取新任务(插件 dispose 序调用;进行中任务照常跑完但不 await)。 */
327
+ stop() {
328
+ this.stopped = true;
329
+ }
330
+ /** 启动闲置兜底定时器(index.ts 装配;idleSeconds=0 关闭)。 */
331
+ startIdleTimer() {
332
+ const idleMs = (this.cfg.extract.idleSeconds ?? 0) * 1000;
333
+ if (!(idleMs > 0))
334
+ return;
335
+ this.ctx.effect(() => {
336
+ const timer = setInterval(() => this.flushIdleSlices(Date.now(), idleMs), IDLE_TICK_MS);
337
+ return () => clearInterval(timer);
338
+ });
339
+ }
340
+ /** 闲置扫描:静默达标且有切片的会话按捕获档位落袋(off 档会话挂起跳过)。 */
341
+ flushIdleSlices(now, idleMs) {
342
+ if (this.stopped)
343
+ return;
344
+ const liveNow = this.live.get();
345
+ if (!(liveNow.enabled && liveNow.distill))
346
+ return;
347
+ // 聚合每会话跨桶切片(sessionId 全局唯一)
348
+ const infos = new Map();
349
+ for (const mode of PENDING_MODES) {
350
+ for (const m of this.pending[mode]) {
351
+ const info = infos.get(m.sessionId) ?? { sessionId: m.sessionId, count: 0, lastMessageAt: 0 };
352
+ info.count++;
353
+ info.lastMessageAt = Math.max(info.lastMessageAt, m.timestamp);
354
+ infos.set(m.sessionId, info);
355
+ }
356
+ }
357
+ if (infos.size === 0)
358
+ return;
359
+ const targets = idleSessionsToFlush([...infos.values()], this.lastActivity, now, idleMs, (sid) => this.modes?.get(sid) === 'off').filter((sid) => !this.inExtractBackoff(sid, now)); // 退避中的会话不入队(任务堆积源头)
360
+ for (const sid of targets) {
361
+ this.logger.info(`[memory] 闲置兜底:会话 ${sid} 静默达标,未蒸馏切片落袋`);
362
+ this.enqueueSessionSlices(sid);
363
+ }
364
+ }
365
+ /** 把某会话在各桶中的切片按捕获档位逐个入队强制蒸馏(闲置兜底 / 档位切换共用)。 */
366
+ enqueueSessionSlices(sessionId) {
367
+ for (const mode of PENDING_MODES) {
368
+ if (this.pending[mode].some((m) => m.sessionId === sessionId)) {
369
+ this.enqueue(sessionId, [], mode, { force: true });
370
+ }
371
+ }
372
+ }
373
+ /**
374
+ * 档位切换同步(session-modes 的 set() 回调,ADR-0003):
375
+ * 非 off 间切换 → 该会话切片立即按捕获档位蒸馏(新档位从空切片起步);
376
+ * 切到 off → 挂起(切片留存,闲置扫描跳过);从 off 切回 → 挂起片按捕获档位落袋。
377
+ */
378
+ onModeChange(sessionId, oldMode, newMode) {
379
+ const action = modeSwitchAction(oldMode, newMode);
380
+ if (action === 'none')
381
+ return;
382
+ if (action === 'park') {
383
+ this.logger.info(`[memory] 档位切换 ${oldMode}→off:会话 ${sessionId} 未蒸馏切片挂起`);
384
+ return;
385
+ }
386
+ this.logger.info(`[memory] 档位切换 ${oldMode}→${newMode}(${action}):会话 ${sessionId} 切片按捕获档位落袋`);
387
+ this.enqueueSessionSlices(sessionId);
388
+ }
389
+ pushTask(task) {
390
+ if (this.stopped)
391
+ return;
392
+ this.tasks.push(task);
393
+ void this.drain();
394
+ }
395
+ /**
396
+ * 图谱泵任务入队(护栏四件套,见 maybeQueueGraphTask;成功投影后有积压则续排,
397
+ * 每轮 drain 至多消费一个 graph 任务且永远让位 live)。
398
+ */
399
+ startGraphBackfillTimer() {
400
+ const graphs = this.stores.graph;
401
+ if (!graphs || !this.cfg.graph?.enabled)
402
+ return;
403
+ this.ctx.effect(() => {
404
+ const timer = setInterval(() => {
405
+ if (this.stopped)
406
+ return;
407
+ const jobs = graphs.queueMissing(500);
408
+ if (jobs > 0) {
409
+ this.logger.debug?.(`[memory] 图谱周期补投影:${jobs} 个任务入队`);
410
+ this.maybeQueueGraphTask();
411
+ }
412
+ }, 30 * 60_000);
413
+ return () => clearInterval(timer);
414
+ });
415
+ }
416
+ /**
417
+ * 图谱投影泵入队判定:
418
+ * - 双门:部署级 cfg.graph.enabled + 运行时 live.enabled/distill,任一为假不入队;
419
+ * - 占位:同一时刻至多一个 graph 任务在队列(靠 graphPumpQueued 标志),永不与
420
+ * live 抢位(pickNextTaskIndex 保证 live > graph);
421
+ * - 退避/attempts 封顶:由 GraphStore.claimNext 的 WHERE 过滤与 fail 转 dead 承接,
422
+ * 退避窗口内的 claim 是廉价空转,不在此重复实现。
423
+ */
424
+ maybeQueueGraphTask() {
425
+ const graphs = this.stores.graph;
426
+ if (!graphs || this.stopped || this.graphPumpQueued)
427
+ return;
428
+ if (!this.cfg.graph?.enabled)
429
+ return;
430
+ const liveNow = this.live.get();
431
+ if (!(liveNow.enabled && liveNow.distill))
432
+ return;
433
+ this.graphPumpQueued = true;
434
+ this.pushTask({
435
+ kind: 'graph',
436
+ run: async () => {
437
+ this.graphPumpQueued = false;
438
+ if (this.stopped)
439
+ return;
440
+ const had = await runGraphProjection(this.ctx, effectiveCfg(this.cfg, this.live), graphs, this.logger);
441
+ // 有实际投影(说明队列还有积压)才续排;claim 落空则停泵,等下一个触发源
442
+ if (had)
443
+ this.maybeQueueGraphTask();
444
+ },
445
+ });
446
+ }
447
+ async drain() {
448
+ if (this.draining)
449
+ return;
450
+ this.draining = true;
451
+ try {
452
+ while (!this.stopped && this.tasks.length > 0) {
453
+ const [task] = this.tasks.splice(pickNextTaskIndex(this.tasks), 1);
454
+ try {
455
+ const result = await task.run();
456
+ // 回调失败不得影响管线
457
+ try {
458
+ task.onDone?.(result);
459
+ }
460
+ catch (err) {
461
+ this.logger.warn(`[memory] 任务完成回调失败(已忽略): ${errDetail(err)}`);
462
+ }
463
+ }
464
+ catch (err) {
465
+ this.logger.warn(`[memory] 管线失败(已兜底): ${errDetail(err)}`);
466
+ }
467
+ }
468
+ }
469
+ finally {
470
+ this.draining = false;
471
+ }
472
+ }
473
+ /** 该会话是否处于抽取退避窗口内。 */
474
+ inExtractBackoff(sessionId, now = Date.now()) {
475
+ const f = this.extractFailures.get(sessionId);
476
+ return !!f && now < f.nextAt;
477
+ }
478
+ /** 缓冲落盘(每次蒸馏尝试后调用;失败只告警不阻断管线)。
479
+ * 非重建轮持久化前按桶截断到上限:重建取消后的大桶不至于在后续每次
480
+ * 蒸馏尝试时反复整量序列化落盘(多 MB 级 IO);重建轮豁免维持。 */
481
+ async persistPending(noBufferCap = false) {
482
+ try {
483
+ if (!noBufferCap) {
484
+ for (const key of PENDING_MODES) {
485
+ const bucket = this.pending[key];
486
+ if (bucket.length > PENDING_BUCKET_CAP)
487
+ this.pending[key] = bucket.slice(-PENDING_BUCKET_CAP);
488
+ }
489
+ }
490
+ await savePending(this.pendingFile, this.pending, this.warmup);
491
+ }
492
+ catch (err) {
493
+ this.logger.warn(`[memory] 未蒸馏缓冲落盘失败: ${errDetail(err)}`);
494
+ }
495
+ }
496
+ async runTurn(sessionId, messages, mode, opts) {
497
+ const turnStart = Date.now();
498
+ this.logger.info(`[memory] 蒸馏管线开始(session=${sessionId},mode=${mode},本轮 ${messages.length} 条消息,待重试 ${this.pendingCount} 条)`);
499
+ // ── L0:原始对话已由 capture 在 turn/end 即时落盘(不排蒸馏队列,防慢 LLM 阻塞/退出丢消息) ──
500
+ const cfg = effectiveCfg(this.cfg, this.live);
501
+ // ── L1:抽取 + 去重(按档分桶、桶内按会话切片;失败按切片保留待重试) ──
502
+ let newRecords = [];
503
+ const liveNow = this.live.get();
504
+ const distillOn = liveNow.enabled && liveNow.distill;
505
+ if (cfg.extract.enabled && distillOn) {
506
+ const bucket = this.pending[mode];
507
+ // 入桶即携带会话标识(会话切片成员;切片内永不跨会话混装)
508
+ bucket.push(...messages.map((m) => ({ ...m, sessionId })));
509
+ if (!opts?.noBufferCap && bucket.length > PENDING_BUCKET_CAP) {
510
+ bucket.splice(0, bucket.length - PENDING_BUCKET_CAP);
511
+ }
512
+ if (messages.length > 0)
513
+ this.lastActivity.set(sessionId, Date.now());
514
+ // 按会话切片触发:只看本会话切片是否达到生效阈值(渐进爬坡),
515
+ // 达标只抽取该切片——其余会话的切片继续攒,绝不跨会话混装。
516
+ // force:重建轮全量蒸馏(历史小会话也必须出记忆,不受阈值约束)。
517
+ const effective = effectiveExtractThreshold(this.warmup[mode], cfg.extract.minMessages);
518
+ const sliceLen = bucket.reduce((n, m) => (m.sessionId === sessionId ? n + 1 : n), 0);
519
+ // 抽取失败退避:重建轮(noBufferCap,用户显式动作、有自己的失败/取消 UI)豁免
520
+ const backedOff = !opts?.noBufferCap && this.inExtractBackoff(sessionId);
521
+ if ((opts?.force || sliceLen >= effective) && !backedOff) {
522
+ newRecords = await this.extractSessionSlice(sessionId, mode, cfg, effective, opts);
523
+ // 新记录入投影队列(优先级恒高于存量补投影)并拉起图谱泵;入队本身廉价,
524
+ // 部署未开图谱(cfg.graph.enabled)时不入队——用户后续开启由启动补投影兜底
525
+ if (newRecords.length > 0 && this.stores.graph && this.cfg.graph?.enabled) {
526
+ this.stores.graph.queueGraphProjection(newRecords.map((r) => r.id), GRAPH_PRIORITY_NEW);
527
+ this.maybeQueueGraphTask();
528
+ }
529
+ }
530
+ else {
531
+ this.logger.debug?.(backedOff
532
+ ? `[memory] 蒸馏退避中,本轮跳过抽取(session=${sessionId},mode=${mode})`
533
+ : `[memory] 会话切片攒批中(session=${sessionId},mode=${mode},${sliceLen}/${effective})`);
534
+ // 攒阈值中途也落盘:进程退出后切片与爬坡状态不丢
535
+ await this.persistPending(opts?.noBufferCap);
536
+ }
537
+ }
538
+ // ── L2/L3:按记录族各自判定与执行 ──
539
+ if (cfg.l2.enabled && distillOn) {
540
+ for (const family of ['chat', 'work']) {
541
+ const familyRecords = newRecords.filter((r) => (r.family ?? 'chat') === family);
542
+ if (familyRecords.length === 0)
543
+ continue;
544
+ const fstate = this.states[family];
545
+ if (fstate.newMemoriesSinceL2 >= cfg.l2.minNewMemories) {
546
+ try {
547
+ const t = Date.now();
548
+ const result = await runSceneConsolidation(this.ctx, cfg, this.stores.scenes[family], familyRecords, this.logger, family);
549
+ fstate.lastL2At = Date.now();
550
+ fstate.newMemoriesSinceL2 = 0;
551
+ if (result.personaRequestedReason)
552
+ fstate.personaRequestedReason = result.personaRequestedReason;
553
+ this.logger.info(`[memory] L2 阶段完成(family=${family},${Date.now() - t}ms)`);
554
+ }
555
+ catch (err) {
556
+ this.logger.warn(`[memory] L2 场景整合失败(family=${family}): ${errDetail(err)}`);
557
+ }
558
+ }
559
+ else {
560
+ this.logger.debug?.(`[memory] L2 跳过(family=${family},本族新增 ${familyRecords.length} 条,累计未整合 ${fstate.newMemoriesSinceL2}/${cfg.l2.minNewMemories})`);
561
+ }
562
+ }
563
+ }
564
+ if (cfg.l3.enabled && distillOn) {
565
+ for (const family of ['chat', 'work']) {
566
+ try {
567
+ await runPersona(this.ctx, cfg, this.stores.scenes[family], this.stores.persona[family], this.states[family], this.logger, family);
568
+ }
569
+ catch (err) {
570
+ this.logger.warn(`[memory] L3 画像蒸馏失败(family=${family}): ${errDetail(err)}`);
571
+ }
572
+ }
573
+ }
574
+ // ── 持久化状态 ──
575
+ try {
576
+ await this.stores.state.save();
577
+ }
578
+ catch (err) {
579
+ this.logger.warn(`[memory] 状态保存失败: ${errDetail(err)}`);
580
+ }
581
+ this.logger.info(`[memory] 蒸馏管线结束(本轮新增 ${newRecords.length} 条,总耗时 ${Date.now() - turnStart}ms)`);
582
+ this.afterRun?.();
583
+ return newRecords.length;
584
+ }
585
+ /**
586
+ * 抽取并消费一个会话切片:成功才把切片移出桶并推进爬坡阈值;失败保留切片待重试。
587
+ * 调用方已保证切片达到生效阈值(或 force)。背景参考按会话从 L0 现查并剔除切片
588
+ * 自身(ADR-0003:会话间互不污染、重启不丢背景)。
589
+ */
590
+ async extractSessionSlice(sessionId, mode, cfg, effectiveThreshold, opts) {
591
+ const bucket = this.pending[mode];
592
+ const slice = bucket.filter((m) => m.sessionId === sessionId);
593
+ if (slice.length === 0)
594
+ return [];
595
+ const rest = bucket.filter((m) => m.sessionId !== sessionId);
596
+ try {
597
+ // 背景参考:该会话最近消息(多取切片条数补偿被剔除者),剔除切片自身
598
+ const background = this.stores.l0
599
+ ? pickSessionBackground(await this.stores.l0.recentBySession(sessionId, cfg.extract.backgroundMessages + slice.length), new Set(slice.map((m) => m.id)), cfg.extract.backgroundMessages)
600
+ : [];
601
+ const t = Date.now();
602
+ const result = await runExtraction(this.ctx, cfg, this.stores.l1, this.states, slice, background, this.logger, mode);
603
+ if (!result.skipped) {
604
+ this.pending[mode] = rest;
605
+ // 重建轮(force)不是有机对话,不推进爬坡
606
+ if (!opts?.force)
607
+ this.warmup[mode] = advanceWarmupThreshold(this.warmup[mode], cfg.extract.minMessages);
608
+ // 会话产出记账(成功消费即记——零产出也算"蒸馏过",lastAt 推进)
609
+ this.noteSessionDistill(sessionId, result.newRecords.length);
610
+ // 成功消费清零失败退避(切片已出桶,后续轮次恢复正常触发)
611
+ this.extractFailures.delete(sessionId);
612
+ }
613
+ this.logger.info(`[memory] L1 阶段完成(session=${sessionId},mode=${mode},切片 ${slice.length} 条,背景 ${background.length} 条,阈值 ${effectiveThreshold},${Date.now() - t}ms)`);
614
+ // 缓冲与爬坡每次尝试后立即落盘:进程中途退出不丢待重试/攒阈值状态
615
+ await this.persistPending(opts?.noBufferCap);
616
+ // L1 计数推进后立即落盘:L2/L3 失败或进程中途退出不得回滚阈值进度
617
+ // (记录已入库但计数丢失会让该族 L2 永远差一截,state 与 DB 脱节)
618
+ try {
619
+ await this.stores.state.save();
620
+ }
621
+ catch (err) {
622
+ this.logger.warn(`[memory] 状态保存失败: ${errDetail(err)}`);
623
+ }
624
+ return result.newRecords;
625
+ }
626
+ catch (err) {
627
+ // 保留切片下次重试(桶入口已裁到 ≤200,防无限堆积;重建轮不裁,量被会话规模约束)
628
+ this.logger.warn(`[memory] L1 抽取失败(session=${sessionId},mode=${mode},切片 ${slice.length} 条): ${errDetail(err)}`);
629
+ // 指数退避:压制闲置兜底/补跑在 LLM 故障期间的连环重试(成功消费时清零)
630
+ const streak = (this.extractFailures.get(sessionId)?.streak ?? 0) + 1;
631
+ const delayMs = extractionBackoffMs(streak);
632
+ this.extractFailures.set(sessionId, { streak, nextAt: Date.now() + delayMs });
633
+ this.logger.info(`[memory] 蒸馏连续失败 ${streak} 次,${Math.round(delayMs / 1000)}s 内暂停该会话的自动重试`);
634
+ await this.persistPending(opts?.noBufferCap).catch(() => { });
635
+ return [];
636
+ }
637
+ }
638
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * 蒸馏触发决策纯函数(ADR-0003 / 规格 B 节;决策与执行分离,不依赖 dsh 宿主即可测
3
+ * 调度语义)。
4
+ *
5
+ * 语义(CONTEXT.md「渐进阈值」「会话切片」):
6
+ * - 生效阈值从 1 起步(新用户首轮即出记忆),每次成功抽取后翻倍至稳态值毕业;
7
+ * - 阈值按会话切片计数,只抽取达到阈值的那个会话的切片;
8
+ * - warmup 值 0 = 已毕业(使用稳态值),按档位桶各记一次(全局爬坡,非按会话)。
9
+ */
10
+ /** 生效阈值:爬坡中取 min(当前爬坡值, 稳态),毕业(0)取稳态。 */
11
+ export declare function effectiveExtractThreshold(warmup: number, steady: number): number;
12
+ /** 成功抽取后推进爬坡:翻倍,达到稳态即毕业(0);已毕业保持 0。 */
13
+ export declare function advanceWarmupThreshold(current: number, steady: number): number;
14
+ export type ModeSwitchAction = 'flush' | 'park' | 'unpark' | 'none';
15
+ /**
16
+ * 档位切换动作表(ADR-0003):
17
+ * - 非 off 档间切换 → flush(该会话切片立即按捕获档位蒸馏,新档位从空切片起步)
18
+ * - 切到 off → park(切片挂起:用户刚说"停止记忆",把存量再蒸馏违背意图)
19
+ * - 从 off 切回 → unpark(挂起片按捕获档位落袋)
20
+ */
21
+ export declare function modeSwitchAction(oldMode: string, newMode: string): ModeSwitchAction;
22
+ /** 从"该会话最近消息"里剔除切片自身成员后取尾部 n 条作为抽取背景。
23
+ * 剔除防止抽取目标混入背景(L0 在 turn/end 已先落盘,现查必含切片)。 */
24
+ export declare function pickSessionBackground<T extends {
25
+ id: string;
26
+ }>(recent: readonly T[], sliceIds: ReadonlySet<string>, n: number): T[];
27
+ /** 连续失败第 failStreak 次后的自动重试等待时长(1 → 60s,2 → 120s,…封顶 30min)。 */
28
+ export declare function extractionBackoffMs(failStreak: number): number;
29
+ export interface IdleSliceInfo {
30
+ sessionId: string;
31
+ /** 该会话跨桶合计的切片条数。 */
32
+ count: number;
33
+ /** 切片内最晚消息时间(无活动记录时的兜底锚点,如重启后的残留切片)。 */
34
+ lastMessageAt: number;
35
+ }
36
+ /**
37
+ * 闲置扫描:静默达标且有切片的会话。off 档会话跳过(挂起语义);
38
+ * 活动时间优先取运行时记录,缺省回退切片内最晚消息时间。
39
+ */
40
+ export declare function idleSessionsToFlush(slices: IdleSliceInfo[], lastActivity: ReadonlyMap<string, number>, now: number, idleMs: number, isOffSession: (sessionId: string) => boolean): string[];