dsh-layered-memory 0.8.7 → 0.8.9

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.
package/dist/settings.js CHANGED
@@ -1,6 +1,68 @@
1
1
  import Schema from '@deepseek-ai/schemastery';
2
2
  import { settingsNamespace } from '@deepseek-ai/dsh-settings';
3
3
  import { EFFORT_CHOICES } from './config.js';
4
+ /**
5
+ * 运行时统一路由链条目:[0] = 主路由(provider/model 双空 = 跟随默认模型),
6
+ * [1..] = 回退链(按序降级);reasoningEffort 为该路由的档位覆盖('' = 跟随部署全局)。
7
+ */
8
+ /** 运行时路由链上限(写入门与 UI 同限,防误粘贴巨数组撑爆 settings 存储)。 */
9
+ export const DISTILL_CHAIN_MAX = 8;
10
+ /**
11
+ * 运行时统一路由链的**展示投影**(llm-providers 的 chain.current 数据源):
12
+ * distillChain 非空即原样返回;为空时投影旧运行时键(distillProvider/distillModel
13
+ * 成对 → 单行主路由,旧档位 reasoningEffort 作为该主路由的档位——旧语义里它
14
+ * 作用的就是当时唯一的路由)。注意:生效逻辑(effectiveCfg)只认显式
15
+ * distillChain、不走本投影——旧键路径在未配链时按旧语义原样生效。
16
+ */
17
+ export function projectDistillChain(s) {
18
+ if (s?.distillChain?.length)
19
+ return s.distillChain;
20
+ if (s?.distillProvider && s?.distillModel) {
21
+ return [{ provider: s.distillProvider, model: s.distillModel, reasoningEffort: s.reasoningEffort || '' }];
22
+ }
23
+ return [];
24
+ }
25
+ /**
26
+ * settings-set 写入门校验:返回错误文案(null = 通过)。
27
+ * opts.requireExplicitHead(#34 层链用):头行必须 provider+model 双显式——层键出现
28
+ * 即意图覆盖;"双空 = 跟随默认模型"是全局链独有语义("默认模型 + 自定义回退"的
29
+ * 诉求由全局 llm.fallbacks 承担),层链禁掉双空头,消除"层链头跟随哪套全局解析"的歧义。
30
+ */
31
+ export function validateDistillChain(chain, opts) {
32
+ if (!Array.isArray(chain))
33
+ return 'distillChain 须为数组';
34
+ if (chain.length > DISTILL_CHAIN_MAX)
35
+ return `路由链最多 ${DISTILL_CHAIN_MAX} 条`;
36
+ const seen = new Set();
37
+ for (let i = 0; i < chain.length; i++) {
38
+ if (!chain[i] || typeof chain[i] !== 'object')
39
+ return `第 ${i + 1} 行须为对象`;
40
+ const e = chain[i];
41
+ const p = typeof e.provider === 'string' ? e.provider : '';
42
+ const m = typeof e.model === 'string' ? e.model : '';
43
+ const eff = typeof e.reasoningEffort === 'string' ? e.reasoningEffort : '';
44
+ if (p.length > 200 || m.length > 200)
45
+ return `第 ${i + 1} 行 provider/model 过长(≤200 字符)`;
46
+ if (!EFFORT_CHOICES.includes(eff))
47
+ return `第 ${i + 1} 行思考档位非法: ${eff || '(空)'}`;
48
+ if (i === 0) {
49
+ if (opts?.requireExplicitHead && (!p || !m))
50
+ return '主路由行必须显式选择供应商与模型(层链不支持跟随默认模型)';
51
+ if ((p && !m) || (!p && m))
52
+ return '主路由行 provider 与 model 须成对(双空 = 跟随默认模型)';
53
+ }
54
+ else if (!p || !m) {
55
+ return `第 ${i + 1} 行回退路由必须显式选择供应商与模型`;
56
+ }
57
+ if (p && m) {
58
+ const key = `${p}::${m}`;
59
+ if (seen.has(key))
60
+ return `第 ${i + 1} 行与前面的路由重复(${p}/${m})`;
61
+ seen.add(key);
62
+ }
63
+ }
64
+ return null;
65
+ }
4
66
  const NS = settingsNamespace('dsh-memory');
5
67
  const ALWAYS_ON = {
6
68
  enabled: true,
@@ -10,8 +72,10 @@ const ALWAYS_ON = {
10
72
  reasoningEffort: '',
11
73
  distillProvider: '',
12
74
  distillModel: '',
75
+ distillChain: [],
13
76
  distillBudgets: { extract: 0, dedup: 0, l2: 0, l3: 0 },
14
77
  distillMaxInputChars: 0,
78
+ distillLayerChains: { l1: [], l2: [], l3: [] },
15
79
  };
16
80
  /**
17
81
  * 进程内 scope 复用(fiber 重启重挂)。
@@ -32,6 +96,13 @@ let cachedUnwatch;
32
96
  let cachedSvc;
33
97
  export function liveSettingsSchema() {
34
98
  const budget = () => Schema.number().min(0).max(1_000_000).default(0);
99
+ // 层链条目形状与 distillChain 相同(档位必填、'' = 跟随);写入校验另在
100
+ // settings-set 门做逐层 requireExplicitHead(schema 层只管形状默认,语义门在 host)
101
+ const chainEntry = () => Schema.object({
102
+ provider: Schema.string().default(''),
103
+ model: Schema.string().default(''),
104
+ reasoningEffort: Schema.union([...EFFORT_CHOICES]).default(''),
105
+ });
35
106
  return Schema.object({
36
107
  enabled: Schema.boolean().default(true),
37
108
  capture: Schema.boolean().default(true),
@@ -40,6 +111,12 @@ export function liveSettingsSchema() {
40
111
  reasoningEffort: Schema.union([...EFFORT_CHOICES]).default(''),
41
112
  distillProvider: Schema.string().default(''),
42
113
  distillModel: Schema.string().default(''),
114
+ distillChain: Schema.array(chainEntry()).default([]),
115
+ distillLayerChains: Schema.object({
116
+ l1: Schema.array(chainEntry()).default([]),
117
+ l2: Schema.array(chainEntry()).default([]),
118
+ l3: Schema.array(chainEntry()).default([]),
119
+ }).default({ l1: [], l2: [], l3: [] }),
43
120
  distillBudgets: Schema.object({
44
121
  extract: budget(),
45
122
  dedup: budget(),
@@ -153,10 +230,33 @@ export function registerLiveSettings(ctx, logger) {
153
230
  /** scope.get() 的防御性解析:异常值回退全开(宁可多记不可静默停摆)。 */
154
231
  function resolveSettings(value) {
155
232
  if (!value || typeof value !== 'object')
156
- return { ...ALWAYS_ON };
233
+ return { ...ALWAYS_ON, distillChain: [] };
157
234
  const v = value;
158
235
  const num = (x) => (typeof x === 'number' && Number.isFinite(x) && x >= 0 ? Math.floor(x) : 0);
159
236
  const rawBudgets = (v.distillBudgets ?? {});
237
+ // 路由链逐条防御:非对象条目剔除、超长截断、非法档位归空、超限截断到上限
238
+ const defuseChain = (raw) => {
239
+ const out = [];
240
+ if (!Array.isArray(raw))
241
+ return out;
242
+ for (const item of raw) {
243
+ if (out.length >= DISTILL_CHAIN_MAX)
244
+ break;
245
+ if (!item || typeof item !== 'object')
246
+ continue;
247
+ const e = item;
248
+ const eff = typeof e.reasoningEffort === 'string' && EFFORT_CHOICES.includes(e.reasoningEffort)
249
+ ? e.reasoningEffort
250
+ : '';
251
+ out.push({
252
+ provider: typeof e.provider === 'string' ? e.provider.slice(0, 200) : '',
253
+ model: typeof e.model === 'string' ? e.model.slice(0, 200) : '',
254
+ reasoningEffort: eff,
255
+ });
256
+ }
257
+ return out;
258
+ };
259
+ const rawLayer = (v.distillLayerChains ?? {});
160
260
  return {
161
261
  enabled: v.enabled !== false,
162
262
  capture: v.capture !== false,
@@ -167,6 +267,12 @@ function resolveSettings(value) {
167
267
  : '',
168
268
  distillProvider: typeof v.distillProvider === 'string' ? v.distillProvider : '',
169
269
  distillModel: typeof v.distillModel === 'string' ? v.distillModel : '',
270
+ distillChain: defuseChain(v.distillChain),
271
+ distillLayerChains: {
272
+ l1: defuseChain(rawLayer.l1),
273
+ l2: defuseChain(rawLayer.l2),
274
+ l3: defuseChain(rawLayer.l3),
275
+ },
170
276
  distillBudgets: {
171
277
  extract: num(rawBudgets.extract),
172
278
  dedup: num(rawBudgets.dedup),
package/dist/stats.d.ts CHANGED
@@ -2,7 +2,7 @@ import type { Context } from '@deepseek-ai/cordis';
2
2
  import { type MemoryConfig } from './config.js';
3
3
  import { type RecallSessionStats } from './hooks/recall.js';
4
4
  import type { RebuildController } from './pipeline/rebuild.js';
5
- import type { LiveSettingsHandle } from './settings.js';
5
+ import { type LiveSettingsHandle } from './settings.js';
6
6
  import type { L0Store } from './store/l0.js';
7
7
  import type { L1Store } from './store/l1.js';
8
8
  import type { PersonaStore } from './store/persona.js';
@@ -28,6 +28,12 @@ export interface MemoryStatusSource {
28
28
  export interface SessionInfoSource {
29
29
  /** 召回统计(recall.ts 注册表;未发生检索的会话返回 undefined)。 */
30
30
  recallStats(sessionId: string): RecallSessionStats | undefined;
31
+ /** 记忆上下文占用账本(context-occupancy 唯一权威实例;未注入过的会话返回 null)。 */
32
+ memoryOccupancy(sessionId: string): MemoryOccupancy | null;
33
+ /** 稳定区份额估算(旧会话回填用;缺省 = 装配未提供,回填隐藏)。 */
34
+ profileEstimate?(sessionId: string): number;
35
+ /** 召回份额回填(live surface 现扫,miss 读盘上日志;缺省 = null)。 */
36
+ recallEstimate?(sessionId: string): Promise<number | null> | number | null;
31
37
  /** 蒸馏管线会话视图(runner:攒批进度/挂起切片/会话产出)。 */
32
38
  runnerView(sessionId: string, mode: string): {
33
39
  pendingSlice: number;
@@ -44,31 +50,8 @@ export interface SessionInfoSource {
44
50
  vectorSearch: boolean;
45
51
  };
46
52
  }
47
- export interface MemoryStats {
48
- ok: boolean;
49
- dataDir: string;
50
- /** 新会话默认记忆档位(auto/chat/work)。 */
51
- family: string;
52
- version: string;
53
- l0Today: number;
54
- l1Count: number;
55
- l1TotalExtracted: number;
56
- sceneCount: number;
57
- personaChars: number;
58
- hasPersona: boolean;
59
- lastExtractAt: string | null;
60
- lastL2At: string | null;
61
- lastL3At: string | null;
62
- memoriesSinceL2: number;
63
- memoriesSinceL3: number;
64
- pendingExtract: number;
65
- message: string;
66
- /** 实际生效的阈值(概览进度分母用,避免 UI 硬编码与部署配置脱节)。 */
67
- thresholds: {
68
- l2MinNewMemories: number;
69
- l3Interval: number;
70
- };
71
- }
53
+ import type { MemoryOccupancy } from './contract.js';
54
+ export type { MemoryStats } from './contract.js';
72
55
  /** 注册状态 RPC(web 侧 connection 服务可选,缺失时跳过,不影响插件主体)。 */
73
56
  export declare function registerMemoryRpc(ctx: Context, cfg: MemoryConfig, stores: {
74
57
  l0: L0Store;
package/dist/stats.js CHANGED
@@ -12,7 +12,8 @@ import { join } from 'node:path';
12
12
  import { EFFORT_CHOICES, resolveDataDir } from './config.js';
13
13
  import { effectiveCfg } from './pipeline/runner.js';
14
14
  import { emptyRecallStats } from './hooks/recall.js';
15
- import { decideSendableEffort, LAYER_DEFAULT_BUDGETS, resolveModelEfforts, resolveModelRoute } from './llm.js';
15
+ import { buildRouteChain, decideSendableEffort, LAYER_DEFAULT_BUDGETS, layerChainOrNull, resolveModelContextWindow, resolveModelEfforts, resolveModelRoute } from './llm.js';
16
+ import { projectDistillChain, validateDistillChain } from './settings.js';
16
17
  import { errDetail } from './util/filelog.js';
17
18
  import { snapshotTokenCost } from './token-cost.js';
18
19
  const require = createRequire(import.meta.url);
@@ -164,7 +165,8 @@ async function handleEndpoint(endpoint, payload, deps) {
164
165
  throw new Error('档位存储未初始化');
165
166
  const p = (payload ?? {});
166
167
  const sessionId = expectSessionId(p.sessionId);
167
- return { sessionId, mode: modes.get(sessionId), defaultMode: modes.default };
168
+ const v = { sessionId, mode: modes.get(sessionId), defaultMode: modes.default };
169
+ return v;
168
170
  }
169
171
  case 'dsh-memory/session-mode-set': {
170
172
  if (!modes)
@@ -177,7 +179,8 @@ async function handleEndpoint(endpoint, payload, deps) {
177
179
  }
178
180
  modes.set(sessionId, p.mode);
179
181
  deps.logger.info(`[memory] 会话档位设置 session=${sessionId} mode=${p.mode}`);
180
- return { sessionId, mode: p.mode };
182
+ const v = { sessionId, mode: p.mode };
183
+ return v;
181
184
  }
182
185
  // ── 会话级统计(悬浮卡信息区;热路径端点,见 SessionInfoSource 的零 I/O 硬规则) ──
183
186
  case 'dsh-memory/session-stats': {
@@ -196,12 +199,36 @@ async function handleEndpoint(endpoint, payload, deps) {
196
199
  const chat = stores.state.forFamily('chat');
197
200
  const work = stores.state.forFamily('work');
198
201
  const lastAt = Math.max(chat.lastExtractAt, work.lastExtractAt);
199
- return {
202
+ // 主对话模型的官方声明窗口(占用指示器分母;advisory 查询读本地快照且有缓存,
203
+ // 轮询热路径下稳态为 Map 命中——遵守本端点"只许内存注册表读取"的硬规则口径。
204
+ // race 封顶防第三方适配器 resolveModelInfo 挂起拖死轮询——llm-models 端点同款先例)
205
+ let contextWindowTokens = null;
206
+ try {
207
+ const sel = deps.ctx.get('agentDefaultModel')?.currentSelection?.();
208
+ if (sel?.provider && sel?.model) {
209
+ contextWindowTokens = await Promise.race([
210
+ resolveModelContextWindow(deps.ctx, sel.provider, sel.model),
211
+ new Promise((resolve) => setTimeout(() => resolve(null), 3_000)),
212
+ ]);
213
+ }
214
+ }
215
+ catch {
216
+ /* 可选服务缺失/解析失败 = 分母未知,UI 降级 */
217
+ }
218
+ const v = {
200
219
  supported: true,
201
220
  sessionId,
202
221
  mode,
203
222
  defaultMode: modes?.default ?? cfg.family,
204
223
  recall: { enabled: recallOn, ...(sessionInfo.recallStats(sessionId) ?? emptyRecallStats()) },
224
+ memoryOccupancy: sessionInfo.memoryOccupancy(sessionId),
225
+ occupancyBackfill: sessionInfo.profileEstimate
226
+ ? {
227
+ recallTokens: sessionInfo.recallEstimate ? await sessionInfo.recallEstimate(sessionId) : null,
228
+ profileTokens: sessionInfo.profileEstimate(sessionId),
229
+ }
230
+ : null,
231
+ contextWindowTokens,
205
232
  distill: distillView,
206
233
  l0Count,
207
234
  retrieval: caps.vectorSearch ? (caps.ftsSearch ? 'hybrid' : 'vector') : caps.ftsSearch ? 'keyword' : 'none',
@@ -211,13 +238,16 @@ async function handleEndpoint(endpoint, payload, deps) {
211
238
  lastExtractAt: lastAt ? new Date(lastAt).toISOString() : null,
212
239
  },
213
240
  };
241
+ return v;
214
242
  }
215
243
  case 'dsh-memory/settings-get': {
216
244
  const s = live?.get();
217
245
  const budgets = s?.distillBudgets ?? { extract: 0, dedup: 0, l2: 0, l3: 0 };
218
246
  // 蒸馏思考档位:current 是运行时值('' = 自动);effective 是能力探询后实际发送值
219
247
  // ('' = 不传,跟随模型默认);options 是当前生效模型声明的档位表(空声明 → 只显示
220
- // high,用户规则:无声明默认 high),供蒸馏思考选择器渲染;fallback 是静态部署值
248
+ // high,用户规则:无声明默认 high),fallback 是静态部署值。注:旧「蒸馏思考」
249
+ // 选择器已删,本块保留给旧 client 版本与 smoke 兼容;新 UI(路由链编辑器)
250
+ // 的逐行档位词表走 llm-models 的 efforts 字段
221
251
  let effortEffective = s?.reasoningEffort || cfg.llm.reasoningEffort;
222
252
  let effortOptions = ['high'];
223
253
  let effortRoute = null;
@@ -234,17 +264,19 @@ async function handleEndpoint(endpoint, payload, deps) {
234
264
  catch {
235
265
  /* 路由解析/探询失败保持占位(effective 用运行时||静态值) */
236
266
  }
237
- return {
267
+ const resp = {
238
268
  supported: live?.supported ?? false,
239
269
  settings: s ?? {
240
270
  enabled: true, capture: true, distill: true, recall: true,
241
- reasoningEffort: '', distillProvider: '', distillModel: '',
271
+ reasoningEffort: '', distillProvider: '', distillModel: '', distillChain: [],
242
272
  distillBudgets: { extract: 0, dedup: 0, l2: 0, l3: 0 }, distillMaxInputChars: 0,
273
+ distillLayerChains: { l1: [], l2: [], l3: [] },
243
274
  },
244
275
  // 静态部署上限(cordis.patch.yml):运行时开关与它取 AND
245
276
  ceilings: { capture: cfg.capture.enabled, distill: cfg.extract.enabled, recall: cfg.recall.enabled },
246
277
  effort: {
247
278
  current: s?.reasoningEffort ?? '',
279
+ // 静态 schema 与 settings-set 写入门都以 EFFORT_CHOICES 白名单校验,这里断言回窄类型
248
280
  effective: effortEffective,
249
281
  fallback: cfg.llm.reasoningEffort,
250
282
  options: effortOptions,
@@ -268,6 +300,7 @@ async function handleEndpoint(endpoint, payload, deps) {
268
300
  effective: s && s.distillMaxInputChars > 0 ? s.distillMaxInputChars : cfg.llm.maxInputChars,
269
301
  },
270
302
  };
303
+ return resp;
271
304
  }
272
305
  case 'dsh-memory/settings-set': {
273
306
  if (!live)
@@ -278,6 +311,34 @@ async function handleEndpoint(endpoint, payload, deps) {
278
311
  if (typeof patch[key] === 'boolean')
279
312
  clean[key] = patch[key];
280
313
  }
314
+ // 运行时统一路由链:结构校验后整体写入(空数组 = 回到跟随部署配置)
315
+ if (patch.distillChain !== undefined) {
316
+ const err = validateDistillChain(patch.distillChain);
317
+ if (err)
318
+ throw new Error(err);
319
+ clean.distillChain = patch.distillChain;
320
+ }
321
+ // 运行时按层路由链(#34):逐层校验(头行必须显式——层覆盖不支持跟随默认模型);
322
+ // patch 语义只带要改的层,写入侧与存量层合并后落盘(空数组 = 该层回到跟随)
323
+ if (patch.distillLayerChains !== undefined) {
324
+ const rawLC = (patch.distillLayerChains ?? {});
325
+ // 与存量层合并后落全三键 Record(settings 视图类型是全量;缺层 = 清空该层跟随)
326
+ const prev = (live.get().distillLayerChains ?? {});
327
+ const merged = {
328
+ l1: prev.l1 ?? [],
329
+ l2: prev.l2 ?? [],
330
+ l3: prev.l3 ?? [],
331
+ };
332
+ for (const key of ['l1', 'l2', 'l3']) {
333
+ if (rawLC[key] === undefined)
334
+ continue;
335
+ const err = validateDistillChain(rawLC[key], { requireExplicitHead: true });
336
+ if (err)
337
+ throw new Error(`层路由 ${key}:${err}`);
338
+ merged[key] = rawLC[key];
339
+ }
340
+ clean.distillLayerChains = merged;
341
+ }
281
342
  if (patch.reasoningEffort !== undefined) {
282
343
  const v = String(patch.reasoningEffort);
283
344
  // 白名单与 schema/settings 同源(config.ts EFFORT_CHOICES)——此前此处漏扩词表,
@@ -322,7 +383,8 @@ async function handleEndpoint(endpoint, payload, deps) {
322
383
  throw new Error('开关更新载荷为空');
323
384
  await live.update(clean);
324
385
  deps.logger.info(`[memory] 设置更新:${JSON.stringify(clean)}`);
325
- return { ok: true, settings: live.get() };
386
+ const v = { ok: true, settings: live.get() };
387
+ return v;
326
388
  }
327
389
  case 'dsh-memory/list-records': {
328
390
  const p = (payload ?? {});
@@ -338,22 +400,24 @@ async function handleEndpoint(endpoint, payload, deps) {
338
400
  const wanted = offset + limit + 1;
339
401
  const hits = await stores.l1.search(p.query, Math.min(wanted, SEARCH_CAP), { type: p.type || undefined });
340
402
  const filtered = p.scene ? hits.filter((h) => h.scene_name === p.scene) : hits;
341
- return {
403
+ const resp = {
342
404
  items: filtered.slice(offset, offset + limit).map(hitToUiRecord),
343
405
  hasMore: filtered.length > offset + limit,
344
406
  total: null,
345
407
  truncated: wanted > SEARCH_CAP,
346
408
  scenes: offset === 0 ? stores.l1.distinctScenes() : undefined,
347
409
  };
410
+ return resp;
348
411
  }
349
412
  const { items, total } = stores.l1.list({ type: p.type || undefined, scene: p.scene || undefined, limit, offset });
350
- return {
413
+ const resp = {
351
414
  items: items.map(hitToUiRecord),
352
415
  hasMore: offset + items.length < total,
353
416
  total,
354
417
  truncated: false,
355
418
  scenes: offset === 0 ? stores.l1.distinctScenes() : undefined,
356
419
  };
420
+ return resp;
357
421
  }
358
422
  case 'dsh-memory/scenes': {
359
423
  // 两族拼接展示(浏览器保持混合视图;路径冲突时后写入的族覆盖显示名,读取仍各自独立)
@@ -381,8 +445,10 @@ async function handleEndpoint(endpoint, payload, deps) {
381
445
  return { lines: readLogTail(join(dataDir, 'memory.log'), Math.min(Math.max(Number(p.lines) || 200, 1), 1000)) };
382
446
  }
383
447
  case 'dsh-memory/rebuild-status': {
384
- if (!rebuild)
385
- return { supported: false, running: false, phase: 'idle' };
448
+ if (!rebuild) {
449
+ const v = { supported: false, running: false, phase: 'idle' };
450
+ return v;
451
+ }
386
452
  return rebuild.getStatus();
387
453
  }
388
454
  case 'dsh-memory/rebuild-start': {
@@ -407,6 +473,7 @@ async function handleEndpoint(endpoint, payload, deps) {
407
473
  // ── 蒸馏模型选择器(用户已配置的供应商路由) ──
408
474
  case 'dsh-memory/llm-providers': {
409
475
  // 供应商目录(已注册适配器的活动路由)+ 默认选择 + 当前覆盖与实际生效路由
476
+ // ——蒸馏路由链编辑器的数据源(供应商下拉/默认模型展示/链状态 chain 块)
410
477
  let providers = [];
411
478
  try {
412
479
  providers = deps.ctx.llm.listProviders();
@@ -425,24 +492,60 @@ async function handleEndpoint(endpoint, payload, deps) {
425
492
  }
426
493
  const s = live?.get();
427
494
  const current = { provider: s?.distillProvider ?? '', model: s?.distillModel ?? '' };
495
+ // 统一路由链块:current = 运行时链(含旧键投影);static = 部署静态回退链;
496
+ // effective = buildRouteChain 语义的实际链(主路由 + 有效条目去重,每条带档位候选);
497
+ // source 标记当前链来自运行时还是部署静态(UI 的跟随态/接管态判定)
498
+ const chainCurrent = projectDistillChain(s);
499
+ let effectiveChain = [];
428
500
  let effective = null;
501
+ let cfgView = cfg;
429
502
  try {
430
- effective = await resolveModelRoute(deps.ctx, effectiveCfg(cfg, live));
503
+ cfgView = effectiveCfg(cfg, live);
504
+ effective = await resolveModelRoute(deps.ctx, cfgView);
505
+ effectiveChain = buildRouteChain({ provider: effective.provider, model: effective.model, effort: cfgView.llm.primaryEffort || '' }, cfgView.llm.fallbacks, cfgView.llm.reasoningEffort);
431
506
  }
432
507
  catch {
433
508
  effective = null; // 无法解析(无默认选择且未覆盖)时 UI 显示占位
434
509
  }
435
- return {
510
+ const pinned = Boolean(cfg.llm.provider && cfg.llm.model);
511
+ // 按层层链视图:与解析真值同径(layerChainOrNull 吃 effectiveCfg 之后的 cfgView,
512
+ // pinned 时运行时层链未注入、静态层链胜出;跟随层直接复用全局 effectiveChain)
513
+ const mkLayerView = (key) => {
514
+ const rt = s?.distillLayerChains?.[key] ?? [];
515
+ const lr = layerChainOrNull(cfgView, key);
516
+ const rtLive = !pinned && rt.length > 0 && !!rt[0].provider && !!rt[0].model;
517
+ return {
518
+ runtime: rt,
519
+ static: cfg.llm.layerRoutes?.[key] ?? [],
520
+ effectiveChain: lr ?? effectiveChain,
521
+ source: rtLive ? 'runtime' : lr ? 'static' : 'global',
522
+ };
523
+ };
524
+ const resp = {
436
525
  supported: true,
437
526
  providers,
438
527
  default: def,
439
528
  // 部署静态 pin(provider+model 双字段)优先于运行时选择,UI 据此禁用选择器
440
- pinned: Boolean(cfg.llm.provider && cfg.llm.model),
529
+ pinned,
441
530
  current,
442
531
  // 所选供应商是否仍在已注册路由中(用户删掉供应商后提示回退)
443
532
  currentRegistered: current.provider === '' || providers.some((p) => p.id === current.provider),
444
533
  effective,
534
+ chain: {
535
+ current: chainCurrent,
536
+ static: cfg.llm.fallbacks ?? [],
537
+ effectiveChain,
538
+ source: chainCurrent.length ? 'runtime' : 'static',
539
+ },
540
+ // 按层层链(#34):source 三态与解析真值同径(layerChainOrNull)——pinned 下
541
+ // 运行时层链不生效(与 effectiveCfg 注入条件一致),存量照实返回供 UI 展示
542
+ layerChains: {
543
+ l1: mkLayerView('l1'),
544
+ l2: mkLayerView('l2'),
545
+ l3: mkLayerView('l3'),
546
+ },
445
547
  };
548
+ return resp;
446
549
  }
447
550
  case 'dsh-memory/llm-models': {
448
551
  const p = (payload ?? {});
@@ -456,16 +559,40 @@ async function handleEndpoint(endpoint, payload, deps) {
456
559
  deps.ctx.llm.listModels(p.provider),
457
560
  new Promise((_, reject) => setTimeout(() => reject(new Error('模型列表查询超时')), 8000)),
458
561
  ]);
459
- return {
460
- provider: p.provider,
461
- models: models.map((m) => ({ id: m.id, name: m.name, description: m.description ?? null })),
462
- };
562
+ // 每个模型附思考档位能力表(resolveModelInfo 复用 effortCache,本地快照不触
563
+ // 网):统一路由链编辑器的逐行档位下拉数据源。整体限时限流——第三方适配器的
564
+ // resolveModelInfo 若为远端查询会拖死端点(client 5s 轮询放大),超时降级空表
565
+ // (探询失败/未声明同样 → 空表,UI 只显示「跟随部署配置」)
566
+ const baseModels = models.map((m) => ({ id: m.id, name: m.name, description: m.description ?? null, efforts: [] }));
567
+ const providerId = p.provider;
568
+ const withEfforts = await Promise.race([
569
+ (async () => {
570
+ const out = [];
571
+ for (const m of models) {
572
+ let efforts = [];
573
+ try {
574
+ efforts = (await resolveModelEfforts(deps.ctx, providerId, m.id))?.efforts ?? [];
575
+ }
576
+ catch {
577
+ efforts = [];
578
+ }
579
+ out.push({ id: m.id, name: m.name, description: m.description ?? null, efforts });
580
+ }
581
+ return out;
582
+ })(),
583
+ new Promise((resolve) => setTimeout(() => resolve(baseModels), 4000)),
584
+ ]);
585
+ const resp = { provider: p.provider, models: withEfforts };
586
+ return resp;
463
587
  }
464
588
  // ── 嵌入源(远程/本地/关闭 三态)与模型管理 ──
465
589
  case 'dsh-memory/embedding-state-get': {
466
- if (!embedManager)
467
- return { supported: false };
468
- return { supported: true, ...(await embedManager.snapshot()) };
590
+ if (!embedManager) {
591
+ const v = { supported: false };
592
+ return v;
593
+ }
594
+ const v = { supported: true, ...(await embedManager.snapshot()) };
595
+ return v;
469
596
  }
470
597
  case 'dsh-memory/embedding-source-set': {
471
598
  if (!embedManager)
@@ -0,0 +1,78 @@
1
+ /**
2
+ * 蒸馏成本账本(token_cost 明细表):从 sqlite.ts 巨石拆出的第一刀(体检 P1)。
3
+ *
4
+ * 职责:明细写入(写入时按保留期滚动清理)+ 四路聚合查询(单窗口总览 / 按模型 /
5
+ * 按层级归并 / 按时间桶)。与检索引擎零关系——唯一的耦合是共享同一个
6
+ * node:sqlite 连接。成本看板是增强能力:降级/异常一律返回零值,不向上抛错。
7
+ *
8
+ * 接口策略:MemoryDb 的四个同名公开方法保持原签名做一行委托("拆文件不拆接口"),
9
+ * 既有调用方(token-cost.ts / smoke)零改动。
10
+ */
11
+ import type { DatabaseSync } from 'node:sqlite';
12
+ import type { CostByModel } from '../contract.js';
13
+ import type { MemoryLogger } from '../types.js';
14
+ /** token_cost 单窗口成本聚合(成本看板用)。 */
15
+ export interface CostAggregate {
16
+ calls: number;
17
+ inputChars: number;
18
+ outputTokens: number;
19
+ reasoningTokens: number;
20
+ /** 单次调用输出 token 均值(无数据为 0)。 */
21
+ avgOutputTokens: number;
22
+ /** 单次调用输出 token 中位数(无数据为 0)。 */
23
+ medianOutputTokens: number;
24
+ }
25
+ /** 按层级(l1/l2/l3 归并)分组的成本行。 */
26
+ export interface CostByLayer {
27
+ layer: string;
28
+ calls: number;
29
+ inputChars: number;
30
+ outputTokens: number;
31
+ reasoningTokens: number;
32
+ avgOutputTokens: number;
33
+ medianOutputTokens: number;
34
+ }
35
+ /** 按时间桶 + provider/model 聚合的扁平行(趋势图与日均/周均/月均 + 中位数统计共用)。 */
36
+ export interface BucketRow {
37
+ bucket: number;
38
+ provider: string;
39
+ model: string;
40
+ calls: number;
41
+ outputTokens: number;
42
+ reasoningTokens: number;
43
+ }
44
+ export declare class CostLedger {
45
+ private db;
46
+ private logger;
47
+ private stmtInsert;
48
+ private stmtDelete;
49
+ /** init 是否成功(未就绪 = 宿主库降级,方法全部返回零值不抛错)。 */
50
+ get ready(): boolean;
51
+ /** 建表 + 迁移 + 语句缓存(MemoryDb.initSchema 内调用;失败冒泡触发库级降级)。 */
52
+ init(db: DatabaseSync, logger?: MemoryLogger): void;
53
+ /**
54
+ * 记录一次蒸馏调用成本(明细表,写入时按 retentionDays 滚动清理;0 = 永久保留)。
55
+ * 失败/成功都记(token 照烧);记账失败记 warn 但不阻断蒸馏(成本看板是增强能力)。
56
+ */
57
+ insertCostCall(provider: string, model: string, layer: string, inputChars: number, outputTokens: number, reasoningTokens: number, retentionDays: number): void;
58
+ /**
59
+ * 查询 token_cost 单窗口聚合(成本看板用;since 为毫秒起点,0 = 全量)。
60
+ * 输入口径:inputChars 是字符(llm 流拿不到输入 token,沿用 llm-usage 的字符折算口径)。
61
+ * median 需取 output_tokens 序列在 JS 侧算(SQLite 无内置 median 函数)。
62
+ */
63
+ aggregateCost(since: number): {
64
+ total: CostAggregate;
65
+ byModel: CostByModel[];
66
+ };
67
+ /**
68
+ * 按层级归并聚合(l1 = l1-extract + l1-dedup;成本看板层级表格用)。
69
+ * 降级/异常返回空数组,不抛错。
70
+ */
71
+ aggregateCostByLayer(since: number): CostByLayer[];
72
+ /**
73
+ * 按时间桶(bucketMs 毫秒)+ model 聚合,返回扁平行。
74
+ * offsetMs 把桶边界对齐本地时区;layer 为空=全部,'l1' 归并 extract/dedup,其余精确匹配。
75
+ * 趋势图与「日均/周均/月均 + 中位数」统计共用:JS 侧按不同 bucketMs 调三次再聚合。
76
+ */
77
+ aggregateByBucket(bucketMs: number, offsetMs: number, since: number, layer: string): BucketRow[];
78
+ }