dsh-prime-memory 0.24.2 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.en.md +28 -0
  2. package/CHANGELOG.ja.md +29 -0
  3. package/CHANGELOG.ko.md +29 -0
  4. package/CHANGELOG.md +30 -0
  5. package/dist/client.js +907 -654
  6. package/dist/config.d.ts +47 -2
  7. package/dist/config.js +30 -2
  8. package/dist/contract.d.ts +107 -1
  9. package/dist/keyword-backfill.d.ts +75 -0
  10. package/dist/keyword-backfill.js +218 -0
  11. package/dist/metadata-validators.d.ts +9 -0
  12. package/dist/metadata-validators.js +31 -0
  13. package/dist/pipeline/relabel.d.ts +11 -12
  14. package/dist/pipeline/relabel.js +54 -66
  15. package/dist/pipeline/runner.js +24 -1
  16. package/dist/projection/recall.d.ts +3 -2
  17. package/dist/projection/recall.js +26 -5
  18. package/dist/projection/slots.d.ts +11 -0
  19. package/dist/projection/slots.js +13 -3
  20. package/dist/redistill-backfill.d.ts +133 -0
  21. package/dist/redistill-backfill.js +352 -0
  22. package/dist/room-review.d.ts +12 -13
  23. package/dist/room-review.js +37 -29
  24. package/dist/settings.js +13 -0
  25. package/dist/stats.js +126 -14
  26. package/dist/store/l1.d.ts +18 -0
  27. package/dist/store/l1.js +17 -0
  28. package/dist/store/memory-backend-worker.d.ts +1 -0
  29. package/dist/store/memory-backend-worker.js +3 -0
  30. package/dist/store/memory-backend.d.ts +3 -0
  31. package/dist/store/memory-backend.js +3 -0
  32. package/dist/store/memory-md.d.ts +8 -0
  33. package/dist/store/memory-md.js +13 -2
  34. package/dist/store/memory-worker.js +3 -0
  35. package/dist/store/sqlite.d.ts +28 -0
  36. package/dist/store/sqlite.js +66 -0
  37. package/dist/wing-backfill.d.ts +16 -14
  38. package/dist/wing-backfill.js +76 -96
  39. package/dsh.plugin.json +1 -1
  40. package/package.json +1 -1
package/dist/stats.js CHANGED
@@ -32,7 +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';
35
+ import { startWingBackfill, wingBackfillState } from './wing-backfill.js';
36
+ import { startKeywordBackfill, keywordBackfillState } from './keyword-backfill.js';
37
+ import { startRedistillBackfill, redistillBackfillState } from './redistill-backfill.js';
36
38
  import { errDetail } from './util/filelog.js';
37
39
  import { snapshotTokenCost } from './token-cost.js';
38
40
  import { computeMaturity } from './util/maturity.js';
@@ -41,13 +43,31 @@ import { relinkWorkspaces } from './pipeline/workspace-relink.js';
41
43
  import { reconcileLedger } from './store/memory-md-export.js';
42
44
  import { mdRunStatus, runProjection } from './store/memory-md-run.js';
43
45
  import { disableProjectionFile } from './store/memory-md-disable.js';
44
- 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';
45
47
  import { ensureTypeSkeleton } from './store/memory-md-skeleton.js';
46
48
  import { sweepOrphanFiles } from './store/memory-md-orphan.js';
47
49
  import { collectProjectionFiles } from './store/memory-md-archive.js';
48
50
  import { syncFromFiles } from './pipeline/md-sync.js';
49
51
  const require = createRequire(import.meta.url);
50
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
+ };
70
+ }
51
71
  /**
52
72
  * 端点全集运行时清单(与 tests/contract-keys.test.ts 的 ENDPOINTS 及
53
73
  * contract.ts 类型映射表三方对齐,漂移由键集 diff 测试暴露)。
@@ -70,10 +90,13 @@ export const MEMORY_ENDPOINTS = [
70
90
  'dsh-memory/room-register',
71
91
  'dsh-memory/room-admin',
72
92
  'dsh-memory/wing-backfill',
93
+ 'dsh-memory/keyword-backfill',
94
+ 'dsh-memory/redistill-backfill',
73
95
  'dsh-memory/session-stats',
74
96
  'dsh-memory/settings-get',
75
97
  'dsh-memory/settings-set',
76
98
  'dsh-memory/list-records',
99
+ 'dsh-memory/records-meta',
77
100
  'dsh-memory/records-delete',
78
101
  'dsh-memory/receipts',
79
102
  'dsh-memory/conflicts',
@@ -631,10 +654,11 @@ export async function handleEndpoint(endpoint, payload, deps) {
631
654
  case 'dsh-memory/mem-sync': {
632
655
  // P2 反向同步:文件 → 库。与导出的**风险不对称**——导出只写文件,本项会写库,
633
656
  // 所以多两道门:要先开投射(没导出过 = 账本为空 = 判不出“谁变了”),要高权限。
634
- const syn = cfg.mdSync;
657
+ const ec = effectiveCfg(cfg, live);
658
+ const syn = ec.mdSync;
635
659
  if (syn?.enabled !== true)
636
- throw new Error('md 反向同步未开启(cfg.mdSync.enabled)');
637
- if (cfg.memExport?.enabled !== true)
660
+ throw new Error('md 反向同步未开启(概览页开关,或 config mdSync.enabled)');
661
+ if (ec.memExport?.enabled !== true)
638
662
  throw new Error('md 反向同步需先开启 md 投射(cfg.memExport.enabled)');
639
663
  if (!live?.get().memoryMutate)
640
664
  throw new Error('记忆写删未开放:请在记忆库面板开启「高权限模式」后才能回灌文件侧修改。');
@@ -792,7 +816,7 @@ export async function handleEndpoint(endpoint, payload, deps) {
792
816
  }
793
817
  // ── 轨3 外部记忆来源(Connect):登记/列表/退役/只读扫描。整面默认关(cfg.memport.enabled)。
794
818
  case 'dsh-memory/memory-sources-list': {
795
- const enabled = cfg.memport?.enabled === true;
819
+ const enabled = effectiveCfg(cfg, live).memport?.enabled === true;
796
820
  const v = {
797
821
  sources: enabled && memorySources
798
822
  ? memorySources.list().map((e) => ({
@@ -811,8 +835,8 @@ export async function handleEndpoint(endpoint, payload, deps) {
811
835
  return v;
812
836
  }
813
837
  case 'dsh-memory/memory-sources-add': {
814
- if (cfg.memport?.enabled !== true)
815
- throw new Error('外部记忆来源未启用(config memport.enabled)。');
838
+ if (effectiveCfg(cfg, live).memport?.enabled !== true)
839
+ throw new Error('外部记忆来源未启用(概览页开关,或 config memport.enabled)。');
816
840
  if (!memorySources)
817
841
  throw new Error('外部来源注册表未装配(需要插件重载以初始化)。');
818
842
  if (!live?.get().memoryMutate)
@@ -832,8 +856,8 @@ export async function handleEndpoint(endpoint, payload, deps) {
832
856
  return v;
833
857
  }
834
858
  case 'dsh-memory/memory-sources-retire': {
835
- if (cfg.memport?.enabled !== true)
836
- throw new Error('外部记忆来源未启用(config memport.enabled)。');
859
+ if (effectiveCfg(cfg, live).memport?.enabled !== true)
860
+ throw new Error('外部记忆来源未启用(概览页开关,或 config memport.enabled)。');
837
861
  if (!memorySources)
838
862
  throw new Error('外部来源注册表未装配(需要插件重载以初始化)。');
839
863
  if (!live?.get().memoryMutate)
@@ -851,8 +875,8 @@ export async function handleEndpoint(endpoint, payload, deps) {
851
875
  return v;
852
876
  }
853
877
  case 'dsh-memory/memport-scan': {
854
- if (cfg.memport?.enabled !== true) {
855
- const v = { enabled: false, sources: [], notice: '外部记忆扫描未启用(config memport.enabled)。' };
878
+ if (effectiveCfg(cfg, live).memport?.enabled !== true) {
879
+ const v = { enabled: false, sources: [], notice: '外部记忆扫描未启用(概览页开关,或 config memport.enabled)。' };
856
880
  return v;
857
881
  }
858
882
  if (!memorySources) {
@@ -862,7 +886,7 @@ export async function handleEndpoint(endpoint, payload, deps) {
862
886
  const p = (payload ?? {});
863
887
  const all = memorySources.listEnabled();
864
888
  const targets = p.sourceId ? all.filter((e) => e.id === p.sourceId) : all;
865
- const maxRecords = cfg.memport?.scanMaxRecords ?? 5000;
889
+ const maxRecords = effectiveCfg(cfg, live).memport?.scanMaxRecords ?? 5000;
866
890
  // 只读扫描:不写库(写走 memory_import)。逐个标扫描时间(失败不影响预览)。
867
891
  const sources = await scanSources({ sources: targets, l1: stores.l1, maxRecords });
868
892
  const nowIso = new Date().toISOString();
@@ -1132,6 +1156,10 @@ export async function handleEndpoint(endpoint, payload, deps) {
1132
1156
  return v;
1133
1157
  }
1134
1158
  case 'dsh-memory/wing-backfill': {
1159
+ // probe=true 只读快照:不启动(UI 离开区域再回来时恢复在途任务用)。
1160
+ if (payload?.probe === true) {
1161
+ return { started: false, running: wingBackfillState().running };
1162
+ }
1135
1163
  const r = startWingBackfill({
1136
1164
  ctx: deps.ctx,
1137
1165
  cfg: deps.cfg,
@@ -1140,6 +1168,49 @@ export async function handleEndpoint(endpoint, payload, deps) {
1140
1168
  });
1141
1169
  return r;
1142
1170
  }
1171
+ case 'dsh-memory/keyword-backfill': {
1172
+ // 门控看生效配置(live.keywordEnabled 覆盖部署 cfg.keyword.enabled);关时不启动。
1173
+ const ec = effectiveCfg(cfg, live);
1174
+ const st0 = keywordBackfillState();
1175
+ // probe=true 只读快照:不启动(UI 离开区域再回来时恢复在途任务用)。
1176
+ if (payload?.probe === true) {
1177
+ return { started: false, running: st0.running, enabled: ec.keyword?.enabled === true, updated: st0.updated, failed: st0.failed, progress: backfillProgress(st0) };
1178
+ }
1179
+ if (!ec.keyword?.enabled)
1180
+ return { started: false, running: false, enabled: false, updated: 0, failed: 0, progress: backfillProgress(st0) };
1181
+ const r = startKeywordBackfill({
1182
+ ctx: deps.ctx,
1183
+ cfg: ec,
1184
+ backend: stores.backend ?? new InProcMemoryBackend(stores.l1),
1185
+ logger: deps.logger,
1186
+ // 运行时并发读取器:拖概览页滑块对**正在跑**的任务即时生效(弹性池每完成一条重读)。
1187
+ concurrency: () => effectiveCfg(cfg, live).backfill?.concurrency,
1188
+ });
1189
+ const st = keywordBackfillState();
1190
+ return { started: r.started, running: r.running, enabled: true, updated: st.updated, failed: st.failed, progress: backfillProgress(st) };
1191
+ }
1192
+ case 'dsh-memory/redistill-backfill': {
1193
+ // 门控看生效配置(live.redistillEnabled 覆盖部署 cfg.redistill.enabled);关时不启动。
1194
+ const ec = effectiveCfg(cfg, live);
1195
+ const st0 = redistillBackfillState();
1196
+ // probe=true 只读快照:不启动(UI 离开区域再回来时恢复在途任务用)。
1197
+ if (payload?.probe === true) {
1198
+ return { started: false, running: st0.running, enabled: ec.redistill?.enabled === true, updated: st0.updated, skipped: st0.skipped, failed: st0.failed, progress: backfillProgress(st0) };
1199
+ }
1200
+ if (!ec.redistill?.enabled)
1201
+ return { started: false, running: false, enabled: false, updated: 0, skipped: 0, failed: 0, progress: backfillProgress(st0) };
1202
+ // 改 content 需进程内重嵌 + 追加 JSONL + 投射点火,直取 stores.l1(不经 worker backend)。
1203
+ const r = startRedistillBackfill({
1204
+ ctx: deps.ctx,
1205
+ cfg: ec,
1206
+ l1: stores.l1,
1207
+ logger: deps.logger,
1208
+ // 运行时并发读取器:拖概览页滑块对**正在跑**的任务即时生效(弹性池每完成一条重读)。
1209
+ concurrency: () => effectiveCfg(cfg, live).backfill?.concurrency,
1210
+ });
1211
+ const st = redistillBackfillState();
1212
+ return { started: r.started, running: r.running, enabled: true, updated: st.updated, skipped: st.skipped, failed: st.failed, progress: backfillProgress(st) };
1213
+ }
1143
1214
  // ── 会话级统计(悬浮卡信息区;热路径端点,见 SessionInfoSource 的零 I/O 硬规则) ──
1144
1215
  case 'dsh-memory/session-stats': {
1145
1216
  if (!sessionInfo)
@@ -1249,7 +1320,12 @@ export async function handleEndpoint(endpoint, payload, deps) {
1249
1320
  memoryMutate: false,
1250
1321
  conflictFreeze: live?.get()?.conflictFreeze === true,
1251
1322
  skillsEnabled: live?.get()?.skillsEnabled === true,
1323
+ keywordEnabled: live?.get()?.keywordEnabled === true,
1324
+ redistillEnabled: live?.get()?.redistillEnabled === true,
1325
+ mdSyncEnabled: live?.get()?.mdSyncEnabled === true,
1326
+ memportEnabled: live?.get()?.memportEnabled === true,
1252
1327
  mdExportMaxRecords: live?.get()?.mdExportMaxRecords ?? 0,
1328
+ backfillConcurrency: live?.get()?.backfillConcurrency ?? 0,
1253
1329
  }),
1254
1330
  // 静态部署上限(cordis.patch.yml):运行时开关与它取 AND
1255
1331
  ceilings: { capture: cfg.capture.enabled, distill: cfg.extract.enabled, recall: cfg.recall.enabled },
@@ -1288,7 +1364,7 @@ export async function handleEndpoint(endpoint, payload, deps) {
1288
1364
  const patch = (payload ?? {});
1289
1365
  const clean = {};
1290
1366
  // 布尔开关组:memoryMutate(高权限写删门)与主开关同列;conflictFreeze(§C 人工冲突裁决)
1291
- for (const key of ['enabled', 'capture', 'distill', 'recall', 'memoryMutate', 'conflictFreeze', 'skillsEnabled', 'toolRoom', 'toolGraph', 'toolRuminate', 'toolConflict', 'toolMutate']) {
1367
+ for (const key of ['enabled', 'capture', 'distill', 'recall', 'memoryMutate', 'conflictFreeze', 'skillsEnabled', 'keywordEnabled', 'redistillEnabled', 'mdSyncEnabled', 'memportEnabled', 'toolRoom', 'toolGraph', 'toolRuminate', 'toolConflict', 'toolMutate']) {
1292
1368
  if (typeof patch[key] === 'boolean')
1293
1369
  clean[key] = patch[key];
1294
1370
  }
@@ -1297,6 +1373,11 @@ export async function handleEndpoint(endpoint, payload, deps) {
1297
1373
  if (typeof patch.mdExportMaxRecords === 'number' && Number.isFinite(patch.mdExportMaxRecords)) {
1298
1374
  clean.mdExportMaxRecords = Math.min(10_000, Math.max(0, Math.floor(patch.mdExportMaxRecords)));
1299
1375
  }
1376
+ // 回填并行度运行时覆盖(概览页滑块):不收白名单 = “写成功但被静默丢”,
1377
+ // 而用户会拿着“并行 1”的 UI 去判断串行跑的结果——比没有这个旋钮更坏。
1378
+ if (typeof patch.backfillConcurrency === 'number' && Number.isFinite(patch.backfillConcurrency)) {
1379
+ clean.backfillConcurrency = Math.min(8, Math.max(0, Math.floor(patch.backfillConcurrency)));
1380
+ }
1300
1381
  // 运行时统一路由链:结构校验后整体写入(空数组 = 回到跟随部署配置)
1301
1382
  if (patch.distillChain !== undefined) {
1302
1383
  const err = validateDistillChain(patch.distillChain);
@@ -1615,6 +1696,37 @@ export async function handleEndpoint(endpoint, payload, deps) {
1615
1696
  })),
1616
1697
  };
1617
1698
  }
1699
+ case 'dsh-memory/records-meta': {
1700
+ const p = (payload ?? {});
1701
+ if (!Array.isArray(p.ids) || p.ids.length === 0)
1702
+ throw new Error('ids 必须是非空字符串数组');
1703
+ const ids = p.ids.filter((x) => typeof x === 'string' && x.length > 0).slice(0, 50);
1704
+ if (ids.length === 0)
1705
+ throw new Error('ids 过滤后为空');
1706
+ const records = stores.l1.getByIds(ids);
1707
+ const items = records.map((r) => {
1708
+ // 与写盘、槽位投影同一算法,不各自抄一份清洗规则。
1709
+ const filePath = relPathFor(r);
1710
+ return {
1711
+ id: r.id,
1712
+ name: r.name ?? null,
1713
+ type: r.type,
1714
+ priority: r.priority,
1715
+ content: r.content,
1716
+ maturity: computeMaturity({ priority: r.priority, usage: null, agingWeight: null, mark: null }),
1717
+ createdAt: r.createdAt ? new Date(r.createdAt).toISOString() : null,
1718
+ updatedAt: r.updatedAt ? new Date(r.updatedAt).toISOString() : null,
1719
+ scene: r.scene_name,
1720
+ scope: (r.scope === 'workspace' ? 'workspace' : 'global'),
1721
+ workspaceId: r.scope === 'workspace' ? (r.workspaceId ?? '') || null : null,
1722
+ ...(Array.isArray(r.metadata?.dsh_keywords) ? { keywords: r.metadata.dsh_keywords.filter((k) => typeof k === 'string' && k !== '') } : {}),
1723
+ ...(Array.isArray(r.metadata?.dsh_usage_scenario) ? { usageScenario: r.metadata.dsh_usage_scenario } : {}),
1724
+ filePath,
1725
+ retired: r.validTo !== undefined,
1726
+ };
1727
+ });
1728
+ return { items };
1729
+ }
1618
1730
  case 'dsh-memory/records-delete': {
1619
1731
  // 面板高权限删除指定记忆;写入删权限门(memoryMutate)防御。
1620
1732
  //
@@ -122,11 +122,29 @@ 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 不存在或写入失败——调用方须记账,不许静默。
128
140
  */
129
141
  patchMetadata(id: string, metadata: Record<string, unknown>): boolean;
142
+ /**
143
+ * 写 metadata_json + 顺带重索引 `l1_fts`(关键词回填专用)。
144
+ * 与 `patchMetadata` 的区分:后者只改主表(适合 tags/room/relabel 这类不进 fts 的元数据);
145
+ * 本方法额外重跑该行的 fts 插入,让新写的 `dsh_keywords` 立刻进 BM25 关键词轴。
146
+ */
147
+ patchMetadataAndReindex(id: string, metadata: Record<string, unknown>): boolean;
130
148
  /** repo 归属修补(ADR-0015 T3.3;人工消歧用)。CAS:expectRepoKeyName 非空时要求当前值匹配。 */
131
149
  patchRepoKey(id: string, patch: {
132
150
  repoKeyName?: string;
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 不存在或写入失败——调用方须记账,不许静默。
@@ -156,6 +165,14 @@ export class L1Store {
156
165
  patchMetadata(id, metadata) {
157
166
  return this.db.patchL1Metadata(id, metadata);
158
167
  }
168
+ /**
169
+ * 写 metadata_json + 顺带重索引 `l1_fts`(关键词回填专用)。
170
+ * 与 `patchMetadata` 的区分:后者只改主表(适合 tags/room/relabel 这类不进 fts 的元数据);
171
+ * 本方法额外重跑该行的 fts 插入,让新写的 `dsh_keywords` 立刻进 BM25 关键词轴。
172
+ */
173
+ patchMetadataAndReindex(id, metadata) {
174
+ return this.db.patchL1MetadataAndReindex(id, metadata);
175
+ }
159
176
  /** repo 归属修补(ADR-0015 T3.3;人工消歧用)。CAS:expectRepoKeyName 非空时要求当前值匹配。 */
160
177
  patchRepoKey(id, patch, expectRepoKeyName) {
161
178
  const changed = this.db.patchRepoKey(id, patch, expectRepoKeyName);
@@ -29,6 +29,7 @@ export declare class WorkerMemoryBackend implements MemoryBackend {
29
29
  getByIds(ids: string[]): Promise<MemoryRecord[]>;
30
30
  list(opts: L1ListOpts): Promise<L1ListResult>;
31
31
  patchMetadata(id: string, metadata: Record<string, unknown>): Promise<boolean>;
32
+ patchMetadataAndReindex(id: string, metadata: Record<string, unknown>): Promise<boolean>;
32
33
  size(): Promise<number>;
33
34
  dispose(): Promise<void>;
34
35
  }
@@ -100,6 +100,9 @@ export class WorkerMemoryBackend {
100
100
  async patchMetadata(id, metadata) {
101
101
  return this.call('patchMetadata', [id, metadata]);
102
102
  }
103
+ async patchMetadataAndReindex(id, metadata) {
104
+ return this.call('patchMetadataAndReindex', [id, metadata]);
105
+ }
103
106
  async size() {
104
107
  return this.call('size', []);
105
108
  }
@@ -50,6 +50,8 @@ export interface MemoryBackend {
50
50
  list(opts: L1ListOpts): Promise<L1ListResult>;
51
51
  /** **只**更新 metadata_json(不动 content,不重算嵌入)。 */
52
52
  patchMetadata(id: string, metadata: Record<string, unknown>): Promise<boolean>;
53
+ /** 写 metadata_json + 重索引该行的 `l1_fts`(关键词回填专用;新 `dsh_keywords` 要立刻进 BM25 轴)。 */
54
+ patchMetadataAndReindex(id: string, metadata: Record<string, unknown>): Promise<boolean>;
53
55
  /** L1 记录总数(巡检进度分母)。 */
54
56
  size(): Promise<number>;
55
57
  /** 释放后端(worker 实现会 terminate 线程)。 */
@@ -68,6 +70,7 @@ export declare class InProcMemoryBackend implements MemoryBackend {
68
70
  getByIds(ids: string[]): Promise<MemoryRecord[]>;
69
71
  list(opts: L1ListOpts): Promise<L1ListResult>;
70
72
  patchMetadata(id: string, metadata: Record<string, unknown>): Promise<boolean>;
73
+ patchMetadataAndReindex(id: string, metadata: Record<string, unknown>): Promise<boolean>;
71
74
  size(): Promise<number>;
72
75
  dispose(): Promise<void>;
73
76
  }
@@ -21,6 +21,9 @@ export class InProcMemoryBackend {
21
21
  async patchMetadata(id, metadata) {
22
22
  return this.l1.patchMetadata(id, metadata);
23
23
  }
24
+ async patchMetadataAndReindex(id, metadata) {
25
+ return this.l1.patchMetadataAndReindex(id, metadata);
26
+ }
24
27
  async size() {
25
28
  return this.l1.size;
26
29
  }
@@ -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
  : [];
@@ -60,6 +60,9 @@ else {
60
60
  case 'patchMetadata':
61
61
  reply(id, true, handle.patchL1Metadata(String(args[0]), args[1]));
62
62
  return;
63
+ case 'patchMetadataAndReindex':
64
+ reply(id, true, handle.patchL1MetadataAndReindex(String(args[0]), args[1]));
65
+ return;
63
66
  case 'size':
64
67
  reply(id, true, handle.countL1());
65
68
  return;
@@ -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
  *
@@ -305,6 +322,17 @@ export declare class MemoryDb {
305
322
  /** 打标作业完成/失败(失败保留行供诊断;重算由下一次触发再入队)。 */
306
323
  finishSceneRecluster(jobId: string, ok: boolean): void;
307
324
  patchL1Metadata(id: string, metadata: Record<string, unknown>): boolean;
325
+ /**
326
+ * 更新 metadata_json 后**顺带重索引该行的 `l1_fts`**(关键词回填专用)。
327
+ *
328
+ * `patchL1Metadata` 本身不碰 fts——多数写回点(tags / room / relabel)不需要、也不想付这个代价;
329
+ * 但关键词轴若只写元数据不重索引,`ftsKeywordText` 读不到新 `dsh_keywords`,那条 BM25 轴
330
+ * 对存量记录永远空。故把「写元数据 + 重索引」焊在一个方法里,杜绝“写了关键词但检索看不到”。
331
+ *
332
+ * 退场/不存在的行:只从 fts 删除(幂等),不插入。fts 不可用时降级为纯 `patchL1Metadata`。
333
+ * 参数列表与 `stmtL1FtsInsert` / `backfillL1Fts` 逐位对齐(同三处列清单,漏一列静默丢索引)。
334
+ */
335
+ patchL1MetadataAndReindex(id: string, metadata: Record<string, unknown>): boolean;
308
336
  /** 全量读取(调试/迁移/重嵌入用;检索请走 FTS/向量)。 */
309
337
  getAllL1(): MemoryRecord[];
310
338
  getL1ByIds(ids: string[]): MemoryRecord[];
@@ -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
  *
@@ -1509,6 +1542,39 @@ export class MemoryDb {
1509
1542
  return false;
1510
1543
  }
1511
1544
  }
1545
+ /**
1546
+ * 更新 metadata_json 后**顺带重索引该行的 `l1_fts`**(关键词回填专用)。
1547
+ *
1548
+ * `patchL1Metadata` 本身不碰 fts——多数写回点(tags / room / relabel)不需要、也不想付这个代价;
1549
+ * 但关键词轴若只写元数据不重索引,`ftsKeywordText` 读不到新 `dsh_keywords`,那条 BM25 轴
1550
+ * 对存量记录永远空。故把「写元数据 + 重索引」焊在一个方法里,杜绝“写了关键词但检索看不到”。
1551
+ *
1552
+ * 退场/不存在的行:只从 fts 删除(幂等),不插入。fts 不可用时降级为纯 `patchL1Metadata`。
1553
+ * 参数列表与 `stmtL1FtsInsert` / `backfillL1Fts` 逐位对齐(同三处列清单,漏一列静默丢索引)。
1554
+ */
1555
+ patchL1MetadataAndReindex(id, metadata) {
1556
+ const ok = this.patchL1Metadata(id, metadata);
1557
+ if (!ok || this.degraded || !this.ftsAvailable)
1558
+ return ok;
1559
+ try {
1560
+ const r = this.db
1561
+ .prepare(`SELECT record_id, content, type, priority, scene_name, session_id, version,
1562
+ timestamp_str, timestamp_start, timestamp_end, metadata_json, family,
1563
+ scope, workspace_id FROM l1_records
1564
+ WHERE record_id = ? AND NOT (${retiredL1Sql()})`)
1565
+ .get(id);
1566
+ // 退场/缺失行:只确保从 fts 剔除(幂等),不插入;活跃行:删旧行再按当前(含新关键词)元数据重插。
1567
+ this.stmtL1FtsDelete.run(id);
1568
+ if (r) {
1569
+ this.stmtL1FtsInsert.run(tokenizeForFts(ftsKeywordText(parseMetadataJson(String(r.metadata_json ?? '{}')))), tokenizeForFts(String(r.content ?? '')), String(r.content ?? ''), String(r.record_id ?? ''), String(r.type ?? ''), Number(r.priority ?? 50), String(r.scene_name ?? ''), String(r.session_id ?? 'default'), Number(r.version ?? 0), String(r.timestamp_str ?? ''), String(r.timestamp_start ?? ''), String(r.timestamp_end ?? ''), String(r.metadata_json ?? '{}'), String(r.family ?? 'chat'), normScope(r.scope), String(r.workspace_id ?? ''));
1570
+ }
1571
+ }
1572
+ catch (err) {
1573
+ // 重索引失败不回滚元数据写(关键词已落主表);下次全量 FTS 重建会对齐,并留痕。
1574
+ this.logger?.warn(`${TAG} 关键词重索引失败(id=${id},元数据已写、fts 待重建对齐): ${err instanceof Error ? err.message : String(err)}`);
1575
+ }
1576
+ return ok;
1577
+ }
1512
1578
  /** 全量读取(调试/迁移/重嵌入用;检索请走 FTS/向量)。 */
1513
1579
  getAllL1() {
1514
1580
  if (this.degraded)
@@ -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';
@@ -32,16 +35,15 @@ export declare function startWingBackfill(deps: HallBackfillDeps): {
32
35
  started: boolean;
33
36
  running: boolean;
34
37
  };
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
38
  /**
41
- * 涌现标签标注器(导出):为一批记录提炼 slug 标签(tags)——Room 的前身。
42
- * 与 Wing 标注共用 LLM 路由(layer='l1-extract');失败返回空数组,调用方零改动。
39
+ * Wing 标注器(导出):一键回填与反刍重标定共用同一 LLM 路径与措辞。
40
+ *
41
+ * **一个请求只装一条**(旧名 `labelWingChunk`,多条一次,已废)。返回该条的 wing,
42
+ * 失败/字段缺失/值非法返回 null(记账归调用方,本函数不动全局状态)。
43
+ */
44
+ export declare function labelWingOne(ctx: Context, cfg: MemoryConfig, logger: MemoryLogger, rec: MemoryRecord, candidates: string, callTimeoutMs?: number): Promise<string | null>;
45
+ /**
46
+ * 涌现标签标注器(导出):为**一条**记录提炼 slug 标签(tags)——Room 的前身。
47
+ * 一个请求只装一条(旧名 `tagChunk`,多条一次,已废)。失败返回 null;合法但为空返回 []。
43
48
  */
44
- export declare function tagChunk(ctx: Context, cfg: MemoryConfig, logger: MemoryLogger, chunk: MemoryRecord[]): Promise<Array<{
45
- record: MemoryRecord;
46
- tags: string[];
47
- }>>;
49
+ export declare function tagOne(ctx: Context, cfg: MemoryConfig, logger: MemoryLogger, rec: MemoryRecord, callTimeoutMs?: number): Promise<string[] | null>;