dsh-prime-memory 0.26.0 → 0.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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;
@@ -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
  }
@@ -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;
@@ -1,10 +1,8 @@
1
1
  import { defineTool } from '@deepseek-ai/dsh-tools';
2
2
  const MUTATE_OFF_NOTICE = '记忆写删未开放:请在记忆库面板开启「高权限模式」后,模型才能改动技能。';
3
- export function registerSkillTools(ctx, cfg, skills, logger, live) {
4
- if (!(cfg.skills.enabled === true || live.get().skillsEnabled === true))
5
- return; // 默认关:部署 cfg 或看板 live 任一开才注册
3
+ export function registerSkillTools(ctx, skills, logger, live, seals) {
6
4
  // memory_skill_list:列全部技能摘要(含 status/sessionCount,candidate 也列出供审阅)。
7
- ctx.tools.register(defineTool({
5
+ seals.reg('skills', () => ctx.tools.register(defineTool({
8
6
  name: 'memory_skill_list',
9
7
  description: '列出已沉淀的技能(程序性记忆)摘要:id/标题/状态/档位/覆盖会话数。用于查看技能库现状与候选晋升情况。',
10
8
  parameters: {},
@@ -44,9 +42,9 @@ export function registerSkillTools(ctx, cfg, skills, logger, live) {
44
42
  }));
45
43
  return { skills: list, notice: `共 ${list.length} 个技能` };
46
44
  },
47
- }));
45
+ })));
48
46
  // memory_skill_read:取某技能完整正文 + 步骤。
49
- ctx.tools.register(defineTool({
47
+ seals.reg('skills', () => ctx.tools.register(defineTool({
50
48
  name: 'memory_skill_read',
51
49
  description: '读取一条技能的完整正文与步骤清单(按 id)。用于展开注入里被截断/excerpt 档的技能细节。',
52
50
  parameters: { id: { type: 'string', required: true, description: '技能 id(来自 memory_skill_list 或注入头)' } },
@@ -76,9 +74,9 @@ export function registerSkillTools(ctx, cfg, skills, logger, live) {
76
74
  return { id, title: '', body: '', steps: [], notice: `未找到技能 ${id}` };
77
75
  return { id: s.id, title: s.title, body: s.body, steps: s.steps };
78
76
  },
79
- }));
77
+ })));
80
78
  // memory_skill_pin:pin/unpin(高权限门)。pinned 技能不受闲置归档影响。
81
- ctx.tools.register(defineTool({
79
+ seals.reg('skills', () => ctx.tools.register(defineTool({
82
80
  name: 'memory_skill_pin',
83
81
  description: '固定/取消固定一条技能(按 id)。固定后不参与闲置自动归档。仅用户显式要求时用;需高权限模式。',
84
82
  parameters: {
@@ -100,9 +98,9 @@ export function registerSkillTools(ctx, cfg, skills, logger, live) {
100
98
  const changed = await skills.update(id, { pinned: args?.pinned === true });
101
99
  return { notice: changed ? `已${args?.pinned === true ? '固定' : '取消固定'}技能 ${id}` : `未找到技能 ${id} 或无变化` };
102
100
  },
103
- }));
101
+ })));
104
102
  // memory_skill_archive:归档(高权限门);走 sidecar 生命周期,不触碰 l1_records 退场。
105
- ctx.tools.register(defineTool({
103
+ seals.reg('skills', () => ctx.tools.register(defineTool({
106
104
  name: 'memory_skill_archive',
107
105
  description: '归档一条技能(按 id,可给原因)。归档后不再注入,但记录保留可恢复。仅用户显式要求时用;需高权限模式。',
108
106
  parameters: {
@@ -126,5 +124,5 @@ export function registerSkillTools(ctx, cfg, skills, logger, live) {
126
124
  logger.info(`[memory] 技能归档 ${id}`);
127
125
  return { notice: done ? `已归档技能 ${id}` : `未找到技能 ${id} 或已归档` };
128
126
  },
129
- }));
127
+ })));
130
128
  }
@@ -28,7 +28,14 @@ export interface HallBackfillState {
28
28
  /** 本进程累计成功打标条数(重启归零;仅作观测,不作账)。 */
29
29
  updated: number;
30
30
  failed: number;
31
+ /** 本轮被人工中止(进端点 cancel)——与“跑完”分开报文案。 */
32
+ cancelled: boolean;
31
33
  }
34
+ /** 请求中止本轮(空闲时无副作用);返回后中止是**有界**的:最多等 graceMs(≤30s)。 */
35
+ export declare function cancelWingBackfill(): {
36
+ cancelled: boolean;
37
+ running: boolean;
38
+ };
32
39
  export declare function wingBackfillState(): HallBackfillState;
33
40
  /** 启动后台回填;已在运行返回 false(单飞)。 */
34
41
  export declare function startWingBackfill(deps: HallBackfillDeps): {
@@ -41,7 +48,9 @@ export declare function startWingBackfill(deps: HallBackfillDeps): {
41
48
  * **一个请求只装一条**(旧名 `labelWingChunk`,多条一次,已废)。返回该条的 wing,
42
49
  * 失败/字段缺失/值非法返回 null(记账归调用方,本函数不动全局状态)。
43
50
  */
44
- export declare function labelWingOne(ctx: Context, cfg: MemoryConfig, logger: MemoryLogger, rec: MemoryRecord, candidates: string, callTimeoutMs?: number): Promise<string | null>;
51
+ export declare function labelWingOne(ctx: Context, cfg: MemoryConfig, logger: MemoryLogger, rec: MemoryRecord, candidates: string, callTimeoutMs?: number,
52
+ /** 本轮中止信号(可选):与单次超时**取或**,宽限到期后由它当场掐断请求。 */
53
+ externalSignal?: AbortSignal): Promise<string | null>;
45
54
  /**
46
55
  * 涌现标签标注器(导出):为**一条**记录提炼 slug 标签(tags)——Room 的前身。
47
56
  * 一个请求只装一条(旧名 `tagChunk`,多条一次,已废)。失败返回 null;合法但为空返回 []。
@@ -10,7 +10,40 @@ const DEFAULT_MAX_RECORDS = 40;
10
10
  const DEFAULT_TIME_BUDGET_MS = 1_800_000;
11
11
  /** 单次 LLM 请求默认超时(可被 cfg.backfill.callTimeoutMs 覆盖)。 */
12
12
  const DEFAULT_CALL_TIMEOUT_MS = 240_000;
13
- const state = { running: false, updated: 0, failed: 0 };
13
+ const state = { running: false, updated: 0, failed: 0, cancelled: false };
14
+ /**
15
+ * 人工中止信号。置位后:①不再取新记录;②在飞的那条先拿**宽限**(cancelGraceMs)
16
+ * 自然收工;③**宽限到期强杀**在飞请求——没有③的话「中止」最坏要等满 callTimeoutMs。
17
+ */
18
+ let abortRequested = false;
19
+ /** 本轮在飞请求的强杀器(start 入口新建)。 */
20
+ let runAbort = null;
21
+ /** 中止宽限计时器(一次性)。 */
22
+ let graceTimer = null;
23
+ /** 本轮生效的中止宽限(启动时从 cfg 读定,已钳位)。 */
24
+ let graceMs = 15_000;
25
+ /**
26
+ * 中止宽限钳位。**硬界 30s**(用户定:中止必须在 30s 内强停)——无论 YAML 写了多少,
27
+ * 都不会超过这个值;未配置/非法取默认 15s。
28
+ */
29
+ function clampGraceMs(raw) {
30
+ const n = typeof raw === 'number' && Number.isFinite(raw) ? Math.round(raw) : 15_000;
31
+ return Math.min(30_000, Math.max(0, n));
32
+ }
33
+ /** 请求中止本轮(空闲时无副作用);返回后中止是**有界**的:最多等 graceMs(≤30s)。 */
34
+ export function cancelWingBackfill() {
35
+ if (!state.running)
36
+ return { cancelled: false, running: false };
37
+ abortRequested = true;
38
+ if (graceTimer === null) {
39
+ graceTimer = setTimeout(() => {
40
+ graceTimer = null;
41
+ if (state.running)
42
+ runAbort?.abort();
43
+ }, Math.max(0, graceMs));
44
+ }
45
+ return { cancelled: true, running: true };
46
+ }
14
47
  export function wingBackfillState() {
15
48
  return { ...state };
16
49
  }
@@ -24,6 +57,13 @@ export function startWingBackfill(deps) {
24
57
  state.running = true;
25
58
  state.updated = 0;
26
59
  state.failed = 0;
60
+ state.cancelled = false;
61
+ abortRequested = false;
62
+ if (graceTimer !== null) {
63
+ clearTimeout(graceTimer);
64
+ graceTimer = null;
65
+ }
66
+ runAbort = new AbortController();
27
67
  void run(deps).finally(() => {
28
68
  state.running = false;
29
69
  deps.logger.info(`[memory] wing 回填结束:成功 ${state.updated} 条,失败 ${state.failed} 条`);
@@ -34,6 +74,7 @@ async function run(deps) {
34
74
  const { ctx, cfg, backend, logger } = deps;
35
75
  const MAX_RECORDS = cfg.backfill?.maxRecords ?? DEFAULT_MAX_RECORDS;
36
76
  const TIME_BUDGET_MS = cfg.backfill?.timeBudgetMs ?? DEFAULT_TIME_BUDGET_MS;
77
+ graceMs = clampGraceMs(cfg.backfill?.cancelGraceMs);
37
78
  const callTimeoutMs = cfg.backfill?.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT_MS;
38
79
  const halls = normWingEnabled(cfg.hall?.enabled);
39
80
  if (halls.length === 0) {
@@ -71,6 +112,11 @@ async function run(deps) {
71
112
  // 逐条串行:取一条 → 送标 → 当场写回。不攒批、不合并(爆炸半径锁在 1 条)。
72
113
  const ids = pendingIds.slice(0, budget);
73
114
  for (let i = 0; i < ids.length; i++) {
115
+ if (abortRequested) {
116
+ state.cancelled = true;
117
+ logger.info(`[memory] wing 回填:已按请求中止,本轮完成 ${i} 条,剩 ${ids.length - i} 条留待再触发`);
118
+ break;
119
+ }
74
120
  if (Date.now() - startedAt > TIME_BUDGET_MS) {
75
121
  logger.info(`[memory] wing 回填:墙钟预算(${TIME_BUDGET_MS / 1000}s)用尽,剩余 ${ids.length - i} 条留待再次触发`);
76
122
  break;
@@ -81,9 +127,11 @@ async function run(deps) {
81
127
  state.failed++;
82
128
  continue;
83
129
  }
84
- const wing = await labelWingOne(ctx, cfg, logger, rec, candidates, callTimeoutMs);
130
+ const wing = await labelWingOne(ctx, cfg, logger, rec, candidates, callTimeoutMs, runAbort?.signal);
85
131
  if (wing === null) {
86
- state.failed++;
132
+ // 中止强杀导致的失败不计入 failed(不是模型/网络的错,是中止信号)
133
+ if (!abortRequested)
134
+ state.failed++;
87
135
  continue;
88
136
  }
89
137
  // 最小写回:只改 metadata(hall 是磁盘兼容键,不改名)。
@@ -103,7 +151,9 @@ async function run(deps) {
103
151
  * **一个请求只装一条**(旧名 `labelWingChunk`,多条一次,已废)。返回该条的 wing,
104
152
  * 失败/字段缺失/值非法返回 null(记账归调用方,本函数不动全局状态)。
105
153
  */
106
- export async function labelWingOne(ctx, cfg, logger, rec, candidates, callTimeoutMs = DEFAULT_CALL_TIMEOUT_MS) {
154
+ export async function labelWingOne(ctx, cfg, logger, rec, candidates, callTimeoutMs = DEFAULT_CALL_TIMEOUT_MS,
155
+ /** 本轮中止信号(可选):与单次超时**取或**,宽限到期后由它当场掐断请求。 */
156
+ externalSignal) {
107
157
  const user = `候选 Wing:${candidates}\n\n记忆:\n${rec.content.slice(0, 600)}`;
108
158
  try {
109
159
  const raw = await callLLM(ctx, cfg, {
@@ -111,8 +161,13 @@ export async function labelWingOne(ctx, cfg, logger, rec, candidates, callTimeou
111
161
  user,
112
162
  maxTokens: 1024,
113
163
  layer: 'l1-extract',
164
+ // 打标是直答任务:显式关思考(不然思考模型把预算烧在 reasoning 上吐 0 字符)。
165
+ reasoningEffort: 'off',
114
166
  logger,
115
- signal: AbortSignal.timeout(callTimeoutMs > 0 ? callTimeoutMs : DEFAULT_CALL_TIMEOUT_MS),
167
+ signal: (() => {
168
+ const t = AbortSignal.timeout(callTimeoutMs > 0 ? callTimeoutMs : DEFAULT_CALL_TIMEOUT_MS);
169
+ return externalSignal ? AbortSignal.any([t, externalSignal]) : t;
170
+ })(),
116
171
  });
117
172
  const parsed = parseJsonLogged(raw, 'wing 回填', logger);
118
173
  // 通吃两种形状:模型偶尔无视“只输出一个对象”而回单元素数组(或带 id 的旧形状)。
@@ -153,6 +208,8 @@ export async function tagOne(ctx, cfg, logger, rec, callTimeoutMs = DEFAULT_CALL
153
208
  user,
154
209
  maxTokens: 1024,
155
210
  layer: 'l1-extract',
211
+ // 同 wing 标:直答任务,显式关思考。
212
+ reasoningEffort: 'off',
156
213
  logger,
157
214
  signal: AbortSignal.timeout(callTimeoutMs > 0 ? callTimeoutMs : DEFAULT_CALL_TIMEOUT_MS),
158
215
  });
package/dsh.plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "dsh-prime-memory",
3
3
  "name": "dsh-prime-memory",
4
- "version": "0.26.0",
4
+ "version": "0.26.1",
5
5
  "description": {
6
6
  "en": "Layered distilled memory plugin (L0 capture → L1 atomic memory → L2 scenes → L3 persona) with automatic recall injection before each model step.",
7
7
  "zh": "L0~L3 分层蒸馏记忆插件:自动捕获对话、抽取原子记忆、整合场景块、蒸馏核心画像,并在模型步骤前自动召回注入。"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-prime-memory",
3
- "version": "0.26.0",
3
+ "version": "0.26.1",
4
4
  "publishConfig": {
5
5
  "registry": "https://registry.npmjs.org",
6
6
  "access": "public",