dsh-layered-memory 0.8.6 → 0.8.8

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,61 @@
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
+ /** settings-set 写入门校验:返回错误文案(null = 通过)。 */
26
+ export function validateDistillChain(chain) {
27
+ if (!Array.isArray(chain))
28
+ return 'distillChain 须为数组';
29
+ if (chain.length > DISTILL_CHAIN_MAX)
30
+ return `路由链最多 ${DISTILL_CHAIN_MAX} 条`;
31
+ const seen = new Set();
32
+ for (let i = 0; i < chain.length; i++) {
33
+ if (!chain[i] || typeof chain[i] !== 'object')
34
+ return `第 ${i + 1} 行须为对象`;
35
+ const e = chain[i];
36
+ const p = typeof e.provider === 'string' ? e.provider : '';
37
+ const m = typeof e.model === 'string' ? e.model : '';
38
+ const eff = typeof e.reasoningEffort === 'string' ? e.reasoningEffort : '';
39
+ if (p.length > 200 || m.length > 200)
40
+ return `第 ${i + 1} 行 provider/model 过长(≤200 字符)`;
41
+ if (!EFFORT_CHOICES.includes(eff))
42
+ return `第 ${i + 1} 行思考档位非法: ${eff || '(空)'}`;
43
+ if (i === 0) {
44
+ if ((p && !m) || (!p && m))
45
+ return '主路由行 provider 与 model 须成对(双空 = 跟随默认模型)';
46
+ }
47
+ else if (!p || !m) {
48
+ return `第 ${i + 1} 行回退路由必须显式选择供应商与模型`;
49
+ }
50
+ if (p && m) {
51
+ const key = `${p}::${m}`;
52
+ if (seen.has(key))
53
+ return `第 ${i + 1} 行与前面的路由重复(${p}/${m})`;
54
+ seen.add(key);
55
+ }
56
+ }
57
+ return null;
58
+ }
4
59
  const NS = settingsNamespace('dsh-memory');
5
60
  const ALWAYS_ON = {
6
61
  enabled: true,
@@ -10,6 +65,7 @@ const ALWAYS_ON = {
10
65
  reasoningEffort: '',
11
66
  distillProvider: '',
12
67
  distillModel: '',
68
+ distillChain: [],
13
69
  distillBudgets: { extract: 0, dedup: 0, l2: 0, l3: 0 },
14
70
  distillMaxInputChars: 0,
15
71
  };
@@ -40,6 +96,11 @@ export function liveSettingsSchema() {
40
96
  reasoningEffort: Schema.union([...EFFORT_CHOICES]).default(''),
41
97
  distillProvider: Schema.string().default(''),
42
98
  distillModel: Schema.string().default(''),
99
+ distillChain: Schema.array(Schema.object({
100
+ provider: Schema.string().default(''),
101
+ model: Schema.string().default(''),
102
+ reasoningEffort: Schema.union([...EFFORT_CHOICES]).default(''),
103
+ })).default([]),
43
104
  distillBudgets: Schema.object({
44
105
  extract: budget(),
45
106
  dedup: budget(),
@@ -153,10 +214,28 @@ export function registerLiveSettings(ctx, logger) {
153
214
  /** scope.get() 的防御性解析:异常值回退全开(宁可多记不可静默停摆)。 */
154
215
  function resolveSettings(value) {
155
216
  if (!value || typeof value !== 'object')
156
- return { ...ALWAYS_ON };
217
+ return { ...ALWAYS_ON, distillChain: [] };
157
218
  const v = value;
158
219
  const num = (x) => (typeof x === 'number' && Number.isFinite(x) && x >= 0 ? Math.floor(x) : 0);
159
220
  const rawBudgets = (v.distillBudgets ?? {});
221
+ // 路由链逐条防御:非对象条目剔除、超长截断、非法档位归空、超限截断到上限
222
+ const rawChain = Array.isArray(v.distillChain) ? v.distillChain : [];
223
+ const chain = [];
224
+ for (const item of rawChain) {
225
+ if (chain.length >= DISTILL_CHAIN_MAX)
226
+ break;
227
+ if (!item || typeof item !== 'object')
228
+ continue;
229
+ const e = item;
230
+ const eff = typeof e.reasoningEffort === 'string' && EFFORT_CHOICES.includes(e.reasoningEffort)
231
+ ? e.reasoningEffort
232
+ : '';
233
+ chain.push({
234
+ provider: typeof e.provider === 'string' ? e.provider.slice(0, 200) : '',
235
+ model: typeof e.model === 'string' ? e.model.slice(0, 200) : '',
236
+ reasoningEffort: eff,
237
+ });
238
+ }
160
239
  return {
161
240
  enabled: v.enabled !== false,
162
241
  capture: v.capture !== false,
@@ -167,6 +246,7 @@ function resolveSettings(value) {
167
246
  : '',
168
247
  distillProvider: typeof v.distillProvider === 'string' ? v.distillProvider : '',
169
248
  distillModel: typeof v.distillModel === 'string' ? v.distillModel : '',
249
+ distillChain: chain,
170
250
  distillBudgets: {
171
251
  extract: num(rawBudgets.extract),
172
252
  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';
@@ -44,31 +44,7 @@ export interface SessionInfoSource {
44
44
  vectorSearch: boolean;
45
45
  };
46
46
  }
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
- }
47
+ export type { MemoryStats } from './contract.js';
72
48
  /** 注册状态 RPC(web 侧 connection 服务可选,缺失时跳过,不影响插件主体)。 */
73
49
  export declare function registerMemoryRpc(ctx: Context, cfg: MemoryConfig, stores: {
74
50
  l0: L0Store;
package/dist/stats.js CHANGED
@@ -12,8 +12,10 @@ 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, resolveModelEfforts, resolveModelRoute } from './llm.js';
16
+ import { projectDistillChain, validateDistillChain } from './settings.js';
16
17
  import { errDetail } from './util/filelog.js';
18
+ import { snapshotTokenCost } from './token-cost.js';
17
19
  const require = createRequire(import.meta.url);
18
20
  export const PLUGIN_VERSION = require('../package.json').version;
19
21
  /** 注册状态 RPC(web 侧 connection 服务可选,缺失时跳过,不影响插件主体)。 */
@@ -148,12 +150,23 @@ async function handleEndpoint(endpoint, payload, deps) {
148
150
  switch (endpoint) {
149
151
  case 'dsh-memory/stats':
150
152
  return buildStats(cfg, stores, status);
153
+ case 'dsh-memory/token-cost': {
154
+ const p = (payload ?? {});
155
+ const granularity = p.granularity === 'week' || p.granularity === 'month' ? p.granularity : 'day';
156
+ // rangeDays 须为正整数且不超过明细保留期(tokenCost.retentionDays,0=永久保留则放行 1~3650),否则回退默认窗口
157
+ const retention = cfg.tokenCost.retentionDays;
158
+ const upper = retention > 0 ? retention : 3650;
159
+ const rawDays = p.rangeDays;
160
+ const rangeDays = typeof rawDays === 'number' && Number.isInteger(rawDays) && rawDays > 0 && rawDays <= upper ? rawDays : 0;
161
+ return snapshotTokenCost(granularity, rangeDays);
162
+ }
151
163
  case 'dsh-memory/session-mode-get': {
152
164
  if (!modes)
153
165
  throw new Error('档位存储未初始化');
154
166
  const p = (payload ?? {});
155
167
  const sessionId = expectSessionId(p.sessionId);
156
- return { sessionId, mode: modes.get(sessionId), defaultMode: modes.default };
168
+ const v = { sessionId, mode: modes.get(sessionId), defaultMode: modes.default };
169
+ return v;
157
170
  }
158
171
  case 'dsh-memory/session-mode-set': {
159
172
  if (!modes)
@@ -166,7 +179,8 @@ async function handleEndpoint(endpoint, payload, deps) {
166
179
  }
167
180
  modes.set(sessionId, p.mode);
168
181
  deps.logger.info(`[memory] 会话档位设置 session=${sessionId} mode=${p.mode}`);
169
- return { sessionId, mode: p.mode };
182
+ const v = { sessionId, mode: p.mode };
183
+ return v;
170
184
  }
171
185
  // ── 会话级统计(悬浮卡信息区;热路径端点,见 SessionInfoSource 的零 I/O 硬规则) ──
172
186
  case 'dsh-memory/session-stats': {
@@ -185,7 +199,7 @@ async function handleEndpoint(endpoint, payload, deps) {
185
199
  const chat = stores.state.forFamily('chat');
186
200
  const work = stores.state.forFamily('work');
187
201
  const lastAt = Math.max(chat.lastExtractAt, work.lastExtractAt);
188
- return {
202
+ const v = {
189
203
  supported: true,
190
204
  sessionId,
191
205
  mode,
@@ -200,13 +214,16 @@ async function handleEndpoint(endpoint, payload, deps) {
200
214
  lastExtractAt: lastAt ? new Date(lastAt).toISOString() : null,
201
215
  },
202
216
  };
217
+ return v;
203
218
  }
204
219
  case 'dsh-memory/settings-get': {
205
220
  const s = live?.get();
206
221
  const budgets = s?.distillBudgets ?? { extract: 0, dedup: 0, l2: 0, l3: 0 };
207
222
  // 蒸馏思考档位:current 是运行时值('' = 自动);effective 是能力探询后实际发送值
208
223
  // ('' = 不传,跟随模型默认);options 是当前生效模型声明的档位表(空声明 → 只显示
209
- // high,用户规则:无声明默认 high),供蒸馏思考选择器渲染;fallback 是静态部署值
224
+ // high,用户规则:无声明默认 high),fallback 是静态部署值。注:旧「蒸馏思考」
225
+ // 选择器已删,本块保留给旧 client 版本与 smoke 兼容;新 UI(路由链编辑器)
226
+ // 的逐行档位词表走 llm-models 的 efforts 字段
210
227
  let effortEffective = s?.reasoningEffort || cfg.llm.reasoningEffort;
211
228
  let effortOptions = ['high'];
212
229
  let effortRoute = null;
@@ -223,17 +240,18 @@ async function handleEndpoint(endpoint, payload, deps) {
223
240
  catch {
224
241
  /* 路由解析/探询失败保持占位(effective 用运行时||静态值) */
225
242
  }
226
- return {
243
+ const resp = {
227
244
  supported: live?.supported ?? false,
228
245
  settings: s ?? {
229
246
  enabled: true, capture: true, distill: true, recall: true,
230
- reasoningEffort: '', distillProvider: '', distillModel: '',
247
+ reasoningEffort: '', distillProvider: '', distillModel: '', distillChain: [],
231
248
  distillBudgets: { extract: 0, dedup: 0, l2: 0, l3: 0 }, distillMaxInputChars: 0,
232
249
  },
233
250
  // 静态部署上限(cordis.patch.yml):运行时开关与它取 AND
234
251
  ceilings: { capture: cfg.capture.enabled, distill: cfg.extract.enabled, recall: cfg.recall.enabled },
235
252
  effort: {
236
253
  current: s?.reasoningEffort ?? '',
254
+ // 静态 schema 与 settings-set 写入门都以 EFFORT_CHOICES 白名单校验,这里断言回窄类型
237
255
  effective: effortEffective,
238
256
  fallback: cfg.llm.reasoningEffort,
239
257
  options: effortOptions,
@@ -257,6 +275,7 @@ async function handleEndpoint(endpoint, payload, deps) {
257
275
  effective: s && s.distillMaxInputChars > 0 ? s.distillMaxInputChars : cfg.llm.maxInputChars,
258
276
  },
259
277
  };
278
+ return resp;
260
279
  }
261
280
  case 'dsh-memory/settings-set': {
262
281
  if (!live)
@@ -267,6 +286,13 @@ async function handleEndpoint(endpoint, payload, deps) {
267
286
  if (typeof patch[key] === 'boolean')
268
287
  clean[key] = patch[key];
269
288
  }
289
+ // 运行时统一路由链:结构校验后整体写入(空数组 = 回到跟随部署配置)
290
+ if (patch.distillChain !== undefined) {
291
+ const err = validateDistillChain(patch.distillChain);
292
+ if (err)
293
+ throw new Error(err);
294
+ clean.distillChain = patch.distillChain;
295
+ }
270
296
  if (patch.reasoningEffort !== undefined) {
271
297
  const v = String(patch.reasoningEffort);
272
298
  // 白名单与 schema/settings 同源(config.ts EFFORT_CHOICES)——此前此处漏扩词表,
@@ -311,7 +337,8 @@ async function handleEndpoint(endpoint, payload, deps) {
311
337
  throw new Error('开关更新载荷为空');
312
338
  await live.update(clean);
313
339
  deps.logger.info(`[memory] 设置更新:${JSON.stringify(clean)}`);
314
- return { ok: true, settings: live.get() };
340
+ const v = { ok: true, settings: live.get() };
341
+ return v;
315
342
  }
316
343
  case 'dsh-memory/list-records': {
317
344
  const p = (payload ?? {});
@@ -327,22 +354,24 @@ async function handleEndpoint(endpoint, payload, deps) {
327
354
  const wanted = offset + limit + 1;
328
355
  const hits = await stores.l1.search(p.query, Math.min(wanted, SEARCH_CAP), { type: p.type || undefined });
329
356
  const filtered = p.scene ? hits.filter((h) => h.scene_name === p.scene) : hits;
330
- return {
357
+ const resp = {
331
358
  items: filtered.slice(offset, offset + limit).map(hitToUiRecord),
332
359
  hasMore: filtered.length > offset + limit,
333
360
  total: null,
334
361
  truncated: wanted > SEARCH_CAP,
335
362
  scenes: offset === 0 ? stores.l1.distinctScenes() : undefined,
336
363
  };
364
+ return resp;
337
365
  }
338
366
  const { items, total } = stores.l1.list({ type: p.type || undefined, scene: p.scene || undefined, limit, offset });
339
- return {
367
+ const resp = {
340
368
  items: items.map(hitToUiRecord),
341
369
  hasMore: offset + items.length < total,
342
370
  total,
343
371
  truncated: false,
344
372
  scenes: offset === 0 ? stores.l1.distinctScenes() : undefined,
345
373
  };
374
+ return resp;
346
375
  }
347
376
  case 'dsh-memory/scenes': {
348
377
  // 两族拼接展示(浏览器保持混合视图;路径冲突时后写入的族覆盖显示名,读取仍各自独立)
@@ -370,8 +399,10 @@ async function handleEndpoint(endpoint, payload, deps) {
370
399
  return { lines: readLogTail(join(dataDir, 'memory.log'), Math.min(Math.max(Number(p.lines) || 200, 1), 1000)) };
371
400
  }
372
401
  case 'dsh-memory/rebuild-status': {
373
- if (!rebuild)
374
- return { supported: false, running: false, phase: 'idle' };
402
+ if (!rebuild) {
403
+ const v = { supported: false, running: false, phase: 'idle' };
404
+ return v;
405
+ }
375
406
  return rebuild.getStatus();
376
407
  }
377
408
  case 'dsh-memory/rebuild-start': {
@@ -396,6 +427,7 @@ async function handleEndpoint(endpoint, payload, deps) {
396
427
  // ── 蒸馏模型选择器(用户已配置的供应商路由) ──
397
428
  case 'dsh-memory/llm-providers': {
398
429
  // 供应商目录(已注册适配器的活动路由)+ 默认选择 + 当前覆盖与实际生效路由
430
+ // ——蒸馏路由链编辑器的数据源(供应商下拉/默认模型展示/链状态 chain 块)
399
431
  let providers = [];
400
432
  try {
401
433
  providers = deps.ctx.llm.listProviders();
@@ -414,14 +446,21 @@ async function handleEndpoint(endpoint, payload, deps) {
414
446
  }
415
447
  const s = live?.get();
416
448
  const current = { provider: s?.distillProvider ?? '', model: s?.distillModel ?? '' };
449
+ // 统一路由链块:current = 运行时链(含旧键投影);static = 部署静态回退链;
450
+ // effective = buildRouteChain 语义的实际链(主路由 + 有效条目去重,每条带档位候选);
451
+ // source 标记当前链来自运行时还是部署静态(UI 的跟随态/接管态判定)
452
+ const chainCurrent = projectDistillChain(s);
453
+ let effectiveChain = [];
417
454
  let effective = null;
418
455
  try {
419
- effective = await resolveModelRoute(deps.ctx, effectiveCfg(cfg, live));
456
+ const cfgView = effectiveCfg(cfg, live);
457
+ effective = await resolveModelRoute(deps.ctx, cfgView);
458
+ effectiveChain = buildRouteChain({ provider: effective.provider, model: effective.model, effort: cfgView.llm.primaryEffort || '' }, cfgView.llm.fallbacks, cfgView.llm.reasoningEffort);
420
459
  }
421
460
  catch {
422
461
  effective = null; // 无法解析(无默认选择且未覆盖)时 UI 显示占位
423
462
  }
424
- return {
463
+ const resp = {
425
464
  supported: true,
426
465
  providers,
427
466
  default: def,
@@ -431,7 +470,14 @@ async function handleEndpoint(endpoint, payload, deps) {
431
470
  // 所选供应商是否仍在已注册路由中(用户删掉供应商后提示回退)
432
471
  currentRegistered: current.provider === '' || providers.some((p) => p.id === current.provider),
433
472
  effective,
473
+ chain: {
474
+ current: chainCurrent,
475
+ static: cfg.llm.fallbacks ?? [],
476
+ effectiveChain,
477
+ source: chainCurrent.length ? 'runtime' : 'static',
478
+ },
434
479
  };
480
+ return resp;
435
481
  }
436
482
  case 'dsh-memory/llm-models': {
437
483
  const p = (payload ?? {});
@@ -445,16 +491,40 @@ async function handleEndpoint(endpoint, payload, deps) {
445
491
  deps.ctx.llm.listModels(p.provider),
446
492
  new Promise((_, reject) => setTimeout(() => reject(new Error('模型列表查询超时')), 8000)),
447
493
  ]);
448
- return {
449
- provider: p.provider,
450
- models: models.map((m) => ({ id: m.id, name: m.name, description: m.description ?? null })),
451
- };
494
+ // 每个模型附思考档位能力表(resolveModelInfo 复用 effortCache,本地快照不触
495
+ // 网):统一路由链编辑器的逐行档位下拉数据源。整体限时限流——第三方适配器的
496
+ // resolveModelInfo 若为远端查询会拖死端点(client 5s 轮询放大),超时降级空表
497
+ // (探询失败/未声明同样 → 空表,UI 只显示「跟随部署配置」)
498
+ const baseModels = models.map((m) => ({ id: m.id, name: m.name, description: m.description ?? null, efforts: [] }));
499
+ const providerId = p.provider;
500
+ const withEfforts = await Promise.race([
501
+ (async () => {
502
+ const out = [];
503
+ for (const m of models) {
504
+ let efforts = [];
505
+ try {
506
+ efforts = (await resolveModelEfforts(deps.ctx, providerId, m.id))?.efforts ?? [];
507
+ }
508
+ catch {
509
+ efforts = [];
510
+ }
511
+ out.push({ id: m.id, name: m.name, description: m.description ?? null, efforts });
512
+ }
513
+ return out;
514
+ })(),
515
+ new Promise((resolve) => setTimeout(() => resolve(baseModels), 4000)),
516
+ ]);
517
+ const resp = { provider: p.provider, models: withEfforts };
518
+ return resp;
452
519
  }
453
520
  // ── 嵌入源(远程/本地/关闭 三态)与模型管理 ──
454
521
  case 'dsh-memory/embedding-state-get': {
455
- if (!embedManager)
456
- return { supported: false };
457
- return { supported: true, ...(await embedManager.snapshot()) };
522
+ if (!embedManager) {
523
+ const v = { supported: false };
524
+ return v;
525
+ }
526
+ const v = { supported: true, ...(await embedManager.snapshot()) };
527
+ return v;
458
528
  }
459
529
  case 'dsh-memory/embedding-source-set': {
460
530
  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
+ }
@@ -0,0 +1,173 @@
1
+ function emptyCostAggregate() {
2
+ return { calls: 0, inputChars: 0, outputTokens: 0, reasoningTokens: 0, avgOutputTokens: 0, medianOutputTokens: 0 };
3
+ }
4
+ /** 已排序序列的中位数(偶数个取中间两者平均;空返回 0)。 */
5
+ function medianOf(sorted) {
6
+ const n = sorted.length;
7
+ if (n === 0)
8
+ return 0;
9
+ const mid = Math.floor(n / 2);
10
+ return n % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
11
+ }
12
+ export class CostLedger {
13
+ db = null;
14
+ logger;
15
+ stmtInsert = null;
16
+ stmtDelete = null;
17
+ /** init 是否成功(未就绪 = 宿主库降级,方法全部返回零值不抛错)。 */
18
+ get ready() {
19
+ return this.stmtInsert !== null;
20
+ }
21
+ /** 建表 + 迁移 + 语句缓存(MemoryDb.initSchema 内调用;失败冒泡触发库级降级)。 */
22
+ init(db, logger) {
23
+ this.db = db;
24
+ this.logger = logger;
25
+ db.exec(`
26
+ CREATE TABLE IF NOT EXISTS token_cost (
27
+ ts INTEGER NOT NULL,
28
+ provider TEXT NOT NULL,
29
+ model TEXT NOT NULL,
30
+ layer TEXT NOT NULL,
31
+ input_chars INTEGER NOT NULL DEFAULT 0,
32
+ output_tokens INTEGER NOT NULL DEFAULT 0,
33
+ reasoning_tokens INTEGER NOT NULL DEFAULT 0
34
+ )
35
+ `);
36
+ // 迁移:provider/model 复合键引入前的旧表补 provider 列(历史行回填 unknown)
37
+ const cols = db.prepare('PRAGMA table_info(token_cost)').all();
38
+ if (!cols.some((c) => c.name === 'provider')) {
39
+ db.exec("ALTER TABLE token_cost ADD COLUMN provider TEXT NOT NULL DEFAULT 'unknown'");
40
+ }
41
+ db.exec('CREATE INDEX IF NOT EXISTS idx_token_cost_ts ON token_cost(ts)');
42
+ this.stmtInsert = db.prepare('INSERT INTO token_cost (ts, provider, model, layer, input_chars, output_tokens, reasoning_tokens) VALUES (?, ?, ?, ?, ?, ?, ?)');
43
+ this.stmtDelete = db.prepare('DELETE FROM token_cost WHERE ts < ?');
44
+ }
45
+ /**
46
+ * 记录一次蒸馏调用成本(明细表,写入时按 retentionDays 滚动清理;0 = 永久保留)。
47
+ * 失败/成功都记(token 照烧);记账失败记 warn 但不阻断蒸馏(成本看板是增强能力)。
48
+ */
49
+ insertCostCall(provider, model, layer, inputChars, outputTokens, reasoningTokens, retentionDays) {
50
+ if (!this.db || !this.stmtInsert || !this.stmtDelete)
51
+ return;
52
+ try {
53
+ this.stmtInsert.run(Date.now(), provider, model, layer, Math.max(0, Math.round(inputChars)), Math.max(0, Math.round(outputTokens)), Math.max(0, Math.round(reasoningTokens)));
54
+ if (retentionDays > 0)
55
+ this.stmtDelete.run(Date.now() - retentionDays * 24 * 3600_000);
56
+ }
57
+ catch (err) {
58
+ this.logger?.warn(`[memory][sqlite] token_cost 记账失败: ${err instanceof Error ? err.message : String(err)}`);
59
+ }
60
+ }
61
+ /**
62
+ * 查询 token_cost 单窗口聚合(成本看板用;since 为毫秒起点,0 = 全量)。
63
+ * 输入口径:inputChars 是字符(llm 流拿不到输入 token,沿用 llm-usage 的字符折算口径)。
64
+ * median 需取 output_tokens 序列在 JS 侧算(SQLite 无内置 median 函数)。
65
+ */
66
+ aggregateCost(since) {
67
+ if (!this.ready || !this.db)
68
+ return { total: emptyCostAggregate(), byModel: [] };
69
+ try {
70
+ const total = this.db
71
+ .prepare(`SELECT COUNT(*) AS calls,
72
+ COALESCE(SUM(input_chars), 0) AS inputChars,
73
+ COALESCE(SUM(output_tokens), 0) AS outputTokens,
74
+ COALESCE(SUM(reasoning_tokens), 0) AS reasoningTokens,
75
+ COALESCE(AVG(output_tokens), 0) AS avgOutputTokens
76
+ FROM token_cost WHERE ts >= ?`)
77
+ .get(since);
78
+ const tokenRows = this.db
79
+ .prepare('SELECT output_tokens FROM token_cost WHERE ts >= ? ORDER BY output_tokens')
80
+ .all(since);
81
+ const byModel = this.db
82
+ .prepare(`SELECT provider, model, COUNT(*) AS calls,
83
+ COALESCE(SUM(input_chars), 0) AS inputChars,
84
+ COALESCE(SUM(output_tokens), 0) AS outputTokens,
85
+ COALESCE(SUM(reasoning_tokens), 0) AS reasoningTokens
86
+ FROM token_cost WHERE ts >= ? GROUP BY provider, model ORDER BY outputTokens DESC`)
87
+ .all(since);
88
+ return {
89
+ total: {
90
+ calls: total.calls,
91
+ inputChars: total.inputChars,
92
+ outputTokens: total.outputTokens,
93
+ reasoningTokens: total.reasoningTokens,
94
+ avgOutputTokens: total.avgOutputTokens,
95
+ medianOutputTokens: medianOf(tokenRows.map((r) => r.output_tokens)),
96
+ },
97
+ byModel,
98
+ };
99
+ }
100
+ catch {
101
+ return { total: emptyCostAggregate(), byModel: [] };
102
+ }
103
+ }
104
+ /**
105
+ * 按层级归并聚合(l1 = l1-extract + l1-dedup;成本看板层级表格用)。
106
+ * 降级/异常返回空数组,不抛错。
107
+ */
108
+ aggregateCostByLayer(since) {
109
+ if (!this.ready || !this.db)
110
+ return [];
111
+ try {
112
+ const rows = this.db
113
+ .prepare(`SELECT CASE WHEN layer IN ('l1-extract','l1-dedup') THEN 'l1' ELSE layer END AS layer,
114
+ COUNT(*) AS calls,
115
+ COALESCE(SUM(input_chars), 0) AS inputChars,
116
+ COALESCE(SUM(output_tokens), 0) AS outputTokens,
117
+ COALESCE(SUM(reasoning_tokens), 0) AS reasoningTokens,
118
+ COALESCE(AVG(output_tokens), 0) AS avgOutputTokens
119
+ FROM token_cost WHERE ts >= ? GROUP BY layer ORDER BY layer`)
120
+ .all(since);
121
+ // 中位数需取 output_tokens 序列在 JS 侧算(SQLite 无内置 median)
122
+ const tokenRows = this.db
123
+ .prepare(`SELECT CASE WHEN layer IN ('l1-extract','l1-dedup') THEN 'l1' ELSE layer END AS layer,
124
+ output_tokens
125
+ FROM token_cost WHERE ts >= ? ORDER BY layer, output_tokens`)
126
+ .all(since);
127
+ const medianByLayer = new Map();
128
+ for (const r of tokenRows) {
129
+ const arr = medianByLayer.get(r.layer);
130
+ if (arr)
131
+ arr.push(r.output_tokens);
132
+ else
133
+ medianByLayer.set(r.layer, [r.output_tokens]);
134
+ }
135
+ return rows.map((r) => ({
136
+ ...r,
137
+ medianOutputTokens: medianOf(medianByLayer.get(r.layer) ?? []),
138
+ }));
139
+ }
140
+ catch {
141
+ return [];
142
+ }
143
+ }
144
+ /**
145
+ * 按时间桶(bucketMs 毫秒)+ model 聚合,返回扁平行。
146
+ * offsetMs 把桶边界对齐本地时区;layer 为空=全部,'l1' 归并 extract/dedup,其余精确匹配。
147
+ * 趋势图与「日均/周均/月均 + 中位数」统计共用:JS 侧按不同 bucketMs 调三次再聚合。
148
+ */
149
+ aggregateByBucket(bucketMs, offsetMs, since, layer) {
150
+ if (!this.ready || !this.db)
151
+ return [];
152
+ try {
153
+ let sql = `SELECT CAST((ts + ?) / ? AS INTEGER) AS bucket, provider, model,
154
+ COUNT(*) AS calls,
155
+ COALESCE(SUM(output_tokens), 0) AS outputTokens,
156
+ COALESCE(SUM(reasoning_tokens), 0) AS reasoningTokens
157
+ FROM token_cost WHERE ts >= ?`;
158
+ const params = [offsetMs, bucketMs, since];
159
+ if (layer === 'l1') {
160
+ sql += ` AND layer IN ('l1-extract','l1-dedup')`;
161
+ }
162
+ else if (layer) {
163
+ sql += ` AND layer = ?`;
164
+ params.push(layer);
165
+ }
166
+ sql += ` GROUP BY bucket, provider, model ORDER BY bucket, provider, model`;
167
+ return this.db.prepare(sql).all(...params);
168
+ }
169
+ catch {
170
+ return [];
171
+ }
172
+ }
173
+ }