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
package/dist/stats.js ADDED
@@ -0,0 +1,929 @@
1
+ /**
2
+ * 状态面板数据通道:Host 侧注册 /rpc 通道的 dsh-memory/* 端点,
3
+ * Client 设置页通过 ctx.connection.rpc.call('/rpc', 'dsh-memory/xxx') 拉取。
4
+ *
5
+ * connection 是可选服务且可能晚于本插件就绪:先探测一次,未就绪则监听
6
+ * internal/service(事件携带 (name, impl),impl=undefined 即下线),服务
7
+ * 上线、下线、替换实例三种迁移都会正确释放/重挂 RPC 注册。
8
+ *
9
+ * 机密纪律:directApiKey / embedRemoteApiKey 永不出现在任何 RPC 响应与日志
10
+ * (settings-get/set 走 sanitizeSettings 脱敏)。
11
+ */
12
+ import { createRequire } from 'node:module';
13
+ import { closeSync, openSync, readSync, statSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ import { EFFORT_CHOICES, resolveDataDir } from './config.js';
16
+ import { effectiveCfg } from './pipeline/runner.js';
17
+ import { emptyRecallStats } from './hooks/recall.js';
18
+ import { buildRouteChain, decideSendableEffort, LAYER_DEFAULT_BUDGETS, layerChainOrNull, resolveModelContextWindow, resolveModelEfforts, resolveModelRoute } from './llm.js';
19
+ import { projectDistillChain, validateDistillChain } from './settings.js';
20
+ import { errDetail } from './util/filelog.js';
21
+ import { snapshotTokenCost } from './token-cost.js';
22
+ const require = createRequire(import.meta.url);
23
+ export const PLUGIN_VERSION = require('../package.json').version;
24
+ /**
25
+ * 组装端点 deps。抽成函数是为了让"哪个控制器落入哪个字段"成为可测接缝:
26
+ * 反刍端点读 deps.ruminate,若此处漏注入,端点会静默恒返 {supported:false}(面板整块不渲染)。
27
+ * @param controller - 反刍控制器;由 index.ts 在存储可用时装配,降级时为 undefined。
28
+ */
29
+ export function buildEndpointDeps(base, sources, controller) {
30
+ const injected = {
31
+ status: sources.status,
32
+ live: sources.live,
33
+ modes: sources.modes,
34
+ dataDir: sources.dataDir ?? resolveDataDir(base.cfg),
35
+ rebuild: sources.rebuild,
36
+ embedManager: sources.embedManager,
37
+ sessionInfo: sources.sessionInfo,
38
+ ruminate: controller,
39
+ };
40
+ return { ...base, ...injected };
41
+ }
42
+ export function registerMemoryRpc(ctx, cfg, stores, logger, status, live, modes, dataDir, rebuild, embedManager, sessionInfo,
43
+ /** 反刍控制器(存储降级时为 undefined):经 buildEndpointDeps 落入 deps.ruminate。 */
44
+ ruminate) {
45
+ /** 当前是否持有一段有效注册(dispose 完成后清空,允许服务重上线时重注册)。 */
46
+ let holding = false;
47
+ /** 当前 handle 绑定的 connection 实例(internal/service 第二参;用于识别实例替换)。 */
48
+ let registeredImpl;
49
+ const tryRegister = () => {
50
+ if (holding)
51
+ return;
52
+ const connection = ctx.get('connection');
53
+ if (!connection)
54
+ return;
55
+ holding = true;
56
+ let active = true;
57
+ // handle() 同步注册并返回异步 disposer(() => Promise<void>)。
58
+ const dispose = connection.rpc.handle('/rpc', async (endpoint, payload) => {
59
+ try {
60
+ const value = await handleEndpoint(endpoint, payload, buildEndpointDeps({ ctx, cfg, stores, logger }, { status, live, modes, dataDir, rebuild, embedManager, sessionInfo }, ruminate));
61
+ return { ok: true, value };
62
+ }
63
+ catch (err) {
64
+ return {
65
+ ok: false,
66
+ error: { code: 'internal', message: err instanceof Error ? err.message : String(err), details: {} },
67
+ };
68
+ }
69
+ }, { authority: 'loopback' });
70
+ registeredImpl = connection;
71
+ if (!active) {
72
+ void dispose();
73
+ return;
74
+ }
75
+ logger.debug?.('[memory] 状态 RPC 已注册(/rpc → dsh-memory/*)');
76
+ disposers.push(() => {
77
+ active = false;
78
+ holding = false;
79
+ void dispose();
80
+ });
81
+ };
82
+ /** 释放全部持有注册(handle 随旧服务实例失效,holding 复位以允许重挂)。 */
83
+ const release = () => {
84
+ for (const dispose of disposers.splice(0))
85
+ dispose();
86
+ };
87
+ const disposers = [];
88
+ ctx.effect(() => {
89
+ tryRegister();
90
+ const off = ctx.on('internal/service', (name, impl) => {
91
+ if (name !== 'connection')
92
+ return;
93
+ if (!impl) {
94
+ // 服务下线:旧 handle 已随旧服务实例失效——主动释放并复位,
95
+ // 服务恢复时本事件再触发即可重挂(否则 holding 恒真 → RPC 永久失联)
96
+ release();
97
+ registeredImpl = undefined;
98
+ logger.debug?.('[memory] connection 服务下线,RPC 注册已释放(待恢复重挂)');
99
+ return;
100
+ }
101
+ if (impl !== registeredImpl) {
102
+ // 实例替换:旧 handle 失效,换新实例重挂
103
+ release();
104
+ registeredImpl = undefined;
105
+ }
106
+ tryRegister();
107
+ });
108
+ return () => {
109
+ off();
110
+ release();
111
+ };
112
+ });
113
+ }
114
+ async function buildStats(cfg, stores, status) {
115
+ // 两族 checkpoint 聚合(浏览器混合视图:总量求和、时间取最新)
116
+ const chat = stores.state.forFamily('chat');
117
+ const work = stores.state.forFamily('work');
118
+ const [chatScenes, workScenes, chatPersona, workPersona] = await Promise.all([
119
+ stores.scenes.chat.list(),
120
+ stores.scenes.work.list(),
121
+ stores.persona.chat.read(),
122
+ stores.persona.work.read(),
123
+ ]);
124
+ const degraded = status?.degraded() ?? false;
125
+ const personaChars = (chatPersona?.length ?? 0) + (workPersona?.length ?? 0);
126
+ const max = (a, b) => Math.max(a, b);
127
+ const iso = (t) => (t ? new Date(t).toISOString() : null);
128
+ return {
129
+ ok: true,
130
+ dataDir: resolveDataDir(cfg),
131
+ family: cfg.family,
132
+ version: PLUGIN_VERSION,
133
+ l0Today: await stores.l0.countToday(),
134
+ l1Count: stores.l1.size,
135
+ l1TotalExtracted: chat.totalExtracted + work.totalExtracted,
136
+ sceneCount: chatScenes.length + workScenes.length,
137
+ personaChars,
138
+ hasPersona: chat.hasPersona || work.hasPersona,
139
+ lastExtractAt: iso(max(chat.lastExtractAt, work.lastExtractAt)),
140
+ lastL2At: iso(max(chat.lastL2At, work.lastL2At)),
141
+ lastL3At: iso(max(chat.lastL3At, work.lastL3At)),
142
+ memoriesSinceL2: chat.newMemoriesSinceL2 + work.newMemoriesSinceL2,
143
+ memoriesSinceL3: chat.memoriesSinceL3 + work.memoriesSinceL3,
144
+ pendingExtract: status?.pending() ?? 0,
145
+ message: degraded ? 'degraded:存储不可用,记忆功能已停用' : 'running',
146
+ thresholds: { l2MinNewMemories: cfg.l2.minNewMemories, l3Interval: cfg.l3.interval },
147
+ };
148
+ }
149
+ /** RPC 字符串入参上限校验:防 loopback 面畸形超长载荷
150
+ * (超长 sessionId 持久化进 session-modes.json / 超长 query 触发 jieba 全量分词 CPU 峰值)。 */
151
+ function expectSessionId(v) {
152
+ if (typeof v !== 'string' || !v)
153
+ throw new Error('sessionId 缺失');
154
+ if (v.length > 512)
155
+ throw new Error('sessionId 过长(≤512 字符)');
156
+ return v;
157
+ }
158
+ /** 机密脱敏:两个 API key(直连蒸馏/远程嵌入)明文永不出宿主。 */
159
+ function sanitizeSettings(s) {
160
+ if (!s.directApiKey && !s.embedRemoteApiKey)
161
+ return s;
162
+ return { ...s, directApiKey: '', embedRemoteApiKey: '' };
163
+ }
164
+ /** 端点分发表(导出供测试直调:可精确注入 rebuild/ruminate 等可选控制器,验证 deps 接线)。 */
165
+ export async function handleEndpoint(endpoint, payload, deps) {
166
+ const { cfg, stores, status, live, modes, dataDir, rebuild, ruminate, embedManager, sessionInfo } = deps;
167
+ switch (endpoint) {
168
+ case 'dsh-memory/stats':
169
+ return buildStats(cfg, stores, status);
170
+ case 'dsh-memory/token-cost': {
171
+ const p = (payload ?? {});
172
+ const granularity = p.granularity === 'week' || p.granularity === 'month' ? p.granularity : 'day';
173
+ // rangeDays 须为正整数且不超过明细保留期(tokenCost.retentionDays,0=永久保留则放行 1~3650),否则回退默认窗口
174
+ const retention = cfg.tokenCost.retentionDays;
175
+ const upper = retention > 0 ? retention : 3650;
176
+ const rawDays = p.rangeDays;
177
+ const rangeDays = typeof rawDays === 'number' && Number.isInteger(rawDays) && rawDays > 0 && rawDays <= upper ? rawDays : 0;
178
+ return snapshotTokenCost(granularity, rangeDays);
179
+ }
180
+ case 'dsh-memory/session-mode-get': {
181
+ if (!modes)
182
+ throw new Error('档位存储未初始化');
183
+ const p = (payload ?? {});
184
+ const sessionId = expectSessionId(p.sessionId);
185
+ // 注入解析权威在 host:recall 是原始覆盖(null=跟随全局),recallResolved 是生效值
186
+ const s = live?.get();
187
+ const globalRecall = s?.recall ?? true;
188
+ const v = {
189
+ sessionId,
190
+ mode: modes.get(sessionId),
191
+ defaultMode: modes.default,
192
+ recall: modes.getRecall(sessionId) ?? null,
193
+ recallResolved: modes.resolvedRecall(sessionId, globalRecall),
194
+ };
195
+ return v;
196
+ }
197
+ case 'dsh-memory/session-mode-set': {
198
+ if (!modes)
199
+ throw new Error('档位存储未初始化');
200
+ const p = (payload ?? {});
201
+ const sessionId = expectSessionId(p.sessionId);
202
+ const allowed = ['auto', 'chat', 'work', 'off'];
203
+ if (typeof p.mode !== 'string' || !allowed.includes(p.mode)) {
204
+ throw new Error(`非法档位: ${String(p.mode)}(允许 ${allowed.join('/')})`);
205
+ }
206
+ // 注入覆盖可选同车:布尔 = 设置覆盖;显式 null = 清除覆盖(跟随全局);
207
+ // 缺省(undefined)= 仅切档、覆盖保持不动(旧 client 永不传 recall,行为不变)。
208
+ // 校验前置:非法 recall 在任何写穿发生前拒绝(不做部分提交)
209
+ if (p.recall !== undefined && typeof p.recall !== 'boolean' && p.recall !== null) {
210
+ throw new Error(`非法注入覆盖: ${String(p.recall)}(允许 true/false/null)`);
211
+ }
212
+ modes.set(sessionId, p.mode);
213
+ if (typeof p.recall === 'boolean') {
214
+ modes.setRecall(sessionId, p.recall);
215
+ }
216
+ else if (p.recall === null) {
217
+ modes.setRecall(sessionId, undefined);
218
+ }
219
+ deps.logger.info(`[memory] 会话档位设置 session=${sessionId} mode=${p.mode} recall=${JSON.stringify(modes.getRecall(sessionId) ?? null)}`);
220
+ const s = live?.get();
221
+ const v = {
222
+ sessionId,
223
+ mode: p.mode,
224
+ recall: modes.getRecall(sessionId) ?? null,
225
+ recallResolved: modes.resolvedRecall(sessionId, s?.recall ?? true),
226
+ };
227
+ return v;
228
+ }
229
+ // ── 会话级统计(悬浮卡信息区;热路径端点,见 SessionInfoSource 的零 I/O 硬规则) ──
230
+ case 'dsh-memory/session-stats': {
231
+ if (!sessionInfo)
232
+ return { supported: false };
233
+ const p = (payload ?? {});
234
+ const sessionId = expectSessionId(p.sessionId);
235
+ const mode = modes ? modes.get(sessionId) : 'auto';
236
+ const caps = sessionInfo.capabilities();
237
+ const l0Count = await sessionInfo.l0Count(sessionId);
238
+ const s = live?.get();
239
+ // 注入生效四因子短路序:部署上限 → 全局开关 → 会话覆盖 → 档位;
240
+ // disabled 时 reason 带第一个为假因子(悬浮卡停用文案的数据源)
241
+ const globalRecall = s?.recall ?? true;
242
+ const sessionRecall = modes ? modes.resolvedRecall(sessionId, globalRecall) : true;
243
+ let recallReason;
244
+ if (!cfg.recall.enabled)
245
+ recallReason = 'deploy';
246
+ else if (!globalRecall)
247
+ recallReason = 'global';
248
+ else if (!sessionRecall)
249
+ recallReason = 'session';
250
+ else if (mode === 'off')
251
+ recallReason = 'mode';
252
+ const recallOn = recallReason === undefined;
253
+ const view = sessionInfo.runnerView(sessionId, mode);
254
+ // lastDistillAt 统一转 ISO(与 global.lastExtractAt 口径一致,client 直接 fmtAgo)
255
+ const distillView = { ...view, lastDistillAt: view.lastDistillAt ? new Date(view.lastDistillAt).toISOString() : null };
256
+ const chat = stores.state.forFamily('chat');
257
+ const work = stores.state.forFamily('work');
258
+ const lastAt = Math.max(chat.lastExtractAt, work.lastExtractAt);
259
+ // 主对话模型的官方声明窗口(占用指示器分母;advisory 查询读本地快照且有缓存,
260
+ // 轮询热路径下稳态为 Map 命中。race 封顶防第三方适配器 resolveModelInfo 挂起拖死轮询)
261
+ let contextWindowTokens = null;
262
+ try {
263
+ const sel = deps.ctx.get('agentDefaultModel')?.currentSelection?.();
264
+ if (sel?.provider && sel?.model) {
265
+ contextWindowTokens = await Promise.race([
266
+ resolveModelContextWindow(deps.ctx, sel.provider, sel.model),
267
+ new Promise((resolve) => setTimeout(() => resolve(null), 3_000)),
268
+ ]);
269
+ }
270
+ }
271
+ catch {
272
+ /* 可选服务缺失/解析失败 = 分母未知,UI 降级 */
273
+ }
274
+ const v = {
275
+ supported: true,
276
+ sessionId,
277
+ mode,
278
+ defaultMode: modes?.default ?? cfg.family,
279
+ recall: {
280
+ enabled: recallOn,
281
+ ...(recallReason ? { reason: recallReason } : {}),
282
+ ...(sessionInfo.recallStats(sessionId) ?? emptyRecallStats()),
283
+ },
284
+ memoryOccupancy: sessionInfo.memoryOccupancy(sessionId),
285
+ occupancyBackfill: sessionInfo.profileEstimate
286
+ ? {
287
+ recallTokens: sessionInfo.recallEstimate ? await sessionInfo.recallEstimate(sessionId) : null,
288
+ profileTokens: sessionInfo.profileEstimate(sessionId),
289
+ }
290
+ : null,
291
+ contextWindowTokens,
292
+ distill: distillView,
293
+ l0Count,
294
+ retrieval: caps.vectorSearch ? (caps.ftsSearch ? 'hybrid' : 'vector') : caps.ftsSearch ? 'keyword' : 'none',
295
+ global: {
296
+ degraded: status?.degraded() ?? false,
297
+ pendingTotal: status?.pending() ?? 0,
298
+ lastExtractAt: lastAt ? new Date(lastAt).toISOString() : null,
299
+ },
300
+ };
301
+ return v;
302
+ }
303
+ case 'dsh-memory/settings-get': {
304
+ const s = live?.get();
305
+ const budgets = s?.distillBudgets ?? { extract: 0, dedup: 0, l2: 0, l3: 0, graph: 0 };
306
+ // 蒸馏思考档位:current 是运行时值('' = 自动);effective 是能力探询后实际发送值
307
+ // ('' = 不传,跟随模型默认);options 是当前生效模型声明的档位表(空声明 → 只显示
308
+ // high,用户规则:无声明默认 high),fallback 是静态部署值。
309
+ let effortEffective = s?.reasoningEffort || cfg.llm.reasoningEffort;
310
+ let effortOptions = ['high'];
311
+ let effortRoute = null;
312
+ try {
313
+ const ecfg = effectiveCfg(cfg, live);
314
+ effortRoute = await resolveModelRoute(deps.ctx, ecfg);
315
+ const cap = await resolveModelEfforts(deps.ctx, effortRoute.provider, effortRoute.model);
316
+ if (cap) {
317
+ effortEffective = decideSendableEffort(cap, ecfg.llm.reasoningEffort).effort;
318
+ if (cap.efforts.length > 0)
319
+ effortOptions = cap.efforts;
320
+ }
321
+ }
322
+ catch {
323
+ /* 路由解析/探询失败保持占位(effective 用运行时||静态值) */
324
+ }
325
+ const resp = {
326
+ supported: live?.supported ?? false,
327
+ settings: sanitizeSettings(s ?? {
328
+ enabled: true, capture: true, distill: true, recall: true,
329
+ reasoningEffort: '', distillProvider: '', distillModel: '', distillChain: [],
330
+ distillBudgets: { extract: 0, dedup: 0, l2: 0, l3: 0, graph: 0 }, distillMaxInputChars: 0,
331
+ distillLayerChains: { l1: [], l2: [], l3: [] },
332
+ distillMode: '', directBaseURL: '', directApiKey: '',
333
+ embedRemoteBaseURL: '', embedRemoteApiKey: '', embedRemoteModel: '', embedRemoteDimensions: 0,
334
+ memoryMutate: false,
335
+ }),
336
+ // 静态部署上限(cordis.patch.yml):运行时开关与它取 AND
337
+ ceilings: { capture: cfg.capture.enabled, distill: cfg.extract.enabled, recall: cfg.recall.enabled },
338
+ effort: {
339
+ current: s?.reasoningEffort ?? '',
340
+ // 静态 schema 与 settings-set 写入门都以 EFFORT_CHOICES 白名单校验,这里断言回窄类型
341
+ effective: effortEffective,
342
+ fallback: cfg.llm.reasoningEffort,
343
+ options: effortOptions,
344
+ ...(effortRoute ? { route: effortRoute } : {}),
345
+ },
346
+ // 分层输出预算:current 是运行时覆盖(0 = 跟随默认),defaults 是内置默认(UI 占位/提示用)
347
+ budgets: {
348
+ current: budgets,
349
+ defaults: { ...LAYER_DEFAULT_BUDGETS },
350
+ effective: {
351
+ extract: budgets.extract > 0 ? budgets.extract : LAYER_DEFAULT_BUDGETS.extract,
352
+ dedup: budgets.dedup > 0 ? budgets.dedup : LAYER_DEFAULT_BUDGETS.dedup,
353
+ l2: budgets.l2 > 0 ? budgets.l2 : LAYER_DEFAULT_BUDGETS.l2,
354
+ l3: budgets.l3 > 0 ? budgets.l3 : LAYER_DEFAULT_BUDGETS.l3,
355
+ graph: budgets.graph > 0 ? budgets.graph : LAYER_DEFAULT_BUDGETS.graph,
356
+ },
357
+ },
358
+ // 输入预算(字符):current 是运行时覆盖(0 = 跟随配置),fallback 是静态配置值
359
+ inputBudget: {
360
+ current: s?.distillMaxInputChars ?? 0,
361
+ fallback: cfg.llm.maxInputChars,
362
+ effective: s && s.distillMaxInputChars > 0 ? s.distillMaxInputChars : cfg.llm.maxInputChars,
363
+ },
364
+ };
365
+ return resp;
366
+ }
367
+ case 'dsh-memory/settings-set': {
368
+ if (!live)
369
+ throw new Error('开关通道未初始化');
370
+ const patch = (payload ?? {});
371
+ const clean = {};
372
+ // 布尔开关组:memoryMutate(高权限写删门)与主开关同列
373
+ for (const key of ['enabled', 'capture', 'distill', 'recall', 'memoryMutate']) {
374
+ if (typeof patch[key] === 'boolean')
375
+ clean[key] = patch[key];
376
+ }
377
+ // 运行时统一路由链:结构校验后整体写入(空数组 = 回到跟随部署配置)
378
+ if (patch.distillChain !== undefined) {
379
+ const err = validateDistillChain(patch.distillChain);
380
+ if (err)
381
+ throw new Error(err);
382
+ clean.distillChain = patch.distillChain;
383
+ }
384
+ // 运行时按层路由链:逐层校验(头行必须显式——层覆盖不支持跟随默认模型);
385
+ // patch 语义只带要改的层,写入侧与存量层合并后落盘(空数组 = 该层回到跟随)
386
+ if (patch.distillLayerChains !== undefined) {
387
+ const rawLC = (patch.distillLayerChains ?? {});
388
+ const prev = (live.get().distillLayerChains ?? {});
389
+ const merged = {
390
+ l1: prev.l1 ?? [],
391
+ l2: prev.l2 ?? [],
392
+ l3: prev.l3 ?? [],
393
+ };
394
+ for (const key of ['l1', 'l2', 'l3']) {
395
+ if (rawLC[key] === undefined)
396
+ continue;
397
+ const err = validateDistillChain(rawLC[key], { requireExplicitHead: true });
398
+ if (err)
399
+ throw new Error(`层路由 ${key}:${err}`);
400
+ merged[key] = rawLC[key];
401
+ }
402
+ clean.distillLayerChains = merged;
403
+ }
404
+ if (patch.reasoningEffort !== undefined) {
405
+ const v = String(patch.reasoningEffort);
406
+ // 白名单与 schema/settings 同源(config.ts EFFORT_CHOICES)
407
+ if (!EFFORT_CHOICES.includes(v)) {
408
+ throw new Error(`非法思考档位: ${v}(允许 '' 或 ${EFFORT_CHOICES.filter((x) => x !== '').join('/')})`);
409
+ }
410
+ clean.reasoningEffort = v;
411
+ }
412
+ // 蒸馏模型运行时覆盖:供应商/模型 id 原样接受(不在此校验存在性——
413
+ // 供应商可被用户随后删除,解析侧按存在性回退并提示)
414
+ for (const key of ['distillProvider', 'distillModel']) {
415
+ if (patch[key] !== undefined) {
416
+ const v = String(patch[key]);
417
+ if (v.length > 200)
418
+ throw new Error(`${key} 过长(≤200 字符)`);
419
+ clean[key] = v;
420
+ }
421
+ }
422
+ // 分层输出预算:五键一起校验,非负整数 ≤ 100 万;0 = 跟随内置默认
423
+ // (键表与 DistillBudgetLayer 同步——漏白名单键 = 静默丢预算,C 节坑①)
424
+ if (patch.distillBudgets !== undefined) {
425
+ const raw = (patch.distillBudgets ?? {});
426
+ const budgets = {};
427
+ for (const key of ['extract', 'dedup', 'l2', 'l3', 'graph']) {
428
+ const n = Number(raw[key] ?? 0);
429
+ if (!Number.isInteger(n) || n < 0 || n > 1_000_000) {
430
+ throw new Error(`distillBudgets.${key} 须为 0~1000000 的整数(0 = 跟随默认)`);
431
+ }
432
+ budgets[key] = n;
433
+ }
434
+ clean.distillBudgets = budgets;
435
+ }
436
+ // 输入预算(字符):0 = 跟随静态配置;正值须落在静态 schema 同款范围(1000~100 万)
437
+ if (patch.distillMaxInputChars !== undefined) {
438
+ const n = Number(patch.distillMaxInputChars);
439
+ if (!Number.isInteger(n) || n < 0 || n > 1_000_000 || (n > 0 && n < 1000)) {
440
+ throw new Error('distillMaxInputChars 须为 0 或 1000~1000000 的整数(0 = 跟随配置)');
441
+ }
442
+ clean.distillMaxInputChars = n;
443
+ }
444
+ // 蒸馏通道运行时覆盖(direct 解耦):mode 白名单;endpoint 可回显;apiKey 属机密——
445
+ // 写入后存 settings,但不回读到 UI、不落本条日志(下方日志先剔除后记录)
446
+ if (patch.distillMode !== undefined) {
447
+ const m = String(patch.distillMode);
448
+ if (m !== '' && m !== 'host' && m !== 'direct') {
449
+ throw new Error("distillMode 须为 '' | 'host' | 'direct'('' = 跟随部署配置)");
450
+ }
451
+ clean.distillMode = m;
452
+ }
453
+ if (patch.directBaseURL !== undefined) {
454
+ const base = String(patch.directBaseURL);
455
+ if (base.length > 2000)
456
+ throw new Error('directBaseURL 过长(≤2000 字符)');
457
+ clean.directBaseURL = base;
458
+ }
459
+ if (patch.directApiKey !== undefined) {
460
+ const key = String(patch.directApiKey);
461
+ if (key.length > 2000)
462
+ throw new Error('directApiKey 过长(≤2000 字符)');
463
+ clean.directApiKey = key;
464
+ }
465
+ // 远程嵌入连接运行时覆盖(设置 UI 可编辑):baseURL/model 回显;apiKey 属机密→脱敏;
466
+ // dimensions 0 = 未配置,须落在 schema 同款范围(0~8192)
467
+ if (patch.embedRemoteBaseURL !== undefined) {
468
+ const base = String(patch.embedRemoteBaseURL);
469
+ if (base.length > 2000)
470
+ throw new Error('embedRemoteBaseURL 过长(≤2000 字符)');
471
+ clean.embedRemoteBaseURL = base;
472
+ }
473
+ if (patch.embedRemoteModel !== undefined) {
474
+ const m = String(patch.embedRemoteModel);
475
+ if (m.length > 200)
476
+ throw new Error('embedRemoteModel 过长(≤200 字符)');
477
+ clean.embedRemoteModel = m;
478
+ }
479
+ if (patch.embedRemoteDimensions !== undefined) {
480
+ const dim = Number(patch.embedRemoteDimensions);
481
+ if (!Number.isInteger(dim) || dim < 0 || dim > 8192) {
482
+ throw new Error('embedRemoteDimensions 须为 0~8192 的整数(0 = 未配置)');
483
+ }
484
+ clean.embedRemoteDimensions = dim;
485
+ }
486
+ if (patch.embedRemoteApiKey !== undefined) {
487
+ const key = String(patch.embedRemoteApiKey);
488
+ if (key.length > 2000)
489
+ throw new Error('embedRemoteApiKey 过长(≤2000 字符)');
490
+ clean.embedRemoteApiKey = key;
491
+ }
492
+ if (Object.keys(clean).length === 0)
493
+ throw new Error('开关更新载荷为空');
494
+ // 日志脱敏:直连/远程 API key 永不出现在日志
495
+ const logSafe = { ...clean };
496
+ delete logSafe.directApiKey;
497
+ delete logSafe.embedRemoteApiKey;
498
+ await live.update(clean);
499
+ deps.logger.info(`[memory] 设置更新:${JSON.stringify(logSafe)}`);
500
+ const v = { ok: true, settings: sanitizeSettings(live.get()) };
501
+ return v;
502
+ }
503
+ case 'dsh-memory/list-records': {
504
+ const p = (payload ?? {});
505
+ if (p.query !== undefined && p.query.length > 4096)
506
+ throw new Error('query 过长(≤4096 字符)');
507
+ const limit = Math.min(Math.max(Number(p.limit) || 50, 1), 200);
508
+ const offset = Math.min(Math.max(Number(p.offset) || 0, 0), 1_000_000);
509
+ // 关键词路径:复用检索唯一缝(与召回同源),取回后做场景/Hall 过滤 + 手工分页。
510
+ // 检索侧单次上限 200:分页窗口触达上限时显式标记 truncated(结果可能不完整)。
511
+ if (p.query && p.query.trim()) {
512
+ const SEARCH_CAP = 200;
513
+ const wanted = offset + limit + 1;
514
+ const hits = await stores.l1.search(p.query, Math.min(wanted, SEARCH_CAP), { type: p.type || undefined });
515
+ let filtered = p.scene ? hits.filter((h) => h.scene_name === p.scene) : hits;
516
+ // Hall 过滤(检索命中不含 metadata):按 id 批量取回元数据后过滤
517
+ let metaById = null;
518
+ if (p.hall && filtered.length > 0) {
519
+ const meta = new Map();
520
+ for (const r of stores.l1.getByIds(filtered.map((h) => h.id))) {
521
+ if (r.metadata)
522
+ meta.set(r.id, r.metadata);
523
+ }
524
+ metaById = meta;
525
+ filtered = filtered.filter((h) => (meta.get(h.id)?.hall) === p.hall);
526
+ }
527
+ const resp = {
528
+ items: filtered.slice(offset, offset + limit).map((h) => hitToUiRecord({ ...h, metadata: metaById?.get(h.id) })),
529
+ hasMore: filtered.length > offset + limit,
530
+ total: null,
531
+ truncated: wanted > SEARCH_CAP,
532
+ scenes: offset === 0 ? stores.l1.distinctScenes() : undefined,
533
+ };
534
+ return resp;
535
+ }
536
+ const { items, total } = stores.l1.list({ type: p.type || undefined, scene: p.scene || undefined, hall: p.hall || undefined, limit, offset });
537
+ const resp = {
538
+ items: items.map(hitToUiRecord),
539
+ hasMore: offset + items.length < total,
540
+ total,
541
+ truncated: false,
542
+ scenes: offset === 0 ? stores.l1.distinctScenes() : undefined,
543
+ };
544
+ return resp;
545
+ }
546
+ case 'dsh-memory/records-delete': {
547
+ // 面板高权限删除指定记忆;写入删权限门(memoryMutate)防御
548
+ if (!live?.get().memoryMutate) {
549
+ throw new Error('记忆写删未开放:请在记忆库面板开启高权限模式');
550
+ }
551
+ const p = (payload ?? {});
552
+ const ids = (Array.isArray(p.ids) ? p.ids : []).filter((x) => typeof x === 'string').slice(0, 200);
553
+ if (ids.length === 0)
554
+ throw new Error('ids 缺失');
555
+ await stores.l1.deleteBatch(ids);
556
+ deps.logger.info(`[memory] 高权限删除记忆 ${ids.length} 条(${ids.join(',')})`);
557
+ return { deleted: ids.length };
558
+ }
559
+ // ── 知识图谱(面板图谱视图;graph 未装配时返空不报错) ──
560
+ case 'dsh-memory/graph-search': {
561
+ const p = (payload ?? {});
562
+ const query = typeof p.query === 'string' ? p.query : '';
563
+ if (query.length > 4096)
564
+ throw new Error('query 过长(≤4096 字符)');
565
+ const limit = Math.min(Math.max(Math.floor(Number(p.limit)) || 8, 1), 20);
566
+ const graph = stores.graph;
567
+ if (!graph)
568
+ return { items: [] };
569
+ const hits = graph.searchNodes(query, limit);
570
+ return {
571
+ items: hits.map((h) => ({
572
+ node: h.node,
573
+ score: Math.round(h.score * 100) / 100,
574
+ matchedFields: h.matchedFields,
575
+ matchReason: h.matchReason,
576
+ })),
577
+ };
578
+ }
579
+ case 'dsh-memory/graph-node-get': {
580
+ const p = (payload ?? {});
581
+ const id = typeof p.id === 'string' ? p.id : '';
582
+ if (!id || id.length > 200)
583
+ throw new Error('id 缺失或过长(≤200 字符)');
584
+ const graph = stores.graph;
585
+ if (!graph)
586
+ return { node: null, edges: [] };
587
+ // 悬挂 id(已归档/不存在)不解析:node=null,调用方展示"节点不存在"
588
+ const node = graph.getNode(id);
589
+ if (!node)
590
+ return { node: null, edges: [] };
591
+ return { node, edges: graph.edgesOf(id) };
592
+ }
593
+ case 'dsh-memory/scenes': {
594
+ // 两族拼接展示(浏览器保持混合视图;路径冲突时后写入的族覆盖显示名,读取仍各自独立)
595
+ const items = [];
596
+ for (const family of ['chat', 'work']) {
597
+ const summaries = await stores.scenes[family].list();
598
+ for (const s of summaries) {
599
+ items.push({ path: s.path, family, summary: s.summary, updated: s.updated, heat: s.heat, content: (await stores.scenes[family].read(s.path)) ?? '' });
600
+ }
601
+ }
602
+ items.sort((a, b) => (a.updated < b.updated ? 1 : -1));
603
+ return { items };
604
+ }
605
+ case 'dsh-memory/persona': {
606
+ const [chat, work] = await Promise.all([stores.persona.chat.read(), stores.persona.work.read()]);
607
+ const parts = [];
608
+ if (chat)
609
+ parts.push(`<!-- family: chat -->\n${chat}`);
610
+ if (work)
611
+ parts.push(`<!-- family: work -->\n${work}`);
612
+ return { content: parts.join('\n\n---\n\n') };
613
+ }
614
+ case 'dsh-memory/log-tail': {
615
+ const p = (payload ?? {});
616
+ return { lines: readLogTail(join(dataDir, 'memory.log'), Math.min(Math.max(Number(p.lines) || 200, 1), 1000)) };
617
+ }
618
+ case 'dsh-memory/rebuild-status': {
619
+ if (!rebuild) {
620
+ const v = { supported: false, running: false, phase: 'idle' };
621
+ return v;
622
+ }
623
+ return rebuild.getStatus();
624
+ }
625
+ case 'dsh-memory/rebuild-start': {
626
+ if (!rebuild)
627
+ throw new Error('重建控制器未初始化(存储不可用)');
628
+ if (status?.degraded())
629
+ throw new Error('存储处于降级状态,无法重建');
630
+ const s = live?.get();
631
+ if (s && (!s.enabled || !s.distill))
632
+ throw new Error('蒸馏开关已关闭,请先开启蒸馏再重建');
633
+ if (!cfg.extract.enabled)
634
+ throw new Error('部署配置已停用蒸馏(extract.enabled=false),无法重建');
635
+ const result = rebuild.start();
636
+ deps.logger.info('[memory] 收到重建指令(设置页按钮)');
637
+ return result;
638
+ }
639
+ case 'dsh-memory/rebuild-cancel': {
640
+ if (!rebuild)
641
+ throw new Error('重建控制器未初始化');
642
+ return rebuild.requestCancel();
643
+ }
644
+ // ── 反刍(记忆消化:按需消化未蒸馏缓冲) ──
645
+ case 'dsh-memory/ruminate-status': {
646
+ if (!ruminate) {
647
+ const v = { supported: false, running: false, phase: 'idle' };
648
+ return v;
649
+ }
650
+ return ruminate.getStatus();
651
+ }
652
+ case 'dsh-memory/ruminate-start': {
653
+ if (!ruminate)
654
+ throw new Error('反刍控制器未初始化(存储不可用)');
655
+ if (status?.degraded())
656
+ throw new Error('存储处于降级状态,无法反刍');
657
+ const s = live?.get();
658
+ if (s && (!s.enabled || !s.distill))
659
+ throw new Error('蒸馏开关已关闭,请先开启蒸馏再反刍');
660
+ if (!cfg.extract.enabled)
661
+ throw new Error('部署配置已停用蒸馏(extract.enabled=false),无法反刍');
662
+ const result = await ruminate.start();
663
+ deps.logger.info('[memory] 收到反刍指令(设置页按钮)');
664
+ return result;
665
+ }
666
+ case 'dsh-memory/ruminate-cancel': {
667
+ if (!ruminate)
668
+ throw new Error('反刍控制器未初始化');
669
+ return ruminate.requestCancel();
670
+ }
671
+ // ── 蒸馏模型选择器(用户已配置的供应商路由) ──
672
+ case 'dsh-memory/llm-providers': {
673
+ // 供应商目录(已注册适配器的活动路由)+ 默认选择 + 当前覆盖与实际生效路由
674
+ // ——蒸馏路由链编辑器的数据源(供应商下拉/默认模型展示/链状态 chain 块)
675
+ let providers = [];
676
+ try {
677
+ providers = deps.ctx.llm.listProviders();
678
+ }
679
+ catch (err) {
680
+ deps.logger.warn(`[memory] 供应商列表读取失败: ${errDetail(err)}`);
681
+ }
682
+ let def = null;
683
+ try {
684
+ const sel = deps.ctx.get('agentDefaultModel')?.currentSelection?.();
685
+ if (sel?.provider && sel?.model)
686
+ def = { provider: sel.provider, model: sel.model };
687
+ }
688
+ catch {
689
+ /* 可选服务缺失 = 无默认选择 */
690
+ }
691
+ const s = live?.get();
692
+ const current = { provider: s?.distillProvider ?? '', model: s?.distillModel ?? '' };
693
+ // 统一路由链块:current = 运行时链(含旧键投影);static = 部署静态回退链;
694
+ // effective = buildRouteChain 语义的实际链(主路由 + 有效条目去重,每条带档位候选);
695
+ // source 标记当前链来自运行时还是部署静态(UI 的跟随态/接管态判定)
696
+ const chainCurrent = projectDistillChain(s);
697
+ let effectiveChain = [];
698
+ let effective;
699
+ let cfgView = cfg;
700
+ try {
701
+ cfgView = effectiveCfg(cfg, live);
702
+ effective = await resolveModelRoute(deps.ctx, cfgView);
703
+ effectiveChain = buildRouteChain({ provider: effective.provider, model: effective.model, effort: cfgView.llm.primaryEffort || '' }, cfgView.llm.fallbacks, cfgView.llm.reasoningEffort);
704
+ }
705
+ catch {
706
+ effective = null; // 无法解析(无默认选择且未覆盖)时 UI 显示占位
707
+ }
708
+ const pinned = Boolean(cfg.llm.provider && cfg.llm.model);
709
+ // 蒸馏通道视图(direct 解耦编辑器数据源):runtime = settings 覆盖档('' = 跟随
710
+ // 部署);effective = effectiveCfg 注入后的实际档;endpoint/密钥只回显非机密部分
711
+ // (apiKey 明文不回传,只给布尔);directReady 供 UI 在 effective=direct 时提示
712
+ // "未配置全(baseURL/model)"
713
+ const cfgChannel = cfgView.llm;
714
+ const effectiveMode = cfgChannel.mode === 'direct' ? 'direct' : 'host';
715
+ const deployedMode = cfg.llm.mode === 'direct' ? 'direct' : 'host';
716
+ const effBase = (s?.directBaseURL || cfg.llm.baseURL || '').trim();
717
+ const effModel = (cfgChannel.model || cfg.llm.model || '').trim();
718
+ const channel = {
719
+ runtime: s?.distillMode === 'host' || s?.distillMode === 'direct' ? s.distillMode : '',
720
+ effective: effectiveMode,
721
+ runtimeBaseURL: s?.directBaseURL ?? '',
722
+ deployedBaseURL: cfg.llm.baseURL ?? '',
723
+ deployed: deployedMode,
724
+ runtimeApiKeySet: Boolean(s?.directApiKey),
725
+ deployedApiKeySet: Boolean(cfg.llm.apiKey),
726
+ directReady: effectiveMode === 'direct' ? effBase !== '' && effModel !== '' : true,
727
+ };
728
+ // 按层层链视图:与解析真值同径(layerChainOrNull 吃 effectiveCfg 之后的 cfgView,
729
+ // pinned 时运行时层链未注入、静态层链胜出;跟随层直接复用全局 effectiveChain)
730
+ const mkLayerView = (key) => {
731
+ const rt = s?.distillLayerChains?.[key] ?? [];
732
+ const lr = layerChainOrNull(cfgView, key);
733
+ const rtLive = !pinned && rt.length > 0 && !!rt[0].provider && !!rt[0].model;
734
+ return {
735
+ runtime: rt,
736
+ static: cfg.llm.layerRoutes?.[key] ?? [],
737
+ effectiveChain: lr ?? effectiveChain,
738
+ source: rtLive ? 'runtime' : lr ? 'static' : 'global',
739
+ };
740
+ };
741
+ const resp = {
742
+ supported: true,
743
+ providers,
744
+ default: def,
745
+ // 部署静态 pin(provider+model 双字段)优先于运行时选择,UI 据此禁用选择器
746
+ pinned,
747
+ current,
748
+ // 所选供应商是否仍在已注册路由中(用户删掉供应商后提示回退)
749
+ currentRegistered: current.provider === '' || providers.some((p) => p.id === current.provider),
750
+ effective,
751
+ chain: {
752
+ current: chainCurrent,
753
+ static: cfg.llm.fallbacks ?? [],
754
+ effectiveChain,
755
+ source: chainCurrent.length ? 'runtime' : 'static',
756
+ },
757
+ // 按层层链:source 三态与解析真值同径(layerChainOrNull)——pinned 下
758
+ // 运行时层链不生效(与 effectiveCfg 注入条件一致),存量照实返回供 UI 展示
759
+ layerChains: {
760
+ l1: mkLayerView('l1'),
761
+ l2: mkLayerView('l2'),
762
+ l3: mkLayerView('l3'),
763
+ },
764
+ channel,
765
+ };
766
+ return resp;
767
+ }
768
+ case 'dsh-memory/llm-models': {
769
+ const p = (payload ?? {});
770
+ if (typeof p.provider !== 'string' || !p.provider)
771
+ throw new Error('provider 缺失');
772
+ if (p.provider.length > 200)
773
+ throw new Error('provider 过长(≤200 字符)');
774
+ // 内置适配器的 listModels 都读本地快照不触网;仍加超时兜底,
775
+ // 防第三方适配器实现为远端查询拖死 RPC 轮询
776
+ const models = await Promise.race([
777
+ deps.ctx.llm.listModels(p.provider),
778
+ new Promise((_, reject) => setTimeout(() => reject(new Error('模型列表查询超时')), 8000)),
779
+ ]);
780
+ // 每个模型附思考档位能力表(resolveModelInfo 复用 effortCache,本地快照不触网):
781
+ // 统一路由链编辑器的逐行档位下拉数据源。整体限时限流,超时降级空表
782
+ const baseModels = models.map((m) => ({ id: m.id, name: m.name, description: m.description ?? null, efforts: [] }));
783
+ const providerId = p.provider;
784
+ const withEfforts = await Promise.race([
785
+ (async () => {
786
+ const out = [];
787
+ for (const m of models) {
788
+ let efforts;
789
+ try {
790
+ efforts = (await resolveModelEfforts(deps.ctx, providerId, m.id))?.efforts ?? [];
791
+ }
792
+ catch {
793
+ efforts = [];
794
+ }
795
+ out.push({ id: m.id, name: m.name, description: m.description ?? null, efforts });
796
+ }
797
+ return out;
798
+ })(),
799
+ new Promise((resolve) => setTimeout(() => resolve(baseModels), 4000)),
800
+ ]);
801
+ const resp = { provider: p.provider, models: withEfforts };
802
+ return resp;
803
+ }
804
+ // ── 嵌入源(远程/本地/关闭 三态)与模型管理 ──
805
+ case 'dsh-memory/embedding-state-get': {
806
+ if (!embedManager) {
807
+ const v = { supported: false };
808
+ return v;
809
+ }
810
+ const v = { supported: true, ...(await embedManager.snapshot()) };
811
+ return v;
812
+ }
813
+ case 'dsh-memory/embedding-source-set': {
814
+ if (!embedManager)
815
+ throw new Error('嵌入管理器未初始化(存储不可用)');
816
+ const p = (payload ?? {});
817
+ if (p.source !== 'remote' && p.source !== 'local' && p.source !== 'off') {
818
+ throw new Error('source 必须是 remote | local | off');
819
+ }
820
+ if (typeof p.activeModel === 'string' && p.activeModel.length > 200) {
821
+ throw new Error('activeModel 过长(≤200 字符)');
822
+ }
823
+ const r = embedManager.requestSource({ source: p.source, activeModel: p.activeModel ?? null });
824
+ if (!r.accepted)
825
+ throw new Error(r.error ?? '切换请求被拒绝');
826
+ deps.logger.info(`[memory] 收到嵌入源切换指令(source=${p.source}${p.activeModel ? ',model=' + p.activeModel : ''})`);
827
+ return { accepted: true };
828
+ }
829
+ case 'dsh-memory/embedding-download-start': {
830
+ if (!embedManager)
831
+ throw new Error('嵌入管理器未初始化(存储不可用)');
832
+ const p = (payload ?? {});
833
+ if (typeof p.modelId !== 'string' || !p.modelId)
834
+ throw new Error('modelId 缺失');
835
+ const r = embedManager.startDownload(p.modelId);
836
+ if (!r.ok)
837
+ throw new Error(r.error ?? '下载请求被拒绝');
838
+ deps.logger.info(`[memory] 收到模型下载指令(${p.modelId})`);
839
+ return { accepted: true };
840
+ }
841
+ case 'dsh-memory/embedding-download-cancel': {
842
+ if (!embedManager)
843
+ throw new Error('嵌入管理器未初始化');
844
+ return { cancelled: embedManager.cancelDownload() };
845
+ }
846
+ case 'dsh-memory/embedding-model-delete': {
847
+ if (!embedManager)
848
+ throw new Error('嵌入管理器未初始化');
849
+ const p = (payload ?? {});
850
+ if (typeof p.modelId !== 'string' || !p.modelId)
851
+ throw new Error('modelId 缺失');
852
+ return embedManager.deleteModel(p.modelId);
853
+ }
854
+ case 'dsh-memory/embedding-runtime-cancel': {
855
+ if (!embedManager)
856
+ throw new Error('嵌入管理器未初始化');
857
+ return { cancelled: embedManager.cancelRuntimeInstall() };
858
+ }
859
+ case 'dsh-memory/embedding-reindex-cancel': {
860
+ if (!embedManager)
861
+ throw new Error('嵌入管理器未初始化');
862
+ return { cancelled: embedManager.cancelReindex() };
863
+ }
864
+ default:
865
+ throw new Error(`unknown endpoint: ${endpoint}`);
866
+ }
867
+ }
868
+ /** 浏览器卡片字段(比 MemoryRecord 精简,去掉大 metadata;Hall 从 metadata 提取)。 */
869
+ function hitToUiRecord(r) {
870
+ return {
871
+ id: r.id,
872
+ content: r.content,
873
+ type: r.type,
874
+ priority: r.priority ?? 60,
875
+ scene: r.scene_name,
876
+ family: r.family ?? null,
877
+ hall: r.metadata && typeof r.metadata.hall === 'string' ? r.metadata.hall : null,
878
+ timestamps: (r.timestamps ?? []).map((t) => new Date(t).toISOString()),
879
+ createdAt: r.createdAt ? new Date(r.createdAt).toISOString() : null,
880
+ updatedAt: r.updatedAt ? new Date(r.updatedAt).toISOString() : null,
881
+ version: r.version ?? 0,
882
+ sourceMessageIds: r.source_message_ids ?? [],
883
+ score: r.score ?? null,
884
+ };
885
+ }
886
+ /**
887
+ * 从文件尾反向分块读取最后 N 行:不整读全文件(轮转上限 2MB,整读会
888
+ * 阻塞事件循环数毫秒)。原始 Buffer 拼接后再解码——分块边界可能切在
889
+ * UTF-8 多字节字符中间,先 toString 再拼接会产生乱码替换符。
890
+ */
891
+ function readLogTail(logPath, maxLines) {
892
+ let fd;
893
+ try {
894
+ fd = openSync(logPath, 'r');
895
+ const { size } = statSync(logPath);
896
+ const CHUNK = 64 * 1024;
897
+ const bufs = [];
898
+ let newlines = 0;
899
+ let pos = size;
900
+ while (pos > 0) {
901
+ const read = Math.min(CHUNK, pos);
902
+ pos -= read;
903
+ const buf = Buffer.alloc(read);
904
+ readSync(fd, buf, 0, read, pos);
905
+ bufs.unshift(buf);
906
+ // \n 是完整单字节,绝不会出现在 UTF-8 续字节里——按字节计数跨块安全
907
+ for (let i = 0; i < buf.length; i++)
908
+ if (buf[i] === 0x0a)
909
+ newlines++;
910
+ if (newlines > maxLines)
911
+ break;
912
+ }
913
+ const lines = Buffer.concat(bufs).toString('utf8').split('\n').filter((l) => l.length > 0);
914
+ return lines.slice(-maxLines);
915
+ }
916
+ catch {
917
+ return [];
918
+ }
919
+ finally {
920
+ if (fd !== undefined) {
921
+ try {
922
+ closeSync(fd);
923
+ }
924
+ catch {
925
+ /* ignore */
926
+ }
927
+ }
928
+ }
929
+ }