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 +15 -2
- package/README.md +4 -2
- package/dist/config.d.ts +7 -0
- package/dist/config.js +3 -0
- package/dist/embedding-worker.cjs +176 -0
- package/dist/hooks/recall.d.ts +10 -6
- package/dist/hooks/recall.js +40 -8
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -2
- package/dist/pipeline/runner.d.ts +5 -0
- package/dist/pipeline/runner.js +23 -4
- package/dist/pipeline/trigger.d.ts +2 -0
- package/dist/pipeline/trigger.js +11 -0
- package/dist/store/embedding-source.d.ts +2 -1
- package/dist/store/embedding-source.js +7 -2
- package/dist/store/embedding.d.ts +2 -1
- package/dist/store/l0.js +10 -4
- package/dist/store/l1.d.ts +11 -1
- package/dist/store/l1.js +26 -6
- package/dist/store/local-embedding.d.ts +69 -46
- package/dist/store/local-embedding.js +179 -75
- package/dist/store/recall-dedupe.d.ts +26 -0
- package/dist/store/recall-dedupe.js +138 -0
- package/dist/store/runtime-installer.d.ts +0 -2
- package/dist/store/runtime-installer.js +0 -6
- package/dist/store/search-utils.d.ts +17 -0
- package/dist/store/search-utils.js +29 -0
- package/dist/store/sqlite.js +23 -9
- package/dist/util/recall-budget.d.ts +2 -2
- package/dist/util/recall-budget.js +2 -2
- package/package.json +1 -1
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.
|
|
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
|
+
});
|
package/dist/hooks/recall.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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;
|
package/dist/hooks/recall.js
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
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
|
-
|
|
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 +=
|
|
157
|
+
st.totalHits += fresh.length;
|
|
129
158
|
}
|
|
130
|
-
if (
|
|
159
|
+
if (fresh.length === 0)
|
|
131
160
|
return decision;
|
|
132
|
-
const lines = applyRecallBudget(
|
|
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);重建轮豁免维持。 */
|
package/dist/pipeline/runner.js
CHANGED
|
@@ -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
|
-
|
|
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?.(
|
|
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
|
/** 该会话跨桶合计的切片条数。 */
|
package/dist/pipeline/trigger.js
CHANGED
|
@@ -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),
|
|
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 {
|