dsh-prime-memory 0.26.10 → 0.26.12

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.
Files changed (55) hide show
  1. package/CHANGELOG.de.md +1638 -0
  2. package/CHANGELOG.en.md +15 -0
  3. package/CHANGELOG.es.md +1641 -0
  4. package/CHANGELOG.fr.md +1643 -0
  5. package/CHANGELOG.it.md +1643 -0
  6. package/CHANGELOG.ja.md +15 -0
  7. package/CHANGELOG.ko.md +15 -0
  8. package/CHANGELOG.md +38 -0
  9. package/CHANGELOG.ru.md +1640 -0
  10. package/INSTALL.de.md +92 -0
  11. package/INSTALL.es.md +92 -0
  12. package/INSTALL.fr.md +92 -0
  13. package/INSTALL.it.md +92 -0
  14. package/INSTALL.ru.md +92 -0
  15. package/README.de.md +1 -1
  16. package/README.en.md +3 -3
  17. package/README.es.md +1 -1
  18. package/README.fr.md +1 -1
  19. package/README.it.md +1 -1
  20. package/README.ja.md +1 -1
  21. package/README.ko.md +1 -1
  22. package/README.md +10 -3
  23. package/README.ru.md +1 -1
  24. package/dist/bench-control.js +3 -1
  25. package/dist/client.js +121 -12
  26. package/dist/clients/drsc.d.ts +34 -0
  27. package/dist/clients/drsc.js +56 -11
  28. package/dist/config.d.ts +10 -1
  29. package/dist/config.js +37 -18
  30. package/dist/contract.d.ts +48 -1
  31. package/dist/graph/gate.d.ts +17 -0
  32. package/dist/graph/gate.js +11 -0
  33. package/dist/graph/preview.d.ts +5 -0
  34. package/dist/graph/preview.js +2 -0
  35. package/dist/index.js +1 -1
  36. package/dist/pipeline/fact-source.d.ts +127 -0
  37. package/dist/pipeline/fact-source.js +192 -0
  38. package/dist/pipeline/rebuild-preserve.d.ts +31 -2
  39. package/dist/pipeline/rebuild-preserve.js +40 -14
  40. package/dist/pipeline/rebuild.d.ts +47 -13
  41. package/dist/pipeline/rebuild.js +153 -57
  42. package/dist/pipeline/runner.d.ts +4 -1
  43. package/dist/pipeline/runner.js +58 -21
  44. package/dist/pipeline/workspace-relink.js +7 -2
  45. package/dist/room-review.d.ts +1 -1
  46. package/dist/room-review.js +10 -5
  47. package/dist/settings.js +4 -0
  48. package/dist/stats.js +10 -3
  49. package/dist/store/graph-store.d.ts +20 -0
  50. package/dist/store/graph-store.js +50 -1
  51. package/dist/store/sqlite.d.ts +42 -0
  52. package/dist/store/sqlite.js +98 -0
  53. package/dist/tools/index.js +4 -3
  54. package/dsh.plugin.json +2 -2
  55. package/package.json +37 -21
@@ -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
- export function groupL0Sessions(records) {
30
- const bySession = new Map();
31
- for (const r of records) {
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
- chunks = [];
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 时附带实时 L0 预估,供确认弹窗显示成本)。 */
100
+ /** 状态快照(idle 时附带实时预估与事实源水位,供确认弹窗讲清"会发生什么")。 */
105
101
  getStatus() {
106
102
  if (this.status.phase === 'idle') {
107
- const est = this.db.l0RebuildEstimate();
108
- return {
109
- ...this.status,
110
- sessionCount: est.sessions,
111
- messageCount: est.messages,
112
- estCalls: estimateCalls(est.sessions, est.messages, est.chars, effectiveCfg(this.cfg, this.live).llm.maxInputChars),
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
- /** 内存中尚未处理的会话快照块数(收尾后应为 0——快照即弃,诊断/冒烟用)。 */
123
+ /** 尚未处理的会话块数(流式下为估算 `total - done`;未运行时为 0,诊断/冒烟用)。 */
118
124
  get chunkCount() {
119
- return this.chunks.length;
125
+ return this.status.running ? Math.max(0, this.status.total - this.status.done) : 0;
120
126
  }
121
- /** 启动重建(校验后入队准备任务;真正的清库/归档在管线队列里串行执行,避开并发竞态)。 */
122
- start() {
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
- const est = this.db.l0RebuildEstimate();
126
- if (est.messages === 0)
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.chunks = [];
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 消息(预计 ≥${this.status.estCalls} 次抽取调用)`);
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
- // 快照:从检索库读全量 L0(事务一致;重建期间新捕获的消息走正常轮次,天然不重不漏)
209
+ // 事实源(issue #14-B):默认本地 L0;drsc 需显式配置且必须有客户端。
210
+ const provider = this.provider();
211
+ // 快照时刻:重建期间新捕获的消息走正常轮次,天然不重不漏
155
212
  this.rebuildStartMs = Date.now();
156
- this.chunks = groupL0Sessions(this.db.listL0All());
157
- if (this.chunks.length === 0) {
158
- this.finish('failed', 'L0 快照为空');
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 = this.chunks.length;
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},${this.chunks.length} 个会话块)`);
201
- this.scheduleChunk(0);
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
- scheduleChunk(i) {
209
- if (this.cancelRequested || i >= this.chunks.length) {
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 = i + 1;
228
- this.scheduleChunk(i + 1);
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
- // 快照即弃:全量 L0 消息(可能几十 MB)在重建结束/取消/失败后必须释放,
311
- // 不能滞留到下一次 start() 覆盖(宿主长跑内存只增不减)
312
- this.chunks = [];
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
- * - 双门:部署级 cfg.graph.enabled + 运行时 live.enabled/distill,任一为假不入队;
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 承接,
@@ -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
- if (this.cfg.graph?.enabled) {
270
- this.ctx.effect(() => {
271
- const timer = setTimeout(() => {
272
- if (this.stopped)
273
- return;
274
- const jobs = graphs.queueMissing(9000);
275
- if (jobs > 0) {
276
- this.logger.info(`[memory] 图谱存量补投影:${jobs} 个任务入队`);
277
- this.maybeQueueGraphTask();
278
- }
279
- }, STARTUP_RETRY_DELAY_MS);
280
- return () => clearTimeout(timer);
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
- if (!graphs || !this.cfg.graph?.enabled)
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 || !this.cfg.graph?.enabled)
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 || !this.cfg.graph?.enabled || recordIds.length === 0)
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
- * - 双门:部署级 cfg.graph.enabled + 运行时 live.enabled/distill,任一为假不入队;
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.graph?.enabled)
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
- // 部署未开图谱(cfg.graph.enabled)时不入队——用户后续开启由启动补投影兜底。
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
- if (routes.length === 0)
17
- return report;
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` 必然归零。
@@ -75,7 +75,7 @@ export interface RoomCandidate {
75
75
  id: string;
76
76
  rooms: string[];
77
77
  }
78
- /** 解析标注器输出的逐条候选(容错:id 配对失败丢弃;isTag 校验;目录内优先,≤3)。 */
78
+ /** 解析标注器输出的逐条候选(容错:id 配对失败丢弃;isRoomSlug 校验(两级制);目录内优先,≤3)。 */
79
79
  export declare function parseRoomCandidateItems(parsed: ReadonlyArray<{
80
80
  id: string;
81
81
  rooms: readonly string[];
@@ -1,8 +1,8 @@
1
- import { isTag } from './metadata-validators.js';
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 配对失败丢弃;isTag 校验;目录内优先,≤3)。 */
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
- if (!slug || !isTag(slug))
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(小写字母/数字/连字符);\n' +
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
- if (!slug || !isTag(slug) || cleaned.includes(slug))
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
- const tag = String(p.tag ?? '').trim();
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
- const result = rebuild.start();
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
  }