dsh-layered-memory 0.7.1 → 0.8.1
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 +165 -75
- package/README.md +174 -104
- package/assets/img/EmbeddingSource.png +0 -0
- package/assets/img/MemoryTools.png +0 -0
- package/assets/img/ToolTrajectory.png +0 -0
- package/assets/readme/bench-dialog.svg +87 -0
- package/assets/readme/bench-workflow.svg +79 -0
- package/assets/readme/flow.svg +74 -74
- package/assets/readme/storage.svg +56 -56
- package/dist/client.js +380 -2
- package/dist/config.d.ts +41 -1
- package/dist/config.js +10 -3
- package/dist/hooks/recall.d.ts +11 -8
- package/dist/hooks/recall.js +70 -51
- package/dist/index.d.ts +20 -0
- package/dist/index.js +15 -4
- package/dist/llm.d.ts +28 -0
- package/dist/llm.js +33 -0
- package/dist/pipeline/l1.js +5 -4
- package/dist/pipeline/l2.js +7 -2
- package/dist/pipeline/l3.js +7 -2
- package/dist/pipeline/rebuild.js +2 -2
- package/dist/pipeline/runner.d.ts +45 -8
- package/dist/pipeline/runner.js +185 -39
- package/dist/pipeline/trigger.d.ts +38 -0
- package/dist/pipeline/trigger.js +64 -0
- package/dist/settings.d.ts +14 -0
- package/dist/settings.js +41 -2
- package/dist/stats.js +113 -1
- package/dist/store/bm25.js +2 -1
- package/dist/store/download-queue.d.ts +35 -2
- package/dist/store/download-queue.js +102 -5
- package/dist/store/embedding.d.ts +12 -6
- package/dist/store/embedding.js +10 -7
- package/dist/store/l0.d.ts +2 -0
- package/dist/store/l0.js +4 -0
- package/dist/store/l1.d.ts +2 -0
- package/dist/store/l1.js +2 -2
- package/dist/store/model-catalog.js +1 -1
- package/dist/store/pending.d.ts +27 -6
- package/dist/store/pending.js +49 -9
- package/dist/store/search-utils.js +3 -2
- package/dist/store/session-modes.d.ts +4 -0
- package/dist/store/session-modes.js +16 -0
- package/dist/store/sqlite.d.ts +12 -0
- package/dist/store/sqlite.js +103 -5
- package/dist/util/recall-budget.d.ts +32 -0
- package/dist/util/recall-budget.js +85 -0
- package/dist/util/text.d.ts +8 -3
- package/dist/util/text.js +41 -15
- package/dist/util/tokenizer.d.ts +14 -0
- package/dist/util/tokenizer.js +53 -0
- package/package.json +6 -3
package/dist/pipeline/runner.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { resolveDataDir } from '../config.js';
|
|
2
|
-
import { emptyPending, loadPending, PENDING_MODES, pendingPathFor, savePending } from '../store/pending.js';
|
|
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
5
|
import { runExtraction } from './l1.js';
|
|
5
6
|
import { runSceneConsolidation } from './l2.js';
|
|
6
7
|
import { runPersona } from './l3.js';
|
|
@@ -13,39 +14,81 @@ export function pickNextTaskIndex(tasks) {
|
|
|
13
14
|
return 0;
|
|
14
15
|
}
|
|
15
16
|
/**
|
|
16
|
-
* 运行时调参视图:UI
|
|
17
|
-
*
|
|
17
|
+
* 运行时调参视图:UI 选择器可临时覆盖蒸馏思考档位、蒸馏模型路由与分层输出预算
|
|
18
|
+
* (空串/0 回退静态 config / 内置默认)。浅拷贝只覆盖 llm 一层,其余键与原 cfg
|
|
19
|
+
* 共享只读引用;pipeline 全链继续收 cfg,无需感知。
|
|
20
|
+
*
|
|
21
|
+
* 蒸馏模型覆盖优先级:部署静态 pin(cfg.llm.provider+model 双字段齐)不可被
|
|
22
|
+
* 运行时覆盖(部署可强制蒸馏走内网路由,防用户选择把对话外送);未 pin 时
|
|
23
|
+
* 运行时选择(distillProvider+distillModel 成对)生效;再退 agentDefaultModel。
|
|
24
|
+
* 输出预算无部署上限语义:非零运行时值直接注入 cfg.llm.budgets(0/缺省不注入)。
|
|
18
25
|
*/
|
|
19
26
|
export function effectiveCfg(cfg, live) {
|
|
20
|
-
const
|
|
21
|
-
|
|
27
|
+
const s = live?.get();
|
|
28
|
+
const eff = s?.reasoningEffort ?? '';
|
|
29
|
+
// 可选链防御:smoke/测试缝构造的最小 cfg 可能没有 llm 字段
|
|
30
|
+
const pinned = Boolean(cfg.llm?.provider && cfg.llm?.model);
|
|
31
|
+
const override = s && !pinned && s.distillProvider && s.distillModel
|
|
32
|
+
? { provider: s.distillProvider, model: s.distillModel }
|
|
33
|
+
: null;
|
|
34
|
+
const b = s?.distillBudgets;
|
|
35
|
+
const budgets = b && (b.extract > 0 || b.dedup > 0 || b.l2 > 0 || b.l3 > 0)
|
|
36
|
+
? {
|
|
37
|
+
...(b.extract > 0 ? { extract: b.extract } : {}),
|
|
38
|
+
...(b.dedup > 0 ? { dedup: b.dedup } : {}),
|
|
39
|
+
...(b.l2 > 0 ? { l2: b.l2 } : {}),
|
|
40
|
+
...(b.l3 > 0 ? { l3: b.l3 } : {}),
|
|
41
|
+
}
|
|
42
|
+
: null;
|
|
43
|
+
const maxInput = s && s.distillMaxInputChars > 0 ? s.distillMaxInputChars : null;
|
|
44
|
+
if (!eff && !override && !budgets && !maxInput)
|
|
45
|
+
return cfg;
|
|
46
|
+
return {
|
|
47
|
+
...cfg,
|
|
48
|
+
llm: {
|
|
49
|
+
...cfg.llm,
|
|
50
|
+
...(eff ? { reasoningEffort: eff } : {}),
|
|
51
|
+
...(override ?? {}),
|
|
52
|
+
...(budgets ? { budgets } : {}),
|
|
53
|
+
...(maxInput ? { maxInputChars: maxInput } : {}),
|
|
54
|
+
},
|
|
55
|
+
};
|
|
22
56
|
}
|
|
23
57
|
/** 单桶堆积上限(防无限堆积;重建分块不受限——历史会话需全量入桶蒸馏)。 */
|
|
24
58
|
const PENDING_BUCKET_CAP = 200;
|
|
25
59
|
/** 启动补跑延迟:避开宿主启动期忙乱。 */
|
|
26
60
|
const STARTUP_RETRY_DELAY_MS = 20_000;
|
|
61
|
+
/** 闲置扫描 tick 粒度(实际落袋延迟 = idleSeconds + 至多一个 tick)。 */
|
|
62
|
+
const IDLE_TICK_MS = 30_000;
|
|
27
63
|
export class MemoryRunner {
|
|
28
64
|
ctx;
|
|
29
65
|
cfg;
|
|
30
66
|
stores;
|
|
31
67
|
logger;
|
|
32
68
|
live;
|
|
69
|
+
modes;
|
|
33
70
|
tasks = [];
|
|
34
71
|
draining = false;
|
|
35
72
|
/** 停止标志(dispose 序置位):不再取新任务;进行中任务自然收尾,其 DB 写入失败由各层兜底捕获。 */
|
|
36
73
|
stopped = false;
|
|
37
74
|
pending = emptyPending();
|
|
75
|
+
/** 各档位桶渐进阈值(1 起步翻倍至稳态毕业;ADR-0003,随 pending.json 持久化)。 */
|
|
76
|
+
warmup = freshWarmup();
|
|
77
|
+
/** 每会话最后活动时间(闲置兜底判定用)。 */
|
|
78
|
+
lastActivity = new Map();
|
|
38
79
|
pendingFile;
|
|
39
|
-
background = [];
|
|
40
80
|
/** 分族 checkpoint(init 后可用;重建收尾也从这里读活引用)。 */
|
|
41
81
|
states;
|
|
42
82
|
afterRun;
|
|
43
|
-
constructor(ctx, cfg, stores, logger, live
|
|
83
|
+
constructor(ctx, cfg, stores, logger, live,
|
|
84
|
+
/** 会话档位只读句柄(闲置扫描跳过 off 档会话;挂起语义)。 */
|
|
85
|
+
modes) {
|
|
44
86
|
this.ctx = ctx;
|
|
45
87
|
this.cfg = cfg;
|
|
46
88
|
this.stores = stores;
|
|
47
89
|
this.logger = logger;
|
|
48
90
|
this.live = live;
|
|
91
|
+
this.modes = modes;
|
|
49
92
|
this.pendingFile = pendingPathFor(resolveDataDir(cfg));
|
|
50
93
|
}
|
|
51
94
|
async init() {
|
|
@@ -60,12 +103,13 @@ export class MemoryRunner {
|
|
|
60
103
|
}
|
|
61
104
|
// 恢复未蒸馏缓冲(上次进程退出前未蒸馏的消息,含失败待重试与攒阈值中途的)
|
|
62
105
|
try {
|
|
63
|
-
const loaded = await loadPending(this.pendingFile, this.logger);
|
|
106
|
+
const { buckets: loaded, warmup } = await loadPending(this.pendingFile, this.logger);
|
|
64
107
|
for (const key of PENDING_MODES) {
|
|
65
108
|
if (loaded[key].length > PENDING_BUCKET_CAP)
|
|
66
109
|
loaded[key] = loaded[key].slice(-PENDING_BUCKET_CAP);
|
|
67
110
|
}
|
|
68
111
|
this.pending = loaded;
|
|
112
|
+
this.warmup = warmup;
|
|
69
113
|
if (this.pendingCount > 0) {
|
|
70
114
|
this.logger.info(`[memory] 未蒸馏缓冲已恢复 ${this.pendingCount} 条(auto=${this.pending.auto.length}/chat=${this.pending.chat.length}/work=${this.pending.work.length}),${STARTUP_RETRY_DELAY_MS / 1000}s 后自动补跑`);
|
|
71
115
|
this.scheduleStartupRetry();
|
|
@@ -75,13 +119,18 @@ export class MemoryRunner {
|
|
|
75
119
|
this.logger.warn(`[memory] 未蒸馏缓冲恢复失败(空桶起步): ${errDetail(err)}`);
|
|
76
120
|
}
|
|
77
121
|
}
|
|
78
|
-
/**
|
|
122
|
+
/** 启动补跑:对每个非空桶的每个会话切片入队一次蒸馏尝试(受 live 开关与
|
|
123
|
+
* 生效阈值约束;不足阈值的消息等用户继续或闲置兜底,失败不无限重试)。 */
|
|
79
124
|
scheduleStartupRetry() {
|
|
80
125
|
const modes = PENDING_MODES.filter((m) => this.pending[m].length > 0);
|
|
81
126
|
this.ctx.effect(() => {
|
|
82
127
|
const timer = setTimeout(() => {
|
|
83
|
-
|
|
84
|
-
|
|
128
|
+
// 到点后按当前桶内容重新分组(期间可能有新轮次已消费切片)
|
|
129
|
+
for (const mode of modes) {
|
|
130
|
+
for (const g of groupPendingBySession(this.pending[mode])) {
|
|
131
|
+
this.enqueue(g.sessionId, [], mode);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
85
134
|
}, STARTUP_RETRY_DELAY_MS);
|
|
86
135
|
return () => clearTimeout(timer);
|
|
87
136
|
});
|
|
@@ -95,21 +144,81 @@ export class MemoryRunner {
|
|
|
95
144
|
this.afterRun = fn;
|
|
96
145
|
}
|
|
97
146
|
/** 一轮对话结束后入队(L0 落盘由 capture 在 turn/end 即时完成,不排蒸馏队列)。 */
|
|
98
|
-
enqueue(sessionId, messages, mode) {
|
|
99
|
-
this.pushTask({ kind: 'live', run: () => this.runTurn(sessionId, messages, mode) });
|
|
147
|
+
enqueue(sessionId, messages, mode, opts) {
|
|
148
|
+
this.pushTask({ kind: 'live', run: () => this.runTurn(sessionId, messages, mode, opts) });
|
|
100
149
|
}
|
|
101
150
|
/** 重建任务入队(低优先级:让位于正常轮次;由 RebuildController 分块驱动)。 */
|
|
102
151
|
enqueueRebuildTask(run) {
|
|
103
152
|
this.pushTask({ kind: 'rebuild', run });
|
|
104
153
|
}
|
|
105
|
-
/** 重建蒸馏轮:统一 auto
|
|
154
|
+
/** 重建蒸馏轮:统一 auto 档,全量强制蒸馏(不受阈值约束——历史小会话也必须出记忆)、
|
|
155
|
+
* 不受缓冲 200 上限(历史会话全量入桶,由 char 预算分块)。 */
|
|
106
156
|
runRebuildTurn(sessionId, messages) {
|
|
107
|
-
return this.runTurn(sessionId, messages, 'auto', { noBufferCap: true });
|
|
157
|
+
return this.runTurn(sessionId, messages, 'auto', { noBufferCap: true, force: true });
|
|
108
158
|
}
|
|
109
159
|
/** 停止取新任务(插件 dispose 序调用;进行中任务照常跑完但不 await——LLM 慢调用不拖住宿主卸载)。 */
|
|
110
160
|
stop() {
|
|
111
161
|
this.stopped = true;
|
|
112
162
|
}
|
|
163
|
+
/** 启动闲置兜底定时器(index.ts 装配;idleSeconds=0 关闭)。 */
|
|
164
|
+
startIdleTimer() {
|
|
165
|
+
const idleMs = (this.cfg.extract.idleSeconds ?? 0) * 1000;
|
|
166
|
+
if (!(idleMs > 0))
|
|
167
|
+
return;
|
|
168
|
+
this.ctx.effect(() => {
|
|
169
|
+
const timer = setInterval(() => this.flushIdleSlices(Date.now(), idleMs), IDLE_TICK_MS);
|
|
170
|
+
return () => clearInterval(timer);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
/** 闲置扫描:静默达标且有切片的会话按捕获档位落袋(off 档会话挂起跳过)。 */
|
|
174
|
+
flushIdleSlices(now, idleMs) {
|
|
175
|
+
if (this.stopped)
|
|
176
|
+
return;
|
|
177
|
+
const liveNow = this.live.get();
|
|
178
|
+
if (!(liveNow.enabled && liveNow.distill))
|
|
179
|
+
return;
|
|
180
|
+
// 聚合每会话跨桶切片(sessionId 全局唯一)
|
|
181
|
+
const infos = new Map();
|
|
182
|
+
for (const mode of PENDING_MODES) {
|
|
183
|
+
for (const m of this.pending[mode]) {
|
|
184
|
+
const info = infos.get(m.sessionId) ?? { sessionId: m.sessionId, count: 0, lastMessageAt: 0 };
|
|
185
|
+
info.count++;
|
|
186
|
+
info.lastMessageAt = Math.max(info.lastMessageAt, m.timestamp);
|
|
187
|
+
infos.set(m.sessionId, info);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (infos.size === 0)
|
|
191
|
+
return;
|
|
192
|
+
const targets = idleSessionsToFlush([...infos.values()], this.lastActivity, now, idleMs, (sid) => this.modes?.get(sid) === 'off');
|
|
193
|
+
for (const sid of targets) {
|
|
194
|
+
this.logger.info(`[memory] 闲置兜底:会话 ${sid} 静默达标,未蒸馏切片落袋`);
|
|
195
|
+
this.enqueueSessionSlices(sid);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/** 把某会话在各桶中的切片按捕获档位逐个入队强制蒸馏(闲置兜底 / 档位切换共用)。 */
|
|
199
|
+
enqueueSessionSlices(sessionId) {
|
|
200
|
+
for (const mode of PENDING_MODES) {
|
|
201
|
+
if (this.pending[mode].some((m) => m.sessionId === sessionId)) {
|
|
202
|
+
this.enqueue(sessionId, [], mode, { force: true });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* 档位切换同步(session-modes 的 set() 回调,ADR-0003):
|
|
208
|
+
* 非 off 间切换 → 该会话切片立即按捕获档位蒸馏(新档位从空切片起步);
|
|
209
|
+
* 切到 off → 挂起(切片留存,闲置扫描跳过);从 off 切回 → 挂起片按捕获档位落袋。
|
|
210
|
+
*/
|
|
211
|
+
onModeChange(sessionId, oldMode, newMode) {
|
|
212
|
+
const action = modeSwitchAction(oldMode, newMode);
|
|
213
|
+
if (action === 'none')
|
|
214
|
+
return;
|
|
215
|
+
if (action === 'park') {
|
|
216
|
+
this.logger.info(`[memory] 档位切换 ${oldMode}→off:会话 ${sessionId} 未蒸馏切片挂起`);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
this.logger.info(`[memory] 档位切换 ${oldMode}→${newMode}(${action}):会话 ${sessionId} 切片按捕获档位落袋`);
|
|
220
|
+
this.enqueueSessionSlices(sessionId);
|
|
221
|
+
}
|
|
113
222
|
pushTask(task) {
|
|
114
223
|
if (this.stopped)
|
|
115
224
|
return;
|
|
@@ -147,7 +256,7 @@ export class MemoryRunner {
|
|
|
147
256
|
this.pending[key] = bucket.slice(-PENDING_BUCKET_CAP);
|
|
148
257
|
}
|
|
149
258
|
}
|
|
150
|
-
await savePending(this.pendingFile, this.pending);
|
|
259
|
+
await savePending(this.pendingFile, this.pending, this.warmup);
|
|
151
260
|
}
|
|
152
261
|
catch (err) {
|
|
153
262
|
this.logger.warn(`[memory] 未蒸馏缓冲落盘失败: ${errDetail(err)}`);
|
|
@@ -158,38 +267,31 @@ export class MemoryRunner {
|
|
|
158
267
|
this.logger.info(`[memory] 蒸馏管线开始(session=${sessionId},mode=${mode},本轮 ${messages.length} 条消息,待重试 ${this.pendingCount} 条)`);
|
|
159
268
|
// ── L0:原始对话已由 capture 在 turn/end 即时落盘(不排蒸馏队列,防慢 LLM 阻塞/退出丢消息) ──
|
|
160
269
|
const cfg = effectiveCfg(this.cfg, this.live);
|
|
161
|
-
// ── L1:抽取 +
|
|
270
|
+
// ── L1:抽取 + 去重(按档分桶、桶内按会话切片;失败按切片保留待重试) ──
|
|
162
271
|
let newRecords = [];
|
|
163
272
|
const liveNow = this.live.get();
|
|
164
273
|
const distillOn = liveNow.enabled && liveNow.distill;
|
|
165
274
|
if (cfg.extract.enabled && distillOn) {
|
|
166
275
|
const bucket = this.pending[mode];
|
|
167
|
-
|
|
276
|
+
// 入桶即携带会话标识(会话切片成员;ADR-0003:切片内永不跨会话混装)
|
|
277
|
+
bucket.push(...messages.map((m) => ({ ...m, sessionId })));
|
|
168
278
|
if (!opts?.noBufferCap && bucket.length > PENDING_BUCKET_CAP) {
|
|
169
279
|
bucket.splice(0, bucket.length - PENDING_BUCKET_CAP);
|
|
170
280
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
281
|
+
if (messages.length > 0)
|
|
282
|
+
this.lastActivity.set(sessionId, Date.now());
|
|
283
|
+
// 按会话切片触发(ADR-0003):只看本会话切片是否达到生效阈值(渐进爬坡),
|
|
284
|
+
// 达标只抽取该切片——其余会话的切片继续攒,绝不跨会话混装。
|
|
285
|
+
// force:重建轮全量蒸馏(历史小会话也必须出记忆,不受阈值约束)。
|
|
286
|
+
const effective = effectiveExtractThreshold(this.warmup[mode], cfg.extract.minMessages);
|
|
287
|
+
const sliceLen = bucket.reduce((n, m) => (m.sessionId === sessionId ? n + 1 : n), 0);
|
|
288
|
+
if (opts?.force || sliceLen >= effective) {
|
|
289
|
+
newRecords = await this.extractSessionSlice(sessionId, mode, cfg, effective, opts);
|
|
179
290
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
// 缓冲每次尝试后立即落盘:进程中途退出不丢待重试/攒阈值状态
|
|
185
|
-
await this.persistPending(opts?.noBufferCap);
|
|
186
|
-
// L1 计数推进后立即落盘:L2/L3 失败或进程中途退出不得回滚阈值进度
|
|
187
|
-
// (记录已入库但计数丢失会让该族 L2 永远差一截,state 与 DB 脱节)
|
|
188
|
-
try {
|
|
189
|
-
await this.stores.state.save();
|
|
190
|
-
}
|
|
191
|
-
catch (err) {
|
|
192
|
-
this.logger.warn(`[memory] 状态保存失败: ${errDetail(err)}`);
|
|
291
|
+
else {
|
|
292
|
+
this.logger.debug?.(`[memory] 会话切片攒批中(session=${sessionId},mode=${mode},${sliceLen}/${effective})`);
|
|
293
|
+
// 攒阈值中途也落盘:进程退出后切片与爬坡状态不丢
|
|
294
|
+
await this.persistPending(opts?.noBufferCap);
|
|
193
295
|
}
|
|
194
296
|
}
|
|
195
297
|
// ── L2/L3:按记录族各自判定与执行 ──
|
|
@@ -239,4 +341,48 @@ export class MemoryRunner {
|
|
|
239
341
|
this.afterRun?.();
|
|
240
342
|
return newRecords.length;
|
|
241
343
|
}
|
|
344
|
+
/**
|
|
345
|
+
* 抽取并消费一个会话切片:成功才把切片移出桶并推进爬坡阈值;失败保留切片待重试。
|
|
346
|
+
* 调用方已保证切片达到生效阈值(或 force)。背景参考按会话从 L0 现查并剔除切片
|
|
347
|
+
* 自身(ADR-0003:会话间互不污染、重启不丢背景)。
|
|
348
|
+
*/
|
|
349
|
+
async extractSessionSlice(sessionId, mode, cfg, effectiveThreshold, opts) {
|
|
350
|
+
const bucket = this.pending[mode];
|
|
351
|
+
const slice = bucket.filter((m) => m.sessionId === sessionId);
|
|
352
|
+
if (slice.length === 0)
|
|
353
|
+
return [];
|
|
354
|
+
const rest = bucket.filter((m) => m.sessionId !== sessionId);
|
|
355
|
+
try {
|
|
356
|
+
// 背景参考:该会话最近消息(多取切片条数补偿被剔除者),剔除切片自身
|
|
357
|
+
const background = this.stores.l0
|
|
358
|
+
? pickSessionBackground(await this.stores.l0.recentBySession(sessionId, cfg.extract.backgroundMessages + slice.length), new Set(slice.map((m) => m.id)), cfg.extract.backgroundMessages)
|
|
359
|
+
: [];
|
|
360
|
+
const t = Date.now();
|
|
361
|
+
const result = await runExtraction(this.ctx, cfg, this.stores.l1, this.states, slice, background, this.logger, mode);
|
|
362
|
+
if (!result.skipped) {
|
|
363
|
+
this.pending[mode] = rest;
|
|
364
|
+
// 重建轮(force)不是有机对话,不推进爬坡
|
|
365
|
+
if (!opts?.force)
|
|
366
|
+
this.warmup[mode] = advanceWarmupThreshold(this.warmup[mode], cfg.extract.minMessages);
|
|
367
|
+
}
|
|
368
|
+
this.logger.info(`[memory] L1 阶段完成(session=${sessionId},mode=${mode},切片 ${slice.length} 条,背景 ${background.length} 条,阈值 ${effectiveThreshold},${Date.now() - t}ms)`);
|
|
369
|
+
// 缓冲与爬坡每次尝试后立即落盘:进程中途退出不丢待重试/攒阈值状态
|
|
370
|
+
await this.persistPending(opts?.noBufferCap);
|
|
371
|
+
// L1 计数推进后立即落盘:L2/L3 失败或进程中途退出不得回滚阈值进度
|
|
372
|
+
// (记录已入库但计数丢失会让该族 L2 永远差一截,state 与 DB 脱节)
|
|
373
|
+
try {
|
|
374
|
+
await this.stores.state.save();
|
|
375
|
+
}
|
|
376
|
+
catch (err) {
|
|
377
|
+
this.logger.warn(`[memory] 状态保存失败: ${errDetail(err)}`);
|
|
378
|
+
}
|
|
379
|
+
return result.newRecords;
|
|
380
|
+
}
|
|
381
|
+
catch (err) {
|
|
382
|
+
// 保留切片下次重试(桶入口已裁到 ≤200,防无限堆积;重建轮不裁,量被会话规模约束)
|
|
383
|
+
this.logger.warn(`[memory] L1 抽取失败(session=${sessionId},mode=${mode},切片 ${slice.length} 条): ${errDetail(err)}`);
|
|
384
|
+
await this.persistPending(opts?.noBufferCap).catch(() => { });
|
|
385
|
+
return [];
|
|
386
|
+
}
|
|
387
|
+
}
|
|
242
388
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 蒸馏触发决策纯函数(ADR-0003 / 规格 B 节;S2 测试缝——决策与执行分离,
|
|
3
|
+
* 不依赖 dsh 宿主即可测调度语义)。
|
|
4
|
+
*
|
|
5
|
+
* 语义(CONTEXT.md「渐进阈值」「会话切片」):
|
|
6
|
+
* - 生效阈值从 1 起步(新用户首轮即出记忆),每次成功抽取后翻倍至稳态值毕业;
|
|
7
|
+
* - 阈值按会话切片计数,只抽取达到阈值的那个会话的切片;
|
|
8
|
+
* - warmup 值 0 = 已毕业(使用稳态值),按档位桶各记一次(全局爬坡,非按会话)。
|
|
9
|
+
*/
|
|
10
|
+
/** 生效阈值:爬坡中取 min(当前爬坡值, 稳态),毕业(0)取稳态。 */
|
|
11
|
+
export declare function effectiveExtractThreshold(warmup: number, steady: number): number;
|
|
12
|
+
/** 成功抽取后推进爬坡:翻倍,达到稳态即毕业(0);已毕业保持 0。 */
|
|
13
|
+
export declare function advanceWarmupThreshold(current: number, steady: number): number;
|
|
14
|
+
export type ModeSwitchAction = 'flush' | 'park' | 'unpark' | 'none';
|
|
15
|
+
/**
|
|
16
|
+
* 档位切换动作表(ADR-0003):
|
|
17
|
+
* - 非 off 档间切换 → flush(该会话切片立即按捕获档位蒸馏,新档位从空切片起步)
|
|
18
|
+
* - 切到 off → park(切片挂起:用户刚说"停止记忆",把存量再蒸馏违背意图)
|
|
19
|
+
* - 从 off 切回 → unpark(挂起片按捕获档位落袋)
|
|
20
|
+
*/
|
|
21
|
+
export declare function modeSwitchAction(oldMode: string, newMode: string): ModeSwitchAction;
|
|
22
|
+
/** 从"该会话最近消息"里剔除切片自身成员后取尾部 n 条作为抽取背景。
|
|
23
|
+
* 剔除防止抽取目标混入背景(L0 在 turn/end 已先落盘,现查必含切片)。 */
|
|
24
|
+
export declare function pickSessionBackground<T extends {
|
|
25
|
+
id: string;
|
|
26
|
+
}>(recent: readonly T[], sliceIds: ReadonlySet<string>, n: number): T[];
|
|
27
|
+
export interface IdleSliceInfo {
|
|
28
|
+
sessionId: string;
|
|
29
|
+
/** 该会话跨桶合计的切片条数。 */
|
|
30
|
+
count: number;
|
|
31
|
+
/** 切片内最晚消息时间(无活动记录时的兜底锚点,如重启后的残留切片)。 */
|
|
32
|
+
lastMessageAt: number;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* 闲置扫描:静默达标且有切片的会话。off 档会话跳过(挂起语义);
|
|
36
|
+
* 活动时间优先取运行时记录,缺省回退切片内最晚消息时间。
|
|
37
|
+
*/
|
|
38
|
+
export declare function idleSessionsToFlush(slices: IdleSliceInfo[], lastActivity: ReadonlyMap<string, number>, now: number, idleMs: number, isOffSession: (sessionId: string) => boolean): string[];
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 蒸馏触发决策纯函数(ADR-0003 / 规格 B 节;S2 测试缝——决策与执行分离,
|
|
3
|
+
* 不依赖 dsh 宿主即可测调度语义)。
|
|
4
|
+
*
|
|
5
|
+
* 语义(CONTEXT.md「渐进阈值」「会话切片」):
|
|
6
|
+
* - 生效阈值从 1 起步(新用户首轮即出记忆),每次成功抽取后翻倍至稳态值毕业;
|
|
7
|
+
* - 阈值按会话切片计数,只抽取达到阈值的那个会话的切片;
|
|
8
|
+
* - warmup 值 0 = 已毕业(使用稳态值),按档位桶各记一次(全局爬坡,非按会话)。
|
|
9
|
+
*/
|
|
10
|
+
/** 生效阈值:爬坡中取 min(当前爬坡值, 稳态),毕业(0)取稳态。 */
|
|
11
|
+
export function effectiveExtractThreshold(warmup, steady) {
|
|
12
|
+
if (!Number.isFinite(warmup) || warmup <= 0)
|
|
13
|
+
return steady;
|
|
14
|
+
return Math.min(warmup, steady);
|
|
15
|
+
}
|
|
16
|
+
/** 成功抽取后推进爬坡:翻倍,达到稳态即毕业(0);已毕业保持 0。 */
|
|
17
|
+
export function advanceWarmupThreshold(current, steady) {
|
|
18
|
+
if (!Number.isFinite(current) || current <= 0)
|
|
19
|
+
return 0;
|
|
20
|
+
const next = current * 2;
|
|
21
|
+
return next >= steady ? 0 : next;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* 档位切换动作表(ADR-0003):
|
|
25
|
+
* - 非 off 档间切换 → flush(该会话切片立即按捕获档位蒸馏,新档位从空切片起步)
|
|
26
|
+
* - 切到 off → park(切片挂起:用户刚说"停止记忆",把存量再蒸馏违背意图)
|
|
27
|
+
* - 从 off 切回 → unpark(挂起片按捕获档位落袋)
|
|
28
|
+
*/
|
|
29
|
+
export function modeSwitchAction(oldMode, newMode) {
|
|
30
|
+
if (oldMode === newMode)
|
|
31
|
+
return 'none';
|
|
32
|
+
if (newMode === 'off')
|
|
33
|
+
return 'park';
|
|
34
|
+
if (oldMode === 'off')
|
|
35
|
+
return 'unpark';
|
|
36
|
+
return 'flush';
|
|
37
|
+
}
|
|
38
|
+
// ── 背景参考按会话现查(ADR-0003:抽取背景从 L0 按会话取,剔除切片自身) ──
|
|
39
|
+
/** 从"该会话最近消息"里剔除切片自身成员后取尾部 n 条作为抽取背景。
|
|
40
|
+
* 剔除防止抽取目标混入背景(L0 在 turn/end 已先落盘,现查必含切片)。 */
|
|
41
|
+
export function pickSessionBackground(recent, sliceIds, n) {
|
|
42
|
+
if (n <= 0)
|
|
43
|
+
return [];
|
|
44
|
+
return recent.filter((m) => !sliceIds.has(m.id)).slice(-n);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* 闲置扫描:静默达标且有切片的会话。off 档会话跳过(挂起语义);
|
|
48
|
+
* 活动时间优先取运行时记录,缺省回退切片内最晚消息时间。
|
|
49
|
+
*/
|
|
50
|
+
export function idleSessionsToFlush(slices, lastActivity, now, idleMs, isOffSession) {
|
|
51
|
+
if (!(idleMs > 0))
|
|
52
|
+
return [];
|
|
53
|
+
const out = [];
|
|
54
|
+
for (const s of slices) {
|
|
55
|
+
if (s.count <= 0)
|
|
56
|
+
continue;
|
|
57
|
+
if (isOffSession(s.sessionId))
|
|
58
|
+
continue;
|
|
59
|
+
const activity = lastActivity.get(s.sessionId) ?? s.lastMessageAt;
|
|
60
|
+
if (now - activity >= idleMs)
|
|
61
|
+
out.push(s.sessionId);
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
package/dist/settings.d.ts
CHANGED
|
@@ -5,9 +5,12 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import type { Context } from '@deepseek-ai/cordis';
|
|
7
7
|
import Schema from '@deepseek-ai/schemastery';
|
|
8
|
+
import type { DistillBudgetLayer } from './llm.js';
|
|
8
9
|
import type { MemoryLogger } from './types.js';
|
|
9
10
|
/** 蒸馏思考档位可选项:'' = 跟随静态 config(部署默认)。 */
|
|
10
11
|
export type EffortChoice = '' | 'off' | 'high' | 'max';
|
|
12
|
+
/** 分层输出预算(与 llm.ts 的 DistillBudgetLayer 同键;0 = 跟随内置默认)。 */
|
|
13
|
+
export type DistillBudgets = Record<DistillBudgetLayer, number>;
|
|
11
14
|
export interface MemoryLiveSettings {
|
|
12
15
|
/** 总开关:关 = 捕获/蒸馏/召回注入全停(数据保留) */
|
|
13
16
|
enabled: boolean;
|
|
@@ -19,6 +22,17 @@ export interface MemoryLiveSettings {
|
|
|
19
22
|
recall: boolean;
|
|
20
23
|
/** 蒸馏思考档位运行时覆盖:'' = 跟随静态 config(llm.reasoningEffort) */
|
|
21
24
|
reasoningEffort: EffortChoice;
|
|
25
|
+
/** 蒸馏模型运行时覆盖(供应商 id,用户已配置的路由):'' = 跟随静态 config/默认选择。
|
|
26
|
+
* 与 distillModel 成对生效(单字段不算);部署静态 pin(provider+model 双字段)优先。 */
|
|
27
|
+
distillProvider: string;
|
|
28
|
+
/** 蒸馏模型运行时覆盖(模型 id):'' = 跟随静态 config/默认选择。 */
|
|
29
|
+
distillModel: string;
|
|
30
|
+
/** 分层输出预算运行时覆盖(token):extract/dedup/l2/l3 四层,0 = 跟随内置默认;
|
|
31
|
+
* 思考档 high/max 的 ×4 放大在覆盖值之上照常生效。 */
|
|
32
|
+
distillBudgets: DistillBudgets;
|
|
33
|
+
/** 输入预算运行时覆盖(字符,≈token):单次蒸馏调用的输入上限,L1 按此分块、
|
|
34
|
+
* 超限截断;0 = 跟随静态配置 llm.maxInputChars。 */
|
|
35
|
+
distillMaxInputChars: number;
|
|
22
36
|
}
|
|
23
37
|
export interface LiveSettingsHandle {
|
|
24
38
|
/** settings 服务是否可用(不可用时 UI 侧隐藏开关面板) */
|
package/dist/settings.js
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
import Schema from '@deepseek-ai/schemastery';
|
|
2
2
|
import { settingsNamespace } from '@deepseek-ai/dsh-settings';
|
|
3
3
|
const NS = settingsNamespace('dsh-memory');
|
|
4
|
-
const ALWAYS_ON = {
|
|
4
|
+
const ALWAYS_ON = {
|
|
5
|
+
enabled: true,
|
|
6
|
+
capture: true,
|
|
7
|
+
distill: true,
|
|
8
|
+
recall: true,
|
|
9
|
+
reasoningEffort: '',
|
|
10
|
+
distillProvider: '',
|
|
11
|
+
distillModel: '',
|
|
12
|
+
distillBudgets: { extract: 0, dedup: 0, l2: 0, l3: 0 },
|
|
13
|
+
distillMaxInputChars: 0,
|
|
14
|
+
};
|
|
5
15
|
/**
|
|
6
16
|
* 进程内 scope 复用(fiber 重启重挂)。
|
|
7
17
|
* dsh-settings 的 register 把注册挂在其**服务自身 ctx** 的 effect 上
|
|
@@ -20,12 +30,22 @@ let cachedScope;
|
|
|
20
30
|
let cachedUnwatch;
|
|
21
31
|
let cachedSvc;
|
|
22
32
|
export function liveSettingsSchema() {
|
|
33
|
+
const budget = () => Schema.number().min(0).max(1_000_000).default(0);
|
|
23
34
|
return Schema.object({
|
|
24
35
|
enabled: Schema.boolean().default(true),
|
|
25
36
|
capture: Schema.boolean().default(true),
|
|
26
37
|
distill: Schema.boolean().default(true),
|
|
27
38
|
recall: Schema.boolean().default(true),
|
|
28
39
|
reasoningEffort: Schema.union(['', 'off', 'high', 'max']).default(''),
|
|
40
|
+
distillProvider: Schema.string().default(''),
|
|
41
|
+
distillModel: Schema.string().default(''),
|
|
42
|
+
distillBudgets: Schema.object({
|
|
43
|
+
extract: budget(),
|
|
44
|
+
dedup: budget(),
|
|
45
|
+
l2: budget(),
|
|
46
|
+
l3: budget(),
|
|
47
|
+
}).default({ extract: 0, dedup: 0, l2: 0, l3: 0 }),
|
|
48
|
+
distillMaxInputChars: Schema.number().min(0).max(1_000_000).default(0),
|
|
29
49
|
});
|
|
30
50
|
}
|
|
31
51
|
export function registerLiveSettings(ctx, logger) {
|
|
@@ -42,8 +62,16 @@ export function registerLiveSettings(ctx, logger) {
|
|
|
42
62
|
cachedUnwatch = scope.watch((next) => {
|
|
43
63
|
const prev = current;
|
|
44
64
|
current = resolveSettings(next);
|
|
65
|
+
const b = current.distillBudgets;
|
|
66
|
+
const budgetNote = (b.extract || b.dedup || b.l2 || b.l3)
|
|
67
|
+
? `,输出预算=抽取 ${b.extract || '默认'}/去重 ${b.dedup || '默认'}/L2 ${b.l2 || '默认'}/L3 ${b.l3 || '默认'}`
|
|
68
|
+
: '';
|
|
69
|
+
const inputNote = current.distillMaxInputChars > 0 ? `,输入预算=${current.distillMaxInputChars}` : '';
|
|
45
70
|
logger.info(`[memory] 记忆模式开关更新:总=${current.enabled} 捕获=${current.capture} 蒸馏=${current.distill} 召回=${current.recall}` +
|
|
46
|
-
`,蒸馏思考=${current.reasoningEffort || '跟随配置'}(此前 总=${prev.enabled})`
|
|
71
|
+
`,蒸馏思考=${current.reasoningEffort || '跟随配置'}(此前 总=${prev.enabled})` +
|
|
72
|
+
(current.distillProvider && current.distillModel
|
|
73
|
+
? `,蒸馏模型=${current.distillProvider}/${current.distillModel}`
|
|
74
|
+
: '') + budgetNote + inputNote);
|
|
47
75
|
});
|
|
48
76
|
return {
|
|
49
77
|
supported: true,
|
|
@@ -127,6 +155,8 @@ function resolveSettings(value) {
|
|
|
127
155
|
return { ...ALWAYS_ON };
|
|
128
156
|
const v = value;
|
|
129
157
|
const efforts = ['', 'off', 'high', 'max'];
|
|
158
|
+
const num = (x) => (typeof x === 'number' && Number.isFinite(x) && x >= 0 ? Math.floor(x) : 0);
|
|
159
|
+
const rawBudgets = (v.distillBudgets ?? {});
|
|
130
160
|
return {
|
|
131
161
|
enabled: v.enabled !== false,
|
|
132
162
|
capture: v.capture !== false,
|
|
@@ -135,5 +165,14 @@ function resolveSettings(value) {
|
|
|
135
165
|
reasoningEffort: typeof v.reasoningEffort === 'string' && efforts.includes(v.reasoningEffort)
|
|
136
166
|
? v.reasoningEffort
|
|
137
167
|
: '',
|
|
168
|
+
distillProvider: typeof v.distillProvider === 'string' ? v.distillProvider : '',
|
|
169
|
+
distillModel: typeof v.distillModel === 'string' ? v.distillModel : '',
|
|
170
|
+
distillBudgets: {
|
|
171
|
+
extract: num(rawBudgets.extract),
|
|
172
|
+
dedup: num(rawBudgets.dedup),
|
|
173
|
+
l2: num(rawBudgets.l2),
|
|
174
|
+
l3: num(rawBudgets.l3),
|
|
175
|
+
},
|
|
176
|
+
distillMaxInputChars: num(v.distillMaxInputChars),
|
|
138
177
|
};
|
|
139
178
|
}
|