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,429 @@
1
+ import { createUserMessage } from '@deepseek-ai/dsh-llm';
2
+ import { RecallDedupeStore } from '../store/recall-dedupe.js';
3
+ import { OccupancyStore } from '../store/occupancy.js';
4
+ import { applyRecallBudget, raceRecallTimeout, RECALL_EMBED_CAP_MS } from '../util/recall-budget.js';
5
+ import { clearProfileShare, emptyOccupancyLedger, estimateInjectedMessageTokens, estimateStableSectionTokens, recordProfileShare, recordRecallInjection, resetForCompaction, } from '../util/context-occupancy.js';
6
+ import { errDetail } from '../util/filelog.js';
7
+ import { blocksToText } from '../util/text.js';
8
+ const PROFILE_TTL = 60_000;
9
+ /** 存储回填缓存上限(超过即按插入序淘汰最旧;查看会话数为百级,500 足够)。 */
10
+ const STORED_ESTIMATE_CACHE_CAP = 500;
11
+ const storedEstimateCache = new Map();
12
+ function cacheStoredEstimate(sessionId, tokens) {
13
+ if (storedEstimateCache.size >= STORED_ESTIMATE_CACHE_CAP) {
14
+ const oldest = storedEstimateCache.keys().next().value;
15
+ if (oldest !== undefined)
16
+ storedEstimateCache.delete(oldest);
17
+ }
18
+ storedEstimateCache.set(sessionId, tokens);
19
+ }
20
+ /** 召回查询只取会话末尾 N 条消息(长会话每步把全史拼进 FTS MATCH 会让检索成本线性上涨)。 */
21
+ const RECALL_QUERY_TAIL_MESSAGES = 8;
22
+ /** 召回查询总字符上限(保留末尾——最新语境权重最高)。 */
23
+ const RECALL_QUERY_MAX_CHARS = 2_000;
24
+ /**
25
+ * 从会话消息构建召回查询(纯函数):末尾 N 条 + 总长截断,空输入返回空串。
26
+ * 全史拼接会让 MATCH 表达式随会话长度线性膨胀(整会话累计二次方成本)。
27
+ */
28
+ export function buildRecallQuery(messages, tailMessages = RECALL_QUERY_TAIL_MESSAGES, maxChars = RECALL_QUERY_MAX_CHARS) {
29
+ const tail = messages.slice(-tailMessages);
30
+ let text = tail.map((m) => blocksToText(m.content)).join(' ').trim();
31
+ if (text.length > maxChars)
32
+ text = text.slice(-maxChars);
33
+ return text;
34
+ }
35
+ const MEMORY_TOOLS_GUIDE = `<memory-tools-guide>
36
+ ## 记忆工具调用指南
37
+
38
+ 当上方注入的记忆片段不足以回答用户问题时,可主动调用以下工具获取更多信息:
39
+
40
+ - **memory_search**:搜索结构化记忆(L1),适用于回忆用户偏好、历史事件、项目事实、任务、规则等。
41
+ - **conversation_search**:搜索原始对话(L0),适用于查找具体消息原文、时间线、上下文细节。
42
+ - **memory_read_scene**:读取记忆文件详情(L2 场景块,如场景目录下的 .md 文件;也可读 persona.md)。
43
+
44
+ ### ⚠️ 调用次数限制
45
+ 每轮对话中,memory_search 和 conversation_search **合计最多调用 3 次**。
46
+ - 首次搜索无结果时,可换关键词或换工具重试,但总调用次数不要超过 3 次。
47
+ - 若 3 次搜索后仍无结果,说明该信息不在记忆中,请直接根据已有信息回复用户。
48
+
49
+ 注:若当前环境限制直接调用工具(如仅允许代码执行入口),请经由该环境的工具调用机制
50
+ (如 run_code 程序内)使用以上记忆工具。
51
+ </memory-tools-guide>`;
52
+ /** 新建零值统计(首次出现的会话)。 */
53
+ export function emptyRecallStats(now = Date.now()) {
54
+ return {
55
+ injectedTurns: 0,
56
+ hitTurns: 0,
57
+ totalHits: 0,
58
+ timeouts: 0,
59
+ suppressedRecalls: 0,
60
+ lastHits: 0,
61
+ lastDurationMs: 0,
62
+ updatedAt: now,
63
+ };
64
+ }
65
+ export function registerRecall(ctx, cfg, stores, logger, live, modes, dataDir) {
66
+ /** 召回去重存储(同会话已注入的记忆不再重复注入;写穿持久化,重启不丢)。 */
67
+ const dedupe = new RecallDedupeStore(dataDir, logger);
68
+ /** 记忆占用流水(账本迁移写穿;重启后历史会话账目由此复生)。 */
69
+ const occupancyStore = new OccupancyStore(dataDir, logger);
70
+ /** 每 agent 召回统计(工具指南门控读 lastHits;悬浮卡信息区读全量计数)。 */
71
+ const recallStats = new Map();
72
+ const statFor = (id) => {
73
+ let s = recallStats.get(id);
74
+ if (!s) {
75
+ s = emptyRecallStats();
76
+ recallStats.set(id, s);
77
+ }
78
+ return s;
79
+ };
80
+ /** 每 agent 记忆占用账本(权威账本的唯一宿主实例;占用指示器与悬浮卡同源消费)。 */
81
+ const occupancyByAgent = new Map();
82
+ const ledgerFor = (id) => {
83
+ let led = occupancyByAgent.get(id);
84
+ if (!led) {
85
+ // 进程重启/agent 重建后回看:从流水复生(新迁移在持久值上继续累加)
86
+ led = occupancyStore.load(id) ?? emptyOccupancyLedger();
87
+ occupancyByAgent.set(id, led);
88
+ }
89
+ return led;
90
+ };
91
+ // 画像/场景导航按族缓存(分族隔离:注入时按会话档位选族)
92
+ const profileCache = {
93
+ chat: { persona: '', nav: '' },
94
+ work: { persona: '', nav: '' },
95
+ };
96
+ const refreshProfile = async () => {
97
+ try {
98
+ const [chat, work] = await Promise.all([
99
+ loadProfileParts(stores, cfg, 'chat'),
100
+ loadProfileParts(stores, cfg, 'work'),
101
+ ]);
102
+ profileCache.chat = chat;
103
+ profileCache.work = work;
104
+ }
105
+ catch (err) {
106
+ logger.warn(`[memory] 画像/场景缓存刷新失败: ${errDetail(err)}`);
107
+ }
108
+ };
109
+ // 初始刷新 + 定时刷新(TTL)
110
+ void refreshProfile();
111
+ ctx.effect(() => {
112
+ const timer = setInterval(() => void refreshProfile(), PROFILE_TTL);
113
+ return () => clearInterval(timer);
114
+ });
115
+ const invalidateProfile = () => {
116
+ void refreshProfile();
117
+ };
118
+ // agent 销毁时清掉召回统计槽(去重记录不随 agent 清——持久化语义:会话恢复后继续压制)
119
+ ctx.on('agent/disposed', (payload) => {
120
+ recallStats.delete(payload.agent.id);
121
+ occupancyByAgent.delete(payload.agent.id);
122
+ });
123
+ // 上下文压缩/清空 → 已注入内容从模型上下文丢失,重置该会话的去重压制
124
+ // (resume/startup 不重置:历史仍在,已注入的记忆模型还持有)。
125
+ // 占用账本同步全量归零(宁低勿高;轮级粒度近似)。
126
+ ctx.on('agent/session-start', (payload) => {
127
+ if (payload.source === 'compact' || payload.source === 'clear') {
128
+ dedupe.reset(payload.agent.id);
129
+ const led = ledgerFor(payload.agent.id);
130
+ resetForCompaction(led);
131
+ occupancyStore.save(payload.agent.id, led); // stock 归零 ⇒ 流水条目删除
132
+ logger.info(`[memory] 召回去重与占用账本重置(agent=${payload.agent.id},source=${payload.source})`);
133
+ }
134
+ });
135
+ // ── 1. pre-step 消息侧注入:记忆先行于每一条新的用户输入(ADR-0001) ──
136
+ // prepend 注册 + 先 next() 再改写:不劫持其他监听器(dsh-time-context 官方范式)。
137
+ if (cfg.recall.enabled) {
138
+ ctx.on('agent/pre-step', async (payload, next) => {
139
+ const decision = await next();
140
+ if (decision.kind === 'reject' || payload.signal.aborted)
141
+ return decision;
142
+ try {
143
+ const s = live.get();
144
+ const mode = modes.get(payload.agent.id);
145
+ // 三级读闸:主闸 → off 档(完全隐身)→ 注入开关(会话覆盖 ?? 全局)
146
+ if (!s.enabled || mode === 'off' || !modes.resolvedRecall(payload.agent.id, s.recall))
147
+ return decision;
148
+ // 只在有新的用户来源消息的步骤注入(轮首 claim 或 steering 插话);纯工具步透传
149
+ const hasNewUserMessage = decision.messages.some((m) => m.source?.kind === 'user');
150
+ if (!hasNewUserMessage)
151
+ return decision;
152
+ const query = buildRecallQuery(payload.messages);
153
+ // 空查询是退化轮(无用户文本),重置命中信号但不计入统计
154
+ if (!query) {
155
+ const degenerate = statFor(payload.agent.id);
156
+ degenerate.lastHits = 0;
157
+ return decision;
158
+ }
159
+ const st = statFor(payload.agent.id);
160
+ st.injectedTurns++;
161
+ st.lastHits = 0;
162
+ st.updatedAt = Date.now();
163
+ const searchStart = Date.now();
164
+ const hits = await raceRecallTimeout(stores.l1.search(query, cfg.recall.maxResults, {
165
+ scoreThreshold: cfg.recall.scoreThreshold,
166
+ family: mode === 'auto' ? undefined : mode,
167
+ // 嵌入内层钳制:给 FTS 降级留出总预算内的时间(远程限 HTTP fetch;本地经 worker 代理 race 放弃)
168
+ embeddingTimeoutMs: RECALL_EMBED_CAP_MS,
169
+ }), cfg.recall.timeoutMs);
170
+ st.updatedAt = Date.now();
171
+ if (hits === undefined) {
172
+ st.timeouts++;
173
+ logger.warn('[memory] 召回超时,跳过本轮注入(不阻塞对话)');
174
+ return decision;
175
+ }
176
+ st.lastDurationMs = Date.now() - searchStart;
177
+ // 召回去重:同会话已注入过的记录不再重复注入(模型上下文已持有,省 token)。
178
+ // 纯过滤——剩几条注几条,全量压制(0 条新鲜命中)是正确状态而非未命中。
179
+ const seen = dedupe.seen(payload.agent.id);
180
+ const fresh = hits.filter((h) => !seen.has(h.id));
181
+ const suppressed = hits.length - fresh.length;
182
+ st.suppressedRecalls += suppressed;
183
+ if (suppressed > 0) {
184
+ logger.debug?.(`[memory] 召回去重:压制 ${suppressed} 条已注入记忆(agent=${payload.agent.id},余 ${fresh.length} 条新鲜命中)`);
185
+ }
186
+ if (hits.length > 0) {
187
+ // 全量压制轮也计入命中:相关记忆已在模型上下文里,本质是命中
188
+ st.hitTurns++;
189
+ st.totalHits += fresh.length;
190
+ }
191
+ if (fresh.length === 0)
192
+ return decision;
193
+ const lines = applyRecallBudget(fresh.map((h) => `- [${h.scene_name ? `${h.type}|${h.scene_name}` : h.type}] ${h.content}`), { maxCharsPerMemory: cfg.recall.maxCharsPerMemory, maxTotalRecallChars: cfg.recall.maxTotalRecallChars });
194
+ if (lines.length === 0)
195
+ return decision;
196
+ // 预算截断只丢尾部(前缀保留):实际注入 = fresh 的前 lines.length 条——只标记模型真实看到的
197
+ dedupe.mark(payload.agent.id, fresh.slice(0, lines.length).map((h) => h.id));
198
+ st.lastHits = lines.length;
199
+ const text = [
200
+ '<relevant-memories>',
201
+ '以下是当前对话召回的相关记忆,不代表当前任务进程,仅作为参考:',
202
+ '',
203
+ ...lines,
204
+ '',
205
+ '</relevant-memories>',
206
+ ].join('\n');
207
+ logger.info(`[memory] 召回注入 ${lines.length} 条 L1(mode=${mode},query="${query.slice(0, 30).replace(/\n/g, ' ')}…",agent=${payload.agent.id},消息侧)`);
208
+ const injection = createUserMessage({
209
+ content: [{ type: 'text', text }],
210
+ // plugin 字段是宿主 UI 的署名后缀("上下文注入 · memory")——用展示友好的
211
+ // 子系统名,不用 cordis id(dsh-memory);kind:'plugin' 的标题恒为通用
212
+ // "上下文注入"(专用"跨会话召回"标题仅留给 session-reference 来源)
213
+ source: { kind: 'plugin', plugin: 'memory', form: 'recall' },
214
+ });
215
+ // 入账在成功构造注入消息之后、返回 enter 之前——任何前置抛错路径账目零扰动
216
+ const led = ledgerFor(payload.agent.id);
217
+ recordRecallInjection(led, text.length);
218
+ occupancyStore.save(payload.agent.id, led);
219
+ // 注入消息排在用户新消息之前(原版 prepend 语义:先线索后问题)
220
+ return { kind: 'enter', messages: [injection, ...decision.messages] };
221
+ }
222
+ catch (err) {
223
+ logger.warn(`[memory] 召回注入失败(跳过本轮): ${errDetail(err)}`);
224
+ return decision;
225
+ }
226
+ }, { prepend: true });
227
+ }
228
+ // ── 2. agent 作用域上下文 provider(系统提示稳定区:画像 + 导航 + 门控指南) ──
229
+ // 插件可能在默认 agent 创建之后才加载(组合顺序由依赖决定),
230
+ // 因此除了监听 agent/created,还要给已存在的 agent 补注册。
231
+ /**
232
+ * 稳定区当前组词(纯读,不记账):text() 的取词部分单独成函数,
233
+ * 供旧会话回填估算(RecallHooks.estimateProfileTokens)复用同一口径。
234
+ */
235
+ const composeStableText = (agentId) => {
236
+ const s = live.get();
237
+ const mode = modes.get(agentId);
238
+ // 与 pre-step 同款三级读闸:主闸 → off 档 → 注入开关;空串即物理离场
239
+ if (!s.enabled || mode === 'off' || !modes.resolvedRecall(agentId, s.recall))
240
+ return '';
241
+ // auto 档:两族按类别归组(画像/导航各一个标签,域内 <domain> 分块);纯档:单族原格式
242
+ const body = mode === 'auto'
243
+ ? formatProfileAuto(profileCache.chat, profileCache.work)
244
+ : formatProfileSingle(profileCache[mode]);
245
+ const hasRecallHit = (recallStats.get(agentId)?.lastHits ?? 0) > 0;
246
+ // 指南三条件门控:工具已注册(cfg.tools)&&(稳定内容 ∥ 本轮召回命中)——
247
+ // 空库用户与关闭工具的用户不付这份固定 token(原版 auto-recall 同款语义)
248
+ if (!cfg.tools)
249
+ return body;
250
+ if (!body && !hasRecallHit)
251
+ return '';
252
+ return body ? `${body}\n\n${MEMORY_TOOLS_GUIDE}` : MEMORY_TOOLS_GUIDE;
253
+ };
254
+ async function estimateRecallFromStorage(sessionId) {
255
+ if (storedEstimateCache.has(sessionId))
256
+ return storedEstimateCache.get(sessionId) ?? null;
257
+ let tokens = null;
258
+ try {
259
+ // 可选服务(JSONL 后端注册名);缺失/其它实现 → 回填隐藏
260
+ const persistence = (await ctx.get?.('sessionPersistence'));
261
+ const stored = typeof persistence?.loadStored === 'function' ? await persistence.loadStored(sessionId) : undefined;
262
+ if (stored?.events) {
263
+ tokens = 0;
264
+ for (const ev of stored.events) {
265
+ if (typeof ev.type === 'string' && ev.type.startsWith('compaction'))
266
+ tokens = 0;
267
+ if (ev.type !== 'user/message')
268
+ continue;
269
+ const src = ev.data?.source;
270
+ if (!src || src.kind !== 'plugin' || src.plugin !== 'memory' || src.form !== 'recall')
271
+ continue;
272
+ let chars = 0;
273
+ for (const b of ev.data?.content ?? []) {
274
+ if (b?.type === 'text' && typeof b.text === 'string')
275
+ chars += b.text.length;
276
+ }
277
+ if (chars > 0)
278
+ tokens += estimateInjectedMessageTokens(chars);
279
+ }
280
+ }
281
+ }
282
+ catch {
283
+ tokens = null;
284
+ }
285
+ cacheStoredEstimate(sessionId, tokens);
286
+ return tokens;
287
+ }
288
+ /**
289
+ * 召回份额回填(旧会话):live 会话的 surface(模型可见序号集)∩ 全事件日志
290
+ * 里本插件的 recall 注入,官方同式折算。窗口语义天然正确——被压缩折叠的注入不在
291
+ * surface.nodes 上,自动出局。会话不在 live store(未打开)返回 null。
292
+ */
293
+ const estimateRecallTokens = async (sessionId) => {
294
+ try {
295
+ // cordis 属性访问(ctx.sessions)对未 inject 的服务抛 "without inject";
296
+ // 可选服务一律走 ctx.get() 的宽容路径
297
+ const sessions = ctx.get?.('sessions');
298
+ const session = typeof sessions?.get === 'function' ? sessions.get(sessionId) : undefined;
299
+ if (session) {
300
+ const visible = new Set(session.surface.nodes);
301
+ let total = 0;
302
+ for (const ev of session.events) {
303
+ if (ev.type !== 'user/message' || !visible.has(ev.seq))
304
+ continue;
305
+ const msg = ev.data;
306
+ const src = msg?.source;
307
+ if (!src || src.kind !== 'plugin' || src.plugin !== 'memory' || src.form !== 'recall')
308
+ continue;
309
+ let chars = 0;
310
+ for (const b of msg.content ?? []) {
311
+ if (b?.type === 'text' && typeof b.text === 'string')
312
+ chars += b.text.length;
313
+ }
314
+ if (chars > 0)
315
+ total += estimateInjectedMessageTokens(chars);
316
+ }
317
+ return total;
318
+ }
319
+ // 仅查看的旧会话不在 live store:官方持久化服务读存储前缀兜底(见函数头)
320
+ return estimateRecallFromStorage(sessionId);
321
+ }
322
+ catch {
323
+ return null; // 服务缺失/形状异常:回填隐藏,不扰动主流程
324
+ }
325
+ };
326
+ const registered = new WeakSet();
327
+ // context() 的 disposer 必须挂到插件自身生命周期:agent.ctx 比插件实例活得久,
328
+ // 不主动清理会导致热重载后旧注册泄漏、新实例撞名("already registered")
329
+ const contextDisposers = [];
330
+ const registerForAgent = (agent) => {
331
+ if (registered.has(agent))
332
+ return;
333
+ registered.add(agent);
334
+ try {
335
+ contextDisposers.push(agent.ctx.systemPrompt.context({
336
+ name: 'memory:profile',
337
+ order: 510,
338
+ text: () => {
339
+ const final = composeStableText(agent.id);
340
+ const ledger = ledgerFor(agent.id);
341
+ // 空串即物理离场(停用/OFF/门控全空):份额同边界清零;否则按实际长度入账
342
+ if (final === '')
343
+ clearProfileShare(ledger);
344
+ else
345
+ recordProfileShare(ledger, final.length);
346
+ occupancyStore.save(agent.id, ledger);
347
+ return final;
348
+ },
349
+ }));
350
+ }
351
+ catch (err) {
352
+ logger.warn(`[memory] 召回上下文注册失败: ${err instanceof Error ? err.message : String(err)}`);
353
+ }
354
+ };
355
+ const agents = ctx.get('agents');
356
+ if (agents) {
357
+ for (const agent of agents.list())
358
+ registerForAgent(agent);
359
+ }
360
+ ctx.on('agent/created', (payload) => {
361
+ registerForAgent(payload.agent);
362
+ });
363
+ ctx.effect(() => () => {
364
+ for (const dispose of contextDisposers.splice(0)) {
365
+ try {
366
+ dispose();
367
+ }
368
+ catch {
369
+ /* agent 可能已先一步销毁 */
370
+ }
371
+ }
372
+ });
373
+ return {
374
+ invalidateProfile,
375
+ stats: (id) => recallStats.get(id),
376
+ /** 占用账本只读出口:内存优先,miss 时从流水复生(重启后历史会话);从未注入返回 null。 */
377
+ occupancy: (id) => {
378
+ const led = occupancyByAgent.get(id) ?? occupancyStore.load(id);
379
+ if (led)
380
+ occupancyByAgent.set(id, led);
381
+ return led ?? null;
382
+ },
383
+ estimateProfileTokens: (id) => estimateStableSectionTokens(composeStableText(id).length),
384
+ estimateRecallTokens,
385
+ };
386
+ }
387
+ /** auto 档 <user-persona> 内的域说明:让模型理解分块结构与两域的独立性。 */
388
+ const DOMAIN_HINT = '以下内容按记忆域分块:chat=用户个人画像(User Narrative Profile),work=团队工作准则(Team Operating Doctrine)。' +
389
+ '两域独立蒸馏与更新,请按当前对话语境参考对应域,不要把一域的内容当作另一域的事实。';
390
+ function wrapDomain(family, content) {
391
+ const label = family === 'chat' ? '用户个人画像' : '团队工作准则';
392
+ return `<domain family="${family}" label="${label}">\n${content.trim()}\n</domain>`;
393
+ }
394
+ /** 纯档注入:单族画像 + 场景导航(沿用原有格式)。 */
395
+ function formatProfileSingle(parts) {
396
+ const segments = [];
397
+ if (parts.persona)
398
+ segments.push(`<user-persona>\n${parts.persona}\n</user-persona>`);
399
+ if (parts.nav)
400
+ segments.push(`<scene-navigation>\n${parts.nav}\n</scene-navigation>`);
401
+ return segments.join('\n\n');
402
+ }
403
+ /** auto 档注入:两族按类别归组——画像共用一个 <user-persona>、导航共用一个 <scene-navigation>,域内 <domain> 分块。 */
404
+ function formatProfileAuto(chat, work) {
405
+ const segments = [];
406
+ const personas = [
407
+ ['chat', chat.persona],
408
+ ['work', work.persona],
409
+ ];
410
+ const personaBlocks = personas.filter(([, p]) => p.trim()).map(([f, p]) => wrapDomain(f, p));
411
+ if (personaBlocks.length > 0) {
412
+ segments.push(`<user-persona>\n${DOMAIN_HINT}\n\n${personaBlocks.join('\n\n')}\n</user-persona>`);
413
+ }
414
+ const navs = [
415
+ ['chat', chat.nav],
416
+ ['work', work.nav],
417
+ ];
418
+ const navBlocks = navs.filter(([, n]) => n.trim()).map(([f, n]) => wrapDomain(f, n));
419
+ if (navBlocks.length > 0) {
420
+ segments.push(`<scene-navigation>\n${navBlocks.join('\n\n')}\n</scene-navigation>`);
421
+ }
422
+ // 注意:工具指南由注入侧统一附加一次(auto 档不重复)
423
+ return segments.join('\n\n');
424
+ }
425
+ async function loadProfileParts(stores, cfg, family) {
426
+ const persona = cfg.recall.includePersona ? ((await stores.persona[family].read()) ?? '') : '';
427
+ const nav = cfg.recall.includeSceneNav ? ((await stores.scenes[family].navigation()) ?? '').trim() : '';
428
+ return { persona, nav };
429
+ }