dsh-prime-memory 0.24.0 → 0.24.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import { dirname } from 'node:path';
18
18
  import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
19
- import { digest, recordDigest, renderRecord, safeJoin, targetFor, resolveMemoriesRoot } from './memory-md.js';
19
+ import { digest, disablePathOf, isRetiredRecord, recordDigest, renderRecord, safeJoin, targetFor, resolveMemoriesRoot } from './memory-md.js';
20
20
  // 路径纪律已收到 `memory-md.ts`(N10 后导出/对账/失效/归档四处共用);这里再导一次
21
21
  // 只为不拆已有调用方(stats.ts 与用例都从本模块拿它们)。
22
22
  export { resolveMemoriesRoot, safeJoin };
@@ -85,6 +85,33 @@ export async function exportRecords(deps) {
85
85
  await tick();
86
86
  continue;
87
87
  }
88
+ // 退场记录的投射可能已经在 `.disable` 上(后缀不变式,见 `memory-md-disable.ts`)。
89
+ // 这时“非退场路径上没文件”**不等于**“从没导出过”:拿 `.disable` 那份比指纹,
90
+ // 一致就记未变。不拦这个的话,每轮导出都会把刚改完名的文件写回非退场路径,
91
+ // 收敛再改一次名——两轮之间来回倒腾,`written` 永远不归零。
92
+ if (existing === undefined && isRetiredRecord(r)) {
93
+ const dis = disablePathOf(abs);
94
+ const disText = await readFile(dis, 'utf8').catch(() => undefined);
95
+ // 两种形状都得认(都是合法盘态):
96
+ // · 先导出后退场 → 改名分支不重写内容,盘上是 live 渲染(既有用例钉着);
97
+ // · 生下来就退场、首次导出就是那份 → 盘上是带 `retired: true` 的当前渲染。
98
+ // 拿其中任一份对上就记未变;都对不上(正文被改过)才落到下面重写,由收敛再改名。
99
+ const liveDg = digest(renderRecord({ ...r, validTo: undefined }).text);
100
+ if (disText !== undefined && (digest(disText) === liveDg || digest(disText) === fileDg)) {
101
+ report.unchanged++;
102
+ if (ledger.get(dis) === undefined) {
103
+ try {
104
+ await stamp(ledger, r, target.root, target.relPath, dis, dbDg, digest(disText));
105
+ worked++;
106
+ }
107
+ catch (err) {
108
+ logger?.warn(`[memory] md 投射退场凭证补账失败(下轮仍会重补): ${dis} - ${err instanceof Error ? err.message : String(err)}`);
109
+ }
110
+ }
111
+ await tick();
112
+ continue;
113
+ }
114
+ }
88
115
  await mkdir(dirname(abs), { recursive: true });
89
116
  await writeFile(abs, text, 'utf8');
90
117
  report.written++;
@@ -0,0 +1,48 @@
1
+ import type { MdRunStatus, SuffixReport } from './memory-md-types.js';
2
+ import type { MdLedgerStore } from './memory-md-ledger.js';
3
+ import type { MemoryLogger, MemoryRecord } from '../types.js';
4
+ /** 进度快照的形状与契约层共用一份定义(在 `memory-md-types.ts`),本模块只转发。 */
5
+ export type { MdRunStatus };
6
+ /** 只读快照副本(防调用方改到执行中的那份)。 */
7
+ export declare function mdRunStatus(): MdRunStatus;
8
+ export interface MdRunDeps {
9
+ /** 全表快照(含退场行);本函数按游标切片喂给两个执行器。 */
10
+ records: readonly MemoryRecord[];
11
+ cfgRoot: string | undefined;
12
+ dataDir: string;
13
+ ledger: MdLedgerStore;
14
+ /** 单轮护栏(与 `cfg.memExport` 同值)。 */
15
+ maxRecords: number;
16
+ timeBudgetMs: number;
17
+ /** 跨轮总闸:最多滚几轮 / 最多花多少毫秒。 */
18
+ maxPasses?: number;
19
+ totalBudgetMs?: number;
20
+ logger?: MemoryLogger;
21
+ /** 注入时钟(测试用)。 */
22
+ now?: () => number;
23
+ }
24
+ /** 滚动执行的汇总(形状与单轮报告一致,多出的字段告诉人滚了几轮)。 */
25
+ export interface MdRunResult {
26
+ root: string;
27
+ inWorkspace: number;
28
+ scanned: number;
29
+ written: number;
30
+ unchanged: number;
31
+ skipped: number;
32
+ /** 0 = 两阶段都滚到完;>0 = 总预算用尽,剩这么多条没处理,再点一次续跑。 */
33
+ remaining: number;
34
+ capped: boolean;
35
+ /** 实际滚了几轮。 */
36
+ passes: number;
37
+ suffix: SuffixReport;
38
+ appliedMaxRecords: number;
39
+ }
40
+ /**
41
+ * 滚到完:先把导出游标推到表尾,再把收敛游标推到表尾。
42
+ *
43
+ * 两阶段分开推进度,是因为收敛必须等导出把该写的文件写完——那时盘态最全,
44
+ * 判"盘上没这份"才准(反过来会把它误报成 `absent`)。
45
+ *
46
+ * @throws 已有任务在跑时抛(不排队,见模块头纪律 1)。
47
+ */
48
+ export declare function runProjection(deps: MdRunDeps): Promise<MdRunResult>;
@@ -0,0 +1,151 @@
1
+ /**
2
+ * md 投影长任务的**滚动执行**与进度面。
3
+ *
4
+ * 为什么要这一层:`exportRecords` / `convergeDisabledSuffix` 各自是"一次有界的一轮"
5
+ * (护栏 `maxRecords` + 墙钟 `timeBudgetMs`),设计上就假定调用方会再点一次。真机反馈
6
+ * 是"点三下才清干净,每次一秒多"——把"要不要继续"这个判断丢给人点是错的:任务自己知道
7
+ * 有没有做完(`capped`),就该自己滚到完。形状照本包既有的两个长任务(`ruminate` /
8
+ * `rebuild`):一个后台任务 + 一个只读状态端点,面板拿状态画进度条。
9
+ *
10
+ * 三条纪律:
11
+ * 1. **同一时刻只跑一个投影任务**:两个任务同时改同一批文件与同一本账,产生的差异
12
+ * 连对账都解释不清。第二个进来直接抛,不排队(排队会让人以为"点了没反应")。
13
+ * 2. **总预算兜底**:单轮的墙钟护栏防的是"一条超大记录挂死一次点击",跨轮还得有一道
14
+ * 总闸(`totalBudgetMs`)+ 轮数上限(`maxPasses`),否则一个病态目录能让任务永远
15
+ * 在滚。用尽时如实写进 `error`,人再点一次接着滚(游标语义幂等,不重复也不漏)。
16
+ * 3. **进度只读不阻塞**:状态是模块级一份快照,端点读它就返回;执行侧只在轮与轮的
17
+ * 边界更新它(不在内层循环里写,省掉上千次无意义赋值)。
18
+ */
19
+ import { exportRecords } from './memory-md-export.js';
20
+ import { convergeDisabledSuffix } from './memory-md-disable.js';
21
+ /** 模块级唯一快照:投影是"同一时刻一个"的任务,不需要多路会话态。 */
22
+ const status = {
23
+ running: false, phase: 'idle', pass: 0, done: 0, total: 0,
24
+ written: 0, renamed: 0, detail: '', startedAt: '', endedAt: '',
25
+ };
26
+ /** 只读快照副本(防调用方改到执行中的那份)。 */
27
+ export function mdRunStatus() {
28
+ return { ...status };
29
+ }
30
+ function blankSuffix() {
31
+ return {
32
+ scanned: 0, renamed: 0, revived: 0, edited: 0, already: 0, both: 0, absent: 0, skipped: 0, remaining: 0, capped: false,
33
+ };
34
+ }
35
+ /** 让出事件循环一次:滚动期间不能独占 tick(进度端点要能挤进来应答)。 */
36
+ function tick() {
37
+ return new Promise((resolveTick) => { setImmediate(resolveTick); });
38
+ }
39
+ /**
40
+ * 滚到完:先把导出游标推到表尾,再把收敛游标推到表尾。
41
+ *
42
+ * 两阶段分开推进度,是因为收敛必须等导出把该写的文件写完——那时盘态最全,
43
+ * 判"盘上没这份"才准(反过来会把它误报成 `absent`)。
44
+ *
45
+ * @throws 已有任务在跑时抛(不排队,见模块头纪律 1)。
46
+ */
47
+ export async function runProjection(deps) {
48
+ if (status.running)
49
+ throw new Error('已有 md 投影任务在跑,请等它结束(进度见面板)');
50
+ const clock = deps.now ?? Date.now;
51
+ const maxPasses = deps.maxPasses ?? 12;
52
+ const totalBudgetMs = deps.totalBudgetMs ?? 300_000;
53
+ const started = clock();
54
+ const total = deps.records.length;
55
+ Object.assign(status, {
56
+ running: true, phase: 'export', pass: 0, done: 0, total,
57
+ written: 0, renamed: 0, detail: `开始投影 ${total} 条`, startedAt: new Date(started).toISOString(), endedAt: '',
58
+ });
59
+ const out = {
60
+ root: '', inWorkspace: 0, scanned: 0, written: 0, unchanged: 0, skipped: 0,
61
+ remaining: 0, capped: false, passes: 0, suffix: blankSuffix(), appliedMaxRecords: deps.maxRecords,
62
+ };
63
+ const agg = blankSuffix();
64
+ try {
65
+ // ── 阶段 A:导出游标 ──
66
+ let cursor = 0;
67
+ for (let pass = 1; cursor < total && pass <= maxPasses; pass++) {
68
+ status.pass = pass;
69
+ status.phase = 'export';
70
+ const rep = await exportRecords({
71
+ records: deps.records.slice(cursor),
72
+ cfgRoot: deps.cfgRoot,
73
+ dataDir: deps.dataDir,
74
+ ledger: deps.ledger,
75
+ maxRecords: deps.maxRecords,
76
+ timeBudgetMs: deps.timeBudgetMs,
77
+ logger: deps.logger,
78
+ now: deps.now,
79
+ });
80
+ out.root = rep.root;
81
+ out.inWorkspace += rep.inWorkspace;
82
+ out.scanned += rep.scanned;
83
+ out.written += rep.written;
84
+ out.unchanged += rep.unchanged;
85
+ out.skipped += rep.skipped;
86
+ status.written = out.written;
87
+ // `remaining` 是"这一轮的输入里没处理完的条数",所以游标 = 表长 - 剩余。
88
+ cursor = total - rep.remaining;
89
+ status.done = cursor;
90
+ status.detail = `导出第 ${pass} 轮:写 ${rep.written} · 未变 ${rep.unchanged} · 进度 ${cursor}/${total}`;
91
+ out.passes = pass;
92
+ out.capped = rep.capped;
93
+ if (clock() - started >= totalBudgetMs)
94
+ break;
95
+ await tick();
96
+ }
97
+ out.remaining = Math.max(0, total - cursor);
98
+ // ── 阶段 B:收敛游标(导出没滚完时也要跑:已写出的那批的后缀要先对上) ──
99
+ if (out.remaining === 0) {
100
+ let cur2 = 0;
101
+ for (let pass = 1; cur2 < total && pass <= maxPasses; pass++) {
102
+ status.pass = out.passes + pass;
103
+ status.phase = 'converge';
104
+ const s = await convergeDisabledSuffix({
105
+ records: deps.records.slice(cur2),
106
+ cfgRoot: deps.cfgRoot,
107
+ dataDir: deps.dataDir,
108
+ ledger: deps.ledger,
109
+ maxRecords: deps.maxRecords,
110
+ timeBudgetMs: deps.timeBudgetMs,
111
+ logger: deps.logger,
112
+ now: deps.now,
113
+ });
114
+ for (const k of ['scanned', 'renamed', 'revived', 'edited', 'already', 'both', 'absent', 'skipped'])
115
+ agg[k] += s[k];
116
+ agg.remaining = s.remaining;
117
+ agg.capped = s.capped;
118
+ cur2 = total - s.remaining;
119
+ status.renamed = agg.renamed;
120
+ status.done = total + cur2;
121
+ status.detail = `后缀对齐第 ${pass} 轮:改名 ${s.renamed} · 进度 ${cur2}/${total}`;
122
+ out.passes += 1;
123
+ out.capped = s.capped;
124
+ if (clock() - started >= totalBudgetMs)
125
+ break;
126
+ await tick();
127
+ }
128
+ out.remaining = Math.max(0, total - cur2);
129
+ }
130
+ out.suffix = agg;
131
+ status.phase = out.remaining > 0 ? 'failed' : 'done';
132
+ status.done = total + Math.max(0, Math.min(total, agg.scanned));
133
+ status.detail = out.remaining > 0
134
+ ? `总预算用尽:还剩 ${out.remaining} 个文件,再点一次续跑`
135
+ : `投影完成:写 ${out.written} · 改名 ${agg.renamed} · 共 ${out.passes} 轮`;
136
+ if (out.remaining > 0) {
137
+ status.notice = `单次任务预算(${maxPasses} 轮 / ${totalBudgetMs}ms)用尽,还剩 ${out.remaining} 个文件未处理——再点一次从断点续跑`;
138
+ out.capped = true;
139
+ }
140
+ return out;
141
+ }
142
+ catch (err) {
143
+ status.phase = 'failed';
144
+ status.notice = err instanceof Error ? err.message : String(err);
145
+ throw err;
146
+ }
147
+ finally {
148
+ status.running = false;
149
+ status.endedAt = new Date(clock()).toISOString();
150
+ }
151
+ }
@@ -0,0 +1,59 @@
1
+ import type { MemExportReport, SuffixReport } from './memory-md-types.js';
2
+ import type { MdLedgerStore } from './memory-md-ledger.js';
3
+ import type { MemoryLogger, MemoryRecord } from '../types.js';
4
+ /** 生效的投射配置(现读,含 `live.mdExportMaxRecords` 覆盖后的预算)。 */
5
+ export interface FileSyncCfg {
6
+ enabled: boolean;
7
+ root: string | undefined;
8
+ maxRecords: number;
9
+ timeBudgetMs: number;
10
+ }
11
+ export interface FileSyncDeps {
12
+ /** 取**变更后**的快照(判据要看新的 `validTo` / `name`)。 */
13
+ getByIds: (ids: readonly string[]) => readonly MemoryRecord[];
14
+ exportCfg: () => FileSyncCfg;
15
+ dataDir: string;
16
+ /** 账本未装配时整个挂钩空转(与投射面其他入口一致)。 */
17
+ ledger: () => MdLedgerStore | undefined;
18
+ logger?: MemoryLogger;
19
+ }
20
+ /**
21
+ * `L1Store` 侧的注入面:两类语义变更,调用方只管点火。
22
+ *
23
+ * - `sync`:有效性变了(增 / 退场 / 恢复)→ 导出该批 + 对齐后缀;
24
+ * - `renamed`:身份(`name`)变了 → 投射文件跟着搬(文件名就是 `name`,不搬就出现
25
+ * 「库里的名字与盘上的文件对不上」的偏离,收敛器也修不了,因为它按当前 name 算路径)。
26
+ */
27
+ export interface L1FileSyncHook {
28
+ sync: (ids: readonly string[], why: string) => void;
29
+ renamed: (oldNameById: ReadonlyMap<string, string>, why: string) => void;
30
+ }
31
+ /** 一轮「导出 + 后缀收敛」的结果(反刍增量投射复用同一形状,不各写一套日志)。 */
32
+ export interface SyncFilesResult {
33
+ exported: MemExportReport;
34
+ suffix: SuffixReport;
35
+ }
36
+ /** 一轮「导出 + 后缀收敛」必需的最小依赖(不需要按 id 回查的调用方拿这一份就够)。 */
37
+ export type SyncFilesCoreDeps = Pick<FileSyncDeps, 'exportCfg' | 'dataDir' | 'ledger' | 'logger'>;
38
+ /**
39
+ * 该批记录的投射跟随:导出 → 后缀收敛。**不打日志**(调用方自己的口径不同:钩子说
40
+ * “联动”,反刍说“增量”,统一格式反而把两者都稀释);也不抛,异常由调用方收敛。
41
+ *
42
+ * @returns 两个报告;投射面关着或账本未装配时返回 `undefined`(调用方据此不打日志)。
43
+ */
44
+ export declare function syncFiles(deps: SyncFilesCoreDeps, records: readonly MemoryRecord[]): Promise<SyncFilesResult | undefined>;
45
+ /** 按 id 取快照后跑一轮 `syncFiles`(钩子走这个入口,判据只有一份)。 */
46
+ export declare function syncFilesByIds(deps: FileSyncDeps, ids: readonly string[], why: string): Promise<SyncFilesResult | undefined>;
47
+ /**
48
+ * 造注入给 `L1Store` 的挂钩。契约与 `disableProjectionFile` 一致:同步返回、异常只落日志。
49
+ *
50
+ * 双触发是**允许的**(`recall-feedback(-1)` 仍直接调 `disableProjectionFile` 拿确定性凭证):
51
+ * 两条路径落到同一形状,第二次要么 ENOENT 兜底、要么 `already` 幂等。
52
+ *
53
+ * **串行队列(真机测出来的,不是洁癖)**:同一批文件与同一本账不能被两个后台任务同时
54
+ * 动。旧写法每次点火就自己开一个 promise,于是「先 `appendNew` 再 `retire`」这种紧邻的两次
55
+ * 点火会重叠:第二个任务的收敛器在第一个还没写出文件时判「盘上没这份」(`absent`),
56
+ * 什么都不做——退场记录就永久停在 `.memory.md` 上,直到下次全量导出才被抬回来。
57
+ * 串成一条链后,后一个任务看到的是前一个的终态;执行时再按 id 回查快照,拿到的也是最新有效性。
58
+ */
59
+ export declare function makeFileSyncHook(deps: FileSyncDeps): L1FileSyncHook;
@@ -0,0 +1,152 @@
1
+ /**
2
+ * 库 → 文件投射的**统一挂钩**(收口点)。
3
+ *
4
+ * 为什么收在 `L1Store` 而不是各个调用方:改变「记录有效性/身份」的写路径全部汇流到
5
+ * `appendNew` / `retire` / `restore` / `rename` 四个方法,而上游有 8 处 `retire()` 调用点
6
+ * (面板批量软删、Room 退役、`memory_delete` 工具、槽位连带、冲突裁决、合并取代、冲突自动
7
+ * 判负、召回判过时)。事件挂钩已被真机证伪:920 条退场记录里只有 8 个 `.disable`,全部来自
8
+ * 召回面板那一条路径。收在汇流口意味着——机器侧自动退场(合并取代、冲突判负)和模型侧
9
+ * `memory_add` 都自动跟随,以后新增调用点不会漏。
10
+ *
11
+ * 一次 `sync` 的形状固定为「导出 → 后缀收敛」,顺序不可反:收敛器判「盘上没有这份」要靠
12
+ * 导出先把这份写出来,反过来会把它算成 `absent` 而漏改名。调用方因此**不需要分辨自己
13
+ * 是增还是删**。
14
+ *
15
+ * 一句话口径:**投射文件形状是库状态的函数,不是任何调用点的事件**(事件只是重算契机)。
16
+ *
17
+ * 四条契约(前三条与收口前一致,收口没有放宽):
18
+ * 1. **不阻塞库**:钩子同步返回,文件侧在后台跑;磁盘满不能变成「记忆删不掉」。
19
+ * 2. **绝不抛**:异常一律收敛成 warn(与 `disableProjectionFile` 同纪律)。
20
+ * 3. **现读配置与账本**:`memExport` 是 volatile,快照会让「事后打开旋钮」一直到下次启动
21
+ * 才生效;账本在 `stores.l1` 之后才装配,所以也只能现拿(见 `setFileSyncHook` 晚绑定)。
22
+ * 4. **后台任务串行**:同一个文件树与同一本账不能被两个任务同时动,否则紧邻的两次点火
23
+ * (先写后退)会互踩,退场记录会停在 `.memory.md` 上(详 `makeFileSyncHook`)。
24
+ *
25
+ * 批量路径**禁止逐条 flush 账本**(账本是整本 JSON 的原子重写,逐条 flush = O(n²)):
26
+ * 统一走 `moveLedger(..., persist=false)` + 收尾一次 `flush()`。
27
+ */
28
+ import { dirname } from 'node:path';
29
+ import { rename, stat } from 'node:fs/promises';
30
+ import { ensureDir } from '../util/io.js';
31
+ import { exportRecords } from './memory-md-export.js';
32
+ import { convergeDisabledSuffix, moveLedger } from './memory-md-disable.js';
33
+ import { disablePathOf, isRetiredRecord, renderRecord, targetFor } from './memory-md.js';
34
+ /** 存在性判定:一次 `stat`,任何错误都当「不存在」(与 `memory-md-disable.ts` 同口径)。 */
35
+ async function exists(p) {
36
+ try {
37
+ await stat(p);
38
+ return true;
39
+ }
40
+ catch {
41
+ return false;
42
+ }
43
+ }
44
+ /**
45
+ * 该批记录的投射跟随:导出 → 后缀收敛。**不打日志**(调用方自己的口径不同:钩子说
46
+ * “联动”,反刍说“增量”,统一格式反而把两者都稀释);也不抛,异常由调用方收敛。
47
+ *
48
+ * @returns 两个报告;投射面关着或账本未装配时返回 `undefined`(调用方据此不打日志)。
49
+ */
50
+ export async function syncFiles(deps, records) {
51
+ if (records.length === 0)
52
+ return undefined;
53
+ const cfg = deps.exportCfg();
54
+ const ledger = deps.ledger();
55
+ if (!cfg.enabled || ledger === undefined)
56
+ return undefined;
57
+ const base = { cfgRoot: cfg.root, dataDir: deps.dataDir, ledger, maxRecords: cfg.maxRecords, timeBudgetMs: cfg.timeBudgetMs, logger: deps.logger };
58
+ const exported = await exportRecords({ records, ...base });
59
+ const suffix = await convergeDisabledSuffix({ records, ...base });
60
+ return { exported, suffix };
61
+ }
62
+ /** 按 id 取快照后跑一轮 `syncFiles`(钩子走这个入口,判据只有一份)。 */
63
+ export async function syncFilesByIds(deps, ids, why) {
64
+ if (ids.length === 0)
65
+ return undefined;
66
+ const out = await syncFiles(deps, deps.getByIds(ids));
67
+ if (out !== undefined) {
68
+ deps.logger?.info(`[memory] 投射联动(${why}): ${ids.length} 条,写 ${out.exported.written},未变 ${out.exported.unchanged},改名 ${out.suffix.renamed},改回 ${out.suffix.revived}`);
69
+ }
70
+ return out;
71
+ }
72
+ /**
73
+ * 改名跟随:把 `name` 变更的那批文件搬到新路径(含 `.disable` 兄弟)。
74
+ *
75
+ * 三种盘态:
76
+ * - 旧路径有文件 → 改名到新路径 + 账本换键(键是绝对路径,不换下一轮对账两边都错);
77
+ * - 旧路径没文件 → 不报错,交给导出按新名补写(这条记录可能从没导出过);
78
+ * - 新路径已被占用 → 只 warn 不动盘:`rename` 到已存在路径在 Windows 上会 EEXIST,
79
+ * 覆盖等于揉掉别人的投射文件,而 name 唯一性由写入侧管,这里不越权裁决。
80
+ */
81
+ async function relocateRenamed(deps, oldNameById, why) {
82
+ const cfg = deps.exportCfg();
83
+ const ledger = deps.ledger();
84
+ if (!cfg.enabled || ledger === undefined || oldNameById.size === 0)
85
+ return;
86
+ const needExport = [];
87
+ let moved = 0;
88
+ for (const [id, oldName] of oldNameById) {
89
+ const r = deps.getByIds([id])[0];
90
+ if (r === undefined || r.name === undefined || r.name === oldName)
91
+ continue;
92
+ const oldTarget = targetFor({ ...r, name: oldName }, cfg.root, deps.dataDir, deps.logger);
93
+ const newTarget = targetFor(r, cfg.root, deps.dataDir, deps.logger);
94
+ if (oldTarget.absPath === undefined || newTarget.absPath === undefined)
95
+ continue;
96
+ // 退场记录的文件住在 `.disable` 上:后缀跟着走,别把无效态搬成有效态。
97
+ const retire = isRetiredRecord(r);
98
+ const from = retire ? disablePathOf(oldTarget.absPath) : oldTarget.absPath;
99
+ const to = retire ? disablePathOf(newTarget.absPath) : newTarget.absPath;
100
+ if (from === to)
101
+ continue;
102
+ if (!(await exists(from))) {
103
+ needExport.push(r);
104
+ continue;
105
+ }
106
+ if (await exists(to)) {
107
+ deps.logger?.warn(`[memory] 改名跟随跳过(目标已存在,不覆盖他人投射): ${to}`);
108
+ continue;
109
+ }
110
+ try {
111
+ await ensureDir(dirname(to));
112
+ await rename(from, to);
113
+ await moveLedger(ledger, from, to, r, renderRecord(r).text, false);
114
+ moved++;
115
+ }
116
+ catch (err) {
117
+ deps.logger?.warn(`[memory] 改名跟随失败(下轮导出/收敛重试): ${from} → ${to} - ${err instanceof Error ? err.message : String(err)}`);
118
+ }
119
+ }
120
+ // 收尾落一次账:循环里只记脏(逐条 flush 会把整本 JSON 重写 N 次)。
121
+ await ledger.flush();
122
+ if (needExport.length > 0)
123
+ await syncFiles(deps, needExport);
124
+ if (moved > 0)
125
+ deps.logger?.info(`[memory] 改名跟随(${why}): 搬运 ${moved} 个投射文件,补写 ${needExport.length} 条`);
126
+ }
127
+ /**
128
+ * 造注入给 `L1Store` 的挂钩。契约与 `disableProjectionFile` 一致:同步返回、异常只落日志。
129
+ *
130
+ * 双触发是**允许的**(`recall-feedback(-1)` 仍直接调 `disableProjectionFile` 拿确定性凭证):
131
+ * 两条路径落到同一形状,第二次要么 ENOENT 兜底、要么 `already` 幂等。
132
+ *
133
+ * **串行队列(真机测出来的,不是洁癖)**:同一批文件与同一本账不能被两个后台任务同时
134
+ * 动。旧写法每次点火就自己开一个 promise,于是「先 `appendNew` 再 `retire`」这种紧邻的两次
135
+ * 点火会重叠:第二个任务的收敛器在第一个还没写出文件时判「盘上没这份」(`absent`),
136
+ * 什么都不做——退场记录就永久停在 `.memory.md` 上,直到下次全量导出才被抬回来。
137
+ * 串成一条链后,后一个任务看到的是前一个的终态;执行时再按 id 回查快照,拿到的也是最新有效性。
138
+ */
139
+ export function makeFileSyncHook(deps) {
140
+ let chain = Promise.resolve();
141
+ const fire = (task, why) => {
142
+ chain = chain
143
+ .then(task)
144
+ .catch((err) => {
145
+ deps.logger?.warn(`[memory] 投射联动失败(${why},不影响库侧结果,下轮导出重试): ${err instanceof Error ? err.message : String(err)}`);
146
+ });
147
+ };
148
+ return {
149
+ sync: (ids, why) => fire(() => syncFilesByIds(deps, ids, why), why),
150
+ renamed: (oldNameById, why) => fire(() => relocateRenamed(deps, oldNameById, why), why),
151
+ };
152
+ }
@@ -25,7 +25,36 @@ export interface MemExportReport {
25
25
  capped: boolean;
26
26
  }
27
27
  /**
28
- * `dsh-memory/mem-export` 的响应 = 导出报告 + 命名回填计数。
28
+ * 后缀不变式收敛报告(库里的“是否有效” ⇄ 文件后缀)。
29
+ *
30
+ * 口径:投射文件的后缀**只由这条记录在库里是否退场决定**,与 `name` 字段无关、
31
+ * 与“是哪条路径把它退场的”也无关。所以这个计数面不挂在某个按钮的事件后面,
32
+ * 而是每次导出都按「文件存在性 + 当前后缀 + 指纹一致性」重算一遍。
33
+ */
34
+ export interface SuffixReport {
35
+ /** 本次遍历过的记录数(含什么都不做的)。 */
36
+ scanned: number;
37
+ /** 已退场但盘上还是 `.memory.md` → 已改名。 */
38
+ renamed: number;
39
+ /** 在库但只剩 `.disable`(且账本认得是我们写的)→ 已改回。 */
40
+ revived: number;
41
+ /** 改名前发现文件与账本指纹不符(人动过正文);**仍照改**,改名不丢字节。 */
42
+ edited: number;
43
+ /** 后缀已经对上,没碰盘。 */
44
+ already: number;
45
+ /** 两份都在(或 `.disable` 不是我们写的):语义相冲,**保守不动**,交人裁决。 */
46
+ both: number;
47
+ /** 盘上根本没有对应文件(从没导出过):不凭空造。 */
48
+ absent: number;
49
+ /** 路径越界 / 单条失败(都各自留痕,不阻断整批)。 */
50
+ skipped: number;
51
+ /** 因护栏未处理到的剩余条数。 */
52
+ remaining: number;
53
+ /** 是否因墙钟/条数护栏提前收工(>0 时 UI 提示“再点一次继续”)。 */
54
+ capped: boolean;
55
+ }
56
+ /**
57
+ * `dsh-memory/mem-export` 的响应 = 导出报告 + 命名回填计数 + 后缀收敛计数。
29
58
  *
30
59
  * 为什么要单开一个类型:投射默认开后,导出这条路径现在会先做一步**写库**的命名回填
31
60
  * (给 `name` 为空的记录派生全库唯一的文件身份名)。它只改 `name`——内容/类型/退场态都不动,
@@ -37,9 +66,49 @@ export interface MemExportResponse extends MemExportReport {
37
66
  named: number;
38
67
  /** 起名算出来但落库失败的条数(下轮会重试)。 */
39
68
  nameFailed: number;
69
+ /** 导出后紧跟的后缀收敛结果(退场记录的文件该带 `.disable`)。 */
70
+ suffix: SuffixReport;
71
+ /**
72
+ * 本次实际滚了几轮(一轮 = 一次有界遍历)。
73
+ *
74
+ * 要报这个:`written`/`renamed` 是各轮累加的,人得知道“一次点击背后跑了 4 轮”
75
+ * 才不会把面板上的时长误读成“一次只能做这么多”。
76
+ */
77
+ passes: number;
78
+ /**
79
+ * 本次**实际生效**的单次动作护栏(包了 `live.mdExportMaxRecords` 覆盖后的值)。
80
+ *
81
+ * 为什么报而不是让人自己算:面板上那个旋钮经 `effectiveCfg` 落地,人看到的数字
82
+ * 必须是被应用过的那个,否则“改了但没生效”与“改了且生效”在报告上长得一样。
83
+ */
84
+ appliedMaxRecords: number;
85
+ }
86
+ /**
87
+ * 投影长任务的进度快照(`dsh-memory/mem-run-status` 的响应)。
88
+ *
89
+ * 形状住在这里而不是执行侧:`contract.ts` 要 import 它但不能把 `node:fs` 拖进
90
+ * client 编译程序(同本文件头的纪律)。
91
+ */
92
+ export interface MdRunStatus {
93
+ running: boolean;
94
+ /** 当前阶段:导出(库→文件)/ 收敛(后缀对齐)/ 已结束。 */
95
+ phase: 'idle' | 'export' | 'converge' | 'done' | 'failed';
96
+ /** 第几轮(一轮 = 一次有界遍历)。 */
97
+ pass: number;
98
+ /** 两阶段合计已推进的条数(分母 = `total`×2,两阶段各算一遍全表)。 */
99
+ done: number;
100
+ total: number;
101
+ written: number;
102
+ renamed: number;
103
+ /** 人读的一行进度说明。 */
104
+ detail: string;
105
+ startedAt: string;
106
+ endedAt: string;
107
+ /** 出错或总预算用尽的说明(非空 = 需要再点一次续跑)。 */
108
+ notice?: string;
40
109
  }
41
110
  /**
42
- * 孤儿投射文件扫描报告(P4.4)。
111
+ * 孤儿投射文件遍历报告(P4.4)。
43
112
  *
44
113
  * “孤儿”只算**有账可查且没人认领**的路径:账本里没有的来路不明文件(人自己放进投射目录
45
114
  * 等着被同步的输入)一律不报也不动,所以这里没有“陌生文件”这一项——那个计数在同步侧。
@@ -62,6 +62,15 @@ export type MdRecordInput = Pick<MemoryRecord, 'id' | 'content' | 'type' | 'prio
62
62
  * Windows 路径大小写不敏感,同一逻辑路径的两种写法解析到同一个文件,键自洽即可。
63
63
  */
64
64
  export declare function targetFor(r: MdRecordInput, cfgRoot: string | undefined, dataDir: string, logger?: MemoryLogger): MdTarget;
65
+ /**
66
+ * 退场判据:与库侧同口径(`sqlite.ts` 的“`valid_to` 非空即退场”,与
67
+ * `retiredL1Count`/`listRetiredL1` 一致)。
68
+ *
69
+ * 为什么住在本文件:导出(要不要把文件放回非退场路径)与失效联动(后缀该不该带)
70
+ * 两侧都得用同一个判据,否则“列表说它退了、文件却没后缀”会回来。放 `memory-md-disable.ts`
71
+ * 会让 `memory-md-export.ts` 反向 import 它形成循环(那边已经 import 这边)。
72
+ */
73
+ export declare function isRetiredRecord(r: Pick<MemoryRecord, 'validTo'>): boolean;
65
74
  /**
66
75
  * 投射文件的失效路径:`x.memory.md` → `x.memory.md.disable`。
67
76
  * 已带后缀的原样返回(幂等靠这个性质,而不是靠调用方记住判重)。
@@ -126,6 +126,17 @@ function warnMissingWorkspace(logger, ws) {
126
126
  warnedMissingWs.add(ws);
127
127
  logger.warn(`[memory] workspace 目录不存在,该条投射落点退回单根: ${ws}`);
128
128
  }
129
+ /**
130
+ * 退场判据:与库侧同口径(`sqlite.ts` 的“`valid_to` 非空即退场”,与
131
+ * `retiredL1Count`/`listRetiredL1` 一致)。
132
+ *
133
+ * 为什么住在本文件:导出(要不要把文件放回非退场路径)与失效联动(后缀该不该带)
134
+ * 两侧都得用同一个判据,否则“列表说它退了、文件却没后缀”会回来。放 `memory-md-disable.ts`
135
+ * 会让 `memory-md-export.ts` 反向 import 它形成循环(那边已经 import 这边)。
136
+ */
137
+ export function isRetiredRecord(r) {
138
+ return typeof r.validTo === 'number' && r.validTo > 0;
139
+ }
129
140
  /**
130
141
  * 投射文件的失效路径:`x.memory.md` → `x.memory.md.disable`。
131
142
  * 已带后缀的原样返回(幂等靠这个性质,而不是靠调用方记住判重)。
@@ -561,10 +561,10 @@ export class MemoryDb {
561
561
  const savedStamp = this.readMetaString('fts_tokenizer') ?? 'bigram-v1';
562
562
  const tokenizerChanged = savedStamp !== wantStamp;
563
563
  let ftsRebuilt = false;
564
- if (this.tableExists('l1_fts') && (!this.hasColumn('l1_fts', 'family') || !this.hasColumn('l1_fts', 'scope') || tokenizerChanged)) {
564
+ if (this.tableExists('l1_fts') && (!this.hasColumn('l1_fts', 'family') || !this.hasColumn('l1_fts', 'scope') || !this.hasColumn('l1_fts', 'keywords') || tokenizerChanged)) {
565
565
  this.db.exec('DROP TABLE l1_fts');
566
566
  ftsRebuilt = true;
567
- this.logger?.info(`${TAG} l1_fts 缺 family/scope 列或分词器已变更(${savedStamp} → ${wantStamp}),重建全文索引`);
567
+ this.logger?.info(`${TAG} l1_fts 缺 family/scope/keywords 列或分词器已变更(${savedStamp} → ${wantStamp}),重建全文索引`);
568
568
  }
569
569
  let l0FtsRebuilt = false;
570
570
  if (this.tableExists('l0_fts') && tokenizerChanged) {
@@ -574,6 +574,7 @@ export class MemoryDb {
574
574
  }
575
575
  this.db.exec(`
576
576
  CREATE VIRTUAL TABLE IF NOT EXISTS l1_fts USING fts5(
577
+ keywords,
577
578
  content,
578
579
  content_original UNINDEXED,
579
580
  record_id UNINDEXED,
@@ -603,10 +604,10 @@ export class MemoryDb {
603
604
  )
604
605
  `);
605
606
  this.stmtL1FtsInsert = this.db.prepare(`
606
- INSERT INTO l1_fts (content, content_original, record_id, type, priority, scene_name,
607
+ INSERT INTO l1_fts (keywords, content, content_original, record_id, type, priority, scene_name,
607
608
  session_id, version, timestamp_str, timestamp_start, timestamp_end, metadata_json, family,
608
609
  scope, workspace_id)
609
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
610
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
610
611
  `);
611
612
  this.stmtL1FtsDelete = this.db.prepare('DELETE FROM l1_fts WHERE record_id = ?');
612
613
  // §E 可见范围过滤的**唯一缝**。设计要点:
@@ -783,7 +784,7 @@ export class MemoryDb {
783
784
  WHERE NOT (${retiredL1Sql()})`);
784
785
  for (const r of stmt.iterate()) {
785
786
  try {
786
- this.stmtL1FtsInsert.run(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'),
787
+ 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'),
787
788
  // §E:回灌必须带上可见范围——漏掉这两列等于**每次 FTS 重建都把隔离抹平**
788
789
  // (所有行回落 'global',跨工作区记忆瞬间互相可见),且没有任何报错。
789
790
  normScope(r.scope), String(r.workspace_id ?? ''));
@@ -1011,7 +1012,7 @@ export class MemoryDb {
1011
1012
  if (ftsExisted || retiredNow)
1012
1013
  this.stmtL1FtsDelete.run(record.id);
1013
1014
  if (!retiredNow) {
1014
- this.stmtL1FtsInsert.run(tokenizeForFts(record.content), record.content, record.id, type, priority, sceneName, record.sessionId ?? 'default', record.version ?? 0, ts.str, ts.start, ts.end, JSON.stringify(record.metadata ?? {}), family, scope, workspaceId);
1015
+ this.stmtL1FtsInsert.run(tokenizeForFts(ftsKeywordText(record.metadata)), tokenizeForFts(record.content), record.content, record.id, type, priority, sceneName, record.sessionId ?? 'default', record.version ?? 0, ts.str, ts.start, ts.end, JSON.stringify(record.metadata ?? {}), family, scope, workspaceId);
1015
1016
  }
1016
1017
  }
1017
1018
  }
@@ -2598,6 +2599,23 @@ export class MemoryDb {
2598
2599
  }
2599
2600
  }
2600
2601
  }
2602
+ /**
2603
+ * 关键词轴的索引文本(保留字段 `metadata.dsh_keywords`)。
2604
+ *
2605
+ * 为什么是**独立列**而不是并进 `content`:关键词是一条**独立的检索轴**——它要么
2606
+ * 精确命中、要么不命中,与正文里的 BM25 词频不是一回事(并进正文等于让关键词的
2607
+ * 权重被正文长度稀释)。`l1_fts` 因此排成「keywords → content」两列索引:将来
2608
+ * 关键词蒸馏上线时**先命中关键词轴、再回落到全文**,无需再改表结构(FTS5 不能 ALTER)。
2609
+ *
2610
+ * 目前无产出方(抽取/命名尚未产出该键)⇒ 恒为空串:空串不产生任何 token,
2611
+ * 索引与改动前逐字相同(零漂移),所以先接线、后接产线是安全的。
2612
+ */
2613
+ function ftsKeywordText(metadata) {
2614
+ const kw = Array.isArray(metadata?.dsh_keywords)
2615
+ ? metadata.dsh_keywords.filter((k) => typeof k === 'string' && k.trim() !== '')
2616
+ : [];
2617
+ return kw.join('\n');
2618
+ }
2601
2619
  /** 容忍坏 JSON 的 metadata 解析(与 rowToRecord 同口径:解析失败退化为空对象,不抛)。 */
2602
2620
  function parseMetadataJson(raw) {
2603
2621
  try {