dsh-prime-memory 0.25.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/stats.js CHANGED
@@ -32,8 +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 } from './wing-backfill.js';
36
- import { startKeywordBackfill, keywordBackfillState } from './keyword-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';
37
38
  import { errDetail } from './util/filelog.js';
38
39
  import { snapshotTokenCost } from './token-cost.js';
39
40
  import { computeMaturity } from './util/maturity.js';
@@ -42,13 +43,34 @@ import { relinkWorkspaces } from './pipeline/workspace-relink.js';
42
43
  import { reconcileLedger } from './store/memory-md-export.js';
43
44
  import { mdRunStatus, runProjection } from './store/memory-md-run.js';
44
45
  import { disableProjectionFile } from './store/memory-md-disable.js';
45
- import { MEMORY_TYPE_DIRS, UNSORTED_DIR, resolveMemoriesRoot, targetFor } from './store/memory-md.js';
46
+ import { MEMORY_TYPE_DIRS, UNSORTED_DIR, resolveMemoriesRoot, targetFor, relPathFor } from './store/memory-md.js';
46
47
  import { ensureTypeSkeleton } from './store/memory-md-skeleton.js';
47
48
  import { sweepOrphanFiles } from './store/memory-md-orphan.js';
48
49
  import { collectProjectionFiles } from './store/memory-md-archive.js';
49
50
  import { syncFromFiles } from './pipeline/md-sync.js';
50
51
  const require = createRequire(import.meta.url);
51
52
  export const PLUGIN_VERSION = require('../package.json').version;
53
+ /**
54
+ * 批扫模块的内部状态 → 契约进度体(keyword 与 redistill 同形状,故共用一个映射)。
55
+ *
56
+ * 写成显式字段而不是 `as`:两边 state 类型各自演化时,这里漏一个字段就是编译错,
57
+ * 而强转会造出一个能编译、但面板上永远显示旧值的进度。
58
+ */
59
+ function backfillProgress(s) {
60
+ return {
61
+ chunk: s.chunk,
62
+ candidates: s.candidates,
63
+ processed: s.processed,
64
+ scanning: s.scanning,
65
+ batchesDone: s.batchesDone,
66
+ batchTotal: s.batchTotal,
67
+ concurrency: s.concurrency,
68
+ deferred: s.deferred,
69
+ cancelled: s.cancelled,
70
+ scanned: s.scanned,
71
+ scanTotal: s.scanTotal,
72
+ };
73
+ }
52
74
  /**
53
75
  * 端点全集运行时清单(与 tests/contract-keys.test.ts 的 ENDPOINTS 及
54
76
  * contract.ts 类型映射表三方对齐,漂移由键集 diff 测试暴露)。
@@ -72,10 +94,12 @@ export const MEMORY_ENDPOINTS = [
72
94
  'dsh-memory/room-admin',
73
95
  'dsh-memory/wing-backfill',
74
96
  'dsh-memory/keyword-backfill',
97
+ 'dsh-memory/redistill-backfill',
75
98
  'dsh-memory/session-stats',
76
99
  'dsh-memory/settings-get',
77
100
  'dsh-memory/settings-set',
78
101
  'dsh-memory/list-records',
102
+ 'dsh-memory/records-meta',
79
103
  'dsh-memory/records-delete',
80
104
  'dsh-memory/receipts',
81
105
  'dsh-memory/conflicts',
@@ -633,10 +657,11 @@ export async function handleEndpoint(endpoint, payload, deps) {
633
657
  case 'dsh-memory/mem-sync': {
634
658
  // P2 反向同步:文件 → 库。与导出的**风险不对称**——导出只写文件,本项会写库,
635
659
  // 所以多两道门:要先开投射(没导出过 = 账本为空 = 判不出“谁变了”),要高权限。
636
- const syn = cfg.mdSync;
660
+ const ec = effectiveCfg(cfg, live);
661
+ const syn = ec.mdSync;
637
662
  if (syn?.enabled !== true)
638
- throw new Error('md 反向同步未开启(cfg.mdSync.enabled)');
639
- if (cfg.memExport?.enabled !== true)
663
+ throw new Error('md 反向同步未开启(概览页开关,或 config mdSync.enabled)');
664
+ if (ec.memExport?.enabled !== true)
640
665
  throw new Error('md 反向同步需先开启 md 投射(cfg.memExport.enabled)');
641
666
  if (!live?.get().memoryMutate)
642
667
  throw new Error('记忆写删未开放:请在记忆库面板开启「高权限模式」后才能回灌文件侧修改。');
@@ -794,7 +819,7 @@ export async function handleEndpoint(endpoint, payload, deps) {
794
819
  }
795
820
  // ── 轨3 外部记忆来源(Connect):登记/列表/退役/只读扫描。整面默认关(cfg.memport.enabled)。
796
821
  case 'dsh-memory/memory-sources-list': {
797
- const enabled = cfg.memport?.enabled === true;
822
+ const enabled = effectiveCfg(cfg, live).memport?.enabled === true;
798
823
  const v = {
799
824
  sources: enabled && memorySources
800
825
  ? memorySources.list().map((e) => ({
@@ -813,8 +838,8 @@ export async function handleEndpoint(endpoint, payload, deps) {
813
838
  return v;
814
839
  }
815
840
  case 'dsh-memory/memory-sources-add': {
816
- if (cfg.memport?.enabled !== true)
817
- throw new Error('外部记忆来源未启用(config memport.enabled)。');
841
+ if (effectiveCfg(cfg, live).memport?.enabled !== true)
842
+ throw new Error('外部记忆来源未启用(概览页开关,或 config memport.enabled)。');
818
843
  if (!memorySources)
819
844
  throw new Error('外部来源注册表未装配(需要插件重载以初始化)。');
820
845
  if (!live?.get().memoryMutate)
@@ -834,8 +859,8 @@ export async function handleEndpoint(endpoint, payload, deps) {
834
859
  return v;
835
860
  }
836
861
  case 'dsh-memory/memory-sources-retire': {
837
- if (cfg.memport?.enabled !== true)
838
- throw new Error('外部记忆来源未启用(config memport.enabled)。');
862
+ if (effectiveCfg(cfg, live).memport?.enabled !== true)
863
+ throw new Error('外部记忆来源未启用(概览页开关,或 config memport.enabled)。');
839
864
  if (!memorySources)
840
865
  throw new Error('外部来源注册表未装配(需要插件重载以初始化)。');
841
866
  if (!live?.get().memoryMutate)
@@ -853,8 +878,8 @@ export async function handleEndpoint(endpoint, payload, deps) {
853
878
  return v;
854
879
  }
855
880
  case 'dsh-memory/memport-scan': {
856
- if (cfg.memport?.enabled !== true) {
857
- const v = { enabled: false, sources: [], notice: '外部记忆扫描未启用(config memport.enabled)。' };
881
+ if (effectiveCfg(cfg, live).memport?.enabled !== true) {
882
+ const v = { enabled: false, sources: [], notice: '外部记忆扫描未启用(概览页开关,或 config memport.enabled)。' };
858
883
  return v;
859
884
  }
860
885
  if (!memorySources) {
@@ -864,7 +889,7 @@ export async function handleEndpoint(endpoint, payload, deps) {
864
889
  const p = (payload ?? {});
865
890
  const all = memorySources.listEnabled();
866
891
  const targets = p.sourceId ? all.filter((e) => e.id === p.sourceId) : all;
867
- const maxRecords = cfg.memport?.scanMaxRecords ?? 5000;
892
+ const maxRecords = effectiveCfg(cfg, live).memport?.scanMaxRecords ?? 5000;
868
893
  // 只读扫描:不写库(写走 memory_import)。逐个标扫描时间(失败不影响预览)。
869
894
  const sources = await scanSources({ sources: targets, l1: stores.l1, maxRecords });
870
895
  const nowIso = new Date().toISOString();
@@ -1134,6 +1159,15 @@ export async function handleEndpoint(endpoint, payload, deps) {
1134
1159
  return v;
1135
1160
  }
1136
1161
  case 'dsh-memory/wing-backfill': {
1162
+ // cancel=true:请求中止本轮(不硬杀在飞请求,跑完当前条即收工)。
1163
+ if (payload?.cancel === true) {
1164
+ cancelWingBackfill();
1165
+ return { started: false, running: wingBackfillState().running };
1166
+ }
1167
+ // probe=true 只读快照:不启动(UI 离开区域再回来时恢复在途任务用)。
1168
+ if (payload?.probe === true) {
1169
+ return { started: false, running: wingBackfillState().running };
1170
+ }
1137
1171
  const r = startWingBackfill({
1138
1172
  ctx: deps.ctx,
1139
1173
  cfg: deps.cfg,
@@ -1143,17 +1177,59 @@ export async function handleEndpoint(endpoint, payload, deps) {
1143
1177
  return r;
1144
1178
  }
1145
1179
  case 'dsh-memory/keyword-backfill': {
1146
- // 门控关:不启动,如实报 enabled=false(面板据此提示“先开关键词回填开关”)。
1147
- if (!cfg.keyword?.enabled)
1148
- return { started: false, running: false, enabled: false, updated: 0, failed: 0 };
1180
+ // 门控看生效配置(live.keywordEnabled 覆盖部署 cfg.keyword.enabled);关时不启动。
1181
+ const ec = effectiveCfg(cfg, live);
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
+ }
1189
+ // probe=true 只读快照:不启动(UI 离开区域再回来时恢复在途任务用)。
1190
+ if (payload?.probe === true) {
1191
+ return { started: false, running: st0.running, enabled: ec.keyword?.enabled === true, updated: st0.updated, failed: st0.failed, progress: backfillProgress(st0) };
1192
+ }
1193
+ if (!ec.keyword?.enabled)
1194
+ return { started: false, running: false, enabled: false, updated: 0, failed: 0, progress: backfillProgress(st0) };
1149
1195
  const r = startKeywordBackfill({
1150
1196
  ctx: deps.ctx,
1151
- cfg: deps.cfg,
1197
+ cfg: ec,
1152
1198
  backend: stores.backend ?? new InProcMemoryBackend(stores.l1),
1153
1199
  logger: deps.logger,
1200
+ // 运行时并发读取器:拖概览页滑块对**正在跑**的任务即时生效(弹性池每完成一条重读)。
1201
+ concurrency: () => effectiveCfg(cfg, live).backfill?.concurrency,
1154
1202
  });
1155
1203
  const st = keywordBackfillState();
1156
- return { started: r.started, running: r.running, enabled: true, updated: st.updated, failed: st.failed };
1204
+ return { started: r.started, running: r.running, enabled: true, updated: st.updated, failed: st.failed, progress: backfillProgress(st) };
1205
+ }
1206
+ case 'dsh-memory/redistill-backfill': {
1207
+ // 门控看生效配置(live.redistillEnabled 覆盖部署 cfg.redistill.enabled);关时不启动。
1208
+ const ec = effectiveCfg(cfg, live);
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
+ }
1216
+ // probe=true 只读快照:不启动(UI 离开区域再回来时恢复在途任务用)。
1217
+ if (payload?.probe === true) {
1218
+ return { started: false, running: st0.running, enabled: ec.redistill?.enabled === true, updated: st0.updated, skipped: st0.skipped, failed: st0.failed, progress: backfillProgress(st0) };
1219
+ }
1220
+ if (!ec.redistill?.enabled)
1221
+ return { started: false, running: false, enabled: false, updated: 0, skipped: 0, failed: 0, progress: backfillProgress(st0) };
1222
+ // 改 content 需进程内重嵌 + 追加 JSONL + 投射点火,直取 stores.l1(不经 worker backend)。
1223
+ const r = startRedistillBackfill({
1224
+ ctx: deps.ctx,
1225
+ cfg: ec,
1226
+ l1: stores.l1,
1227
+ logger: deps.logger,
1228
+ // 运行时并发读取器:拖概览页滑块对**正在跑**的任务即时生效(弹性池每完成一条重读)。
1229
+ concurrency: () => effectiveCfg(cfg, live).backfill?.concurrency,
1230
+ });
1231
+ const st = redistillBackfillState();
1232
+ return { started: r.started, running: r.running, enabled: true, updated: st.updated, skipped: st.skipped, failed: st.failed, progress: backfillProgress(st) };
1157
1233
  }
1158
1234
  // ── 会话级统计(悬浮卡信息区;热路径端点,见 SessionInfoSource 的零 I/O 硬规则) ──
1159
1235
  case 'dsh-memory/session-stats': {
@@ -1255,7 +1331,7 @@ export async function handleEndpoint(endpoint, payload, deps) {
1255
1331
  supported: live?.supported ?? false,
1256
1332
  settings: sanitizeSettings(s ?? {
1257
1333
  enabled: true, capture: true, distill: true, recall: true,
1258
- toolRoom: true, toolGraph: true, toolRuminate: true, toolConflict: true, toolMutate: true,
1334
+ toolRoom: true, toolGraph: true, toolRuminate: true, toolConflict: true, toolMutate: true, toolSkills: true,
1259
1335
  reasoningEffort: '', distillProvider: '', distillModel: '', distillChain: [],
1260
1336
  distillBudgets: { extract: 0, dedup: 0, l2: 0, l3: 0, graph: 0 }, distillMaxInputChars: 0,
1261
1337
  distillLayerChains: { l1: [], l2: [], l3: [] },
@@ -1264,7 +1340,12 @@ export async function handleEndpoint(endpoint, payload, deps) {
1264
1340
  memoryMutate: false,
1265
1341
  conflictFreeze: live?.get()?.conflictFreeze === true,
1266
1342
  skillsEnabled: live?.get()?.skillsEnabled === true,
1343
+ keywordEnabled: live?.get()?.keywordEnabled === true,
1344
+ redistillEnabled: live?.get()?.redistillEnabled === true,
1345
+ mdSyncEnabled: live?.get()?.mdSyncEnabled === true,
1346
+ memportEnabled: live?.get()?.memportEnabled === true,
1267
1347
  mdExportMaxRecords: live?.get()?.mdExportMaxRecords ?? 0,
1348
+ backfillConcurrency: live?.get()?.backfillConcurrency ?? 0,
1268
1349
  }),
1269
1350
  // 静态部署上限(cordis.patch.yml):运行时开关与它取 AND
1270
1351
  ceilings: { capture: cfg.capture.enabled, distill: cfg.extract.enabled, recall: cfg.recall.enabled },
@@ -1303,7 +1384,7 @@ export async function handleEndpoint(endpoint, payload, deps) {
1303
1384
  const patch = (payload ?? {});
1304
1385
  const clean = {};
1305
1386
  // 布尔开关组:memoryMutate(高权限写删门)与主开关同列;conflictFreeze(§C 人工冲突裁决)
1306
- for (const key of ['enabled', 'capture', 'distill', 'recall', 'memoryMutate', 'conflictFreeze', 'skillsEnabled', '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']) {
1307
1388
  if (typeof patch[key] === 'boolean')
1308
1389
  clean[key] = patch[key];
1309
1390
  }
@@ -1312,6 +1393,11 @@ export async function handleEndpoint(endpoint, payload, deps) {
1312
1393
  if (typeof patch.mdExportMaxRecords === 'number' && Number.isFinite(patch.mdExportMaxRecords)) {
1313
1394
  clean.mdExportMaxRecords = Math.min(10_000, Math.max(0, Math.floor(patch.mdExportMaxRecords)));
1314
1395
  }
1396
+ // 回填并行度运行时覆盖(概览页滑块):不收白名单 = “写成功但被静默丢”,
1397
+ // 而用户会拿着“并行 1”的 UI 去判断串行跑的结果——比没有这个旋钮更坏。
1398
+ if (typeof patch.backfillConcurrency === 'number' && Number.isFinite(patch.backfillConcurrency)) {
1399
+ clean.backfillConcurrency = Math.min(8, Math.max(0, Math.floor(patch.backfillConcurrency)));
1400
+ }
1315
1401
  // 运行时统一路由链:结构校验后整体写入(空数组 = 回到跟随部署配置)
1316
1402
  if (patch.distillChain !== undefined) {
1317
1403
  const err = validateDistillChain(patch.distillChain);
@@ -1630,6 +1716,37 @@ export async function handleEndpoint(endpoint, payload, deps) {
1630
1716
  })),
1631
1717
  };
1632
1718
  }
1719
+ case 'dsh-memory/records-meta': {
1720
+ const p = (payload ?? {});
1721
+ if (!Array.isArray(p.ids) || p.ids.length === 0)
1722
+ throw new Error('ids 必须是非空字符串数组');
1723
+ const ids = p.ids.filter((x) => typeof x === 'string' && x.length > 0).slice(0, 50);
1724
+ if (ids.length === 0)
1725
+ throw new Error('ids 过滤后为空');
1726
+ const records = stores.l1.getByIds(ids);
1727
+ const items = records.map((r) => {
1728
+ // 与写盘、槽位投影同一算法,不各自抄一份清洗规则。
1729
+ const filePath = relPathFor(r);
1730
+ return {
1731
+ id: r.id,
1732
+ name: r.name ?? null,
1733
+ type: r.type,
1734
+ priority: r.priority,
1735
+ content: r.content,
1736
+ maturity: computeMaturity({ priority: r.priority, usage: null, agingWeight: null, mark: null }),
1737
+ createdAt: r.createdAt ? new Date(r.createdAt).toISOString() : null,
1738
+ updatedAt: r.updatedAt ? new Date(r.updatedAt).toISOString() : null,
1739
+ scene: r.scene_name,
1740
+ scope: (r.scope === 'workspace' ? 'workspace' : 'global'),
1741
+ workspaceId: r.scope === 'workspace' ? (r.workspaceId ?? '') || null : null,
1742
+ ...(Array.isArray(r.metadata?.dsh_keywords) ? { keywords: r.metadata.dsh_keywords.filter((k) => typeof k === 'string' && k !== '') } : {}),
1743
+ ...(Array.isArray(r.metadata?.dsh_usage_scenario) ? { usageScenario: r.metadata.dsh_usage_scenario } : {}),
1744
+ filePath,
1745
+ retired: r.validTo !== undefined,
1746
+ };
1747
+ });
1748
+ return { items };
1749
+ }
1633
1750
  case 'dsh-memory/records-delete': {
1634
1751
  // 面板高权限删除指定记忆;写入删权限门(memoryMutate)防御。
1635
1752
  //
@@ -122,6 +122,18 @@ export declare class L1Store {
122
122
  * 配套写回必须用 `patchMetadata()` —— 本方法拿不到 content,用 upsert 会把正文清空。
123
123
  */
124
124
  allLite(limit: number, offset: number): L1MetaLite[];
125
+ /**
126
+ * 游标分批取**活跃**记录的骨架判定输入(含 content)——redistill 扫描阶段专用。
127
+ *
128
+ * 与 `allLite` 的分工:本方法**只为读**(判骨架),写回仍走 `appendNew`(改正文)/
129
+ * `patchMetadata`(只打标)——不得拿本投影去 upsert,退场行已被 SQL 排除。
130
+ */
131
+ activeForSkeleton(limit: number, offset: number): Array<{
132
+ id: string;
133
+ type: string;
134
+ content: string;
135
+ metadata: Record<string, unknown>;
136
+ }>;
125
137
  /**
126
138
  * 只更新 metadata_json 的最小写回(不动 content 与其它列)。
127
139
  * 返回 false 表示 id 不存在或写入失败——调用方须记账,不许静默。
package/dist/store/l1.js CHANGED
@@ -149,6 +149,15 @@ export class L1Store {
149
149
  allLite(limit, offset) {
150
150
  return this.db.getAllL1Lite(limit, offset);
151
151
  }
152
+ /**
153
+ * 游标分批取**活跃**记录的骨架判定输入(含 content)——redistill 扫描阶段专用。
154
+ *
155
+ * 与 `allLite` 的分工:本方法**只为读**(判骨架),写回仍走 `appendNew`(改正文)/
156
+ * `patchMetadata`(只打标)——不得拿本投影去 upsert,退场行已被 SQL 排除。
157
+ */
158
+ activeForSkeleton(limit, offset) {
159
+ return this.db.getActiveL1ForSkeleton(limit, offset);
160
+ }
152
161
  /**
153
162
  * 只更新 metadata_json 的最小写回(不动 content 与其它列)。
154
163
  * 返回 false 表示 id 不存在或写入失败——调用方须记账,不许静默。
@@ -105,6 +105,14 @@ export declare function recordDigest(r: Pick<MemoryRecord, 'content'>): string;
105
105
  * 免得后来人以为"回落名也能双向"。
106
106
  */
107
107
  export declare function fileStemFor(r: Pick<MemoryRecord, 'id' | 'name' | 'content'>): string;
108
+ /**
109
+ * 一条记录的投射相对路径 `<dir>/<stem>.memory.md`(分隔符恒 `/`)。
110
+ *
111
+ * 单一事实源:`renderRecord` 写盘、`dsh-memory/records-meta` 端点、槽位投影的
112
+ * `refViews.filePath` 都走这里。各自抄一份清洗规则必然漂移——漂移之后「投影给的地址」
113
+ * 和「磁盘上的真文件」对不上,而这三处本来就是给人核对同一个事实用的。
114
+ */
115
+ export declare function relPathFor(r: Pick<MemoryRecord, 'id' | 'name' | 'content' | 'type'>): string;
108
116
  /**
109
117
  * 渲染一条记录。
110
118
  *
@@ -184,6 +184,17 @@ export function fileStemFor(r) {
184
184
  // 借 sanitizeFilename 的字符/保留名纪律,再剥掉它补的 .md(我们后面统一补)。
185
185
  return sanitizeFilename(base).replace(/\.md$/i, '') || `mem-${r.id.slice(-8)}`;
186
186
  }
187
+ /**
188
+ * 一条记录的投射相对路径 `<dir>/<stem>.memory.md`(分隔符恒 `/`)。
189
+ *
190
+ * 单一事实源:`renderRecord` 写盘、`dsh-memory/records-meta` 端点、槽位投影的
191
+ * `refViews.filePath` 都走这里。各自抄一份清洗规则必然漂移——漂移之后「投影给的地址」
192
+ * 和「磁盘上的真文件」对不上,而这三处本来就是给人核对同一个事实用的。
193
+ */
194
+ export function relPathFor(r) {
195
+ const dir = MEMORY_TYPE_DIRS.has(r.type) ? r.type : UNSORTED_DIR;
196
+ return `${dir}/${fileStemFor(r)}.memory.md`;
197
+ }
187
198
  /** 把值写成可解析回读的 frontmatter 标量/数组(JSON 编码,转义由它负责)。 */
188
199
  function fmLine(key, value) {
189
200
  return `${key}: ${JSON.stringify(value)}`;
@@ -195,9 +206,9 @@ function fmLine(key, value) {
195
206
  * @returns `relPath`(相对投射根,已归一分隔符)与 `text`(全文)。
196
207
  */
197
208
  export function renderRecord(r) {
198
- const dir = MEMORY_TYPE_DIRS.has(r.type) ? r.type : UNSORTED_DIR;
209
+ const relPath = normalize(relPathFor(r)).replace(/\\/g, '/');
210
+ // frontmatter 的 `name` 必须与文件名干同一值,所以单独再取一次(同一纯函数,不会漂)。
199
211
  const stem = fileStemFor(r);
200
- const relPath = normalize(`${dir}/${stem}.memory.md`).replace(/\\/g, '/');
201
212
  const scenario = Array.isArray(r.metadata?.dsh_usage_scenario)
202
213
  ? (r.metadata?.dsh_usage_scenario).filter((s) => typeof s === 'string')
203
214
  : [];
@@ -229,6 +229,23 @@ export declare class MemoryDb {
229
229
  * 巡检期间即使有写回也不会有分页漂移;updated_time 会被写回改动。
230
230
  */
231
231
  getAllL1Lite(limit: number, offset: number): L1MetaLite[];
232
+ /**
233
+ * 游标分批取**活跃** L1 的骨架判定所需列(`id / type / content / metadata`)。
234
+ *
235
+ * 为什么单开一个方法而不是复用 `getAllL1Lite`:后者刻意不返 content(防写回把正文清空),
236
+ * 而 redistill 的**扫描阶段就要用 content 判骨架**——把筛子下推到扫描期,才能像反刍
237
+ * 重标定那样“只把真目标送 LLM”,而不是每批 hydrate 20 条候选后发现只有 4 条要整形。
238
+ *
239
+ * 退场判据走 `retiredL1Sql()`(全库唯一事实源)下推到 SQL:活跃集本就只占全库
240
+ * 两成(真机 289/1221),先筛掉退场行可把扫描代价降到五分之一。
241
+ * 分页同 `getAllL1Lite`:`ORDER BY record_id`(主键不变 ⇒ 无分页漂移)。
242
+ */
243
+ getActiveL1ForSkeleton(limit: number, offset: number): Array<{
244
+ id: string;
245
+ type: string;
246
+ content: string;
247
+ metadata: Record<string, unknown>;
248
+ }>;
232
249
  /**
233
250
  * **只**更新 metadata_json(顺带 updated_time),绝不碰 content 或其它列。
234
251
  *
@@ -1275,6 +1275,39 @@ export class MemoryDb {
1275
1275
  return [];
1276
1276
  }
1277
1277
  }
1278
+ /**
1279
+ * 游标分批取**活跃** L1 的骨架判定所需列(`id / type / content / metadata`)。
1280
+ *
1281
+ * 为什么单开一个方法而不是复用 `getAllL1Lite`:后者刻意不返 content(防写回把正文清空),
1282
+ * 而 redistill 的**扫描阶段就要用 content 判骨架**——把筛子下推到扫描期,才能像反刍
1283
+ * 重标定那样“只把真目标送 LLM”,而不是每批 hydrate 20 条候选后发现只有 4 条要整形。
1284
+ *
1285
+ * 退场判据走 `retiredL1Sql()`(全库唯一事实源)下推到 SQL:活跃集本就只占全库
1286
+ * 两成(真机 289/1221),先筛掉退场行可把扫描代价降到五分之一。
1287
+ * 分页同 `getAllL1Lite`:`ORDER BY record_id`(主键不变 ⇒ 无分页漂移)。
1288
+ */
1289
+ getActiveL1ForSkeleton(limit, offset) {
1290
+ if (this.degraded)
1291
+ return [];
1292
+ if (limit <= 0)
1293
+ return [];
1294
+ try {
1295
+ const rows = this.db
1296
+ .prepare(`SELECT record_id, type, content, metadata_json FROM l1_records
1297
+ WHERE NOT (${retiredL1Sql()}) ORDER BY record_id LIMIT ? OFFSET ?`)
1298
+ .all(limit, offset);
1299
+ return rows.map((r) => ({
1300
+ id: r.record_id,
1301
+ type: r.type,
1302
+ content: r.content ?? '',
1303
+ metadata: parseMetadataJson(r.metadata_json),
1304
+ }));
1305
+ }
1306
+ catch (err) {
1307
+ this.logger?.warn(`${TAG} L1 骨架取数失败(返回空,本轮跳过): ${err instanceof Error ? err.message : String(err)}`);
1308
+ return [];
1309
+ }
1310
+ }
1278
1311
  /**
1279
1312
  * **只**更新 metadata_json(顺带 updated_time),绝不碰 content 或其它列。
1280
1313
  *
@@ -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
  }
@@ -1,13 +1,16 @@
1
1
  /**
2
2
  * 存量未打标记录的一键回填(task_15):从 WingWheel"未打标 M"入口触发,
3
- * 对 metadata 无 wing 的 L1 记录按 8 角词表批量补打标签(复用 L1 抽取的打标路径:
3
+ * 对 metadata 无 wing 的 L1 记录按 8 角词表**逐条**补打标签(复用 L1 抽取的打标路径:
4
4
  * 同一候选词表与措辞,经既有蒸馏 LLM 路由)。
5
5
  *
6
6
  * 安全边界:
7
7
  * - **单飞**:同时最多一个回填任务,重复触发直接返回 running(不排队不叠加);
8
- * - **后台执行**:端点立即返回,逐块推进(每块 20 条),不阻塞 RPC/管线;
9
- * - **失败可退**:LLM 失败/输出不合法只跳过当前块,原记录零改动(标签只写成功的);
8
+ * - **后台执行**:端点立即返回,**逐条推进、跑完一条再发下一条**,不阻塞 RPC/管线;
9
+ * - **失败可退**:LLM 失败/输出不合法只跳过当前条,原记录零改动(标签只写成功的);
10
10
  * - **有界**:单次任务最多处理 MAX_RECORDS 条,防止一次跑飞;其余可再次触发。
11
+ *
12
+ * 形状约定(用户定):**按单元、串行、不合并**——一个请求只装一条记忆。旧形状把 20 条
13
+ * 合并进一个 prompt(一次截断/空输出就整批丢弃),逐条只把爆炸半径限制在 1 条。
11
14
  */
12
15
  import type { Context } from '@deepseek-ai/cordis';
13
16
  import { type MemoryLogger, type MemoryRecord } from './types.js';
@@ -25,23 +28,31 @@ export interface HallBackfillState {
25
28
  /** 本进程累计成功打标条数(重启归零;仅作观测,不作账)。 */
26
29
  updated: number;
27
30
  failed: number;
31
+ /** 本轮被人工中止(进端点 cancel)——与“跑完”分开报文案。 */
32
+ cancelled: boolean;
28
33
  }
34
+ /** 请求中止本轮(空闲时无副作用);返回后中止是**有界**的:最多等 graceMs(≤30s)。 */
35
+ export declare function cancelWingBackfill(): {
36
+ cancelled: boolean;
37
+ running: boolean;
38
+ };
29
39
  export declare function wingBackfillState(): HallBackfillState;
30
40
  /** 启动后台回填;已在运行返回 false(单飞)。 */
31
41
  export declare function startWingBackfill(deps: HallBackfillDeps): {
32
42
  started: boolean;
33
43
  running: boolean;
34
44
  };
35
- /** Wing 标注器(导出):一键回填与反刍重标定共用同一 LLM 路径与措辞。 */
36
- export declare function labelWingChunk(ctx: Context, cfg: MemoryConfig, logger: MemoryLogger, chunk: MemoryRecord[], candidates: string): Promise<Array<{
37
- record: MemoryRecord;
38
- wing: string;
39
- }>>;
40
45
  /**
41
- * 涌现标签标注器(导出):为一批记录提炼 slug 标签(tags)——Room 的前身。
42
- * 与 Wing 标注共用 LLM 路由(layer='l1-extract');失败返回空数组,调用方零改动。
46
+ * Wing 标注器(导出):一键回填与反刍重标定共用同一 LLM 路径与措辞。
47
+ *
48
+ * **一个请求只装一条**(旧名 `labelWingChunk`,多条一次,已废)。返回该条的 wing,
49
+ * 失败/字段缺失/值非法返回 null(记账归调用方,本函数不动全局状态)。
50
+ */
51
+ export declare function labelWingOne(ctx: Context, cfg: MemoryConfig, logger: MemoryLogger, rec: MemoryRecord, candidates: string, callTimeoutMs?: number,
52
+ /** 本轮中止信号(可选):与单次超时**取或**,宽限到期后由它当场掐断请求。 */
53
+ externalSignal?: AbortSignal): Promise<string | null>;
54
+ /**
55
+ * 涌现标签标注器(导出):为**一条**记录提炼 slug 标签(tags)——Room 的前身。
56
+ * 一个请求只装一条(旧名 `tagChunk`,多条一次,已废)。失败返回 null;合法但为空返回 []。
43
57
  */
44
- export declare function tagChunk(ctx: Context, cfg: MemoryConfig, logger: MemoryLogger, chunk: MemoryRecord[]): Promise<Array<{
45
- record: MemoryRecord;
46
- tags: string[];
47
- }>>;
58
+ export declare function tagOne(ctx: Context, cfg: MemoryConfig, logger: MemoryLogger, rec: MemoryRecord, callTimeoutMs?: number): Promise<string[] | null>;