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,176 @@
1
+ /**
2
+ * 本地嵌入 worker:transformers.js 的模型加载与推理在本线程执行,主线程只
3
+ * 投递文本、收回向量。onnxruntime-node(v1.24.3)的 run/loadModel 是
4
+ * setImmediate 回调里的同步调用(Promise 包装不卸载计算),留在主线程会
5
+ * 冻结宿主事件循环——dsh 页面一切交互无响应数秒的根因。
6
+ *
7
+ * 协议(与 src/store/local-embedding.ts 的 RealWorkerChannel 配对;自增 id):
8
+ * - 入向:{id,type:'ping'} | {id,type:'warmup'} | {id,type:'embed',texts,priority}
9
+ * - 出向:{id,ok:true,type:'pong'|'ready'|'embedded',vectors?}(向量经
10
+ * transfer 列表零拷贝回传)| {id,ok:false,stage:'load'|'infer',error}
11
+ * | {type:'fatal',error}
12
+ *
13
+ * 调度:单线程串行。embedBatch 拆成逐条 job、条间 setImmediate 让路——
14
+ * 单条请求(priority,即召回 query)unshift 插队,最坏只等"正在算的那一条",
15
+ * 不会被 reindex 批次(16/32 条 ≈5-10s)堵在队尾。
16
+ *
17
+ * 信任边界(沿 AGENTS.md 0.8.2 审查记录):runtime/ 目录在数据目录信任边界内,
18
+ * 本线程按其 package.json createRequire 加载 transformers——对数据目录有写
19
+ * 权限的进程理论上可植入恶意模块;模型文件逐文件 sha256 锁定,运行时代码
20
+ * 无同级防护,完整修复需安装指纹设计,暂以文档化假设为准。
21
+ */
22
+ 'use strict';
23
+
24
+ const { parentPort, workerData } = require('node:worker_threads');
25
+ const path = require('node:path');
26
+ const { createRequire } = require('node:module');
27
+
28
+ /** 错误消息归一化(Error 取 message,其余 String 化)。 */
29
+ function errMsg(err) {
30
+ return err && err.message ? err.message : String(err);
31
+ }
32
+
33
+ const cfg = workerData || {};
34
+ const RUNTIME_DIR = typeof cfg.runtimeDir === 'string' ? cfg.runtimeDir : '';
35
+ const MODEL_DIR = typeof cfg.modelDir === 'string' ? cfg.modelDir : '';
36
+ const POOLING = cfg.pooling === 'cls' ? 'cls' : 'mean';
37
+ const DTYPE = typeof cfg.dtype === 'string' ? cfg.dtype : 'q8';
38
+ const MAX_INPUT_CHARS = cfg.maxInputChars > 0 ? cfg.maxInputChars : 5000;
39
+
40
+ let extractor = null; // pipeline('feature-extraction', …)
41
+ let loadState = 'idle'; // idle | loading | ready | failed
42
+ let loadPromise = null;
43
+ let loadError = null;
44
+
45
+ // ── 模型懒加载(首请求触发;失败后经 warmup 可重试,embed 请求失败快返回) ──
46
+ function ensureLoaded() {
47
+ if (loadState === 'ready') return Promise.resolve();
48
+ if (loadState === 'loading' && loadPromise) return loadPromise;
49
+ loadState = 'loading';
50
+ loadError = null;
51
+ loadPromise = (async () => {
52
+ try {
53
+ if (!RUNTIME_DIR || !MODEL_DIR) throw new Error('workerData 缺少 runtimeDir/modelDir');
54
+ const req = createRequire(path.join(RUNTIME_DIR, 'package.json'));
55
+ const mod = req('@huggingface/transformers');
56
+ if (mod.env) {
57
+ mod.env.allowRemoteModels = false;
58
+ if (mod.env.allowLocalModels !== undefined) mod.env.allowLocalModels = true;
59
+ }
60
+ extractor = await mod.pipeline('feature-extraction', MODEL_DIR, { dtype: DTYPE });
61
+ loadState = 'ready';
62
+ } catch (err) {
63
+ loadState = 'failed';
64
+ loadError = errMsg(err);
65
+ loadPromise = null; // 失败后下一次 warmup 重新走加载
66
+ throw err;
67
+ }
68
+ })();
69
+ return loadPromise;
70
+ }
71
+
72
+ // ── 推理队列(逐条 job;priority 插队;条间让路) ──
73
+ const queue = []; // [{ request, index, text }]
74
+ let pumping = false;
75
+
76
+ function postReply(msg, transfer) {
77
+ parentPort.postMessage(msg, transfer || []);
78
+ }
79
+
80
+ /** 请求全部条目完成 → 回执(transfer 向量缓冲零拷贝)。 */
81
+ function settle(request) {
82
+ if (request.failed || request.remaining !== 0) return;
83
+ postReply(
84
+ { id: request.id, ok: true, type: 'embedded', vectors: request.vectors },
85
+ request.vectors.map((v) => v.buffer),
86
+ );
87
+ }
88
+
89
+ /** 首个失败即回执错误,并移除该请求尚未开跑的条目(正在跑的至多 1 条,算完即弃)。 */
90
+ function failRequest(request, error, stage) {
91
+ if (request.failed) return;
92
+ request.failed = true;
93
+ for (let i = queue.length - 1; i >= 0; i--) {
94
+ if (queue[i].request === request) queue.splice(i, 1);
95
+ }
96
+ postReply({ id: request.id, ok: false, stage: stage, error: errMsg(error) });
97
+ }
98
+
99
+ async function pump() {
100
+ if (pumping) return;
101
+ pumping = true;
102
+ try {
103
+ while (queue.length > 0) {
104
+ const job = queue.shift();
105
+ if (job.request.failed) continue;
106
+ try {
107
+ const result = await extractor([job.text.slice(0, MAX_INPUT_CHARS)], { pooling: POOLING, normalize: true });
108
+ const data = result[0] && result[0].data;
109
+ job.request.vectors[job.index] = data instanceof Float32Array ? data : new Float32Array(data);
110
+ job.request.remaining -= 1;
111
+ settle(job.request);
112
+ } catch (err) {
113
+ failRequest(job.request, err, 'infer');
114
+ }
115
+ // 条间让路:交还本线程事件循环,新消息(含 priority 插队)可在批间被处理
116
+ await new Promise((resolve) => setImmediate(resolve));
117
+ }
118
+ } finally {
119
+ pumping = false;
120
+ }
121
+ }
122
+
123
+ function handleEmbed(msg) {
124
+ const texts = Array.isArray(msg.texts) ? msg.texts : [];
125
+ if (texts.length === 0) {
126
+ postReply({ id: msg.id, ok: true, type: 'embedded', vectors: [] });
127
+ return;
128
+ }
129
+ const request = { id: msg.id, vectors: new Array(texts.length), remaining: texts.length, failed: false };
130
+ // 单条请求(召回 query)插队到队首;批次追加到队尾(批内条目间仍会被后来的插队)
131
+ const priority = msg.priority === true && texts.length === 1;
132
+ for (let i = 0; i < texts.length; i++) {
133
+ const job = { request: request, index: i, text: String(texts[i] == null ? '' : texts[i]) };
134
+ if (priority) queue.unshift(job);
135
+ else queue.push(job);
136
+ }
137
+ void pump();
138
+ }
139
+
140
+ parentPort.on('message', (msg) => {
141
+ if (!msg || typeof msg.id !== 'number' || typeof msg.type !== 'string') return;
142
+ if (msg.type === 'ping') {
143
+ postReply({ id: msg.id, ok: true, type: 'pong' });
144
+ return;
145
+ }
146
+ if (msg.type === 'warmup') {
147
+ ensureLoaded().then(
148
+ () => postReply({ id: msg.id, ok: true, type: 'ready' }),
149
+ () => postReply({ id: msg.id, ok: false, stage: 'load', error: loadError ?? '模型加载失败' }),
150
+ );
151
+ return;
152
+ }
153
+ if (msg.type === 'embed') {
154
+ void (async () => {
155
+ try {
156
+ await ensureLoaded();
157
+ } catch {
158
+ postReply({ id: msg.id, ok: false, stage: 'load', error: loadError ?? '模型加载失败' });
159
+ return;
160
+ }
161
+ handleEmbed(msg);
162
+ })();
163
+ return;
164
+ }
165
+ });
166
+
167
+ // 未捕获异常:尽力通知主线程后强制退出(主线程的 exit 处理会拒绝全部 pending
168
+ // 并转入 failed 态)。不保持半死状态——半死的 worker 只会让调用方挂到超时。
169
+ process.on('uncaughtException', (err) => {
170
+ try {
171
+ postReply({ type: 'fatal', error: errMsg(err) });
172
+ } catch {
173
+ /* 端口已坏,exit 处理兜底 */
174
+ }
175
+ setTimeout(() => process.exit(1), 50);
176
+ });
@@ -32,17 +32,21 @@ export declare function buildRecallQuery(messages: Array<{
32
32
  }>, tailMessages?: number, maxChars?: number): string;
33
33
  /** 单会话召回统计(悬浮卡信息区数据源;每轮 O(1) 记账,agent/disposed 清理)。
34
34
  * 口径声明:这是"注入统计"而非 bench 的离线 recall@k——运行时没有 ground truth,
35
- * 命中率 = hitTurns / injectedTurns(发生过检索的轮次中命中 ≥1 条的占比)。 */
35
+ * 命中率 = hitTurns / injectedTurns。去重语义(0.8.6):全量压制轮计入 hitTurns
36
+ * (相关记忆已在模型上下文里,本质是命中而非未命中),injectedTurns 仍计全部
37
+ * 发生过检索的轮次(保住分母语义与悬浮卡口径连续性)。 */
36
38
  export interface RecallSessionStats {
37
- /** 发生过召回检索的轮次数(含零命中与超时)。 */
39
+ /** 发生过召回检索的轮次数(含零命中、全量压制与超时)。 */
38
40
  injectedTurns: number;
39
- /** 命中(≥1 条)轮次数。 */
41
+ /** 命中(≥1 条实际注入,或有命中但被去重全量压制)轮次数。 */
40
42
  hitTurns: number;
41
- /** 累计命中条数(预算截断前)。 */
43
+ /** 累计注入条数(去重过滤后、预算截断前)。 */
42
44
  totalHits: number;
43
45
  /** 总预算超时跳过次数。 */
44
46
  timeouts: number;
45
- /** 最近一轮命中条数(0 = 零命中/超时;工具指南门控沿用此信号)。 */
47
+ /** 累计被去重压制的命中条数(同会话已注入过,不重复注入)。 */
48
+ suppressedRecalls: number;
49
+ /** 最近一轮实际注入条数(0 = 零命中/超时/全量压制;工具指南门控沿用此信号)。 */
46
50
  lastHits: number;
47
51
  /** 最近一轮检索耗时 ms。 */
48
52
  lastDurationMs: number;
@@ -61,4 +65,4 @@ export declare function registerRecall(ctx: Context, cfg: MemoryConfig, stores:
61
65
  l1: L1Store;
62
66
  scenes: Record<'chat' | 'work', SceneStore>;
63
67
  persona: Record<'chat' | 'work', PersonaStore>;
64
- }, logger: MemoryLogger, live: LiveSettingsHandle, modes: SessionModeStore): RecallHooks;
68
+ }, logger: MemoryLogger, live: LiveSettingsHandle, modes: SessionModeStore, dataDir: string): RecallHooks;
@@ -1,4 +1,5 @@
1
1
  import { createUserMessage } from '@deepseek-ai/dsh-llm';
2
+ import { RecallDedupeStore } from '../store/recall-dedupe.js';
2
3
  import { applyRecallBudget, raceRecallTimeout, RECALL_EMBED_CAP_MS } from '../util/recall-budget.js';
3
4
  import { errDetail } from '../util/filelog.js';
4
5
  import { blocksToText } from '../util/text.js';
@@ -37,9 +38,20 @@ const MEMORY_TOOLS_GUIDE = `<memory-tools-guide>
37
38
  </memory-tools-guide>`;
38
39
  /** 新建零值统计(首次出现的会话)。 */
39
40
  export function emptyRecallStats(now = Date.now()) {
40
- return { injectedTurns: 0, hitTurns: 0, totalHits: 0, timeouts: 0, lastHits: 0, lastDurationMs: 0, updatedAt: now };
41
+ return {
42
+ injectedTurns: 0,
43
+ hitTurns: 0,
44
+ totalHits: 0,
45
+ timeouts: 0,
46
+ suppressedRecalls: 0,
47
+ lastHits: 0,
48
+ lastDurationMs: 0,
49
+ updatedAt: now,
50
+ };
41
51
  }
42
- export function registerRecall(ctx, cfg, stores, logger, live, modes) {
52
+ export function registerRecall(ctx, cfg, stores, logger, live, modes, dataDir) {
53
+ /** 召回去重存储(同会话已注入的记忆不再重复注入;写穿持久化,重启不丢)。 */
54
+ const dedupe = new RecallDedupeStore(dataDir, logger);
43
55
  /** 每 agent 召回统计(工具指南门控读 lastHits;悬浮卡信息区读全量计数)。 */
44
56
  const recallStats = new Map();
45
57
  const statFor = (id) => {
@@ -77,10 +89,18 @@ export function registerRecall(ctx, cfg, stores, logger, live, modes) {
77
89
  const invalidateProfile = () => {
78
90
  void refreshProfile();
79
91
  };
80
- // agent 销毁时清掉召回统计槽
92
+ // agent 销毁时清掉召回统计槽(去重记录不随 agent 清——持久化语义:会话恢复后继续压制)
81
93
  ctx.on('agent/disposed', (payload) => {
82
94
  recallStats.delete(payload.agent.id);
83
95
  });
96
+ // 上下文压缩/清空 → 已注入内容从模型上下文丢失,重置该会话的去重压制
97
+ // (resume/startup 不重置:历史仍在,已注入的记忆模型还持有)。
98
+ ctx.on('agent/session-start', (payload) => {
99
+ if (payload.source === 'compact' || payload.source === 'clear') {
100
+ dedupe.reset(payload.agent.id);
101
+ logger.info(`[memory] 召回去重重置(agent=${payload.agent.id},source=${payload.source})`);
102
+ }
103
+ });
84
104
  // ── 1. pre-step 消息侧注入:记忆先行于每一条新的用户输入(ADR-0001) ──
85
105
  // prepend 注册 + 先 next() 再改写:不劫持其他监听器(dsh-time-context 官方范式)。
86
106
  if (cfg.recall.enabled) {
@@ -112,7 +132,7 @@ export function registerRecall(ctx, cfg, stores, logger, live, modes) {
112
132
  const hits = await raceRecallTimeout(stores.l1.search(query, cfg.recall.maxResults, {
113
133
  scoreThreshold: cfg.recall.scoreThreshold,
114
134
  family: mode === 'auto' ? undefined : mode,
115
- // 远程嵌入 fetch 内层钳制:给 FTS 降级留出总预算内的时间(本地推理不钳)
135
+ // 嵌入内层钳制:给 FTS 降级留出总预算内的时间(远程限 HTTP fetch;本地经 worker 代理 race 放弃)
116
136
  embeddingTimeoutMs: RECALL_EMBED_CAP_MS,
117
137
  }), cfg.recall.timeoutMs);
118
138
  st.updatedAt = Date.now();
@@ -122,16 +142,28 @@ export function registerRecall(ctx, cfg, stores, logger, live, modes) {
122
142
  return decision;
123
143
  }
124
144
  st.lastDurationMs = Date.now() - searchStart;
125
- st.lastHits = hits.length;
145
+ // 召回去重:同会话已注入过的记录不再重复注入(模型上下文已持有,省 token)。
146
+ // 纯过滤——剩几条注几条,全量压制(0 条新鲜命中)是正确状态而非未命中。
147
+ const seen = dedupe.seen(payload.agent.id);
148
+ const fresh = hits.filter((h) => !seen.has(h.id));
149
+ const suppressed = hits.length - fresh.length;
150
+ st.suppressedRecalls += suppressed;
151
+ if (suppressed > 0) {
152
+ logger.debug?.(`[memory] 召回去重:压制 ${suppressed} 条已注入记忆(agent=${payload.agent.id},余 ${fresh.length} 条新鲜命中)`);
153
+ }
126
154
  if (hits.length > 0) {
155
+ // 全量压制轮也计入命中:相关记忆已在模型上下文里,本质是命中
127
156
  st.hitTurns++;
128
- st.totalHits += hits.length;
157
+ st.totalHits += fresh.length;
129
158
  }
130
- if (hits.length === 0)
159
+ if (fresh.length === 0)
131
160
  return decision;
132
- const lines = applyRecallBudget(hits.map((h) => `- [${h.scene_name ? `${h.type}|${h.scene_name}` : h.type}] ${h.content}`), { maxCharsPerMemory: cfg.recall.maxCharsPerMemory, maxTotalRecallChars: cfg.recall.maxTotalRecallChars });
161
+ const lines = applyRecallBudget(fresh.map((h) => `- [${h.scene_name ? `${h.type}|${h.scene_name}` : h.type}] ${h.content}`), { maxCharsPerMemory: cfg.recall.maxCharsPerMemory, maxTotalRecallChars: cfg.recall.maxTotalRecallChars });
133
162
  if (lines.length === 0)
134
163
  return decision;
164
+ // 预算截断只丢尾部(前缀保留):实际注入 = fresh 的前 lines.length 条——只标记模型真实看到的
165
+ dedupe.mark(payload.agent.id, fresh.slice(0, lines.length).map((h) => h.id));
166
+ st.lastHits = lines.length;
135
167
  const text = [
136
168
  '<relevant-memories>',
137
169
  '以下是当前对话召回的相关记忆,不代表当前任务进程,仅作为参考:',
package/dist/index.d.ts CHANGED
@@ -61,6 +61,7 @@ export declare const Config: import("@deepseek-ai/schemastery").default<Schemast
61
61
  includeSceneNav: import("@deepseek-ai/schemastery").default<boolean, boolean>;
62
62
  strategy: import("@deepseek-ai/schemastery").default<"keyword" | "embedding" | "hybrid", "keyword" | "embedding" | "hybrid">;
63
63
  scoreThreshold: import("@deepseek-ai/schemastery").default<number, number>;
64
+ decayHalfLifeDays: import("@deepseek-ai/schemastery").default<number, number>;
64
65
  }>, Schemastery.ObjectT<{
65
66
  enabled: import("@deepseek-ai/schemastery").default<boolean, boolean>;
66
67
  maxResults: import("@deepseek-ai/schemastery").default<number, number>;
@@ -71,6 +72,7 @@ export declare const Config: import("@deepseek-ai/schemastery").default<Schemast
71
72
  includeSceneNav: import("@deepseek-ai/schemastery").default<boolean, boolean>;
72
73
  strategy: import("@deepseek-ai/schemastery").default<"keyword" | "embedding" | "hybrid", "keyword" | "embedding" | "hybrid">;
73
74
  scoreThreshold: import("@deepseek-ai/schemastery").default<number, number>;
75
+ decayHalfLifeDays: import("@deepseek-ai/schemastery").default<number, number>;
74
76
  }>>;
75
77
  embedding: import("@deepseek-ai/schemastery").default<Schemastery.ObjectS<{
76
78
  enabled: import("@deepseek-ai/schemastery").default<boolean, boolean>;
@@ -112,6 +114,11 @@ export declare const Config: import("@deepseek-ai/schemastery").default<Schemast
112
114
  maxInputChars: import("@deepseek-ai/schemastery").default<number, number>;
113
115
  timeoutMs: import("@deepseek-ai/schemastery").default<number, number>;
114
116
  }>>;
117
+ tokenCost: import("@deepseek-ai/schemastery").default<Schemastery.ObjectS<{
118
+ retentionDays: import("@deepseek-ai/schemastery").default<number, number>;
119
+ }>, Schemastery.ObjectT<{
120
+ retentionDays: import("@deepseek-ai/schemastery").default<number, number>;
121
+ }>>;
115
122
  tools: import("@deepseek-ai/schemastery").default<boolean, boolean>;
116
123
  benchControl: import("@deepseek-ai/schemastery").default<boolean, boolean>;
117
124
  }>, Schemastery.ObjectT<{
@@ -167,6 +174,7 @@ export declare const Config: import("@deepseek-ai/schemastery").default<Schemast
167
174
  includeSceneNav: import("@deepseek-ai/schemastery").default<boolean, boolean>;
168
175
  strategy: import("@deepseek-ai/schemastery").default<"keyword" | "embedding" | "hybrid", "keyword" | "embedding" | "hybrid">;
169
176
  scoreThreshold: import("@deepseek-ai/schemastery").default<number, number>;
177
+ decayHalfLifeDays: import("@deepseek-ai/schemastery").default<number, number>;
170
178
  }>, Schemastery.ObjectT<{
171
179
  enabled: import("@deepseek-ai/schemastery").default<boolean, boolean>;
172
180
  maxResults: import("@deepseek-ai/schemastery").default<number, number>;
@@ -177,6 +185,7 @@ export declare const Config: import("@deepseek-ai/schemastery").default<Schemast
177
185
  includeSceneNav: import("@deepseek-ai/schemastery").default<boolean, boolean>;
178
186
  strategy: import("@deepseek-ai/schemastery").default<"keyword" | "embedding" | "hybrid", "keyword" | "embedding" | "hybrid">;
179
187
  scoreThreshold: import("@deepseek-ai/schemastery").default<number, number>;
188
+ decayHalfLifeDays: import("@deepseek-ai/schemastery").default<number, number>;
180
189
  }>>;
181
190
  embedding: import("@deepseek-ai/schemastery").default<Schemastery.ObjectS<{
182
191
  enabled: import("@deepseek-ai/schemastery").default<boolean, boolean>;
@@ -218,6 +227,11 @@ export declare const Config: import("@deepseek-ai/schemastery").default<Schemast
218
227
  maxInputChars: import("@deepseek-ai/schemastery").default<number, number>;
219
228
  timeoutMs: import("@deepseek-ai/schemastery").default<number, number>;
220
229
  }>>;
230
+ tokenCost: import("@deepseek-ai/schemastery").default<Schemastery.ObjectS<{
231
+ retentionDays: import("@deepseek-ai/schemastery").default<number, number>;
232
+ }>, Schemastery.ObjectT<{
233
+ retentionDays: import("@deepseek-ai/schemastery").default<number, number>;
234
+ }>>;
221
235
  tools: import("@deepseek-ai/schemastery").default<boolean, boolean>;
222
236
  benchControl: import("@deepseek-ai/schemastery").default<boolean, boolean>;
223
237
  }>>;
package/dist/index.js CHANGED
@@ -41,6 +41,7 @@ import { registerMemoryTools } from './tools/index.js';
41
41
  import { errDetail, withFileLog } from './util/filelog.js';
42
42
  import { resolveModelRoute, invalidateEffortCache } from './llm.js';
43
43
  import { effectiveCfg } from './pipeline/runner.js';
44
+ import { initTokenCost, resetTokenCost } from './token-cost.js';
44
45
  export const name = 'dsh-memory-plugin';
45
46
  /** 硬依赖:蒸馏要用 llm,工具注册要用 tools,召回注入要用 systemPrompt。 */
46
47
  export const inject = ['llm', 'tools', 'systemPrompt'];
@@ -113,6 +114,7 @@ export async function apply(ctx, config) {
113
114
  // ── 检索引擎(memory.db)与向量服务 ──
114
115
  const embed = initial.svc;
115
116
  const db = new MemoryDb(path.join(dataDir, 'memory.db'), initial.dims, logger);
117
+ initTokenCost(db, config.tokenCost.retentionDays);
116
118
  // 插件卸载时关闭连接(WAL 落盘),注册一次即可
117
119
  ctx.effect(() => () => db.close());
118
120
  let dbInit = { needsReindex: false };
@@ -131,7 +133,7 @@ export async function apply(ctx, config) {
131
133
  }
132
134
  const stores = {
133
135
  l0: new L0Store(dataDir, db, embed, logger),
134
- l1: new L1Store(dataDir, db, embed, config.recall.strategy, logger),
136
+ l1: new L1Store(dataDir, db, embed, config.recall.strategy, logger, config.recall.decayHalfLifeDays),
135
137
  // L2/L3 分族隔离:各自目录与文件(scenes/chat|work、persona-chat|work.md)
136
138
  scenes: {
137
139
  chat: new SceneStore(dataDir, 'chat', logger),
@@ -292,7 +294,7 @@ export async function apply(ctx, config) {
292
294
  if (storageOk) {
293
295
  flushL0 = registerCapture(ctx, config, runner, stores.l0, logger, live, modes);
294
296
  }
295
- const recall = registerRecall(ctx, config, stores, logger, live, modes);
297
+ const recall = registerRecall(ctx, config, stores, logger, live, modes, dataDir);
296
298
  runner.setAfterRun(recall.invalidateProfile);
297
299
  registerMemoryTools(ctx, config, stores, logger, modes);
298
300
  registerMemoryRpc(ctx, config, stores, logger, {
@@ -325,6 +327,7 @@ export async function apply(ctx, config) {
325
327
  return (async () => {
326
328
  await flushL0?.();
327
329
  db.close();
330
+ resetTokenCost();
328
331
  })();
329
332
  });
330
333
  }
package/dist/llm.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm';
2
2
  import { errDetail } from './util/filelog.js';
3
3
  import { recordDistillCall } from './llm-usage.js';
4
+ import { recordCostCall } from './token-cost.js';
4
5
  // ── 分层输出预算(规格 C 节):结构化蒸馏远用不到总闸级预算,逐层设护栏;
5
6
  // 模型跑偏时单次损失有界。数值"先试跑"状态,按线上截断率调整。
6
7
  /** L1 抽取(大输入块的 JSON 记忆数组输出)。 */
@@ -203,11 +204,15 @@ export async function callLLM(ctx, cfg, opts) {
203
204
  // 记账含失败路径(failures 计数;tokens 尽当时流内已到的 usage)
204
205
  if (opts.layer)
205
206
  recordDistillCall(opts.layer, user.length, outputTokens, reasoningTokens, true);
207
+ if (opts.layer)
208
+ recordCostCall(provider, model, opts.layer, user.length, outputTokens, reasoningTokens);
206
209
  opts.logger?.warn(`[memory] LLM 调用失败 ${provider}/${model}(${((Date.now() - startedAt) / 1000).toFixed(1)}s): ${errDetail(err)}`);
207
210
  throw err;
208
211
  }
209
212
  if (opts.layer)
210
213
  recordDistillCall(opts.layer, user.length, outputTokens, reasoningTokens, false);
214
+ if (opts.layer)
215
+ recordCostCall(provider, model, opts.layer, user.length, outputTokens, reasoningTokens);
211
216
  const out = (blockText || deltaText).trim();
212
217
  if (out.length === 0) {
213
218
  // 空输出是最难排查的失败:流正常结束但一个字没吐。必须记录 finish 原因、
@@ -65,6 +65,9 @@ export declare class MemoryRunner {
65
65
  private lastActivity;
66
66
  /** 每会话累计产出 L1 条数与最近蒸馏时间(session-stats 数据源;LRU 上限防泄漏)。 */
67
67
  private sessionProduced;
68
+ /** 抽取连续失败退避(瞬态,不持久化:重启后允许首试再退避):
69
+ * sessionId → 连败次数与下次可自动重试时间(修闲置兜底×LLM 超时的重试风暴)。 */
70
+ private extractFailures;
68
71
  private readonly pendingFile;
69
72
  /** 分族 checkpoint(init 后可用;重建收尾也从这里读活引用)。 */
70
73
  states: FamilyStates;
@@ -121,6 +124,8 @@ export declare class MemoryRunner {
121
124
  onModeChange(sessionId: string, oldMode: string, newMode: string): void;
122
125
  private pushTask;
123
126
  private drain;
127
+ /** 该会话是否处于抽取退避窗口内。 */
128
+ private inExtractBackoff;
124
129
  /** 缓冲落盘(每次蒸馏尝试后调用;失败只告警不阻断管线)。
125
130
  * 非重建轮持久化前按桶截断到上限:重建取消后的大桶不至于在后续每次
126
131
  * 蒸馏尝试时反复整量序列化落盘(多 MB 级 IO);重建轮豁免维持。 */
@@ -1,7 +1,7 @@
1
1
  import { resolveDataDir } from '../config.js';
2
2
  import { emptyPending, freshWarmup, groupPendingBySession, loadPending, PENDING_MODES, pendingPathFor, savePending, } from '../store/pending.js';
3
3
  import { errDetail } from '../util/filelog.js';
4
- import { advanceWarmupThreshold, effectiveExtractThreshold, idleSessionsToFlush, modeSwitchAction, pickSessionBackground, } from './trigger.js';
4
+ import { advanceWarmupThreshold, effectiveExtractThreshold, extractionBackoffMs, idleSessionsToFlush, modeSwitchAction, pickSessionBackground, } from './trigger.js';
5
5
  import { runExtraction } from './l1.js';
6
6
  import { runSceneConsolidation } from './l2.js';
7
7
  import { runPersona } from './l3.js';
@@ -84,6 +84,9 @@ export class MemoryRunner {
84
84
  lastActivity = new Map();
85
85
  /** 每会话累计产出 L1 条数与最近蒸馏时间(session-stats 数据源;LRU 上限防泄漏)。 */
86
86
  sessionProduced = new Map();
87
+ /** 抽取连续失败退避(瞬态,不持久化:重启后允许首试再退避):
88
+ * sessionId → 连败次数与下次可自动重试时间(修闲置兜底×LLM 超时的重试风暴)。 */
89
+ extractFailures = new Map();
87
90
  pendingFile;
88
91
  /** 分族 checkpoint(init 后可用;重建收尾也从这里读活引用)。 */
89
92
  states;
@@ -251,7 +254,7 @@ export class MemoryRunner {
251
254
  }
252
255
  if (infos.size === 0)
253
256
  return;
254
- const targets = idleSessionsToFlush([...infos.values()], this.lastActivity, now, idleMs, (sid) => this.modes?.get(sid) === 'off');
257
+ const targets = idleSessionsToFlush([...infos.values()], this.lastActivity, now, idleMs, (sid) => this.modes?.get(sid) === 'off').filter((sid) => !this.inExtractBackoff(sid, now)); // 退避中的会话不入队(任务堆积源头)
255
258
  for (const sid of targets) {
256
259
  this.logger.info(`[memory] 闲置兜底:会话 ${sid} 静默达标,未蒸馏切片落袋`);
257
260
  this.enqueueSessionSlices(sid);
@@ -306,6 +309,11 @@ export class MemoryRunner {
306
309
  this.draining = false;
307
310
  }
308
311
  }
312
+ /** 该会话是否处于抽取退避窗口内。 */
313
+ inExtractBackoff(sessionId, now = Date.now()) {
314
+ const f = this.extractFailures.get(sessionId);
315
+ return !!f && now < f.nextAt;
316
+ }
309
317
  /** 缓冲落盘(每次蒸馏尝试后调用;失败只告警不阻断管线)。
310
318
  * 非重建轮持久化前按桶截断到上限:重建取消后的大桶不至于在后续每次
311
319
  * 蒸馏尝试时反复整量序列化落盘(多 MB 级 IO);重建轮豁免维持。 */
@@ -347,11 +355,15 @@ export class MemoryRunner {
347
355
  // force:重建轮全量蒸馏(历史小会话也必须出记忆,不受阈值约束)。
348
356
  const effective = effectiveExtractThreshold(this.warmup[mode], cfg.extract.minMessages);
349
357
  const sliceLen = bucket.reduce((n, m) => (m.sessionId === sessionId ? n + 1 : n), 0);
350
- if (opts?.force || sliceLen >= effective) {
358
+ // 抽取失败退避:重建轮(noBufferCap,用户显式动作、有自己的失败/取消 UI)豁免
359
+ const backedOff = !opts?.noBufferCap && this.inExtractBackoff(sessionId);
360
+ if ((opts?.force || sliceLen >= effective) && !backedOff) {
351
361
  newRecords = await this.extractSessionSlice(sessionId, mode, cfg, effective, opts);
352
362
  }
353
363
  else {
354
- this.logger.debug?.(`[memory] 会话切片攒批中(session=${sessionId},mode=${mode},${sliceLen}/${effective})`);
364
+ this.logger.debug?.(backedOff
365
+ ? `[memory] 蒸馏退避中,本轮跳过抽取(session=${sessionId},mode=${mode})`
366
+ : `[memory] 会话切片攒批中(session=${sessionId},mode=${mode},${sliceLen}/${effective})`);
355
367
  // 攒阈值中途也落盘:进程退出后切片与爬坡状态不丢
356
368
  await this.persistPending(opts?.noBufferCap);
357
369
  }
@@ -428,6 +440,8 @@ export class MemoryRunner {
428
440
  this.warmup[mode] = advanceWarmupThreshold(this.warmup[mode], cfg.extract.minMessages);
429
441
  // 会话产出记账(成功消费即记——零产出也算"蒸馏过",lastAt 推进)
430
442
  this.noteSessionDistill(sessionId, result.newRecords.length);
443
+ // 成功消费清零失败退避(切片已出桶,后续轮次恢复正常触发)
444
+ this.extractFailures.delete(sessionId);
431
445
  }
432
446
  this.logger.info(`[memory] L1 阶段完成(session=${sessionId},mode=${mode},切片 ${slice.length} 条,背景 ${background.length} 条,阈值 ${effectiveThreshold},${Date.now() - t}ms)`);
433
447
  // 缓冲与爬坡每次尝试后立即落盘:进程中途退出不丢待重试/攒阈值状态
@@ -445,6 +459,11 @@ export class MemoryRunner {
445
459
  catch (err) {
446
460
  // 保留切片下次重试(桶入口已裁到 ≤200,防无限堆积;重建轮不裁,量被会话规模约束)
447
461
  this.logger.warn(`[memory] L1 抽取失败(session=${sessionId},mode=${mode},切片 ${slice.length} 条): ${errDetail(err)}`);
462
+ // 指数退避:压制闲置兜底/补跑在 LLM 故障期间的连环重试(成功消费时清零)
463
+ const streak = (this.extractFailures.get(sessionId)?.streak ?? 0) + 1;
464
+ const delayMs = extractionBackoffMs(streak);
465
+ this.extractFailures.set(sessionId, { streak, nextAt: Date.now() + delayMs });
466
+ this.logger.info(`[memory] 蒸馏连续失败 ${streak} 次,${Math.round(delayMs / 1000)}s 内暂停该会话的自动重试`);
448
467
  await this.persistPending(opts?.noBufferCap).catch(() => { });
449
468
  return [];
450
469
  }
@@ -24,6 +24,8 @@ export declare function modeSwitchAction(oldMode: string, newMode: string): Mode
24
24
  export declare function pickSessionBackground<T extends {
25
25
  id: string;
26
26
  }>(recent: readonly T[], sliceIds: ReadonlySet<string>, n: number): T[];
27
+ /** 连续失败第 failStreak 次后的自动重试等待时长(1 → 60s,2 → 120s,…封顶 30min)。 */
28
+ export declare function extractionBackoffMs(failStreak: number): number;
27
29
  export interface IdleSliceInfo {
28
30
  sessionId: string;
29
31
  /** 该会话跨桶合计的切片条数。 */
@@ -43,6 +43,17 @@ export function pickSessionBackground(recent, sliceIds, n) {
43
43
  return [];
44
44
  return recent.filter((m) => !sliceIds.has(m.id)).slice(-n);
45
45
  }
46
+ // ── 抽取失败退避(修"重试风暴":LLM 网关故障期间闲置兜底每 30s 入队一个任务,
47
+ // 在 120s LLM 超时的等待中堆积成连环调用)──
48
+ /** 退避基数与封顶:60s 起步指数翻倍,封顶 30 分钟(LLM 故障自愈的合理量级)。 */
49
+ const EXTRACT_BACKOFF_BASE_MS = 60_000;
50
+ const EXTRACT_BACKOFF_CAP_MS = 30 * 60_000;
51
+ /** 连续失败第 failStreak 次后的自动重试等待时长(1 → 60s,2 → 120s,…封顶 30min)。 */
52
+ export function extractionBackoffMs(failStreak) {
53
+ if (!Number.isFinite(failStreak) || failStreak <= 0)
54
+ return EXTRACT_BACKOFF_BASE_MS;
55
+ return Math.min(EXTRACT_BACKOFF_BASE_MS * 2 ** (failStreak - 1), EXTRACT_BACKOFF_CAP_MS);
56
+ }
46
57
  /**
47
58
  * 闲置扫描:静默达标且有切片的会话。off 档会话跳过(挂起语义);
48
59
  * 活动时间优先取运行时记录,缺省回退切片内最晚消息时间。
package/dist/stats.js CHANGED
@@ -14,6 +14,7 @@ import { effectiveCfg } from './pipeline/runner.js';
14
14
  import { emptyRecallStats } from './hooks/recall.js';
15
15
  import { decideSendableEffort, LAYER_DEFAULT_BUDGETS, resolveModelEfforts, resolveModelRoute } from './llm.js';
16
16
  import { errDetail } from './util/filelog.js';
17
+ import { snapshotTokenCost } from './token-cost.js';
17
18
  const require = createRequire(import.meta.url);
18
19
  export const PLUGIN_VERSION = require('../package.json').version;
19
20
  /** 注册状态 RPC(web 侧 connection 服务可选,缺失时跳过,不影响插件主体)。 */
@@ -148,6 +149,16 @@ async function handleEndpoint(endpoint, payload, deps) {
148
149
  switch (endpoint) {
149
150
  case 'dsh-memory/stats':
150
151
  return buildStats(cfg, stores, status);
152
+ case 'dsh-memory/token-cost': {
153
+ const p = (payload ?? {});
154
+ const granularity = p.granularity === 'week' || p.granularity === 'month' ? p.granularity : 'day';
155
+ // rangeDays 须为正整数且不超过明细保留期(tokenCost.retentionDays,0=永久保留则放行 1~3650),否则回退默认窗口
156
+ const retention = cfg.tokenCost.retentionDays;
157
+ const upper = retention > 0 ? retention : 3650;
158
+ const rawDays = p.rangeDays;
159
+ const rangeDays = typeof rawDays === 'number' && Number.isInteger(rawDays) && rawDays > 0 && rawDays <= upper ? rawDays : 0;
160
+ return snapshotTokenCost(granularity, rangeDays);
161
+ }
151
162
  case 'dsh-memory/session-mode-get': {
152
163
  if (!modes)
153
164
  throw new Error('档位存储未初始化');
@@ -35,7 +35,8 @@ export interface InitialEmbedding {
35
35
  /** 远程档部署上限:静态四件套 + enabled。 */
36
36
  export declare function remoteCeiling(cfg: MemoryConfig): boolean;
37
37
  export declare function resolveInitialEmbedding(cfg: MemoryConfig, sourceStore: EmbeddingSourceStore, downloader: ModelDownloadQueue, makeLocal: (modelId: string) => LocalEmbeddingService | null, logger?: MemoryLogger): Promise<InitialEmbedding>;
38
- /** 本地服务构造工厂(index.ts 的初始解析与 Manager 共用一份实现,防漂移)。 */
38
+ /** 本地服务构造工厂(index.ts 的初始解析与 Manager 共用一份实现,防漂移)。
39
+ * 推理在 worker 线程(见 local-embedding.ts);此处只传 runtime 目录与模型目录。 */
39
40
  export declare function makeLocalServiceFactory(installer: RuntimeInstaller, downloader: ModelDownloadQueue, logger?: MemoryLogger, maxInputChars?: number): (modelId: string) => LocalEmbeddingService | null;
40
41
  export type ApplyPhase = 'idle' | 'installing-runtime' | 'warming' | 'switching' | 'reindexing' | 'done' | 'error';
41
42
  export interface ReindexProgressState {
@@ -101,13 +101,18 @@ export async function resolveInitialEmbedding(cfg, sourceStore, downloader, make
101
101
  });
102
102
  return { svc, dims: cfg.embedding.dimensions, providerInfo: svc.getProviderInfo() };
103
103
  }
104
- /** 本地服务构造工厂(index.ts 的初始解析与 Manager 共用一份实现,防漂移)。 */
104
+ /** 本地服务构造工厂(index.ts 的初始解析与 Manager 共用一份实现,防漂移)。
105
+ * 推理在 worker 线程(见 local-embedding.ts);此处只传 runtime 目录与模型目录。 */
105
106
  export function makeLocalServiceFactory(installer, downloader, logger, maxInputChars) {
106
107
  return (modelId) => {
107
108
  const entry = catalogById(modelId);
108
109
  if (!entry)
109
110
  return null;
110
- return new LocalEmbeddingService(entry, downloader.modelsDir(entry.id), () => Promise.resolve(installer.resolveModule()), logger, maxInputChars);
111
+ return new LocalEmbeddingService(entry, downloader.modelsDir(entry.id), {
112
+ runtimeDir: installer.runtimeDir,
113
+ logger,
114
+ maxInputChars,
115
+ });
111
116
  };
112
117
  }
113
118
  export class EmbeddingManager {
@@ -11,7 +11,8 @@ export interface EmbeddingProviderInfo {
11
11
  dimensions: number;
12
12
  }
13
13
  /** 单次嵌入调用的可选参数:timeoutMs 只允许缩短服务配置的超时(内层钳制),
14
- * 永不放大——召回路径用它给 FTS 降级留时间(规格 A 节)。本地实现可忽略。 */
14
+ * 永不放大——召回路径用它给 FTS 降级留时间(规格 A 节)。本地实现经 worker
15
+ * 代理以 Promise.race 钳制(迟到回复丢弃,推理在 worker 线程无法真正取消)。 */
15
16
  export interface EmbedCallOptions {
16
17
  timeoutMs?: number;
17
18
  }
package/dist/store/l0.js CHANGED
@@ -42,10 +42,16 @@ export class L0Store {
42
42
  if (!f.endsWith('.jsonl'))
43
43
  continue;
44
44
  const records = await readJsonl(path.join(this.legacyDir, f));
45
- if (records.length > 0) {
46
- total += records.length;
47
- if (this.db.upsertL0Batch(records))
48
- imported += records.length;
45
+ // 最小有效性门(同 L1 的 importLegacy):坏行读取时丢弃,按 valid 数判迁移完成
46
+ const valid = records.filter((r) => r && typeof r.id === 'string' && r.content);
47
+ const badCount = records.length - valid.length;
48
+ if (badCount > 0) {
49
+ this.logger?.warn(`[memory] 旧版 L0 文件 ${f} 丢弃 ${badCount} 条坏行(缺 id/content)`);
50
+ }
51
+ if (valid.length > 0) {
52
+ total += valid.length;
53
+ if (this.db.upsertL0Batch(valid))
54
+ imported += valid.length;
49
55
  }
50
56
  }
51
57
  // 只有全部批次入库成功(或目录为空)才改名,避免数据被改名带走