dsh-prime-memory 0.24.0 → 0.24.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.
@@ -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
+ * 四条契约(前三条与 `makeSuffixSync` 时期一致,收口没有放宽):
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
  * 已带后缀的原样返回(幂等靠这个性质,而不是靠调用方记住判重)。
@@ -633,6 +633,7 @@ roomRegistry) {
633
633
  const record = buildRecord(args, scene, Date.now(), workspaceIdOf(exec));
634
634
  applyManualRedaction(record, cfg, logger);
635
635
  await stores.l1.appendNew([record]);
636
+ // 新增的投射落盘由 `appendNew` 里的 store 钩子承接(写删对称,这里不双写)。
636
637
  logger.info(`[memory] 高权限写入记忆(${record.type}${record.metadata?.hall ? '/' + String(record.metadata.hall) : ''},时间轴 ${record.persistence ?? '?'}):${record.content.slice(0, 120)}`);
637
638
  return { id: record.id };
638
639
  },
@@ -759,7 +760,7 @@ roomRegistry) {
759
760
  records.push(record);
760
761
  });
761
762
  if (records.length > 0)
762
- await stores.l1.appendNew(records);
763
+ await stores.l1.appendNew(records); // 同上:批量导入也由钩子上盘
763
764
  logger.info(`[memory] 批量导入 ${records.length} 条(幂等跳过 ${deduped} 条,场景 ${scene}${sourceId ? `,来源 ${sourceId}` : ''})`);
764
765
  return { written: records.length, deduped, ids: records.map((r) => r.id), skipped };
765
766
  },
@@ -832,6 +833,7 @@ roomRegistry) {
832
833
  // 可以在记忆列表里恢复,而不必去 records/*.jsonl 事实源手工捞。
833
834
  const n = stores.l1.retire(ids, { at: new Date().toISOString(), reason: 'manual' });
834
835
  logger.info(`[memory] 高权限退场(软删)记忆 ${n} 条(${ids.join(',')})`);
836
+ // 投射文件跟随不在这里点火:`l1.retire()` 是汇流口,钩子在那里(`store/memory-md-sync.ts`)。
835
837
  return { deleted: n, ids };
836
838
  },
837
839
  })));
@@ -13,5 +13,6 @@ import type { SessionModeStore } from '../store/session-modes.js';
13
13
  import type { SlotStore } from '../store/slots.js';
14
14
  import type { MemoryLogger } from '../types.js';
15
15
  export declare function registerSlotTools(ctx: Context, cfg: MemoryConfig, slots: SlotStore, logger: MemoryLogger, modes: SessionModeStore, live: LiveSettingsHandle,
16
- /** L1 存储句柄:close 的 refs→记忆勾连(解析/退场)依赖;缺省时 close 只关槽位不碰 L1。 */
16
+ /** L1 存储句柄:close 的 refs→记忆勾连(解析/退场)依赖;缺省时 close 只关槽位不碰 L1。
17
+ * 退场文件的跟随不在本模块点火:`l1.retire()` 已收口(`store/memory-md-sync.ts`)。 */
17
18
  l1?: L1Store): void;
@@ -3,7 +3,8 @@ const MUTATE_OFF_NOTICE = '槽位写操作未开放:请在记忆库面板开启
3
3
  const OFF_NOTICE = '本会话的记忆档位为"关闭":槽位读取不可用。';
4
4
  const GLOBAL_OFF_NOTICE = '记忆召回已关闭:槽位读取不可用。';
5
5
  export function registerSlotTools(ctx, cfg, slots, logger, modes, live,
6
- /** L1 存储句柄:close 的 refs→记忆勾连(解析/退场)依赖;缺省时 close 只关槽位不碰 L1。 */
6
+ /** L1 存储句柄:close 的 refs→记忆勾连(解析/退场)依赖;缺省时 close 只关槽位不碰 L1。
7
+ * 退场文件的跟随不在本模块点火:`l1.retire()` 已收口(`store/memory-md-sync.ts`)。 */
7
8
  l1) {
8
9
  if (!cfg.tools)
9
10
  return;
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.24.0",
4
+ "version": "0.24.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.24.0",
3
+ "version": "0.24.1",
4
4
  "publishConfig": {
5
5
  "registry": "https://registry.npmjs.org",
6
6
  "access": "public",