dsh-layered-memory 0.8.5 → 0.8.7

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.
@@ -0,0 +1,138 @@
1
+ /**
2
+ * 召回去重存储:sessionId → 已注入 L1 记录 id 集合的持久化映射。
3
+ *
4
+ * 语义(2026-08-24 设计共识):
5
+ * - 同会话内已注入过的记忆不再重复注入(模型上下文已持有,重复注入浪费 token);
6
+ * - 压制粒度 = 记录 id——去重合并更新会换新 id,新内容天然解除压制重新注入;
7
+ * - compact/clear 事件重置(上下文被压缩/清空,注入内容已丢失);resume 不重置;
8
+ * - 热路径(召回 pre-step)同步内存读取,mark/reset 写穿持久化(session-modes 同款:
9
+ * 串行化原子写 + 失败降级内存态),任何 I/O 失败绝不抛进召回路径。
10
+ */
11
+ import * as path from 'node:path';
12
+ import { errDetail } from '../util/filelog.js';
13
+ import { atomicWriteJson, ensureDir, readJsonIfExists } from './io.js';
14
+ /** 会话条目上限(按 updatedAt 淘汰最旧;防文件无限增长)。 */
15
+ export const RECALL_DEDUPE_SESSION_CAP = 200;
16
+ /** 单会话记录 id 上限(按插入序淘汰最旧;Set 迭代序即插入序)。 */
17
+ export const RECALL_DEDUPE_IDS_CAP = 512;
18
+ /** 条目过期清理(90 天未更新即丢弃,与 session-modes 同款量级)。 */
19
+ const PRUNE_MS = 90 * 24 * 3600_000;
20
+ export class RecallDedupeStore {
21
+ logger;
22
+ file;
23
+ entries = new Map();
24
+ persistFailed = false;
25
+ /** 串行化持久化写(避免并发原子写撞临时文件名);init 链最前(先载入再落盘,防丢更新)。 */
26
+ writeChain;
27
+ constructor(dataDir, logger) {
28
+ this.logger = logger;
29
+ this.file = path.join(dataDir, 'recall-dedupe.json');
30
+ this.writeChain = this.init();
31
+ }
32
+ /** 载入持久化映射(合并进内存——构造与载入之间发生的 mark 不丢);失败降级内存态。 */
33
+ async init() {
34
+ const data = await readJsonIfExists(this.file);
35
+ if (!data?.sessions || typeof data.sessions !== 'object')
36
+ return;
37
+ const now = Date.now();
38
+ let count = 0;
39
+ for (const [sid, entry] of Object.entries(data.sessions)) {
40
+ if (!Array.isArray(entry?.recordIds))
41
+ continue;
42
+ if (now - (entry.updatedAt ?? 0) > PRUNE_MS)
43
+ continue;
44
+ const existing = this.entries.get(sid);
45
+ if (existing) {
46
+ // 合并:构造后、载入完成前已发生的 mark(保留较大 updatedAt)
47
+ for (const id of entry.recordIds)
48
+ existing.ids.add(id);
49
+ existing.updatedAt = Math.max(existing.updatedAt, entry.updatedAt ?? 0);
50
+ }
51
+ else {
52
+ this.entries.set(sid, { ids: new Set(entry.recordIds), updatedAt: entry.updatedAt ?? now });
53
+ }
54
+ count++;
55
+ }
56
+ if (count > 0)
57
+ this.logger?.info(`[memory] 召回去重记录载入 ${count} 个会话`);
58
+ }
59
+ /** 该会话的已注入集合(热路径同步读;未出现过的会话返回空集合,惰性建条)。 */
60
+ seen(sessionId) {
61
+ let entry = this.entries.get(sessionId);
62
+ if (!entry) {
63
+ entry = { ids: new Set(), updatedAt: 0 };
64
+ this.entries.set(sessionId, entry);
65
+ }
66
+ return entry.ids;
67
+ }
68
+ /** 标记本轮实际注入的记录 id(写穿;调用方保证只传模型真实看到的条目)。 */
69
+ mark(sessionId, recordIds) {
70
+ if (recordIds.length === 0)
71
+ return;
72
+ const ids = this.seen(sessionId);
73
+ for (const id of recordIds)
74
+ ids.add(id);
75
+ // 插入序淘汰最旧(Set 迭代序 = 插入序)
76
+ while (ids.size > RECALL_DEDUPE_IDS_CAP) {
77
+ const oldest = ids.values().next().value;
78
+ if (oldest === undefined)
79
+ break;
80
+ ids.delete(oldest);
81
+ }
82
+ const entry = this.entries.get(sessionId);
83
+ entry.updatedAt = Date.now();
84
+ this.writeChain = this.writeChain.then(() => this.persist());
85
+ }
86
+ /** 清空该会话的记录(compact/clear 后上下文已丢失,记忆需可重新注入)。 */
87
+ reset(sessionId) {
88
+ if (!this.entries.has(sessionId))
89
+ return;
90
+ this.entries.delete(sessionId);
91
+ this.writeChain = this.writeChain.then(() => this.persist());
92
+ }
93
+ /** 等待在途持久化写完成(测试/停机用)。 */
94
+ flush() {
95
+ return this.writeChain;
96
+ }
97
+ async persist() {
98
+ try {
99
+ await ensureDir(path.dirname(this.file));
100
+ await atomicWriteJson(this.file, this.serialize());
101
+ this.persistFailed = false;
102
+ }
103
+ catch (err) {
104
+ if (!this.persistFailed) {
105
+ this.persistFailed = true;
106
+ this.logger?.warn(`[memory] 召回去重持久化失败(降级内存态): ${errDetail(err)}`);
107
+ }
108
+ }
109
+ }
110
+ serialize() {
111
+ const now = Date.now();
112
+ // 超期清理 + 条数上限(按 updatedAt 淘汰最旧)
113
+ for (const [sid, e] of this.entries) {
114
+ if (now - e.updatedAt > PRUNE_MS && e.updatedAt > 0)
115
+ this.entries.delete(sid);
116
+ }
117
+ while (this.entries.size > RECALL_DEDUPE_SESSION_CAP) {
118
+ let oldest;
119
+ let oldestAt = Infinity;
120
+ for (const [sid, e] of this.entries) {
121
+ if (e.updatedAt > 0 && e.updatedAt < oldestAt) {
122
+ oldest = sid;
123
+ oldestAt = e.updatedAt;
124
+ }
125
+ }
126
+ if (oldest === undefined)
127
+ break; // 只剩惰性空条目(updatedAt=0),不占文件体积可留待过期清理
128
+ this.entries.delete(oldest);
129
+ }
130
+ const sessions = {};
131
+ for (const [sid, e] of this.entries) {
132
+ if (e.ids.size === 0)
133
+ continue; // 空集合不落盘
134
+ sessions[sid] = { recordIds: [...e.ids], updatedAt: e.updatedAt };
135
+ }
136
+ return { version: 1, sessions };
137
+ }
138
+ }
@@ -60,8 +60,6 @@ export declare class RuntimeInstaller {
60
60
  * runNpm 起跑前复查即不再起新进程(否则回退的 npm 会跑到自然结束且无法再取消)。
61
61
  */
62
62
  cancel(): boolean;
63
- /** 从 runtime 目录解析已安装的 transformers 模块(LocalEmbeddingService 用)。 */
64
- resolveModule(): unknown;
65
63
  private pushLine;
66
64
  /** 跑一次 npm 子进程(采集尾行 + 超时 kill),返回退出码(null = 被杀死/启动失败)。 */
67
65
  private runNpm;
@@ -14,7 +14,6 @@
14
14
  * - 幂等:已装版本 == 目标版本直接就绪;版本漂移(插件升级换了钉死版本)重装覆盖。
15
15
  */
16
16
  import { spawn } from 'node:child_process';
17
- import { createRequire } from 'node:module';
18
17
  import { promises as fs } from 'node:fs';
19
18
  import * as path from 'node:path';
20
19
  import { fileURLToPath } from 'node:url';
@@ -139,11 +138,6 @@ export class RuntimeInstaller {
139
138
  this.child?.kill();
140
139
  return true;
141
140
  }
142
- /** 从 runtime 目录解析已安装的 transformers 模块(LocalEmbeddingService 用)。 */
143
- resolveModule() {
144
- const req = createRequire(path.join(this.runtimeDir, 'package.json'));
145
- return req(RuntimeInstaller.packageName);
146
- }
147
141
  pushLine(line) {
148
142
  const lines = this.progress.lastLines;
149
143
  lines.push(line.length > 300 ? line.slice(0, 300) + '…' : line);
@@ -1,5 +1,22 @@
1
1
  /** 标准 RRF 常数(原论文值);k 越大越偏向低排名项(分布更平滑)。 */
2
2
  export declare const RRF_K = 60;
3
+ /** 衰减地板(#29 时效加权的安全边界):老记忆最多损失一半排序分,永不沉底。
4
+ * 内部常量不进配置——它是安全机制不是调参旋钮。 */
5
+ export declare const DECAY_FLOOR = 0.5;
6
+ /**
7
+ * 时效衰减加权(#29,读路径专用):score × max(FLOOR, 0.5^(Δ天/半衰期)) 后重排序。
8
+ *
9
+ * - Δ 按 updated_at(内容版本时间)起算,缺失/非法按最老 → 地板接管(零特判分支);
10
+ * - 乘法保相关性主导:只在相关度相近的候选之间轮转名次(名额新鲜度),不淘汰不
11
+ * 硬过滤——score≈0 的新记忆乘什么都是 ≈0;hit 的原 score 字段不被改写(排序用
12
+ * 加权分,展示仍反映检索相关度);
13
+ * - halfLifeDays ≤ 0 直接原样返回(开关关闭);
14
+ * - 仅用于召回/工具检索;searchCandidates(去重候选)不得应用——写路径找同语义
15
+ * 旧记录要无视新旧,衰减会让去重漏检(同事实双记录)。
16
+ */
17
+ export declare function applyDecayWeight<T extends {
18
+ score: number;
19
+ }>(hits: T[], halfLifeDays: number, updatedAtOf: (hit: T) => number | undefined, now?: number): T[];
3
20
  /**
4
21
  * RRF 融合多个已排序列表:每项得分 = 各列表 1/(k + rank + 1) 之和。
5
22
  * 出现在多个列表的项得分累加,按得分降序返回(附 rrfScore)。
@@ -10,6 +10,35 @@
10
10
  import { tokenize } from '../util/text.js';
11
11
  /** 标准 RRF 常数(原论文值);k 越大越偏向低排名项(分布更平滑)。 */
12
12
  export const RRF_K = 60;
13
+ /** 衰减地板(#29 时效加权的安全边界):老记忆最多损失一半排序分,永不沉底。
14
+ * 内部常量不进配置——它是安全机制不是调参旋钮。 */
15
+ export const DECAY_FLOOR = 0.5;
16
+ /**
17
+ * 时效衰减加权(#29,读路径专用):score × max(FLOOR, 0.5^(Δ天/半衰期)) 后重排序。
18
+ *
19
+ * - Δ 按 updated_at(内容版本时间)起算,缺失/非法按最老 → 地板接管(零特判分支);
20
+ * - 乘法保相关性主导:只在相关度相近的候选之间轮转名次(名额新鲜度),不淘汰不
21
+ * 硬过滤——score≈0 的新记忆乘什么都是 ≈0;hit 的原 score 字段不被改写(排序用
22
+ * 加权分,展示仍反映检索相关度);
23
+ * - halfLifeDays ≤ 0 直接原样返回(开关关闭);
24
+ * - 仅用于召回/工具检索;searchCandidates(去重候选)不得应用——写路径找同语义
25
+ * 旧记录要无视新旧,衰减会让去重漏检(同事实双记录)。
26
+ */
27
+ export function applyDecayWeight(hits, halfLifeDays, updatedAtOf, now = Date.now()) {
28
+ if (!(halfLifeDays > 0) || hits.length === 0)
29
+ return hits;
30
+ const weight = (h) => {
31
+ const t = updatedAtOf(h);
32
+ if (t == null || !Number.isFinite(t))
33
+ return DECAY_FLOOR;
34
+ const days = Math.max(0, (now - t) / 86_400_000);
35
+ return Math.max(DECAY_FLOOR, 0.5 ** (days / halfLifeDays));
36
+ };
37
+ return hits
38
+ .map((h) => ({ h, weighted: h.score * weight(h) }))
39
+ .sort((a, b) => b.weighted - a.weighted)
40
+ .map((x) => x.h);
41
+ }
13
42
  /**
14
43
  * RRF 融合多个已排序列表:每项得分 = 各列表 1/(k + rank + 1) 之和。
15
44
  * 出现在多个列表的项得分累加,按得分降序返回(附 rrfScore)。
@@ -9,6 +9,45 @@ export interface StoreCapabilities {
9
9
  ftsSearch: boolean;
10
10
  vectorSearch: boolean;
11
11
  }
12
+ /** token_cost 单窗口成本聚合(成本看板用)。 */
13
+ export interface CostAggregate {
14
+ calls: number;
15
+ inputChars: number;
16
+ outputTokens: number;
17
+ reasoningTokens: number;
18
+ /** 单次调用输出 token 均值(无数据为 0)。 */
19
+ avgOutputTokens: number;
20
+ /** 单次调用输出 token 中位数(无数据为 0)。 */
21
+ medianOutputTokens: number;
22
+ }
23
+ /** 按 model 分组的成本行(成本看板用)。 */
24
+ export interface CostByModel {
25
+ provider: string;
26
+ model: string;
27
+ calls: number;
28
+ inputChars: number;
29
+ outputTokens: number;
30
+ reasoningTokens: number;
31
+ }
32
+ /** 按层级(l1/l2/l3 归并)分组的成本行。 */
33
+ export interface CostByLayer {
34
+ layer: string;
35
+ calls: number;
36
+ inputChars: number;
37
+ outputTokens: number;
38
+ reasoningTokens: number;
39
+ avgOutputTokens: number;
40
+ medianOutputTokens: number;
41
+ }
42
+ /** 按时间桶 + provider/model 聚合的扁平行(趋势图与日均/周均/月均 + 中位数统计共用)。 */
43
+ export interface BucketRow {
44
+ bucket: number;
45
+ provider: string;
46
+ model: string;
47
+ calls: number;
48
+ outputTokens: number;
49
+ reasoningTokens: number;
50
+ }
12
51
  /** L1 检索命中(含 BM25/余弦归一分数)。 */
13
52
  export interface L1SearchHit {
14
53
  id: string;
@@ -44,6 +83,9 @@ export declare class MemoryDb {
44
83
  private stmtL1FtsDelete;
45
84
  private stmtL1FtsSearch;
46
85
  private stmtL1FtsSearchFamily;
86
+ /** token_cost 明细写入 / 滚动清理语句(构造期 prepare 缓存)。 */
87
+ private stmtInsertCost;
88
+ private stmtDeleteCost;
47
89
  private stmtUpsertL0;
48
90
  private stmtGetL0;
49
91
  /** 主表存在性点查(同 L1:防御性 FTS 删除的前置判断)。 */
@@ -145,6 +187,32 @@ export declare class MemoryDb {
145
187
  searchL1Vector(embedding: Float32Array, topK: number, family?: string): L1SearchHit[];
146
188
  /** 批量 upsert L0 消息(元数据 + FTS;embeddings 与 records 等长,可省略)。 */
147
189
  upsertL0Batch(records: L0MessageRecord[], embeddings?: Array<Float32Array | undefined>): boolean;
190
+ /**
191
+ * 记录一次蒸馏调用成本(明细表,写入时按 retentionDays 滚动清理;0 = 永久保留)。
192
+ * 失败/成功都记(token 照烧);记账失败记 warn 但不阻断蒸馏(成本看板是增强能力)。
193
+ */
194
+ insertCostCall(provider: string, model: string, layer: string, inputChars: number, outputTokens: number, reasoningTokens: number, retentionDays: number): void;
195
+ /**
196
+ * 查询 token_cost 单窗口聚合(成本看板用;since 为毫秒起点,0 = 全量)。
197
+ * 输入口径:inputChars 是字符(llm 流拿不到输入 token,沿用 llm-usage 的字符折算口径)。
198
+ * 成本看板是增强能力:降级态/查询异常一律返回零值,不向上抛错。
199
+ * median 需取 output_tokens 序列在 JS 侧算(SQLite 无内置 median 函数)。
200
+ */
201
+ aggregateCost(since: number): {
202
+ total: CostAggregate;
203
+ byModel: CostByModel[];
204
+ };
205
+ /**
206
+ * 按层级归并聚合(l1 = l1-extract + l1-dedup;成本看板层级表格用)。
207
+ * 降级/异常返回空数组,不抛错。
208
+ */
209
+ aggregateCostByLayer(since: number): CostByLayer[];
210
+ /**
211
+ * 按时间桶(bucketMs 毫秒)+ model 聚合,返回扁平行。
212
+ * offsetMs 把桶边界对齐本地时区;layer 为空=全部,'l1' 归并 extract/dedup,其余精确匹配。
213
+ * 趋势图与「日均/周均/月均 + 中位数」统计共用:JS 侧按不同 bucketMs 调三次再聚合。
214
+ */
215
+ aggregateByBucket(bucketMs: number, offsetMs: number, since: number, layer: string): BucketRow[];
148
216
  countL0(): number;
149
217
  /** 统计 recorded_at >= iso 的消息数(状态面板"今日捕获"用)。 */
150
218
  countL0Since(iso: string): number;
@@ -35,6 +35,17 @@ function chunkIds(ids) {
35
35
  out.push(ids.slice(i, i + IN_CHUNK));
36
36
  return out;
37
37
  }
38
+ function emptyCostAggregate() {
39
+ return { calls: 0, inputChars: 0, outputTokens: 0, reasoningTokens: 0, avgOutputTokens: 0, medianOutputTokens: 0 };
40
+ }
41
+ /** 已排序序列的中位数(偶数个取中间两者平均;空返回 0)。 */
42
+ function medianOf(sorted) {
43
+ const n = sorted.length;
44
+ if (n === 0)
45
+ return 0;
46
+ const mid = Math.floor(n / 2);
47
+ return n % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
48
+ }
38
49
  export class MemoryDb {
39
50
  db;
40
51
  degraded = false;
@@ -56,6 +67,9 @@ export class MemoryDb {
56
67
  stmtL1FtsDelete;
57
68
  stmtL1FtsSearch;
58
69
  stmtL1FtsSearchFamily;
70
+ /** token_cost 明细写入 / 滚动清理语句(构造期 prepare 缓存)。 */
71
+ stmtInsertCost;
72
+ stmtDeleteCost;
59
73
  stmtUpsertL0;
60
74
  stmtGetL0;
61
75
  /** 主表存在性点查(同 L1:防御性 FTS 删除的前置判断)。 */
@@ -325,6 +339,25 @@ export class MemoryDb {
325
339
  this.stmtGetL0 = this.db.prepare('SELECT session_id, role, message_text, recorded_at, timestamp FROM l0_conversations WHERE record_id = ?');
326
340
  this.stmtL0Exists = this.db.prepare('SELECT 1 FROM l0_conversations WHERE record_id = ?');
327
341
  this.prepareL0VecStatements();
342
+ // ── token_cost:蒸馏成本明细表(成本看板用;保留期经 retentionDays 滚动清理) ──
343
+ this.db.exec(`
344
+ CREATE TABLE IF NOT EXISTS token_cost (
345
+ ts INTEGER NOT NULL,
346
+ provider TEXT NOT NULL,
347
+ model TEXT NOT NULL,
348
+ layer TEXT NOT NULL,
349
+ input_chars INTEGER NOT NULL DEFAULT 0,
350
+ output_tokens INTEGER NOT NULL DEFAULT 0,
351
+ reasoning_tokens INTEGER NOT NULL DEFAULT 0
352
+ )
353
+ `);
354
+ // 迁移:provider/model 复合键引入前的旧表补 provider 列(历史行回填 unknown)
355
+ if (this.tableExists('token_cost') && !this.hasColumn('token_cost', 'provider')) {
356
+ this.db.exec("ALTER TABLE token_cost ADD COLUMN provider TEXT NOT NULL DEFAULT 'unknown'");
357
+ }
358
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_token_cost_ts ON token_cost(ts)');
359
+ this.stmtInsertCost = this.db.prepare('INSERT INTO token_cost (ts, provider, model, layer, input_chars, output_tokens, reasoning_tokens) VALUES (?, ?, ?, ?, ?, ?, ?)');
360
+ this.stmtDeleteCost = this.db.prepare('DELETE FROM token_cost WHERE ts < ?');
328
361
  // ── FTS5 全文索引(建表失败仅停用 FTS,不降级整个库) ──
329
362
  try {
330
363
  // 索引重建判据(FTS5 无法 ALTER,只能 drop 后从源表全量回灌):
@@ -670,12 +703,19 @@ export class MemoryDb {
670
703
  /** 事务内的单条写入体(upsertL1 / upsertL1Batch 共用;调用方负责 BEGIN/COMMIT)。 */
671
704
  upsertL1InTx(record, embedding) {
672
705
  const ts = timestampsToDb(record.timestamps);
706
+ // 绑定层字段兜底(取 schema 列默认):旧版 JSONL 等外部数据缺字段时 undefined
707
+ // 无法绑定(node:sqlite 拒绝绑定),曾致旧版导入逐条全挂、每次启动无限重试(#28)。
708
+ // 主表与 FTS 两条语句共用同源归一化值;type 归一化后 familyForType 也不再收到 undefined。
709
+ const type = record.type ?? '';
710
+ const priority = record.priority ?? 50;
711
+ const sceneName = record.scene_name ?? '';
712
+ const family = record.family ?? familyForType(type);
673
713
  // 防御性 FTS 删除的前置点查(主键索引,微秒级):record_id 在 FTS 表是 UNINDEXED,
674
714
  // 按 id DELETE 是 O(N) 全表扫描——导入/重建/重嵌等"全新增"路径曾为每条记录白付一次
675
715
  // 全扫(批量写整体 O(N²))。只有主表已有该行(覆盖/合并)才可能有旧 FTS 行需要删。
676
716
  // 同批重复 id 也能正确处理:首条插入后,第二条的点查在同一事务内已见新行。
677
717
  const ftsExisted = this.ftsAvailable ? this.stmtL1Exists.get(record.id) !== undefined : false;
678
- this.stmtUpsertL1.run(record.id, record.content, record.type, record.priority, record.scene_name, record.sessionId ?? 'default', record.version ?? 0, ts.str, ts.start, ts.end, toIso(record.createdAt), toIso(record.updatedAt), JSON.stringify(record.metadata ?? {}), record.family ?? familyForType(record.type));
718
+ this.stmtUpsertL1.run(record.id, record.content, type, priority, sceneName, record.sessionId ?? 'default', record.version ?? 0, ts.str, ts.start, ts.end, toIso(record.createdAt), toIso(record.updatedAt), JSON.stringify(record.metadata ?? {}), family);
679
719
  // vec0 不支持 ON CONFLICT → 先删后插;零向量跳过(cosine 未定义)
680
720
  if (this.stmtDeleteL1Vec && this.stmtInsertL1Vec) {
681
721
  this.stmtDeleteL1Vec.run(record.id);
@@ -688,7 +728,7 @@ export class MemoryDb {
688
728
  if (this.ftsAvailable) {
689
729
  if (ftsExisted)
690
730
  this.stmtL1FtsDelete.run(record.id);
691
- this.stmtL1FtsInsert.run(tokenizeForFts(record.content), record.content, record.id, record.type, record.priority, record.scene_name, record.sessionId ?? 'default', record.version ?? 0, ts.str, ts.start, ts.end, JSON.stringify(record.metadata ?? {}), record.family ?? familyForType(record.type));
731
+ this.stmtL1FtsInsert.run(tokenizeForFts(record.content), record.content, record.id, type, priority, sceneName, record.sessionId ?? 'default', record.version ?? 0, ts.str, ts.start, ts.end, JSON.stringify(record.metadata ?? {}), family);
692
732
  }
693
733
  }
694
734
  /** 批量删除 L1(元数据 + 向量 + FTS),返回删除条数。IN 按 ≤900 分块(避变量数上限)。 */
@@ -932,22 +972,29 @@ export class MemoryDb {
932
972
  try {
933
973
  this.db.exec('BEGIN');
934
974
  for (let i = 0; i < records.length; i++) {
935
- const r = records[i];
975
+ const rec = records[i];
976
+ // 绑定层字段兜底(取 schema 列默认):同 upsertL1InTx——外部数据缺字段时
977
+ // undefined 无法绑定(#28);主表/向量/FTS 共用同源归一化值
978
+ const sessionId = rec.sessionId ?? 'default';
979
+ const role = rec.role ?? '';
980
+ const content = rec.content ?? '';
981
+ const recordedAt = rec.recordedAt ?? '';
982
+ const timestamp = rec.timestamp ?? 0;
936
983
  // 同 upsertL1 的点查预判:全新增路径跳过 UNINDEXED 列的 FTS 全扫删除
937
- const ftsExisted = this.ftsAvailable ? this.stmtL0Exists.get(r.id) !== undefined : false;
938
- this.stmtUpsertL0.run(r.id, r.sessionId, r.role, r.content, r.recordedAt, r.timestamp);
984
+ const ftsExisted = this.ftsAvailable ? this.stmtL0Exists.get(rec.id) !== undefined : false;
985
+ this.stmtUpsertL0.run(rec.id, sessionId, role, content, recordedAt, timestamp);
939
986
  if (this.stmtDeleteL0Vec && this.stmtInsertL0Vec) {
940
- this.stmtDeleteL0Vec.run(r.id);
987
+ this.stmtDeleteL0Vec.run(rec.id);
941
988
  const vec = embeddings?.[i];
942
989
  if (vec && !isZeroVector(vec)) {
943
- this.stmtInsertL0Vec.run(r.id, vecToBuffer(vec), r.recordedAt);
990
+ this.stmtInsertL0Vec.run(rec.id, vecToBuffer(vec), recordedAt);
944
991
  }
945
992
  }
946
993
  if (this.ftsAvailable) {
947
994
  // 同 upsertL1:FTS 失败冒泡触发整批回滚,禁止"删了没补"的索引空洞。
948
995
  if (ftsExisted)
949
- this.stmtL0FtsDelete.run(r.id);
950
- this.stmtL0FtsInsert.run(tokenizeForFts(r.content), r.content, r.id, r.sessionId, r.role, r.recordedAt, r.timestamp);
996
+ this.stmtL0FtsDelete.run(rec.id);
997
+ this.stmtL0FtsInsert.run(tokenizeForFts(content), content, rec.id, sessionId, role, recordedAt, timestamp);
951
998
  }
952
999
  }
953
1000
  this.db.exec('COMMIT');
@@ -964,6 +1011,135 @@ export class MemoryDb {
964
1011
  return false;
965
1012
  }
966
1013
  }
1014
+ /**
1015
+ * 记录一次蒸馏调用成本(明细表,写入时按 retentionDays 滚动清理;0 = 永久保留)。
1016
+ * 失败/成功都记(token 照烧);记账失败记 warn 但不阻断蒸馏(成本看板是增强能力)。
1017
+ */
1018
+ insertCostCall(provider, model, layer, inputChars, outputTokens, reasoningTokens, retentionDays) {
1019
+ if (this.degraded)
1020
+ return;
1021
+ try {
1022
+ this.stmtInsertCost.run(Date.now(), provider, model, layer, Math.max(0, Math.round(inputChars)), Math.max(0, Math.round(outputTokens)), Math.max(0, Math.round(reasoningTokens)));
1023
+ if (retentionDays > 0)
1024
+ this.stmtDeleteCost.run(Date.now() - retentionDays * 24 * 3600_000);
1025
+ }
1026
+ catch (err) {
1027
+ this.logger?.warn(`${TAG} token_cost 记账失败: ${err instanceof Error ? err.message : String(err)}`);
1028
+ }
1029
+ }
1030
+ /**
1031
+ * 查询 token_cost 单窗口聚合(成本看板用;since 为毫秒起点,0 = 全量)。
1032
+ * 输入口径:inputChars 是字符(llm 流拿不到输入 token,沿用 llm-usage 的字符折算口径)。
1033
+ * 成本看板是增强能力:降级态/查询异常一律返回零值,不向上抛错。
1034
+ * median 需取 output_tokens 序列在 JS 侧算(SQLite 无内置 median 函数)。
1035
+ */
1036
+ aggregateCost(since) {
1037
+ if (this.degraded)
1038
+ return { total: emptyCostAggregate(), byModel: [] };
1039
+ try {
1040
+ const total = this.db
1041
+ .prepare(`SELECT COUNT(*) AS calls,
1042
+ COALESCE(SUM(input_chars), 0) AS inputChars,
1043
+ COALESCE(SUM(output_tokens), 0) AS outputTokens,
1044
+ COALESCE(SUM(reasoning_tokens), 0) AS reasoningTokens,
1045
+ COALESCE(AVG(output_tokens), 0) AS avgOutputTokens
1046
+ FROM token_cost WHERE ts >= ?`)
1047
+ .get(since);
1048
+ const tokenRows = this.db
1049
+ .prepare('SELECT output_tokens FROM token_cost WHERE ts >= ? ORDER BY output_tokens')
1050
+ .all(since);
1051
+ const byModel = this.db
1052
+ .prepare(`SELECT provider, model, COUNT(*) AS calls,
1053
+ COALESCE(SUM(input_chars), 0) AS inputChars,
1054
+ COALESCE(SUM(output_tokens), 0) AS outputTokens,
1055
+ COALESCE(SUM(reasoning_tokens), 0) AS reasoningTokens
1056
+ FROM token_cost WHERE ts >= ? GROUP BY provider, model ORDER BY outputTokens DESC`)
1057
+ .all(since);
1058
+ return {
1059
+ total: {
1060
+ calls: total.calls,
1061
+ inputChars: total.inputChars,
1062
+ outputTokens: total.outputTokens,
1063
+ reasoningTokens: total.reasoningTokens,
1064
+ avgOutputTokens: total.avgOutputTokens,
1065
+ medianOutputTokens: medianOf(tokenRows.map((r) => r.output_tokens)),
1066
+ },
1067
+ byModel,
1068
+ };
1069
+ }
1070
+ catch {
1071
+ return { total: emptyCostAggregate(), byModel: [] };
1072
+ }
1073
+ }
1074
+ /**
1075
+ * 按层级归并聚合(l1 = l1-extract + l1-dedup;成本看板层级表格用)。
1076
+ * 降级/异常返回空数组,不抛错。
1077
+ */
1078
+ aggregateCostByLayer(since) {
1079
+ if (this.degraded)
1080
+ return [];
1081
+ try {
1082
+ const rows = this.db
1083
+ .prepare(`SELECT CASE WHEN layer IN ('l1-extract','l1-dedup') THEN 'l1' ELSE layer END AS layer,
1084
+ COUNT(*) AS calls,
1085
+ COALESCE(SUM(input_chars), 0) AS inputChars,
1086
+ COALESCE(SUM(output_tokens), 0) AS outputTokens,
1087
+ COALESCE(SUM(reasoning_tokens), 0) AS reasoningTokens,
1088
+ COALESCE(AVG(output_tokens), 0) AS avgOutputTokens
1089
+ FROM token_cost WHERE ts >= ? GROUP BY layer ORDER BY layer`)
1090
+ .all(since);
1091
+ // 中位数需取 output_tokens 序列在 JS 侧算(SQLite 无内置 median)
1092
+ const tokenRows = this.db
1093
+ .prepare(`SELECT CASE WHEN layer IN ('l1-extract','l1-dedup') THEN 'l1' ELSE layer END AS layer,
1094
+ output_tokens
1095
+ FROM token_cost WHERE ts >= ? ORDER BY layer, output_tokens`)
1096
+ .all(since);
1097
+ const medianByLayer = new Map();
1098
+ for (const r of tokenRows) {
1099
+ const arr = medianByLayer.get(r.layer);
1100
+ if (arr)
1101
+ arr.push(r.output_tokens);
1102
+ else
1103
+ medianByLayer.set(r.layer, [r.output_tokens]);
1104
+ }
1105
+ return rows.map((r) => ({
1106
+ ...r,
1107
+ medianOutputTokens: medianOf(medianByLayer.get(r.layer) ?? []),
1108
+ }));
1109
+ }
1110
+ catch {
1111
+ return [];
1112
+ }
1113
+ }
1114
+ /**
1115
+ * 按时间桶(bucketMs 毫秒)+ model 聚合,返回扁平行。
1116
+ * offsetMs 把桶边界对齐本地时区;layer 为空=全部,'l1' 归并 extract/dedup,其余精确匹配。
1117
+ * 趋势图与「日均/周均/月均 + 中位数」统计共用:JS 侧按不同 bucketMs 调三次再聚合。
1118
+ */
1119
+ aggregateByBucket(bucketMs, offsetMs, since, layer) {
1120
+ if (this.degraded)
1121
+ return [];
1122
+ try {
1123
+ let sql = `SELECT CAST((ts + ?) / ? AS INTEGER) AS bucket, provider, model,
1124
+ COUNT(*) AS calls,
1125
+ COALESCE(SUM(output_tokens), 0) AS outputTokens,
1126
+ COALESCE(SUM(reasoning_tokens), 0) AS reasoningTokens
1127
+ FROM token_cost WHERE ts >= ?`;
1128
+ const params = [offsetMs, bucketMs, since];
1129
+ if (layer === 'l1') {
1130
+ sql += ` AND layer IN ('l1-extract','l1-dedup')`;
1131
+ }
1132
+ else if (layer) {
1133
+ sql += ` AND layer = ?`;
1134
+ params.push(layer);
1135
+ }
1136
+ sql += ` GROUP BY bucket, provider, model ORDER BY bucket, provider, model`;
1137
+ return this.db.prepare(sql).all(...params);
1138
+ }
1139
+ catch {
1140
+ return [];
1141
+ }
1142
+ }
967
1143
  countL0() {
968
1144
  if (this.degraded)
969
1145
  return 0;
@@ -0,0 +1,90 @@
1
+ /**
2
+ * 蒸馏成本看板账本:把每次蒸馏调用(model × layer)的 token 成本写入 SQLite 明细表。
3
+ *
4
+ * 模块级单例 + init 注入 db:callLLM 拿不到 db(db 在 index.ts 运行时创建),
5
+ * 故通过 initTokenCost(db, retentionDays) 在插件启动时注入;recordCostCall 每次调用写一行明细。
6
+ *
7
+ * 与作者 llm-usage.ts 的关系:作者是"按 layer 累计的纯内存计数器"(给 bench 用);
8
+ * 本模块补上三个缺口——按 model 分组、持久化(保留期可配置,默认 365 天)、面向 UI 成本看板。
9
+ */
10
+ import type { DistillLayer } from './llm-usage.js';
11
+ import type { CostByModel, MemoryDb } from './store/sqlite.js';
12
+ /** 插件启动时注入 db 与明细保留期(index.ts 调用;retentionDays 0 = 永久保留)。 */
13
+ export declare function initTokenCost(d: MemoryDb, retention: number): void;
14
+ /** 插件卸载时清空 db 引用(index.ts 的 ctx.effect 清理里调用,防悬空引用)。 */
15
+ export declare function resetTokenCost(): void;
16
+ /** 记录一次蒸馏调用成本(callLLM 出口调用;provider/model 由调用方传入)。 */
17
+ export declare function recordCostCall(provider: string, model: string, layer: DistillLayer, inputChars: number, outputTokens: number, reasoningTokens: number): void;
18
+ /** 成本看板单个时间窗口(day/week/month/all)。 */
19
+ export interface CostWindow {
20
+ range: 'day' | 'week' | 'month' | 'all';
21
+ /** 窗口起点(毫秒;all 为 0)。 */
22
+ since: number;
23
+ calls: number;
24
+ inputChars: number;
25
+ outputTokens: number;
26
+ reasoningTokens: number;
27
+ avgOutputTokens: number;
28
+ medianOutputTokens: number;
29
+ }
30
+ /** 成本看板时间粒度(趋势图 + 统计口径共用)。 */
31
+ export type Granularity = 'day' | 'week' | 'month';
32
+ /** 每模型统计指标(层级表格行;均值/中位数按 since=0 全量历史的活跃桶口径,非「最近窗口」)。 */
33
+ export interface ModelMetrics {
34
+ model: string;
35
+ dayCalls: number;
36
+ weekCalls: number;
37
+ monthCalls: number;
38
+ dayOutput: number;
39
+ dayMedian: number;
40
+ weekOutput: number;
41
+ weekMedian: number;
42
+ monthOutput: number;
43
+ monthMedian: number;
44
+ }
45
+ /** 每层级(l1/l2/l3)的模型统计(层级表格)。 */
46
+ export interface LayerMetrics {
47
+ layer: 'l1' | 'l2' | 'l3';
48
+ models: ModelMetrics[];
49
+ }
50
+ /** 层级 × 窗口矩阵里的单个窗口格子。 */
51
+ export interface LayerWindow {
52
+ range: 'day' | 'week' | 'month' | 'all';
53
+ calls: number;
54
+ inputChars: number;
55
+ outputTokens: number;
56
+ reasoningTokens: number;
57
+ avgOutputTokens: number;
58
+ medianOutputTokens: number;
59
+ }
60
+ /** 单个层级在四个窗口的聚合(层级×窗口表格行)。 */
61
+ export interface LayerCost {
62
+ layer: 'l1' | 'l2' | 'l3';
63
+ windows: LayerWindow[];
64
+ }
65
+ /** 趋势单桶。 */
66
+ export interface TrendBucket {
67
+ ts: number;
68
+ total: number;
69
+ byModel: Record<string, number>;
70
+ }
71
+ /** 趋势快照:三个层级各一份连续桶序列。 */
72
+ export interface TrendSnapshot {
73
+ granularity: Granularity;
74
+ byLayer: Record<'l1' | 'l2' | 'l3', TrendBucket[]>;
75
+ }
76
+ /** 成本看板快照(dsh-memory/token-cost 端点返回值)。 */
77
+ export interface CostSnapshot {
78
+ /** day/week/month/all 四窗口聚合。 */
79
+ windows: CostWindow[];
80
+ /** 全量窗口(all)按 model 分组。 */
81
+ byModel: CostByModel[];
82
+ /** 按层级(l1/l2/l3 归并)在四个窗口的聚合(层级×窗口表格)。 */
83
+ byLayer: LayerCost[];
84
+ /** 每层级的模型统计指标(层级表格)。 */
85
+ byLayerStats: LayerMetrics[];
86
+ /** 趋势图数据。 */
87
+ trend: TrendSnapshot;
88
+ }
89
+ /** 读成本看板快照(db 未注入/降级时返回全零结构,不抛错;rangeDays>0 = 趋势展示近 N 天)。 */
90
+ export declare function snapshotTokenCost(granularity: Granularity, rangeDays: number): CostSnapshot;