dsh-prime-memory 0.26.0 → 0.26.2

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.
@@ -29,7 +29,42 @@ const state = {
29
29
  running: false, updated: 0, failed: 0,
30
30
  chunk: 1, candidates: 0, processed: 0,
31
31
  scanning: false, batchesDone: 0, batchTotal: 0, concurrency: DEFAULT_CONCURRENCY, deferred: 0,
32
+ cancelled: false, scanned: 0, scanTotal: 0,
32
33
  };
34
+ /**
35
+ * 人工中止信号。置位后:①池子不再派新活;②已发出的请求先拿**宽限**(cancelGraceMs)
36
+ * 自然收工(token 不白费、已写回的全入账);③**宽限到期强杀**在飞请求——
37
+ * 没有③的话「中止」最坏要等满 callTimeoutMs(240s) 才生效,等于没中止。
38
+ */
39
+ let abortRequested = false;
40
+ /** 本轮在飞请求的强杀器(runOnce 入口新建)。 */
41
+ let runAbort = null;
42
+ /** 中止宽限计时器(一次性)。 */
43
+ let graceTimer = null;
44
+ /** 本轮生效的中止宽限(启动时从 cfg 读定,已钳位)。 */
45
+ let graceMs = 15_000;
46
+ /**
47
+ * 中止宽限钳位。**硬界 30s**(用户定:中止必须在 30s 内强停)——无论 YAML 写了多少,
48
+ * 都不会超过这个值;未配置/非法取默认 15s。
49
+ */
50
+ function clampGraceMs(raw) {
51
+ const n = typeof raw === 'number' && Number.isFinite(raw) ? Math.round(raw) : 15_000;
52
+ return Math.min(30_000, Math.max(0, n));
53
+ }
54
+ /** 请求中止本轮(空闲时无副作用);返回后中止是**有界**的:最多等 graceMs。 */
55
+ export function cancelKeywordBackfill() {
56
+ if (!state.running)
57
+ return { cancelled: false, running: false };
58
+ abortRequested = true;
59
+ if (graceTimer === null) {
60
+ graceTimer = setTimeout(() => {
61
+ graceTimer = null;
62
+ if (state.running)
63
+ runAbort?.abort();
64
+ }, Math.max(0, graceMs));
65
+ }
66
+ return { cancelled: true, running: true };
67
+ }
33
68
  export function keywordBackfillState() {
34
69
  return { ...state };
35
70
  }
@@ -61,6 +96,8 @@ export function startKeywordBackfill(deps) {
61
96
  state.batchesDone = 0;
62
97
  state.batchTotal = 0;
63
98
  state.deferred = 0;
99
+ state.cancelled = false;
100
+ abortRequested = false;
64
101
  state.concurrency = clampConcurrency(deps.cfg.backfill?.concurrency);
65
102
  void runKeywordBackfillOnce(deps).finally(() => {
66
103
  state.running = false;
@@ -76,12 +113,21 @@ export function startKeywordBackfill(deps) {
76
113
  */
77
114
  export async function runKeywordBackfillOnce(deps) {
78
115
  const { cfg, backend, logger } = deps;
116
+ // 新一轮入口清中止信号(同 redistill:直调 runOnce 的路径不经过 startX 的复位)。
117
+ abortRequested = false;
118
+ state.cancelled = false;
119
+ if (graceTimer !== null) {
120
+ clearTimeout(graceTimer);
121
+ graceTimer = null;
122
+ }
123
+ runAbort = new AbortController();
79
124
  if (!cfg.keyword?.enabled) {
80
125
  logger.info('[memory] keyword 回填跳过:keyword.enabled = false(功能关,不回填)');
81
126
  return;
82
127
  }
83
128
  const MAX_RECORDS = cfg.backfill?.maxRecords ?? DEFAULT_MAX_RECORDS;
84
129
  const TIME_BUDGET_MS = cfg.backfill?.timeBudgetMs ?? DEFAULT_TIME_BUDGET_MS;
130
+ graceMs = clampGraceMs(cfg.backfill?.cancelGraceMs);
85
131
  const LIMIT = clampConcurrency(cfg.backfill?.concurrency);
86
132
  state.concurrency = LIMIT;
87
133
  const startedAt = Date.now();
@@ -91,6 +137,8 @@ export async function runKeywordBackfillOnce(deps) {
91
137
  let scanned = 0;
92
138
  state.scanning = true;
93
139
  for (let offset = 0;; offset += PAGE) {
140
+ if (abortRequested)
141
+ break; // 中止:扫描段也当场退出(不把最后一页白扫完)
94
142
  const page = await backend.allLite(PAGE, offset);
95
143
  if (page.length === 0)
96
144
  break;
@@ -101,6 +149,8 @@ export async function runKeywordBackfillOnce(deps) {
101
149
  }
102
150
  scanned += page.length;
103
151
  state.candidates = pendingIds.length;
152
+ state.scanned = scanned;
153
+ state.scanTotal = total;
104
154
  await yieldLoop();
105
155
  }
106
156
  state.scanning = false;
@@ -118,9 +168,11 @@ export async function runKeywordBackfillOnce(deps) {
118
168
  state.batchesDone++;
119
169
  return;
120
170
  }
121
- const labeled = await labelKeywordOne(deps.ctx, cfg, logger, rec, callTimeoutMs);
171
+ const labeled = await labelKeywordOne(deps.ctx, cfg, logger, rec, callTimeoutMs, runAbort?.signal);
122
172
  if (!labeled) {
123
- state.failed++;
173
+ // 中止强杀导致的失败不计入 failed(不是模型/网络的错,是中止信号)
174
+ if (!abortRequested)
175
+ state.failed++;
124
176
  }
125
177
  else {
126
178
  const meta = { ...(rec.metadata ?? {}) };
@@ -150,6 +202,8 @@ export async function runKeywordBackfillOnce(deps) {
150
202
  let budgetHit = false;
151
203
  const work = async () => {
152
204
  for (;;) {
205
+ if (abortRequested)
206
+ return; // 中止:不再取新活(在飞的那条自然跑完)
153
207
  if (budgetHit || Date.now() - startedAt > TIME_BUDGET_MS) {
154
208
  budgetHit = true;
155
209
  return;
@@ -168,12 +222,17 @@ export async function runKeywordBackfillOnce(deps) {
168
222
  for (;;) {
169
223
  const target = Math.max(1, Math.min(readLimit(), batch.length));
170
224
  state.concurrency = target; // 面板「并行 N」跟实时值走
171
- while (!budgetHit && inFlight.size < target && cursor < batch.length)
225
+ while (!budgetHit && !abortRequested && inFlight.size < target && cursor < batch.length)
172
226
  spawn();
173
227
  if (inFlight.size === 0)
174
228
  break;
175
229
  await Promise.race(inFlight);
176
230
  }
231
+ if (abortRequested) {
232
+ state.cancelled = true;
233
+ state.deferred += Math.max(0, batch.length - state.processed);
234
+ logger.info(`[memory] keyword 回填:已按请求中止(在飞请求跑完即收工),本轮完成 ${state.processed} 条,剩 ${state.deferred} 条留待再触发`);
235
+ }
177
236
  if (budgetHit) {
178
237
  state.deferred += Math.max(0, batch.length - state.processed);
179
238
  logger.info(`[memory] keyword 回填:墙钟预算(${Math.round(TIME_BUDGET_MS / 1000)}s)用尽,累计留待再触发 ${state.deferred} 条`);
@@ -186,7 +245,9 @@ export async function runKeywordBackfillOnce(deps) {
186
245
  * 而逐条只花秒级。记账(state.failed 等)全部归调用方——标注器不再动全局状态,
187
246
  * 否则同一条会被两处各计一次。
188
247
  */
189
- export async function labelKeywordOne(ctx, cfg, logger, rec, callTimeoutMs = DEFAULT_CALL_TIMEOUT_MS) {
248
+ export async function labelKeywordOne(ctx, cfg, logger, rec, callTimeoutMs = DEFAULT_CALL_TIMEOUT_MS,
249
+ /** 本轮中止信号(可选):与单次超时**取或**,宽限到期后由它当场掐断请求。 */
250
+ externalSignal) {
190
251
  const user = `记忆:\n标题:${rec.name ?? ''}\n正文:${rec.content.slice(0, 900)}`;
191
252
  const t0 = Date.now();
192
253
  const secs = () => Math.round((Date.now() - t0) / 10);
@@ -196,8 +257,14 @@ export async function labelKeywordOne(ctx, cfg, logger, rec, callTimeoutMs = DEF
196
257
  user,
197
258
  maxTokens: LABEL_MAX_TOKENS,
198
259
  layer: 'l1-extract',
260
+ // 关键词/情景标注是直答任务:显式关思考,否则思考模型会把 512 预算烧在
261
+ // reasoning 上、正文吐 0 字符(真机 156/162 次 `llm empty output` 皆为此)。
262
+ reasoningEffort: 'off',
199
263
  logger,
200
- signal: AbortSignal.timeout(callTimeoutMs > 0 ? callTimeoutMs : DEFAULT_CALL_TIMEOUT_MS),
264
+ signal: (() => {
265
+ const t = AbortSignal.timeout(callTimeoutMs > 0 ? callTimeoutMs : DEFAULT_CALL_TIMEOUT_MS);
266
+ return externalSignal ? AbortSignal.any([t, externalSignal]) : t;
267
+ })(),
201
268
  });
202
269
  logger.debug?.(`[memory] keyword 单条耗时 ${(Date.now() - t0) / 1000}s(id=${rec.id})`);
203
270
  const parsed = parseJsonLogged(raw, 'keyword 回填', logger);
package/dist/llm.d.ts CHANGED
@@ -17,6 +17,15 @@ export interface LlmCallOptions {
17
17
  maxTokens?: number;
18
18
  temperature?: number;
19
19
  signal?: AbortSignal;
20
+ /**
21
+ * **调用级思考档位覆盖**(可选)。非空时优先于路由/层链/全局解析出的档位。
22
+ *
23
+ * 为什么需要:回填批扫(整形/关键词/wing/room)是「重排 + 拎标识」的直答任务,
24
+ * 不需要推理;而思考模型默认开思考,会把整个 maxTokens 预算烧在 reasoning 上、
25
+ * 正文吐 0 字符(真机:156/162 次 `llm empty output` 都是这么来的)。
26
+ * 显式传 `off` 后预算全留给答案。档位仍过一遍模型能力钳制:模型不支持 off 时不传。
27
+ */
28
+ reasoningEffort?: string;
20
29
  /** 诊断日志(缺失时静默,不影响调用) */
21
30
  logger?: MemoryLogger;
22
31
  }
package/dist/llm.js CHANGED
@@ -393,8 +393,10 @@ async function callRoute(ctx, cfg, opts, route) {
393
393
  const { provider, model } = route;
394
394
  const signal = opts.signal ?? AbortSignal.timeout(cfg.llm.timeoutMs);
395
395
  // 档位按模型能力决策(跨供应商 effort 兼容):不支持的档位不传 + 告警一次,
396
- // 空配置 = 自动(模型默认 → high);路由的档位候选已在链解析时定好(条目 > 全局)
397
- const effort = await planDistillEffort(ctx, provider, model, route.effort, opts.logger);
396
+ // 空配置 = 自动(模型默认 → high);路由的档位候选已在链解析时定好(条目 > 全局)。
397
+ // 调用级覆盖(opts.reasoningEffort)优先——批扫靠它关掉思考,避免预算被 reasoning 吃光。
398
+ const routeEffort = opts.reasoningEffort ?? route.effort;
399
+ const effort = await planDistillEffort(ctx, provider, model, routeEffort, opts.logger);
398
400
  // 输入预算兜底:任何蒸馏调用的用户 prompt 不超过 maxInputChars
399
401
  // (L1 已在数据层分块,这里是 L2/L3 与异常场景的最后一道网)
400
402
  const user = opts.user.length > cfg.llm.maxInputChars
@@ -406,9 +408,10 @@ async function callRoute(ctx, cfg, opts, route) {
406
408
  // 配置本身就是高档时不再放大(防 ×16 双乘)
407
409
  const baseMaxTokens = opts.maxTokens ?? cfg.llm.maxTokens;
408
410
  const highTiers = HIGH_EFFORT_TIERS;
409
- const triggerEffort = opts.layer
411
+ // 触发档位也跟调用级覆盖走:显式 off 时不得被配置里的高档触发而 ×4。
412
+ const triggerEffort = opts.reasoningEffort ?? (opts.layer
410
413
  ? layerEffortTrigger(cfg, layerKeyFor(opts.layer))
411
- : cfg.llm.primaryEffort || cfg.llm.reasoningEffort;
414
+ : cfg.llm.primaryEffort || cfg.llm.reasoningEffort);
412
415
  const maxTokens = highTiers.includes(effort.effort) && !highTiers.includes(triggerEffort)
413
416
  ? layerMaxTokens(baseMaxTokens, 'high')
414
417
  : baseMaxTokens;
@@ -178,7 +178,7 @@ export function parseBundle(text) {
178
178
  declaredSha: null,
179
179
  bodyLines: [],
180
180
  };
181
- const clean = text.replace(/^/, '');
181
+ const clean = text.replace(/^\uFEFF/, '');
182
182
  const rawLines = clean.split(/\r?\n/);
183
183
  const body = [];
184
184
  let seqLine = 0;
@@ -321,7 +321,7 @@ export async function syncFromFiles(deps) {
321
321
  continue;
322
322
  }
323
323
  const cached = textCache.get(abs);
324
- let text = '';
324
+ let text; // catch 分支必 continue,初始值从未被读
325
325
  try {
326
326
  text = cached ?? await readFile(abs, 'utf8');
327
327
  }
@@ -83,7 +83,7 @@ export const EXTRACTION_FEW_SHOT = `
83
83
  ${TITLE_GOOD_EXAMPLES.map(renderGoodExample).join('\n\n')}
84
84
 
85
85
  【不要这样】
86
- ${TITLE_BAD_EXAMPLES.map((b) => `- ✗ ${b.title} → ${b.why}`).join('\n')}
86
+ ${TITLE_BAD_EXAMPLES.map((b) => `- ✗ ${b.title}\u3000→ ${b.why}`).join('\n')}
87
87
  `;
88
88
  /**
89
89
  * 各类型的"前因后果"要素表(照 G2 `NARRATIVE_CLAUSE` 写,不另造一套定义)。
@@ -81,7 +81,20 @@ export interface RedistillBackfillState {
81
81
  concurrency: number;
82
82
  /** 预算/上限用尽未处理、留下次的条数。 */
83
83
  deferred: number;
84
+ /** 本轮被人工中止(进端点 cancel)——与“预算用尽”分开报文案。 */
85
+ cancelled: boolean;
86
+ /**
87
+ * 扫描期已扫行数(活跃集逐页扫)/ 总行数(本模块拿不到总数,恒 0 = 未知)。
88
+ * 为何要暴露:扫描段原本在面板上只是一个静态「正在扫描…」,分不清在跑还是卡死。
89
+ */
90
+ scanned: number;
91
+ scanTotal: number;
84
92
  }
93
+ /** 请求中止本轮(空闲时无副作用);返回后中止是**有界**的:最多等 graceMs(≤60s)。 */
94
+ export declare function cancelRedistillBackfill(): {
95
+ cancelled: boolean;
96
+ running: boolean;
97
+ };
85
98
  export declare function redistillBackfillState(): RedistillBackfillState;
86
99
  /**
87
100
  * 空心标题检测:某个 `## 节名` 到下一标题之间没有实质文字。
@@ -130,4 +143,6 @@ export interface ReshapeOneResult {
130
143
  * 导出不带 ctx/cfg 以外的参数,供单测注入桩 LLM。旧名 `reshapeChunk`(多条一次)已废:
131
144
  * 合并请求把爆炸半径从 1 条放大到 20 条,而实测逐条只花 2.6s,合并毫无收益。
132
145
  */
133
- export declare function reshapeOne(ctx: Context, cfg: MemoryConfig, logger: MemoryLogger, rec: MemoryRecord, callTimeoutMs?: number): Promise<ReshapeOneResult | null>;
146
+ export declare function reshapeOne(ctx: Context, cfg: MemoryConfig, logger: MemoryLogger, rec: MemoryRecord, callTimeoutMs?: number,
147
+ /** 本轮中止信号(可选):与单次超时**取或**,宽限到期后由它当场掐断请求。 */
148
+ externalSignal?: AbortSignal): Promise<ReshapeOneResult | null>;
@@ -49,7 +49,42 @@ const state = {
49
49
  running: false, updated: 0, skipped: 0, failed: 0,
50
50
  chunk: 1, candidates: 0, processed: 0,
51
51
  scanning: false, batchesDone: 0, batchTotal: 0, concurrency: DEFAULT_CONCURRENCY, deferred: 0,
52
+ cancelled: false, scanned: 0, scanTotal: 0,
52
53
  };
54
+ /**
55
+ * 人工中止信号。置位后:①池子不再派新活;②已发出的请求先拿**宽限**(cancelGraceMs)
56
+ * 自然收工(token 不白费、已写回的全入账);③**宽限到期强杀**在飞请求——
57
+ * 没有③的话「中止」最坏要等满 callTimeoutMs(240s) 才生效,等于没中止。
58
+ */
59
+ let abortRequested = false;
60
+ /** 本轮在飞请求的强杀器(runOnce 入口新建)。 */
61
+ let runAbort = null;
62
+ /** 中止宽限计时器(一次性)。 */
63
+ let graceTimer = null;
64
+ /** 本轮生效的中止宽限(启动时从 cfg 读定,已钳位)。 */
65
+ let graceMs = 15_000;
66
+ /**
67
+ * 中止宽限钳位。**硬界 30s**(用户定:中止必须在 30s 内强停)——无论 YAML 写了多少,
68
+ * 都不会超过这个值;未配置/非法取默认 15s。
69
+ */
70
+ function clampGraceMs(raw) {
71
+ const n = typeof raw === 'number' && Number.isFinite(raw) ? Math.round(raw) : 15_000;
72
+ return Math.min(30_000, Math.max(0, n));
73
+ }
74
+ /** 请求中止本轮(空闲时无副作用);返回后中止是**有界**的:最多等 graceMs(≤60s)。 */
75
+ export function cancelRedistillBackfill() {
76
+ if (!state.running)
77
+ return { cancelled: false, running: false };
78
+ abortRequested = true;
79
+ if (graceTimer === null) {
80
+ graceTimer = setTimeout(() => {
81
+ graceTimer = null;
82
+ if (state.running)
83
+ runAbort?.abort();
84
+ }, Math.max(0, graceMs));
85
+ }
86
+ return { cancelled: true, running: true };
87
+ }
53
88
  export function redistillBackfillState() {
54
89
  return { ...state };
55
90
  }
@@ -150,6 +185,8 @@ export function startRedistillBackfill(deps) {
150
185
  state.batchesDone = 0;
151
186
  state.batchTotal = 0;
152
187
  state.deferred = 0;
188
+ state.cancelled = false;
189
+ abortRequested = false;
153
190
  state.concurrency = clampConcurrency(deps.cfg.backfill?.concurrency);
154
191
  void runRedistillBackfillOnce(deps).finally(() => {
155
192
  state.running = false;
@@ -170,6 +207,15 @@ export function startRedistillBackfill(deps) {
170
207
  */
171
208
  export async function runRedistillBackfillOnce(deps) {
172
209
  const { cfg, l1, logger } = deps;
210
+ // 新一轮入口清中止信号:abortRequested 是模块级,直调 runOnce 的路径(单测/未来调用方)
211
+ // 不经过 startX 的复位,上一轮的中止信号会粘住这一轮。
212
+ abortRequested = false;
213
+ state.cancelled = false;
214
+ if (graceTimer !== null) {
215
+ clearTimeout(graceTimer);
216
+ graceTimer = null;
217
+ }
218
+ runAbort = new AbortController();
173
219
  if (!cfg.redistill?.enabled) {
174
220
  logger.info('[memory] redistill 回填跳过:redistill.enabled = false(功能关,不整形)');
175
221
  return;
@@ -180,6 +226,7 @@ export async function runRedistillBackfillOnce(deps) {
180
226
  }
181
227
  const MAX_RECORDS = cfg.backfill?.maxRecords ?? DEFAULT_MAX_RECORDS;
182
228
  const TIME_BUDGET_MS = cfg.backfill?.timeBudgetMs ?? DEFAULT_TIME_BUDGET_MS;
229
+ graceMs = clampGraceMs(cfg.backfill?.cancelGraceMs);
183
230
  const LIMIT = clampConcurrency(cfg.backfill?.concurrency);
184
231
  state.concurrency = LIMIT;
185
232
  const startedAt = Date.now();
@@ -191,6 +238,8 @@ export async function runRedistillBackfillOnce(deps) {
191
238
  let scanned = 0;
192
239
  state.scanning = true;
193
240
  for (let offset = 0;; offset += PAGE) {
241
+ if (abortRequested)
242
+ break; // 中止:扫描段也当场退出(不把最后一页白扫完)
194
243
  const page = l1.activeForSkeleton(PAGE, offset);
195
244
  if (page.length === 0)
196
245
  break;
@@ -205,6 +254,7 @@ export async function runRedistillBackfillOnce(deps) {
205
254
  targets.push(r.id);
206
255
  }
207
256
  state.candidates = targets.length;
257
+ state.scanned = scanned;
208
258
  await yieldLoop();
209
259
  }
210
260
  state.scanning = false;
@@ -228,9 +278,11 @@ export async function runRedistillBackfillOnce(deps) {
228
278
  state.batchesDone++;
229
279
  return;
230
280
  }
231
- const item = await reshapeOne(deps.ctx, cfg, logger, rec, callTimeoutMs);
281
+ const item = await reshapeOne(deps.ctx, cfg, logger, rec, callTimeoutMs, runAbort?.signal);
232
282
  if (!item) {
233
- state.failed++;
283
+ // 中止强杀导致的失败不计入 failed(不是模型/网络的错,是中止信号)
284
+ if (!abortRequested)
285
+ state.failed++;
234
286
  }
235
287
  else if (reshapePassesGate(rec.type, rec.content, item.content)) {
236
288
  const meta = { ...(rec.metadata ?? {}) };
@@ -280,6 +332,8 @@ export async function runRedistillBackfillOnce(deps) {
280
332
  let budgetHit = false;
281
333
  const work = async () => {
282
334
  for (;;) {
335
+ if (abortRequested)
336
+ return; // 中止:不再取新活(在飞的那条自然跑完)
283
337
  if (budgetHit || Date.now() - startedAt > TIME_BUDGET_MS) {
284
338
  budgetHit = true;
285
339
  return;
@@ -298,12 +352,17 @@ export async function runRedistillBackfillOnce(deps) {
298
352
  for (;;) {
299
353
  const target = Math.max(1, Math.min(readLimit(), batch.length));
300
354
  state.concurrency = target; // 面板「并行 N」跟着实时值走,不再停在启动值上骗人
301
- while (!budgetHit && inFlight.size < target && cursor < batch.length)
355
+ while (!budgetHit && !abortRequested && inFlight.size < target && cursor < batch.length)
302
356
  spawn();
303
357
  if (inFlight.size === 0)
304
358
  break;
305
359
  await Promise.race(inFlight);
306
360
  }
361
+ if (abortRequested) {
362
+ state.cancelled = true;
363
+ state.deferred += Math.max(0, batch.length - state.processed);
364
+ logger.info(`[memory] redistill 回填:已按请求中止(在飞请求跑完即收工),本轮完成 ${state.processed} 条,剩 ${state.deferred} 条留待再触发`);
365
+ }
307
366
  if (budgetHit) {
308
367
  state.deferred += Math.max(0, batch.length - state.processed);
309
368
  logger.info(`[memory] redistill 回填:墙钟预算(${Math.round(TIME_BUDGET_MS / 1000)}s)用尽,累计留待再触发 ${state.deferred} 条`);
@@ -315,7 +374,9 @@ export async function runRedistillBackfillOnce(deps) {
315
374
  * 导出不带 ctx/cfg 以外的参数,供单测注入桩 LLM。旧名 `reshapeChunk`(多条一次)已废:
316
375
  * 合并请求把爆炸半径从 1 条放大到 20 条,而实测逐条只花 2.6s,合并毫无收益。
317
376
  */
318
- export async function reshapeOne(ctx, cfg, logger, rec, callTimeoutMs = DEFAULT_CALL_TIMEOUT_MS) {
377
+ export async function reshapeOne(ctx, cfg, logger, rec, callTimeoutMs = DEFAULT_CALL_TIMEOUT_MS,
378
+ /** 本轮中止信号(可选):与单次超时**取或**,宽限到期后由它当场掐断请求。 */
379
+ externalSignal) {
319
380
  const user = `记忆:\n类型:${rec.type}\n标题:${rec.name ?? ''}\n当前正文:${rec.content.slice(0, 1200)}`;
320
381
  // 每条都记耗时:本轮排查的就是“单条到底要多久”,没这个数就只能猜并发/超时该怎么配。
321
382
  const t0 = Date.now();
@@ -326,9 +387,16 @@ export async function reshapeOne(ctx, cfg, logger, rec, callTimeoutMs = DEFAULT_
326
387
  user,
327
388
  maxTokens: RESHAPE_MAX_TOKENS,
328
389
  layer: 'l1-extract',
390
+ // 整形是「重排 + 拎标识」的直答任务:显式关思考,否则思考模型会把预算烧在
391
+ // reasoning 上吐 0 字符(真机 156/162 次 `llm empty output` 皆为此)。
392
+ reasoningEffort: 'off',
329
393
  logger,
330
- // 自己给超时信号:沿用 cfg.llm.timeoutMs(120s) 会在回退链上被掐死。
331
- signal: AbortSignal.timeout(callTimeoutMs > 0 ? callTimeoutMs : DEFAULT_CALL_TIMEOUT_MS),
394
+ // 自己给超时信号:沿用 cfg.llm.timeoutMs(120s) 会在回退链上被掐死;
395
+ // 再与「本轮中止」取或——宽限到期后由它当场掐断。
396
+ signal: (() => {
397
+ const t = AbortSignal.timeout(callTimeoutMs > 0 ? callTimeoutMs : DEFAULT_CALL_TIMEOUT_MS);
398
+ return externalSignal ? AbortSignal.any([t, externalSignal]) : t;
399
+ })(),
332
400
  });
333
401
  logger.info(`[memory] redistill 单条耗时 ${secs()}s(id=${rec.id})`);
334
402
  const parsed = parseJsonLogged(raw, 'redistill 回填', logger);
@@ -53,6 +53,8 @@ export async function roomCandidateOne(ctx, cfg, logger, rec, roomCatalog) {
53
53
  user,
54
54
  maxTokens: 1024,
55
55
  layer: 'l1-extract',
56
+ // 归类是直答任务:显式关思考(不然思考模型把预算烧在 reasoning 上吐 0 字符)。
57
+ reasoningEffort: 'off',
56
58
  logger,
57
59
  });
58
60
  const parsed = parseJsonLogged(raw, 'Room 候选标注', logger);
package/dist/settings.js CHANGED
@@ -94,6 +94,7 @@ const ALWAYS_ON = {
94
94
  toolRuminate: true,
95
95
  toolConflict: true,
96
96
  toolMutate: true,
97
+ toolSkills: true,
97
98
  };
98
99
  /** 自带自定义设置页(settings.section 顶层「记忆」分节,client 半挂载):
99
100
  * 关掉宿主按 volatile 字段自动生成的表单页,避免同一个插件出现两份设置入口。
@@ -338,5 +339,6 @@ function resolveSettings(value) {
338
339
  toolRuminate: v.toolRuminate !== false,
339
340
  toolConflict: v.toolConflict !== false,
340
341
  toolMutate: v.toolMutate !== false,
342
+ toolSkills: v.toolSkills !== false,
341
343
  };
342
344
  }
package/dist/stats.js CHANGED
@@ -32,9 +32,9 @@ import { WING_CATALOG, WING_FALLBACK } from './types.js';
32
32
  import { isRoomSlug } from './metadata-validators.js';
33
33
  import { InProcMemoryBackend } from './store/memory-backend.js';
34
34
  import { isWingCorner } from './store/session-modes.js';
35
- import { startWingBackfill, wingBackfillState } from './wing-backfill.js';
36
- import { startKeywordBackfill, keywordBackfillState } from './keyword-backfill.js';
37
- import { startRedistillBackfill, redistillBackfillState } from './redistill-backfill.js';
35
+ import { startWingBackfill, wingBackfillState, cancelWingBackfill } from './wing-backfill.js';
36
+ import { startKeywordBackfill, keywordBackfillState, cancelKeywordBackfill } from './keyword-backfill.js';
37
+ import { startRedistillBackfill, redistillBackfillState, cancelRedistillBackfill } from './redistill-backfill.js';
38
38
  import { errDetail } from './util/filelog.js';
39
39
  import { snapshotTokenCost } from './token-cost.js';
40
40
  import { computeMaturity } from './util/maturity.js';
@@ -66,6 +66,9 @@ function backfillProgress(s) {
66
66
  batchTotal: s.batchTotal,
67
67
  concurrency: s.concurrency,
68
68
  deferred: s.deferred,
69
+ cancelled: s.cancelled,
70
+ scanned: s.scanned,
71
+ scanTotal: s.scanTotal,
69
72
  };
70
73
  }
71
74
  /**
@@ -1156,6 +1159,11 @@ export async function handleEndpoint(endpoint, payload, deps) {
1156
1159
  return v;
1157
1160
  }
1158
1161
  case 'dsh-memory/wing-backfill': {
1162
+ // cancel=true:请求中止本轮(不硬杀在飞请求,跑完当前条即收工)。
1163
+ if (payload?.cancel === true) {
1164
+ cancelWingBackfill();
1165
+ return { started: false, running: wingBackfillState().running };
1166
+ }
1159
1167
  // probe=true 只读快照:不启动(UI 离开区域再回来时恢复在途任务用)。
1160
1168
  if (payload?.probe === true) {
1161
1169
  return { started: false, running: wingBackfillState().running };
@@ -1172,6 +1180,12 @@ export async function handleEndpoint(endpoint, payload, deps) {
1172
1180
  // 门控看生效配置(live.keywordEnabled 覆盖部署 cfg.keyword.enabled);关时不启动。
1173
1181
  const ec = effectiveCfg(cfg, live);
1174
1182
  const st0 = keywordBackfillState();
1183
+ // cancel=true:请求中止本轮(不硬杀在飞请求,跑完当前条即收工)。
1184
+ if (payload?.cancel === true) {
1185
+ cancelKeywordBackfill();
1186
+ const st = keywordBackfillState();
1187
+ return { started: false, running: st.running, enabled: ec.keyword?.enabled === true, updated: st.updated, failed: st.failed, progress: backfillProgress(st) };
1188
+ }
1175
1189
  // probe=true 只读快照:不启动(UI 离开区域再回来时恢复在途任务用)。
1176
1190
  if (payload?.probe === true) {
1177
1191
  return { started: false, running: st0.running, enabled: ec.keyword?.enabled === true, updated: st0.updated, failed: st0.failed, progress: backfillProgress(st0) };
@@ -1193,6 +1207,12 @@ export async function handleEndpoint(endpoint, payload, deps) {
1193
1207
  // 门控看生效配置(live.redistillEnabled 覆盖部署 cfg.redistill.enabled);关时不启动。
1194
1208
  const ec = effectiveCfg(cfg, live);
1195
1209
  const st0 = redistillBackfillState();
1210
+ // cancel=true:请求中止本轮(不硬杀在飞请求,跑完当前条即收工)。
1211
+ if (payload?.cancel === true) {
1212
+ cancelRedistillBackfill();
1213
+ const st = redistillBackfillState();
1214
+ return { started: false, running: st.running, enabled: ec.redistill?.enabled === true, updated: st.updated, skipped: st.skipped, failed: st.failed, progress: backfillProgress(st) };
1215
+ }
1196
1216
  // probe=true 只读快照:不启动(UI 离开区域再回来时恢复在途任务用)。
1197
1217
  if (payload?.probe === true) {
1198
1218
  return { started: false, running: st0.running, enabled: ec.redistill?.enabled === true, updated: st0.updated, skipped: st0.skipped, failed: st0.failed, progress: backfillProgress(st0) };
@@ -1311,7 +1331,7 @@ export async function handleEndpoint(endpoint, payload, deps) {
1311
1331
  supported: live?.supported ?? false,
1312
1332
  settings: sanitizeSettings(s ?? {
1313
1333
  enabled: true, capture: true, distill: true, recall: true,
1314
- toolRoom: true, toolGraph: true, toolRuminate: true, toolConflict: true, toolMutate: true,
1334
+ toolRoom: true, toolGraph: true, toolRuminate: true, toolConflict: true, toolMutate: true, toolSkills: true,
1315
1335
  reasoningEffort: '', distillProvider: '', distillModel: '', distillChain: [],
1316
1336
  distillBudgets: { extract: 0, dedup: 0, l2: 0, l3: 0, graph: 0 }, distillMaxInputChars: 0,
1317
1337
  distillLayerChains: { l1: [], l2: [], l3: [] },
@@ -1364,7 +1384,7 @@ export async function handleEndpoint(endpoint, payload, deps) {
1364
1384
  const patch = (payload ?? {});
1365
1385
  const clean = {};
1366
1386
  // 布尔开关组:memoryMutate(高权限写删门)与主开关同列;conflictFreeze(§C 人工冲突裁决)
1367
- for (const key of ['enabled', 'capture', 'distill', 'recall', 'memoryMutate', 'conflictFreeze', 'skillsEnabled', 'keywordEnabled', 'redistillEnabled', 'mdSyncEnabled', 'memportEnabled', 'toolRoom', 'toolGraph', 'toolRuminate', 'toolConflict', 'toolMutate']) {
1387
+ for (const key of ['enabled', 'capture', 'distill', 'recall', 'memoryMutate', 'conflictFreeze', 'skillsEnabled', 'keywordEnabled', 'redistillEnabled', 'mdSyncEnabled', 'memportEnabled', 'toolRoom', 'toolGraph', 'toolRuminate', 'toolConflict', 'toolMutate', 'toolSkills']) {
1368
1388
  if (typeof patch[key] === 'boolean')
1369
1389
  clean[key] = patch[key];
1370
1390
  }
@@ -388,7 +388,7 @@ export async function exportThenPurge(db, dataDir, ids, reason, logger, now = ne
388
388
  // 门禁第二半——**批次级投影**。不再拿整库哈希当门禁:那会把“本批逐字未动、但库里有
389
389
  // 别的东西被并发改过”误判成不安全,而事实恰好相反——要删的那批在快照里完整可信,
390
390
  // 删错了能原样回灌。整库漂移仍计数(它是要给人看的信息),但它不拦住删除。
391
- let subset = [];
391
+ let subset; // try 内先赋值后读,初始空数组从未被用
392
392
  let otherChanged = 0;
393
393
  try {
394
394
  const want = new Set(ids);
@@ -10,6 +10,15 @@ import type { SceneStore } from '../store/scenes.js';
10
10
  import type { SessionModeStore } from '../store/session-modes.js';
11
11
  import type { MemoryFamily, MemoryLogger } from '../types.js';
12
12
  import type { RoomRegistryStore } from '../store/rooms-registry.js';
13
+ /**
14
+ * 封印登记面:把某个工具组的延迟注册交给 {@link registerMemoryTools} 的封印机制。
15
+ * 组名见 `toolSealed` 的 switch;登记后是否真正注册由当前封印状态决定,
16
+ * 并在 `loader/volatile-update` 时随 `applyToolSeals` 重放(热更)。
17
+ */
18
+ export interface ToolSealRegistry {
19
+ /** 登记一个工具组的一次性注册器;`make()` 须返回 `ctx.tools.register` 的 disposer。 */
20
+ reg(group: string, make: () => () => void): void;
21
+ }
13
22
  export declare function registerMemoryTools(ctx: Context, cfg: MemoryConfig, stores: {
14
23
  l0: L0Store;
15
24
  l1: L1Store;
@@ -25,4 +34,4 @@ export declare function registerMemoryTools(ctx: Context, cfg: MemoryConfig, sto
25
34
  /** 反刍控制器(可选:未装配时 ruminate 工具返回未启用提示)。 */
26
35
  ruminate?: RuminateController,
27
36
  /** Room 注册表(分类管理 beta.4;缺省 = memory_room_admin 返回未装配提示)。 */
28
- roomRegistry?: RoomRegistryStore): void;
37
+ roomRegistry?: RoomRegistryStore): ToolSealRegistry;
@@ -52,7 +52,7 @@ ruminate,
52
52
  /** Room 注册表(分类管理 beta.4;缺省 = memory_room_admin 返回未装配提示)。 */
53
53
  roomRegistry) {
54
54
  if (!cfg.tools)
55
- return;
55
+ return { reg: () => { } };
56
56
  // ── 工具分组封印(v0.21.0):sealable 组的注册经 reg() 登记,按 live 开关动态
57
57
  // 注册/反注册。封印 = 从模型工具列表消失(反注册),不是执行期拒绝;开关经
58
58
  // 设置页 volatile 热更(loader/volatile-update)即时生效,无需重启。
@@ -60,6 +60,9 @@ roomRegistry) {
60
60
  // memory_receipts)与槽位三件(slots.ts)不参与封印。
61
61
  const toolDisposers = new Map();
62
62
  const toolMakers = new Map();
63
+ // 技能组是「功能开关 + 封印」双条件:功能关(部署 cfg.skills.enabled 与 live.skillsEnabled 取或)或
64
+ // 封印(toolSkills===false)任一满足即不注册——功能关时保持零表面积,开门后仍可单独封印。
65
+ const skillsFeatureOn = () => cfg.skills.enabled === true || live.get().skillsEnabled === true;
63
66
  const toolSealed = (group) => {
64
67
  const snap = live.get();
65
68
  switch (group) {
@@ -68,6 +71,7 @@ roomRegistry) {
68
71
  case 'ruminate': return snap.toolRuminate === false;
69
72
  case 'conflict': return snap.toolConflict === false;
70
73
  case 'mutate': return snap.toolMutate === false;
74
+ case 'skills': return !skillsFeatureOn() || snap.toolSkills === false;
71
75
  default: return false;
72
76
  }
73
77
  };
@@ -1412,6 +1416,8 @@ roomRegistry) {
1412
1416
  ctx.on('loader/volatile-update', () => applyToolSeals());
1413
1417
  }
1414
1418
  logger.info('[memory] 工具已注册: memory_search / conversation_search / memory_read_scene / memory_receipts / memory_search_graph / memory_expand_graph_node,及高权限 memory_add/memory_import/memory_delete / memory_resolve_conflict / memory_ruminate / memory_ruminate_cancel / memory_ruminate_status');
1419
+ // 技能组(registerSkillTools)在注册期经本面登记,随 volatile 热更重放封印。
1420
+ return { reg };
1415
1421
  }
1416
1422
  /** 凭证回溯的人类可读渲染(含"还有多少条没显示")。 */
1417
1423
  function renderReceipts(dimension, items, total) {
@@ -1,13 +1,16 @@
1
1
  /**
2
- * 技能工具面(轨2)。**整块仅在 cfg.skills.enabled 时注册**——默认关时零表面积
3
- * (对齐 benchControl"生产部署零表面积"先例,避免注册了却被门控的空壳工具污染模型工具列表)。
2
+ * 技能工具面(轨2)。**经宿主封印机制登记**(`seals.reg('skills', …)`),不再自行判门——
3
+ * 是否真正注册由 `registerMemoryTools` 的 `toolSealed('skills')` 决定:
4
+ * - 功能关(部署 `cfg.skills.enabled` 与 live `skillsEnabled` 取或为 false)⇒ 封印,零表面积;
5
+ * - 功能开但 `live.toolSkills === false` ⇒ 封印,从模型工具列表移除。
6
+ * 两者都随 `loader/volatile-update` 热更(面板拨开关即时注册/反注册,无需重启)。
4
7
  *
5
8
  * 读工具 list/read;写工具 pin/archive 过 live.memoryMutate 高权限门(与 memory_add 同门)。
6
9
  * 技能不是 memory `type`,这些工具只操作 SkillStore sidecar,不触碰 l1_records / ADD_TYPES / prompt 词表。
7
10
  */
8
11
  import type { Context } from '@deepseek-ai/cordis';
9
- import type { MemoryConfig } from '../config.js';
10
12
  import type { LiveSettingsHandle } from '../settings.js';
11
13
  import type { SkillStore } from '../store/skill-store.js';
12
14
  import type { MemoryLogger } from '../types.js';
13
- export declare function registerSkillTools(ctx: Context, cfg: MemoryConfig, skills: SkillStore, logger: MemoryLogger, live: LiveSettingsHandle): void;
15
+ import type { ToolSealRegistry } from './index.js';
16
+ export declare function registerSkillTools(ctx: Context, skills: SkillStore, logger: MemoryLogger, live: LiveSettingsHandle, seals: ToolSealRegistry): void;