dsh-layered-memory 0.8.5 → 0.8.6

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/README.en.md CHANGED
@@ -90,7 +90,16 @@ synthetic message placed right before the user's new message, rendered as a
90
90
  **"Context injection · memory"** row in the chat flow (expand to see the hits) — so you
91
91
  can see "memory at work" directly. Injected content is bounded by length and
92
92
  time budgets — oversized lines are truncated (pointing the model at the memory tools for
93
- the full text) and a timed-out recall silently skips that turn, never slowing the chat. It
93
+ the full text) and a timed-out recall silently skips that turn, never slowing the chat.
94
+ **Per-session dedupe**: a memory already injected in this session is not injected again
95
+ (the model's context already holds it — follow-up questions on the same topic save
96
+ tokens); the record resets when the context is compacted or cleared, so memories can
97
+ flow back in, and an updated memory (new id after a content change) is never held back
98
+ by the old suppression. **Freshness weighting**: recall ranking applies a soft weight
99
+ `relevance × max(0.5, 0.5^(days since last update / 30))` — among candidates of similar
100
+ relevance the fresh one wins (slots rotate naturally), while a strongly relevant old
101
+ memory still recalls fine (the floor caps its loss at half a ranking score, so
102
+ long-lived facts never sink); tune via `recall.decayHalfLifeDays`, `0` disables. It
94
103
  also registers three model-callable memory tools: `memory_search` /
95
104
  `conversation_search` / `memory_read_scene`.
96
105
 
@@ -243,6 +252,7 @@ the bundle layer appends and causes `duplicate loader entry id` startup failure)
243
252
  | `recall.includeSceneNav` | `true` | Inject scene navigation into the system prompt (`<scene-navigation>`, stable zone) |
244
253
  | `recall.strategy` | `hybrid` | Retrieval strategy: `keyword` / `embedding` / `hybrid` |
245
254
  | `recall.scoreThreshold` | `0.3` | Recall score threshold (below is not injected; applies to keyword/embedding only, not pre-fusion hybrid; tool path unfiltered) |
255
+ | `recall.decayHalfLifeDays` | `30` | Freshness-decay half-life for recall ranking (days, 0=off): ranking applies `relevance × max(0.5, 0.5^(days since last update / half-life))` — among similarly relevant candidates the fresh one wins (slots rotate), and an old memory loses at most half its ranking score (floor keeps long-lived facts afloat) |
246
256
  | `embedding.enabled` | `false` | Vector retrieval switch; off = pure FTS |
247
257
  | `embedding.baseUrl` | empty | OpenAI-compatible /embeddings endpoint (e.g. `https://api.siliconflow.cn/v1`) |
248
258
  | `embedding.apiKey` | empty | API key |
@@ -299,7 +309,10 @@ with per-file sha256; arbitrary repos cannot be downloaded).
299
309
  under `models/<id>/` in the data directory, deletable from the settings page at any time;
300
310
  - **On-demand runtime**: the inference runtime (transformers.js, ~100–200MB) is
301
311
  installed only on first switch to the local tier, into `runtime/` in the data
302
- directory — never in the plugin's dependency tree or install directory;
312
+ directory — never in the plugin's dependency tree or install directory; model
313
+ loading and inference run on a **dedicated worker thread**, so the host event
314
+ loop is never frozen (conversations and page interactions stay responsive while
315
+ text is being embedded);
303
316
  - **Live switching**: one click to swap sources — everything is re-embedded in the
304
317
  background (visible progress, cancellable; retrieval silently degrades to keywords
305
318
  in the meantime, conversations unaffected; a dimension change rebuilds the vector
package/README.md CHANGED
@@ -74,7 +74,7 @@ npx tsc src/smoke.ts --outDir dist-smoke --module nodenext --moduleResolution no
74
74
  alt="dsh-layered-memory 运行时数据流:左侧 User 与 Assistant 的会话事件流入插件(L0 捕获、L1–L3 蒸馏、检索召回、记忆工具),插件经 agent/pre-step 把相关记忆注入右侧 DSH 核心;蒸馏复用核心的 ctx.llm,数据双写 ~/.dsh/memory/">
75
75
  </p>
76
76
 
77
- 插件挂在 dsh 原生事件上(`session/event` 捕获、`agent/pre-step` 注入),蒸馏调用复用宿主 `ctx.llm`。召回以**消息侧注入**呈现:相关记忆作为一条合成消息排在用户新消息之前,会话流里显示为**"上下文注入 · memory"**行(点开看命中内容)——用户能直接看到"记忆生效了";注入内容有长度预算与时间预算,超限截断/超时跳过,绝不拖慢对话。
77
+ 插件挂在 dsh 原生事件上(`session/event` 捕获、`agent/pre-step` 注入),蒸馏调用复用宿主 `ctx.llm`。召回以**消息侧注入**呈现:相关记忆作为一条合成消息排在用户新消息之前,会话流里显示为**"上下文注入 · memory"**行(点开看命中内容)——用户能直接看到"记忆生效了";注入内容有长度预算与时间预算,超限截断/超时跳过,绝不拖慢对话。**同会话去重**:已注入过的记忆不再重复注入(模型上下文里已经有了,追问同类问题时省 token);上下文被 `/compact` 压缩或清空时自动重置,记忆可重新注入;被更新的记忆(内容变化换新 id)不受旧压制。**时效加权**:召回排序按 `相关度 × max(0.5, 0.5^(距上次更新天数/30))` 软加权——相关度相近的候选之间新鲜记忆优先(名额自然轮转),相关度足够高的老记忆照常召回(地板保证最多损失一半排序分,长期事实不沉底);`recall.decayHalfLifeDays` 可调,0=关闭。
78
78
 
79
79
  **记忆工具(3):**
80
80
  - memory_search
@@ -206,7 +206,8 @@ ONNX 量化 **CPU 推理**——无需 API Key,数据不出本机)。本地
206
206
  绕开镜像 CDN 偶发的坏缓存对象),校验失配从零重下、网络错误保留断点续传;
207
207
  落盘数据目录 `models/<id>/`,不用了随时在设置页删除;
208
208
  - **按需运行时**:首次切换本地档才安装推理运行时(transformers.js,约 100~200MB,
209
- 装进数据目录 `runtime/`——不进插件依赖树,不碰插件安装目录);
209
+ 装进数据目录 `runtime/`——不进插件依赖树,不碰插件安装目录);模型加载与推理在
210
+ **独立 worker 线程**执行,不冻结宿主事件循环(嵌入计算期间对话与页面交互照常);
210
211
  - **活切换**:一键换源——自动后台全量重嵌(进度可见、可取消,期间检索自动降级
211
212
  关键词,不影响对话;维度变化时向量表按新维度重建);切换失败保持旧源,重启仍按原源运行;
212
213
  - **生效规则 = 部署上限 AND 运行时选择**:`embedding.allowLocalModels=false` 可整体
@@ -256,6 +257,7 @@ ONNX 量化 **CPU 推理**——无需 API Key,数据不出本机)。本地
256
257
  | `recall.includeSceneNav` | `true` | 系统提示注入场景导航(`<scene-navigation>`,稳定区) |
257
258
  | `recall.strategy` | `hybrid` | 检索策略:`keyword` / `embedding` / `hybrid` |
258
259
  | `recall.scoreThreshold` | `0.3` | 召回分数阈值(低于不注入;仅 keyword/embedding 策略生效,hybrid 融合前不过滤;工具路径不过滤) |
260
+ | `recall.decayHalfLifeDays` | `30` | 召回时效衰减半衰期(天,0=关):排序按 `相关度 × max(0.5, 0.5^(距更新天数/半衰期))` 软加权——相关度相近的候选间新鲜记忆优先(名额轮转),老记忆最多损失一半排序分(地板兜底,长期事实不沉底) |
259
261
  | `embedding.enabled` | `false` | 向量检索开关;关闭即纯 FTS 运行 |
260
262
  | `embedding.baseUrl` | 空 | OpenAI 兼容 /embeddings 地址(如 `https://api.siliconflow.cn/v1`) |
261
263
  | `embedding.apiKey` | 空 | API Key |
package/dist/config.d.ts CHANGED
@@ -66,6 +66,9 @@ export interface MemoryConfig {
66
66
  strategy: 'keyword' | 'embedding' | 'hybrid';
67
67
  /** 召回路径分数阈值(0~1,低于该分不注入;工具路径不过滤)。 */
68
68
  scoreThreshold: number;
69
+ /** 时效衰减半衰期(天,0=关):召回排序的 freshness 加权——score × max(0.5, 0.5^(Δ天/半衰期)),
70
+ * 只影响相关度相近候选间的名次(老记忆最多损失一半排序分,不淘汰)。 */
71
+ decayHalfLifeDays: number;
69
72
  };
70
73
  embedding: {
71
74
  /** 向量检索总开关;关闭时纯 FTS 运行(官方 provider="none" 同款)。 */
@@ -170,6 +173,7 @@ export declare const memorySchema: Schema<Schemastery.ObjectS<{
170
173
  includeSceneNav: Schema<boolean, boolean>;
171
174
  strategy: Schema<"keyword" | "embedding" | "hybrid", "keyword" | "embedding" | "hybrid">;
172
175
  scoreThreshold: Schema<number, number>;
176
+ decayHalfLifeDays: Schema<number, number>;
173
177
  }>, Schemastery.ObjectT<{
174
178
  enabled: Schema<boolean, boolean>;
175
179
  maxResults: Schema<number, number>;
@@ -180,6 +184,7 @@ export declare const memorySchema: Schema<Schemastery.ObjectS<{
180
184
  includeSceneNav: Schema<boolean, boolean>;
181
185
  strategy: Schema<"keyword" | "embedding" | "hybrid", "keyword" | "embedding" | "hybrid">;
182
186
  scoreThreshold: Schema<number, number>;
187
+ decayHalfLifeDays: Schema<number, number>;
183
188
  }>>;
184
189
  embedding: Schema<Schemastery.ObjectS<{
185
190
  enabled: Schema<boolean, boolean>;
@@ -276,6 +281,7 @@ export declare const memorySchema: Schema<Schemastery.ObjectS<{
276
281
  includeSceneNav: Schema<boolean, boolean>;
277
282
  strategy: Schema<"keyword" | "embedding" | "hybrid", "keyword" | "embedding" | "hybrid">;
278
283
  scoreThreshold: Schema<number, number>;
284
+ decayHalfLifeDays: Schema<number, number>;
279
285
  }>, Schemastery.ObjectT<{
280
286
  enabled: Schema<boolean, boolean>;
281
287
  maxResults: Schema<number, number>;
@@ -286,6 +292,7 @@ export declare const memorySchema: Schema<Schemastery.ObjectS<{
286
292
  includeSceneNav: Schema<boolean, boolean>;
287
293
  strategy: Schema<"keyword" | "embedding" | "hybrid", "keyword" | "embedding" | "hybrid">;
288
294
  scoreThreshold: Schema<number, number>;
295
+ decayHalfLifeDays: Schema<number, number>;
289
296
  }>>;
290
297
  embedding: Schema<Schemastery.ObjectS<{
291
298
  enabled: Schema<boolean, boolean>;
package/dist/config.js CHANGED
@@ -48,6 +48,9 @@ export const memorySchema = Schema.object({
48
48
  includeSceneNav: Schema.boolean().default(true),
49
49
  strategy: Schema.union(['keyword', 'embedding', 'hybrid']).default('hybrid'),
50
50
  scoreThreshold: Schema.number().min(0).max(1).default(0.3),
51
+ // 时效衰减(#29):乘法软加权 + 地板 0.5(老记忆最多损失一半排序分),只轮转
52
+ // 相关度相近候选的名次;0=关(bench 基线可比性可 pin 0)
53
+ decayHalfLifeDays: Schema.number().min(0).max(3650).default(30),
51
54
  }),
52
55
  embedding: Schema.object({
53
56
  enabled: Schema.boolean().default(false),
@@ -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>;
@@ -167,6 +169,7 @@ export declare const Config: import("@deepseek-ai/schemastery").default<Schemast
167
169
  includeSceneNav: import("@deepseek-ai/schemastery").default<boolean, boolean>;
168
170
  strategy: import("@deepseek-ai/schemastery").default<"keyword" | "embedding" | "hybrid", "keyword" | "embedding" | "hybrid">;
169
171
  scoreThreshold: import("@deepseek-ai/schemastery").default<number, number>;
172
+ decayHalfLifeDays: import("@deepseek-ai/schemastery").default<number, number>;
170
173
  }>, Schemastery.ObjectT<{
171
174
  enabled: import("@deepseek-ai/schemastery").default<boolean, boolean>;
172
175
  maxResults: import("@deepseek-ai/schemastery").default<number, number>;
@@ -177,6 +180,7 @@ export declare const Config: import("@deepseek-ai/schemastery").default<Schemast
177
180
  includeSceneNav: import("@deepseek-ai/schemastery").default<boolean, boolean>;
178
181
  strategy: import("@deepseek-ai/schemastery").default<"keyword" | "embedding" | "hybrid", "keyword" | "embedding" | "hybrid">;
179
182
  scoreThreshold: import("@deepseek-ai/schemastery").default<number, number>;
183
+ decayHalfLifeDays: import("@deepseek-ai/schemastery").default<number, number>;
180
184
  }>>;
181
185
  embedding: import("@deepseek-ai/schemastery").default<Schemastery.ObjectS<{
182
186
  enabled: import("@deepseek-ai/schemastery").default<boolean, boolean>;
package/dist/index.js CHANGED
@@ -131,7 +131,7 @@ export async function apply(ctx, config) {
131
131
  }
132
132
  const stores = {
133
133
  l0: new L0Store(dataDir, db, embed, logger),
134
- l1: new L1Store(dataDir, db, embed, config.recall.strategy, logger),
134
+ l1: new L1Store(dataDir, db, embed, config.recall.strategy, logger, config.recall.decayHalfLifeDays),
135
135
  // L2/L3 分族隔离:各自目录与文件(scenes/chat|work、persona-chat|work.md)
136
136
  scenes: {
137
137
  chat: new SceneStore(dataDir, 'chat', logger),
@@ -292,7 +292,7 @@ export async function apply(ctx, config) {
292
292
  if (storageOk) {
293
293
  flushL0 = registerCapture(ctx, config, runner, stores.l0, logger, live, modes);
294
294
  }
295
- const recall = registerRecall(ctx, config, stores, logger, live, modes);
295
+ const recall = registerRecall(ctx, config, stores, logger, live, modes, dataDir);
296
296
  runner.setAfterRun(recall.invalidateProfile);
297
297
  registerMemoryTools(ctx, config, stores, logger, modes);
298
298
  registerMemoryRpc(ctx, config, stores, logger, {
@@ -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
  * 活动时间优先取运行时记录,缺省回退切片内最晚消息时间。
@@ -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 {