dsh-prime-memory 0.26.10 → 0.26.11
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/CHANGELOG.de.md +1638 -0
- package/CHANGELOG.en.md +15 -0
- package/CHANGELOG.es.md +1641 -0
- package/CHANGELOG.fr.md +1643 -0
- package/CHANGELOG.it.md +1643 -0
- package/CHANGELOG.ja.md +15 -0
- package/CHANGELOG.ko.md +15 -0
- package/CHANGELOG.md +34 -0
- package/CHANGELOG.ru.md +1640 -0
- package/README.en.md +2 -2
- package/README.md +9 -2
- package/dist/bench-control.js +3 -1
- package/dist/client.js +121 -12
- package/dist/clients/drsc.d.ts +34 -0
- package/dist/clients/drsc.js +56 -11
- package/dist/config.d.ts +10 -1
- package/dist/config.js +37 -18
- package/dist/contract.d.ts +48 -1
- package/dist/graph/gate.d.ts +17 -0
- package/dist/graph/gate.js +11 -0
- package/dist/graph/preview.d.ts +5 -0
- package/dist/graph/preview.js +2 -0
- package/dist/index.js +1 -1
- package/dist/pipeline/fact-source.d.ts +127 -0
- package/dist/pipeline/fact-source.js +192 -0
- package/dist/pipeline/rebuild-preserve.d.ts +31 -2
- package/dist/pipeline/rebuild-preserve.js +40 -14
- package/dist/pipeline/rebuild.d.ts +47 -13
- package/dist/pipeline/rebuild.js +153 -57
- package/dist/pipeline/runner.d.ts +4 -1
- package/dist/pipeline/runner.js +58 -21
- package/dist/pipeline/workspace-relink.js +7 -2
- package/dist/room-review.d.ts +1 -1
- package/dist/room-review.js +10 -5
- package/dist/settings.js +4 -0
- package/dist/stats.js +10 -3
- package/dist/store/graph-store.d.ts +20 -0
- package/dist/store/graph-store.js +50 -1
- package/dist/store/sqlite.d.ts +42 -0
- package/dist/store/sqlite.js +98 -0
- package/dist/tools/index.js +4 -3
- package/dsh.plugin.json +1 -1
- package/package.json +16 -11
package/dist/pipeline/rebuild.js
CHANGED
|
@@ -26,29 +26,9 @@ import { runSceneConsolidation } from './l2.js';
|
|
|
26
26
|
import { runPersona } from './l3.js';
|
|
27
27
|
import { describePlan, gateClear, planPreserve, readFactSource, restorePreserved } from './rebuild-preserve.js';
|
|
28
28
|
import { effectiveCfg } from './runner.js';
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (!r || typeof r.id !== 'string' || typeof r.content !== 'string')
|
|
33
|
-
continue;
|
|
34
|
-
if (r.role !== 'user' && r.role !== 'assistant')
|
|
35
|
-
continue;
|
|
36
|
-
if (!r.content.trim())
|
|
37
|
-
continue;
|
|
38
|
-
const key = r.sessionId || 'default';
|
|
39
|
-
const arr = bySession.get(key) ?? [];
|
|
40
|
-
arr.push({ id: r.id, role: r.role, content: r.content, timestamp: r.timestamp ?? 0 });
|
|
41
|
-
bySession.set(key, arr);
|
|
42
|
-
}
|
|
43
|
-
const chunks = [];
|
|
44
|
-
for (const [sessionId, messages] of bySession) {
|
|
45
|
-
messages.sort((a, b) => a.timestamp - b.timestamp);
|
|
46
|
-
chunks.push({ sessionId, messages });
|
|
47
|
-
}
|
|
48
|
-
// 会话按首条消息时间升序:情境链按时间顺序衔接(与原始发生顺序一致)
|
|
49
|
-
chunks.sort((a, b) => a.messages[0].timestamp - b.messages[0].timestamp);
|
|
50
|
-
return chunks;
|
|
51
|
-
}
|
|
29
|
+
import { DrscFactSource, LocalL0FactSource, } from './fact-source.js';
|
|
30
|
+
export { DrscFactSource, groupL0Sessions, LocalL0FactSource } from './fact-source.js';
|
|
31
|
+
// `RebuildChunk` 与 `groupL0Sessions` 已迁到 pipeline/fact-source.ts(上面已转出)。
|
|
52
32
|
/** 抽取调用数下界估算(与 l1.ts 的 perChunk 同式;会话数与字符预算取大)。 */
|
|
53
33
|
export function estimateCalls(sessions, messages, chars, maxInputChars) {
|
|
54
34
|
if (messages === 0)
|
|
@@ -56,6 +36,15 @@ export function estimateCalls(sessions, messages, chars, maxInputChars) {
|
|
|
56
36
|
const perChunk = Math.max(20_000, maxInputChars - 42_000);
|
|
57
37
|
return Math.max(sessions, Math.ceil((chars + 64 * messages) / perChunk));
|
|
58
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* 水位落差**提示**阀值:检索库里晚于 L0 水位的占比超过此值,就要求二次确认。
|
|
41
|
+
*
|
|
42
|
+
* 为什么叫“提示阀值”而不是“拒绝阀值”:L0 写停后这个比例只会单调上升,
|
|
43
|
+
* 拿它做硬门槛等于把重建**永久锁死**(issue #14 原提案的致命缺陷)。
|
|
44
|
+
* 所以它只负责“提醒你正在做什么”,放行靠 `force`;真正的**不丢**由
|
|
45
|
+
* `planPreserve` 的集合判据(`post-watermark`)结构性保证。
|
|
46
|
+
*/
|
|
47
|
+
export const STALE_RATIO_THRESHOLD = 0.5;
|
|
59
48
|
function idleStatus() {
|
|
60
49
|
return {
|
|
61
50
|
running: false,
|
|
@@ -72,6 +61,9 @@ function idleStatus() {
|
|
|
72
61
|
error: null,
|
|
73
62
|
archiveNote: null,
|
|
74
63
|
preserveNote: null,
|
|
64
|
+
rederivableCount: null,
|
|
65
|
+
preserveCount: null,
|
|
66
|
+
factWatermark: null,
|
|
75
67
|
};
|
|
76
68
|
}
|
|
77
69
|
/** 时间戳后缀(归档命名,秒级防撞)。 */
|
|
@@ -88,7 +80,11 @@ export class RebuildController {
|
|
|
88
80
|
logger;
|
|
89
81
|
live;
|
|
90
82
|
status = idleStatus();
|
|
91
|
-
|
|
83
|
+
/**
|
|
84
|
+
* 会话级事实源迭代器(issue #14-B):不再把全量 L0 正文驻留内存。
|
|
85
|
+
* null = 尚未进入蒸馏 / 已收尾。
|
|
86
|
+
*/
|
|
87
|
+
chunkIter = null;
|
|
92
88
|
cancelRequested = false;
|
|
93
89
|
/** 快照时刻(收尾时按它区分重建产物与重建后新对话的记录)。 */
|
|
94
90
|
rebuildStartMs = 0;
|
|
@@ -101,32 +97,88 @@ export class RebuildController {
|
|
|
101
97
|
this.logger = logger;
|
|
102
98
|
this.live = live;
|
|
103
99
|
}
|
|
104
|
-
/** 状态快照(idle
|
|
100
|
+
/** 状态快照(idle 时附带实时预估与事实源水位,供确认弹窗讲清"会发生什么")。 */
|
|
105
101
|
getStatus() {
|
|
106
102
|
if (this.status.phase === 'idle') {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
103
|
+
try {
|
|
104
|
+
const provider = this.provider();
|
|
105
|
+
const est = provider.estimate();
|
|
106
|
+
const cov = provider.coverage();
|
|
107
|
+
return {
|
|
108
|
+
...this.status,
|
|
109
|
+
sessionCount: est.sessions,
|
|
110
|
+
messageCount: est.messages,
|
|
111
|
+
estCalls: estimateCalls(est.sessions, est.messages, est.chars, effectiveCfg(this.cfg, this.live).llm.maxInputChars),
|
|
112
|
+
factWatermark: cov.untilIso,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// 事实源未就绪(如选了 drsc 但 collector 未装配):面板不能因此整块挂,
|
|
117
|
+
// 只把水位置空;真正的错误留给 start() 显式报。
|
|
118
|
+
return { ...this.status, factWatermark: null };
|
|
119
|
+
}
|
|
114
120
|
}
|
|
115
121
|
return { ...this.status };
|
|
116
122
|
}
|
|
117
|
-
/**
|
|
123
|
+
/** 尚未处理的会话块数(流式下为估算 `total - done`;未运行时为 0,诊断/冒烟用)。 */
|
|
118
124
|
get chunkCount() {
|
|
119
|
-
return this.
|
|
125
|
+
return this.status.running ? Math.max(0, this.status.total - this.status.done) : 0;
|
|
120
126
|
}
|
|
121
|
-
/**
|
|
122
|
-
|
|
127
|
+
/**
|
|
128
|
+
* 按 `cfg.rebuild.factSource` 选事实源(issue #14-B)。
|
|
129
|
+
*
|
|
130
|
+
* 选 drsc 但客户端未装配时**显式抛错**,不静默回退本地表 —— “以为用了新源其实还是旧表”
|
|
131
|
+
* 会让用户拿重建结果去判断 DRSC 数据是否完整,那是最坏的误判。
|
|
132
|
+
*/
|
|
133
|
+
provider() {
|
|
134
|
+
if (this.cfg.rebuild?.factSource === 'drsc') {
|
|
135
|
+
const drsc = this.stores.drsc;
|
|
136
|
+
if (!drsc) {
|
|
137
|
+
throw new Error('重建事实源配置为 drsc,但 collector 客户端未装配 —— 已中止且**未动任何数据**。'
|
|
138
|
+
+ '请先启用 reference-session-collector,或把 rebuild.factSource 改回 local。');
|
|
139
|
+
}
|
|
140
|
+
return new DrscFactSource(drsc, this.logger);
|
|
141
|
+
}
|
|
142
|
+
return new LocalL0FactSource(this.db, this.logger);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* 启动重建(校验后入队准备任务;真正的清库/归档在管线队列里串行执行,避开并发竞态)。
|
|
146
|
+
*
|
|
147
|
+
* `force` 只用于跨过**提示门槛**(水位落差超过 `STALE_RATIO_THRESHOLD` 时的二次确认),
|
|
148
|
+
* 不能跨过唯一那条硬拒:「L0 一条消息都没有、检索库却有记录」—— 那种形状下重建
|
|
149
|
+
* 只会清空、一条也重造不出来,属于纯亏。
|
|
150
|
+
*/
|
|
151
|
+
start(force = false) {
|
|
123
152
|
if (this.status.running)
|
|
124
153
|
throw new Error('重建已在进行中');
|
|
125
|
-
|
|
126
|
-
|
|
154
|
+
// drsc 但客户端未装配 → provider() 在这里显式抛(未动任何数据)
|
|
155
|
+
const provider = this.provider();
|
|
156
|
+
const est = provider.estimate();
|
|
157
|
+
const wm = this.db.rebuildWatermark();
|
|
158
|
+
// 同步门只对“能同步报数”的事实源成立(本地 L0)。DRSC 要发请求才知道会话数,
|
|
159
|
+
// known=false 时必须跳过 —— 否则会把“未知”当成“空”直接拒掉。
|
|
160
|
+
if (est.known && est.messages === 0) {
|
|
161
|
+
if (wm.l1Total > 0) {
|
|
162
|
+
throw new Error(`本地 L0 一条消息都没有,而检索库有 ${wm.l1Total} 条记忆 ——`
|
|
163
|
+
+ 'L0 正文自 T8.4 起已停写,用它重建只会清空检索库、一条也重造不出来。'
|
|
164
|
+
+ '已中止重建且**未动任何数据**。若确实需要以其他事实源重建,请先把该事实源接上后重试。');
|
|
165
|
+
}
|
|
127
166
|
throw new Error('L0 无任何消息,无需重建');
|
|
167
|
+
}
|
|
168
|
+
// 水位落差**只做提示**:L0 停写后这个比例会单调上升,硬拒等于永久锁死重建。
|
|
169
|
+
// 真正的“不丢”由 planPreserve 的集合判据结构性保证,这里只负责让用户看清楚。
|
|
170
|
+
if (est.known && !force && wm.l1Total > 0) {
|
|
171
|
+
const ratio = wm.l1After / wm.l1Total;
|
|
172
|
+
if (ratio > STALE_RATIO_THRESHOLD) {
|
|
173
|
+
const pct = Math.round(ratio * 100);
|
|
174
|
+
throw new Error(`检索库有 ${wm.l1Total} 条记忆,其中 ${wm.l1After} 条(${pct}%)晚于本地 L0 水位`
|
|
175
|
+
+ `${wm.l0Iso ? ` ${wm.l0Iso}` : '(L0 为空)'} —— 这些记忆的原材料已不在本次快照里,`
|
|
176
|
+
+ '重建会把它们**原样保留但不再重蒸馏**(不会丢,只是不重造)。'
|
|
177
|
+
+ '确认要继续请带 force 重试。');
|
|
178
|
+
}
|
|
179
|
+
}
|
|
128
180
|
this.cancelRequested = false;
|
|
129
|
-
this.
|
|
181
|
+
this.chunkIter = null;
|
|
130
182
|
this.status = {
|
|
131
183
|
...idleStatus(),
|
|
132
184
|
running: true,
|
|
@@ -135,9 +187,12 @@ export class RebuildController {
|
|
|
135
187
|
messageCount: est.messages,
|
|
136
188
|
estCalls: estimateCalls(est.sessions, est.messages, est.chars, effectiveCfg(this.cfg, this.live).llm.maxInputChars),
|
|
137
189
|
startedAt: Date.now(),
|
|
190
|
+
factWatermark: provider.coverage().untilIso,
|
|
138
191
|
};
|
|
139
192
|
this.runner.enqueueRebuildTask(() => this.prepare());
|
|
140
|
-
this.logger.info(`[memory] 重建开始:${est.sessions} 个会话 / ${est.messages} 条 L0
|
|
193
|
+
this.logger.info(`[memory] 重建开始:${est.sessions} 个会话 / ${est.messages} 条 L0 消息`
|
|
194
|
+
+ `(预计 ≥${this.status.estCalls} 次抽取调用;事实源水位 ${wm.l0Iso ?? '空'};`
|
|
195
|
+
+ `检索库 ${wm.l1Total} 条,其中晚于水位 ${wm.l1After} 条)`);
|
|
141
196
|
return { ...this.status };
|
|
142
197
|
}
|
|
143
198
|
/** 请求取消:当前块完成后停止,已重建部分保留并照常收尾 L2/L3。 */
|
|
@@ -151,18 +206,28 @@ export class RebuildController {
|
|
|
151
206
|
}
|
|
152
207
|
async prepare() {
|
|
153
208
|
try {
|
|
154
|
-
//
|
|
209
|
+
// 事实源(issue #14-B):默认本地 L0;drsc 需显式配置且必须有客户端。
|
|
210
|
+
const provider = this.provider();
|
|
211
|
+
// 快照时刻:重建期间新捕获的消息走正常轮次,天然不重不漏
|
|
155
212
|
this.rebuildStartMs = Date.now();
|
|
156
|
-
|
|
157
|
-
if (
|
|
158
|
-
this.finish('failed',
|
|
213
|
+
const est = await provider.prepare();
|
|
214
|
+
if (est.sessions === 0) {
|
|
215
|
+
this.finish('failed', `事实源(${provider.kind})为空,无可重建内容`);
|
|
159
216
|
return;
|
|
160
217
|
}
|
|
161
|
-
this.status.total =
|
|
218
|
+
this.status.total = est.sessions;
|
|
219
|
+
if (est.messages > 0)
|
|
220
|
+
this.status.messageCount = est.messages;
|
|
221
|
+
// 本次事实源的**消息 id 集**:判"来源是否还在快照里"的唯一依据。
|
|
222
|
+
// 不取时间:时间差对停写的 L0 会永久走扩(详见 rebuild-preserve 的 post-watermark)。
|
|
223
|
+
// 注意这里**不读正文**:2.5 万条 id 约 1.5MB,而全量正文是 88MB。
|
|
224
|
+
const snapshotIds = await provider.snapshotIds();
|
|
162
225
|
const dataDir = resolveDataDir(this.cfg);
|
|
163
226
|
// ── ① 读事实源 + 判定保留集(必须在 archiveDerived 之前:归档会把 records/ 搬走)──
|
|
164
227
|
const factRead = await readFactSource(path.join(dataDir, 'records'));
|
|
165
|
-
const plan = planPreserve(factRead.records, this.stores.l1.all());
|
|
228
|
+
const plan = planPreserve(factRead.records, this.stores.l1.all(), { snapshotIds });
|
|
229
|
+
this.status.rederivableCount = plan.rederivable;
|
|
230
|
+
this.status.preserveCount = plan.preserved.length;
|
|
166
231
|
// ── ② 过闸门:判不出该保留什么时**拒绝清空**(见 rebuild-preserve.ts 文件头)──
|
|
167
232
|
const gate = gateClear(factRead, plan);
|
|
168
233
|
if (!gate.allowed) {
|
|
@@ -197,26 +262,53 @@ export class RebuildController {
|
|
|
197
262
|
this.stores.persona.work.init(),
|
|
198
263
|
]);
|
|
199
264
|
this.status.phase = 'distilling';
|
|
200
|
-
this.logger.info(`[memory] 重建准备完成(归档:${archiveNote ?? '无旧产物'},保留:${this.status.preserveNote},${
|
|
201
|
-
this.
|
|
265
|
+
this.logger.info(`[memory] 重建准备完成(归档:${archiveNote ?? '无旧产物'},保留:${this.status.preserveNote},${est.sessions} 个会话块,事实源=${provider.kind})`);
|
|
266
|
+
this.chunkIter = provider.chunks()[Symbol.asyncIterator]();
|
|
267
|
+
this.scheduleNext();
|
|
202
268
|
}
|
|
203
269
|
catch (err) {
|
|
204
270
|
this.finish('failed', `准备阶段失败: ${errDetail(err)}`);
|
|
205
271
|
}
|
|
206
272
|
}
|
|
207
|
-
/**
|
|
208
|
-
|
|
209
|
-
|
|
273
|
+
/**
|
|
274
|
+
* 分块链:一次只挂一个重建块,跑完再挂下一块——正常轮次可随时插队。
|
|
275
|
+
*
|
|
276
|
+
* 流式版(issue #14-B):从事实源迭代器拉下一个会话,不再按下标从内存数组取。
|
|
277
|
+
* 内存峰值从"全量正文"降到"单会话"。
|
|
278
|
+
*/
|
|
279
|
+
scheduleNext() {
|
|
280
|
+
if (this.cancelRequested) {
|
|
281
|
+
this.runner.enqueueRebuildTask(() => this.finalize());
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
const iter = this.chunkIter;
|
|
285
|
+
if (!iter) {
|
|
210
286
|
this.runner.enqueueRebuildTask(() => this.finalize());
|
|
211
287
|
return;
|
|
212
288
|
}
|
|
213
|
-
const chunk = this.chunks[i];
|
|
214
289
|
this.runner.enqueueRebuildTask(async () => {
|
|
215
290
|
// 入队后开跑前可能已收到取消(等待插队的正常轮次期间),直接跳到收尾
|
|
216
291
|
if (this.cancelRequested) {
|
|
217
292
|
this.runner.enqueueRebuildTask(() => this.finalize());
|
|
218
293
|
return;
|
|
219
294
|
}
|
|
295
|
+
let next;
|
|
296
|
+
try {
|
|
297
|
+
next = await iter.next();
|
|
298
|
+
}
|
|
299
|
+
catch (err) {
|
|
300
|
+
// 事实源中途失败**不能**当“跑完了”:那会把“只重建了一部分”报成成功,
|
|
301
|
+
// 而用户会拿这个结论去判断数据是否重新生成。
|
|
302
|
+
this.logger.error(`[memory] 重建事实源中断(已完成 ${this.status.done}/${this.status.total} 个会话): ${errDetail(err)}`);
|
|
303
|
+
this.finish('failed', `事实源读取中断: ${errDetail(err)}`);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (next.done === true) {
|
|
307
|
+
this.chunkIter = null;
|
|
308
|
+
this.runner.enqueueRebuildTask(() => this.finalize());
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
const chunk = next.value;
|
|
220
312
|
try {
|
|
221
313
|
const n = await this.runner.runRebuildTurn(chunk.sessionId, chunk.messages);
|
|
222
314
|
this.status.recordsBuilt += n;
|
|
@@ -224,8 +316,8 @@ export class RebuildController {
|
|
|
224
316
|
catch (err) {
|
|
225
317
|
this.logger.warn(`[memory] 重建块失败(session=${chunk.sessionId},跳过继续): ${errDetail(err)}`);
|
|
226
318
|
}
|
|
227
|
-
this.status.done
|
|
228
|
-
this.
|
|
319
|
+
this.status.done += 1;
|
|
320
|
+
this.scheduleNext();
|
|
229
321
|
});
|
|
230
322
|
}
|
|
231
323
|
async finalize() {
|
|
@@ -307,9 +399,13 @@ export class RebuildController {
|
|
|
307
399
|
this.status.phase = phase;
|
|
308
400
|
this.status.error = error;
|
|
309
401
|
this.status.finishedAt = Date.now();
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
|
|
402
|
+
// 事实源释放:迭代器持有单会话内存,收尾必须断开(不能滞留到下一次 start 覆盖)。
|
|
403
|
+
// 若它实现了 `return`(异步生成器),顺手关掉,避免上游资源悬着。
|
|
404
|
+
const iter = this.chunkIter;
|
|
405
|
+
this.chunkIter = null;
|
|
406
|
+
if (iter !== null && typeof iter.return === 'function') {
|
|
407
|
+
void iter.return().catch(() => { });
|
|
408
|
+
}
|
|
313
409
|
const cost = this.status.finishedAt - (this.status.startedAt ?? this.status.finishedAt);
|
|
314
410
|
this.logger.info(`[memory] 重建结束(${phase}):${this.status.done}/${this.status.total} 会话,产出 ${this.status.recordsBuilt} 条记录,耗时 ${Math.round(cost / 1000)}s` +
|
|
315
411
|
(error ? `,错误:${error}` : ''));
|
|
@@ -16,6 +16,7 @@ import { type MemoryConfig } from '../config.js';
|
|
|
16
16
|
import type { LiveSettingsHandle } from '../settings.js';
|
|
17
17
|
import type { DrscClient } from '../clients/drsc.js';
|
|
18
18
|
import type { GraphStore } from '../store/graph-store.js';
|
|
19
|
+
import { graphFeatureOn } from '../graph/gate.js';
|
|
19
20
|
import type { L1Store } from '../store/l1.js';
|
|
20
21
|
import type { PersonaStore } from '../store/persona.js';
|
|
21
22
|
import type { SceneStore } from '../store/scenes.js';
|
|
@@ -73,6 +74,7 @@ export declare function pickNextTaskIndex(tasks: PipelineTask[]): number;
|
|
|
73
74
|
* primaryEffort;非链模式旧键对静态回退条目盖章接管(存量兼容)。
|
|
74
75
|
* - 输出预算/输入预算/通道覆盖/远程嵌入覆盖:非零/非空运行时值直接注入对应子树。
|
|
75
76
|
*/
|
|
77
|
+
export { graphFeatureOn };
|
|
76
78
|
export declare function effectiveCfg(cfg: MemoryConfig, live?: LiveSettingsHandle): MemoryConfig;
|
|
77
79
|
export declare class MemoryRunner {
|
|
78
80
|
private readonly ctx;
|
|
@@ -192,7 +194,8 @@ export declare class MemoryRunner {
|
|
|
192
194
|
};
|
|
193
195
|
/**
|
|
194
196
|
* 图谱投影泵入队判定:
|
|
195
|
-
* -
|
|
197
|
+
* - 双门:`graphFeatureOn`(部署 cfg.graph.enabled **或** 面板 live.graphEnabled)
|
|
198
|
+
* + 运行时 live.enabled/distill,任一为假不入队;
|
|
196
199
|
* - 占位:同一时刻至多一个 graph 任务在队列(靠 graphPumpQueued 标志),永不与
|
|
197
200
|
* live 抢位(pickNextTaskIndex 保证 live > graph);
|
|
198
201
|
* - 退避/attempts 封顶:由 GraphStore.claimNext 的 WHERE 过滤与 fail 转 dead 承接,
|
package/dist/pipeline/runner.js
CHANGED
|
@@ -3,6 +3,7 @@ import { GRAPH_PRIORITY_NEW } from '../graph/types.js';
|
|
|
3
3
|
import { NoDistillRouteError } from '../llm.js';
|
|
4
4
|
import { startValidityBackfill } from '../validity-backfill.js';
|
|
5
5
|
import { drscRowToConversationMessage } from '../clients/drsc.js';
|
|
6
|
+
import { graphFeatureOn } from '../graph/gate.js';
|
|
6
7
|
import { trace } from '../store/trace.js';
|
|
7
8
|
import { emptyPending, freshWarmup, groupPendingBySession, loadPending, PENDING_MODES, pendingPathFor, savePending, } from '../store/pending.js';
|
|
8
9
|
import { errDetail } from '../util/filelog.js';
|
|
@@ -41,6 +42,8 @@ export function pickNextTaskIndex(tasks) {
|
|
|
41
42
|
* primaryEffort;非链模式旧键对静态回退条目盖章接管(存量兼容)。
|
|
42
43
|
* - 输出预算/输入预算/通道覆盖/远程嵌入覆盖:非零/非空运行时值直接注入对应子树。
|
|
43
44
|
*/
|
|
45
|
+
// 外部(tools / index)用的同一判据从这里取,避免两处各写一份。
|
|
46
|
+
export { graphFeatureOn };
|
|
44
47
|
export function effectiveCfg(cfg, live) {
|
|
45
48
|
const s = live?.get();
|
|
46
49
|
const eff = s?.reasoningEffort ?? '';
|
|
@@ -145,6 +148,12 @@ export function effectiveCfg(cfg, live) {
|
|
|
145
148
|
const vtOverride = s?.validityTiering !== undefined
|
|
146
149
|
? { validity: { ...(cfg.validity ?? {}), tieringEnabled: cfg.validity?.tieringEnabled === true || s.validityTiering === true } }
|
|
147
150
|
: null;
|
|
151
|
+
// 知识图谱投影(issue #16):live.graphEnabled 与部署 cfg.graph.enabled 取或。
|
|
152
|
+
// 这里**直接复用 graphFeatureOn**,而不是再手写一遍“部署开关 或 面板开关”:
|
|
153
|
+
// 判据一旦有两份,将来改一处漏一处就会出现“面板显示开、覆盖算出来却是关”类的分叉。
|
|
154
|
+
const grOverride = s?.graphEnabled !== undefined
|
|
155
|
+
? { graph: { ...(cfg.graph ?? { enabled: false }), enabled: graphFeatureOn(cfg, live) } }
|
|
156
|
+
: null;
|
|
148
157
|
// md 投射/后缀收敛的单次动作护栏运行时覆盖(0 = 跟随部署 cfg.memExport.maxRecords)。
|
|
149
158
|
// 只覆盖条数护栏,不动 enabled/root——开关与根路径仍属部署配置。
|
|
150
159
|
const mdx = s?.mdExportMaxRecords ?? 0;
|
|
@@ -153,7 +162,7 @@ export function effectiveCfg(cfg, live) {
|
|
|
153
162
|
// 不动 maxRecords/timeBudgetMs/callTimeoutMs——那三个仍属部署护栏,要改得改 YAML。
|
|
154
163
|
const bf = s?.backfillConcurrency ?? 0;
|
|
155
164
|
const backfillOverride = bf > 0 ? { backfill: { ...(cfg.backfill ?? {}), concurrency: bf } } : null;
|
|
156
|
-
if (!override && !budgets && !maxInput && !fallbacksTakeover && !chainEffort && !chainFallbacks && !effortInject && !layerChains && !hasChannel && !embedOverride && !cfOverride && !memExportOverride && !backfillOverride && !kwOverride && !rdOverride && !mdsOverride && !mpOverride && !vtOverride)
|
|
165
|
+
if (!override && !budgets && !maxInput && !fallbacksTakeover && !chainEffort && !chainFallbacks && !effortInject && !layerChains && !hasChannel && !embedOverride && !cfOverride && !memExportOverride && !backfillOverride && !kwOverride && !rdOverride && !mdsOverride && !mpOverride && !vtOverride && !grOverride)
|
|
157
166
|
return cfg;
|
|
158
167
|
return {
|
|
159
168
|
...cfg,
|
|
@@ -164,6 +173,7 @@ export function effectiveCfg(cfg, live) {
|
|
|
164
173
|
...(mdsOverride ?? {}),
|
|
165
174
|
...(mpOverride ?? {}),
|
|
166
175
|
...(vtOverride ?? {}),
|
|
176
|
+
...(grOverride ?? {}),
|
|
167
177
|
...(memExportOverride ?? {}),
|
|
168
178
|
...(backfillOverride ?? {}),
|
|
169
179
|
llm: {
|
|
@@ -266,20 +276,22 @@ export class MemoryRunner {
|
|
|
266
276
|
const graphs = this.stores.graph;
|
|
267
277
|
if (graphs) {
|
|
268
278
|
graphs.recoverRunning();
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
279
|
+
// **无条件**挂启动补跑,开关在 tick 内判:
|
|
280
|
+
// 若在这里就判门注册,面板热开启后存量要等重启才补(issue #16 的半开态)。
|
|
281
|
+
this.ctx.effect(() => {
|
|
282
|
+
const timer = setTimeout(() => {
|
|
283
|
+
if (this.stopped)
|
|
284
|
+
return;
|
|
285
|
+
if (!graphFeatureOn(this.cfg, this.live))
|
|
286
|
+
return;
|
|
287
|
+
const jobs = graphs.queueMissing(9000);
|
|
288
|
+
if (jobs > 0) {
|
|
289
|
+
this.logger.info(`[memory] 图谱存量补投影:${jobs} 个任务入队`);
|
|
290
|
+
this.maybeQueueGraphTask();
|
|
291
|
+
}
|
|
292
|
+
}, STARTUP_RETRY_DELAY_MS);
|
|
293
|
+
return () => clearTimeout(timer);
|
|
294
|
+
});
|
|
283
295
|
}
|
|
284
296
|
}
|
|
285
297
|
/** 启动补跑:对每个非空桶的每个会话切片入队一次蒸馏尝试(受 live 开关与
|
|
@@ -468,12 +480,28 @@ export class MemoryRunner {
|
|
|
468
480
|
*/
|
|
469
481
|
startGraphBackfillTimer() {
|
|
470
482
|
const graphs = this.stores.graph;
|
|
471
|
-
|
|
483
|
+
// 只依赖 store 存在:**不能**在注册时判 graph 开关 —— timer 只在 init 注册一次,
|
|
484
|
+
// 那样运行时开启后存量永远补不到(issue #16 的实证半开态)。开关改在 tick 内判。
|
|
485
|
+
if (!graphs)
|
|
472
486
|
return;
|
|
487
|
+
// 热开启即时补一批:否则用户拨开开关要等一个周期(30 分钟)才看到图谱动起来。
|
|
488
|
+
this.ctx.on('loader/volatile-update', (paths) => {
|
|
489
|
+
if (!paths.some((p) => p[0] === 'live'))
|
|
490
|
+
return;
|
|
491
|
+
if (!graphFeatureOn(this.cfg, this.live))
|
|
492
|
+
return;
|
|
493
|
+
const jobs = graphs.queueMissing(500);
|
|
494
|
+
if (jobs > 0) {
|
|
495
|
+
this.logger.info(`[memory] 图谱热开启补投影:${jobs} 个任务入队`);
|
|
496
|
+
this.maybeQueueGraphTask();
|
|
497
|
+
}
|
|
498
|
+
});
|
|
473
499
|
this.ctx.effect(() => {
|
|
474
500
|
const timer = setInterval(() => {
|
|
475
501
|
if (this.stopped)
|
|
476
502
|
return;
|
|
503
|
+
if (!graphFeatureOn(this.cfg, this.live))
|
|
504
|
+
return;
|
|
477
505
|
const jobs = graphs.queueMissing(500);
|
|
478
506
|
if (jobs > 0) {
|
|
479
507
|
this.logger.debug?.(`[memory] 图谱周期补投影:${jobs} 个任务入队`);
|
|
@@ -522,8 +550,14 @@ export class MemoryRunner {
|
|
|
522
550
|
* 用户后续开启由启动/周期补投影兜底(与 distill 分支同一口径)。
|
|
523
551
|
*/
|
|
524
552
|
enqueueGraphFor(recordIds) {
|
|
525
|
-
if (recordIds.length === 0 || !this.stores.graph
|
|
553
|
+
if (recordIds.length === 0 || !this.stores.graph)
|
|
554
|
+
return;
|
|
555
|
+
if (!graphFeatureOn(this.cfg, this.live)) {
|
|
556
|
+
// 关闭期写入也要记账:`graph_projected_records` 只按 record_id+version 记账、无内容版本,
|
|
557
|
+
// 若不抹掉,重开后 queueMissing 会认为“这条已投过”而永不补 → 图谱内容永久陈旧。
|
|
558
|
+
this.stores.graph.invalidateProjected(recordIds);
|
|
526
559
|
return;
|
|
560
|
+
}
|
|
527
561
|
this.stores.graph.queueGraphProjection([...recordIds], GRAPH_PRIORITY_NEW);
|
|
528
562
|
this.maybeQueueGraphTask();
|
|
529
563
|
}
|
|
@@ -535,7 +569,9 @@ export class MemoryRunner {
|
|
|
535
569
|
*/
|
|
536
570
|
reprojectGraph(recordIds) {
|
|
537
571
|
const graphs = this.stores.graph;
|
|
538
|
-
if (!graphs ||
|
|
572
|
+
if (!graphs || recordIds.length === 0)
|
|
573
|
+
return { queued: 0, jobIds: [] };
|
|
574
|
+
if (!graphFeatureOn(this.cfg, this.live))
|
|
539
575
|
return { queued: 0, jobIds: [] };
|
|
540
576
|
const r = graphs.forceReprojectRecord(recordIds);
|
|
541
577
|
if (r.queued > 0)
|
|
@@ -544,7 +580,8 @@ export class MemoryRunner {
|
|
|
544
580
|
}
|
|
545
581
|
/**
|
|
546
582
|
* 图谱投影泵入队判定:
|
|
547
|
-
* -
|
|
583
|
+
* - 双门:`graphFeatureOn`(部署 cfg.graph.enabled **或** 面板 live.graphEnabled)
|
|
584
|
+
* + 运行时 live.enabled/distill,任一为假不入队;
|
|
548
585
|
* - 占位:同一时刻至多一个 graph 任务在队列(靠 graphPumpQueued 标志),永不与
|
|
549
586
|
* live 抢位(pickNextTaskIndex 保证 live > graph);
|
|
550
587
|
* - 退避/attempts 封顶:由 GraphStore.claimNext 的 WHERE 过滤与 fail 转 dead 承接,
|
|
@@ -554,7 +591,7 @@ export class MemoryRunner {
|
|
|
554
591
|
const graphs = this.stores.graph;
|
|
555
592
|
if (!graphs || this.stopped || this.graphPumpQueued)
|
|
556
593
|
return;
|
|
557
|
-
if (!this.cfg.
|
|
594
|
+
if (!graphFeatureOn(this.cfg, this.live))
|
|
558
595
|
return;
|
|
559
596
|
const liveNow = this.live.get();
|
|
560
597
|
if (!(liveNow.enabled && liveNow.distill))
|
|
@@ -663,7 +700,7 @@ export class MemoryRunner {
|
|
|
663
700
|
if ((opts?.force || sliceLen >= effective) && !backedOff) {
|
|
664
701
|
newRecords = await this.extractSessionSlice(sessionId, mode, cfg, effective, opts);
|
|
665
702
|
// 新记录入投影队列(优先级恒高于存量补投影)并拉起图谱泵;入队本身廉价,
|
|
666
|
-
//
|
|
703
|
+
// 开关关时由 enqueueGraphFor 抹投影凭证(避免重开后永久陈旧),重开由补投影兜底。
|
|
667
704
|
// (写面 R2:分支本体抽成 enqueueGraphFor,与工具三路径同源,不再两处漂移。)
|
|
668
705
|
this.enqueueGraphFor(newRecords.map((r) => r.id));
|
|
669
706
|
}
|
|
@@ -13,8 +13,13 @@ export async function relinkWorkspaces(deps) {
|
|
|
13
13
|
const report = {
|
|
14
14
|
dryRun, scanned: 0, matched: 0, unchanged: 0, toWrite: 0, written: 0, remaining: 0, byWorkspace: {},
|
|
15
15
|
};
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
// 空路由表 = **未配置**(issue #13 把默认值改成 `[]`)。这里必须显式拒绕:
|
|
17
|
+
// 旧行为是返回全零报告,面板显示“扫 0 / 剩 0” —— 看起来像“已经做完了”,
|
|
18
|
+
// 真正的原因(一条路由都没配)反而看不见。
|
|
19
|
+
if (routes.length === 0) {
|
|
20
|
+
throw new Error('未配置任何内容路由规则(workspaceSource.routes 为空):'
|
|
21
|
+
+ '先在配置里写下至少一条 {path, anyOf},再执行归属回填');
|
|
22
|
+
}
|
|
18
23
|
let offset = 0;
|
|
19
24
|
let batch = [];
|
|
20
25
|
// 计数在此结算:`flush()` 会清空 batch,调用点再拿 `batch.length` 必然归零。
|
package/dist/room-review.d.ts
CHANGED
|
@@ -75,7 +75,7 @@ export interface RoomCandidate {
|
|
|
75
75
|
id: string;
|
|
76
76
|
rooms: string[];
|
|
77
77
|
}
|
|
78
|
-
/** 解析标注器输出的逐条候选(容错:id 配对失败丢弃;
|
|
78
|
+
/** 解析标注器输出的逐条候选(容错:id 配对失败丢弃;isRoomSlug 校验(两级制);目录内优先,≤3)。 */
|
|
79
79
|
export declare function parseRoomCandidateItems(parsed: ReadonlyArray<{
|
|
80
80
|
id: string;
|
|
81
81
|
rooms: readonly string[];
|
package/dist/room-review.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isRoomSlug } from './metadata-validators.js';
|
|
2
2
|
import { callLLM, parseJsonLogged } from './llm.js';
|
|
3
3
|
export const ROOM_CANDIDATES_KEY = 'roomCandidates';
|
|
4
4
|
export const ROOM_REVIEW_KEY = 'roomReview';
|
|
5
|
-
/** 解析标注器输出的逐条候选(容错:id 配对失败丢弃;
|
|
5
|
+
/** 解析标注器输出的逐条候选(容错:id 配对失败丢弃;isRoomSlug 校验(两级制);目录内优先,≤3)。 */
|
|
6
6
|
export function parseRoomCandidateItems(parsed, ids, roomCatalog) {
|
|
7
7
|
const idSet = new Set(ids);
|
|
8
8
|
const catalogSet = new Set(roomCatalog.map((r) => r.room));
|
|
@@ -13,7 +13,9 @@ export function parseRoomCandidateItems(parsed, ids, roomCatalog) {
|
|
|
13
13
|
const cleaned = [];
|
|
14
14
|
for (const r of item.rooms) {
|
|
15
15
|
const slug = typeof r === 'string' ? r.trim().toLowerCase() : '';
|
|
16
|
-
|
|
16
|
+
// 两级制(`major` 或 `major/minor`):与 rooms-registry 同一判据。用 isTag 会把
|
|
17
|
+
// `mpu/qos` 这类合法子类静默丢掉——注册表收得下、写回却挂不上(issue #12)。
|
|
18
|
+
if (!slug || !isRoomSlug(slug))
|
|
17
19
|
continue;
|
|
18
20
|
if (!cleaned.includes(slug))
|
|
19
21
|
cleaned.push(slug);
|
|
@@ -35,7 +37,8 @@ export function roomCandidateSystemPrompt(roomCatalog) {
|
|
|
35
37
|
return ('你是记忆库的 Room 归类器。给你一条记忆与现有 Room 词表(带计数),' +
|
|
36
38
|
'为这条记忆选出它最可能归属的 1-3 个 Room slug。规则:\n' +
|
|
37
39
|
'1. **优先从现有 Room 词表里选**(保持分类收敛,避免碎片化);\n' +
|
|
38
|
-
'2. 词表确实没有合适主题时,才提议最多 2 个新 slug(
|
|
40
|
+
'2. 词表确实没有合适主题时,才提议最多 2 个新 slug(小写字母/数字/连字符;' +
|
|
41
|
+
'两级制写成 `大类/子类` 形式,如 `mpu/qos`,全串至多一个斜杠);\n' +
|
|
39
42
|
'3. 完全无法判断时返回空数组(宁缺毋滥,不硬猜)。\n' +
|
|
40
43
|
`现有 Room 词表:\n${catalog}\n\n` +
|
|
41
44
|
'输出 JSON 对象:{"rooms":["slug1","slug2"]}。只输出 JSON,不要多余文字。');
|
|
@@ -264,7 +267,9 @@ export function confirmReview(l1, id, rooms) {
|
|
|
264
267
|
if (typeof r !== 'string')
|
|
265
268
|
continue;
|
|
266
269
|
const slug = r.trim().toLowerCase();
|
|
267
|
-
|
|
270
|
+
// 同 parseRoomCandidateItems:走两级制判据,否则 `mpu/qos` 会被判非法、
|
|
271
|
+
// confirm 返回 null 且错误文案误导成"id 不存在"(issue #12)。
|
|
272
|
+
if (!slug || !isRoomSlug(slug) || cleaned.includes(slug))
|
|
268
273
|
continue;
|
|
269
274
|
cleaned.push(slug);
|
|
270
275
|
}
|
package/dist/settings.js
CHANGED
|
@@ -82,6 +82,8 @@ const ALWAYS_ON = {
|
|
|
82
82
|
memoryMutate: false,
|
|
83
83
|
conflictFreeze: false,
|
|
84
84
|
skillsEnabled: false,
|
|
85
|
+
// 知识图谱投影:默认关(部署 cfg.graph.enabled 仍可强制开;这里只做追加开)
|
|
86
|
+
graphEnabled: false,
|
|
85
87
|
keywordEnabled: false,
|
|
86
88
|
redistillEnabled: false,
|
|
87
89
|
mdSyncEnabled: false,
|
|
@@ -329,6 +331,8 @@ function resolveSettings(value) {
|
|
|
329
331
|
conflictFreeze: v.conflictFreeze === true,
|
|
330
332
|
// 轨2 技能结晶:严格 === true(默认关,新功能默认关)
|
|
331
333
|
skillsEnabled: v.skillsEnabled === true,
|
|
334
|
+
// 知识图谱投影:严格 === true(默认关);与部署 graph.enabled 取或后生效
|
|
335
|
+
graphEnabled: v.graphEnabled === true,
|
|
332
336
|
// 存量回填/进阶功能启用开关:严格 === true(默认关)
|
|
333
337
|
keywordEnabled: v.keywordEnabled === true,
|
|
334
338
|
redistillEnabled: v.redistillEnabled === true,
|
package/dist/stats.js
CHANGED
|
@@ -1119,9 +1119,13 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
1119
1119
|
return v;
|
|
1120
1120
|
}
|
|
1121
1121
|
if (kind === 'records') {
|
|
1122
|
-
|
|
1122
|
+
// 与 list-records 同口径(见下方 tagSel):Room slug 是自生长的两级制形状,
|
|
1123
|
+
// 非法形状在这里就拒,不做“静默返回空 CSV”。
|
|
1124
|
+
const tag = typeof p.tag === 'string' ? p.tag.trim().slice(0, 64) : '';
|
|
1123
1125
|
if (!tag)
|
|
1124
1126
|
throw new Error('kind=records 需要 tag(目标 Room slug)');
|
|
1127
|
+
if (!isRoomSlug(tag))
|
|
1128
|
+
throw new Error('tag 非法(两级制:major 或 major/minor,各段小写字母数字连字符)');
|
|
1125
1129
|
const header = 'id,type,scene_name,updated_at,tags,content';
|
|
1126
1130
|
const lines = [header];
|
|
1127
1131
|
let n = 0;
|
|
@@ -1351,6 +1355,7 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
1351
1355
|
memoryMutate: false,
|
|
1352
1356
|
conflictFreeze: live?.get()?.conflictFreeze === true,
|
|
1353
1357
|
skillsEnabled: live?.get()?.skillsEnabled === true,
|
|
1358
|
+
graphEnabled: live?.get()?.graphEnabled === true,
|
|
1354
1359
|
keywordEnabled: live?.get()?.keywordEnabled === true,
|
|
1355
1360
|
redistillEnabled: live?.get()?.redistillEnabled === true,
|
|
1356
1361
|
mdSyncEnabled: live?.get()?.mdSyncEnabled === true,
|
|
@@ -1402,7 +1407,7 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
1402
1407
|
const patch = (payload ?? {});
|
|
1403
1408
|
const clean = {};
|
|
1404
1409
|
// 布尔开关组:memoryMutate(高权限写删门)与主开关同列;conflictFreeze(§C 人工冲突裁决)
|
|
1405
|
-
for (const key of ['enabled', 'capture', 'distill', 'recall', 'memoryMutate', 'conflictFreeze', 'skillsEnabled', 'keywordEnabled', 'redistillEnabled', 'mdSyncEnabled', 'memportEnabled', 'toolRoom', 'toolGraph', 'toolRuminate', 'toolConflict', 'toolMutate', 'toolSkills']) {
|
|
1410
|
+
for (const key of ['enabled', 'capture', 'distill', 'recall', 'memoryMutate', 'conflictFreeze', 'skillsEnabled', 'graphEnabled', 'keywordEnabled', 'redistillEnabled', 'mdSyncEnabled', 'memportEnabled', 'toolRoom', 'toolGraph', 'toolRuminate', 'toolConflict', 'toolMutate', 'toolSkills']) {
|
|
1406
1411
|
if (typeof patch[key] === 'boolean')
|
|
1407
1412
|
clean[key] = patch[key];
|
|
1408
1413
|
}
|
|
@@ -2117,7 +2122,9 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
2117
2122
|
throw new Error('蒸馏开关已关闭,请先开启蒸馏再重建');
|
|
2118
2123
|
if (!cfg.extract.enabled)
|
|
2119
2124
|
throw new Error('部署配置已停用蒸馏(extract.enabled=false),无法重建');
|
|
2120
|
-
|
|
2125
|
+
// force 只跨“水位落差”的二次确认;“L0 无正文而检索库非空”仍由 start() 硬拒。
|
|
2126
|
+
const p = (payload ?? {});
|
|
2127
|
+
const result = rebuild.start(p.force === true);
|
|
2121
2128
|
deps.logger.info('[memory] 收到重建指令(设置页按钮)');
|
|
2122
2129
|
return result;
|
|
2123
2130
|
}
|