dsh-prime-memory 0.22.2 → 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.
Files changed (104) hide show
  1. package/CHANGELOG.en.md +421 -0
  2. package/CHANGELOG.ja.md +416 -0
  3. package/CHANGELOG.ko.md +416 -0
  4. package/CHANGELOG.md +120 -0
  5. package/dist/client.js +1266 -845
  6. package/dist/config.d.ts +131 -1341
  7. package/dist/config.js +89 -4
  8. package/dist/conflict-service.d.ts +7 -1
  9. package/dist/conflict-service.js +7 -1
  10. package/dist/contract.d.ts +211 -0
  11. package/dist/domain-gate.d.ts +11 -0
  12. package/dist/domain-gate.js +15 -0
  13. package/dist/hooks/obsolete-notice.d.ts +26 -0
  14. package/dist/hooks/obsolete-notice.js +64 -0
  15. package/dist/hooks/recall.d.ts +39 -0
  16. package/dist/hooks/recall.js +110 -30
  17. package/dist/index.d.ts +1 -1127
  18. package/dist/index.js +52 -2
  19. package/dist/memory-types.d.ts +69 -0
  20. package/dist/memory-types.js +165 -0
  21. package/dist/memport/format.d.ts +6 -1
  22. package/dist/memport/format.js +42 -2
  23. package/dist/pipeline/l1.d.ts +44 -2
  24. package/dist/pipeline/l1.js +163 -9
  25. package/dist/pipeline/md-sync.d.ts +65 -0
  26. package/dist/pipeline/md-sync.js +456 -0
  27. package/dist/pipeline/ruminate.d.ts +46 -1
  28. package/dist/pipeline/ruminate.js +109 -0
  29. package/dist/pipeline/runner.d.ts +10 -0
  30. package/dist/pipeline/runner.js +13 -2
  31. package/dist/pipeline/workspace-relink.d.ts +60 -0
  32. package/dist/pipeline/workspace-relink.js +73 -0
  33. package/dist/projection/recall.d.ts +51 -13
  34. package/dist/projection/recall.js +47 -12
  35. package/dist/prompts/examples.d.ts +73 -0
  36. package/dist/prompts/examples.js +178 -0
  37. package/dist/prompts/l1-dedup.d.ts +9 -3
  38. package/dist/prompts/l1-dedup.js +34 -13
  39. package/dist/prompts/l1-extraction.d.ts +5 -3
  40. package/dist/prompts/l1-extraction.js +49 -4
  41. package/dist/settings.d.ts +8 -0
  42. package/dist/settings.js +16 -1
  43. package/dist/stats.d.ts +12 -0
  44. package/dist/stats.js +352 -17
  45. package/dist/store/embedding-source.d.ts +8 -0
  46. package/dist/store/embedding-source.js +39 -2
  47. package/dist/store/file-versions.d.ts +2 -0
  48. package/dist/store/file-versions.js +2 -0
  49. package/dist/store/graph-store.d.ts +14 -0
  50. package/dist/store/graph-store.js +35 -0
  51. package/dist/store/l1-snapshot.d.ts +26 -1
  52. package/dist/store/l1-snapshot.js +53 -8
  53. package/dist/store/l1.d.ts +94 -3
  54. package/dist/store/l1.js +169 -4
  55. package/dist/store/memory-md-archive.d.ts +84 -0
  56. package/dist/store/memory-md-archive.js +155 -0
  57. package/dist/store/memory-md-disable.d.ts +74 -0
  58. package/dist/store/memory-md-disable.js +302 -0
  59. package/dist/store/memory-md-export.d.ts +50 -0
  60. package/dist/store/memory-md-export.js +227 -0
  61. package/dist/store/memory-md-ledger.d.ts +94 -0
  62. package/dist/store/memory-md-ledger.js +243 -0
  63. package/dist/store/memory-md-orphan.d.ts +27 -0
  64. package/dist/store/memory-md-orphan.js +153 -0
  65. package/dist/store/memory-md-parse.d.ts +35 -0
  66. package/dist/store/memory-md-parse.js +85 -0
  67. package/dist/store/memory-md-run.d.ts +48 -0
  68. package/dist/store/memory-md-run.js +151 -0
  69. package/dist/store/memory-md-skeleton.d.ts +18 -0
  70. package/dist/store/memory-md-skeleton.js +61 -0
  71. package/dist/store/memory-md-sync.d.ts +59 -0
  72. package/dist/store/memory-md-sync.js +152 -0
  73. package/dist/store/memory-md-types.d.ts +243 -0
  74. package/dist/store/memory-md-types.js +10 -0
  75. package/dist/store/memory-md.d.ts +117 -0
  76. package/dist/store/memory-md.js +230 -0
  77. package/dist/store/obsolete-notices.d.ts +39 -0
  78. package/dist/store/obsolete-notices.js +153 -0
  79. package/dist/store/recall-feedback.d.ts +75 -0
  80. package/dist/store/recall-feedback.js +213 -0
  81. package/dist/store/recall-usage.d.ts +11 -0
  82. package/dist/store/recall-usage.js +29 -0
  83. package/dist/store/session-recalled.d.ts +38 -3
  84. package/dist/store/session-recalled.js +156 -9
  85. package/dist/store/sqlite.d.ts +66 -0
  86. package/dist/store/sqlite.js +233 -15
  87. package/dist/store/supersede.d.ts +1 -1
  88. package/dist/store/supersede.js +1 -1
  89. package/dist/tools/index.d.ts +4 -0
  90. package/dist/tools/index.js +26 -7
  91. package/dist/tools/slots.d.ts +2 -1
  92. package/dist/tools/slots.js +2 -1
  93. package/dist/types.d.ts +24 -7
  94. package/dist/types.js +10 -2
  95. package/dist/util/maturity.d.ts +20 -3
  96. package/dist/util/maturity.js +18 -1
  97. package/dist/util/md-name.d.ts +129 -0
  98. package/dist/util/md-name.js +366 -0
  99. package/dist/util/md-normalize.d.ts +23 -0
  100. package/dist/util/md-normalize.js +131 -0
  101. package/dist/workspace-route.d.ts +22 -0
  102. package/dist/workspace-route.js +66 -0
  103. package/dsh.plugin.json +1 -1
  104. package/package.json +6 -5
@@ -0,0 +1,84 @@
1
+ /**
2
+ * P3 归档面:退场批次被物理删除时,把它的人类可读归档正文与投射文件一起收进**快照目录**。
3
+ *
4
+ * 两个刻意的决定:
5
+ *
6
+ * ① **归档不另起目录树,就住在快照目录里**(`<dataDir>/snapshots/<name>/`)。计划里原本写
7
+ * `<dataDir>/archives/<name>/`,但那样一次清理就有两个"批次"事实源(快照一份、归档一份),
8
+ * 名字对不上时没人能回答"这批到底删了什么"。住在一起之后:`manifest.json` 是库内容快照、
9
+ * `retired.md` 是给人读的归档正文、`files/` 是搬进来的投射文件——同一个批次名寻址,
10
+ * `snapshot-restore` 天然还能用得上。名字白名单也因此**直接继承** `isSnapshotName`
11
+ * (长度上限 / 固定前缀 / 禁 `/ \ ..`),不需要再造一个 `isArchiveName`,更不吃调用方的绝对路径。
12
+ *
13
+ * ② **搬运在物理删除之后**,且失败只 warn。库删了是既成事实,文件留在原地是可修的不对称
14
+ * (下一次导出会按 `retired` 把它们重写回去);反过来先搬文件再删库,一旦删除中止就变成
15
+ * "记录还活着、文件已经不见了",那才是真的说不清。
16
+ */
17
+ import type { MemoryLogger, MemoryRecord } from '../types.js';
18
+ /** 归档正文文件名(人在归档目录里第一个该看见的东西)。 */
19
+ export declare const ARCHIVE_MD = "retired.md";
20
+ /** 投射文件在归档里的落点子目录。 */
21
+ export declare const ARCHIVE_FILES_DIR = "files";
22
+ /** 一次清理的归档元信息。 */
23
+ export interface ArchiveMeta {
24
+ /** 批次原因(`cleanup-retired` / `pre-rebuild`)。 */
25
+ reason: string;
26
+ /** 归档时刻。 */
27
+ at: Date;
28
+ /** 操作者可选附的一句话(进正文头部,不参与任何判据)。 */
29
+ note?: string;
30
+ }
31
+ /** 一个磁盘上真实存在的投射文件(侧别在收集时就算好,搬运时不猜)。 */
32
+ export interface ProjectionFile {
33
+ absPath: string;
34
+ /** 落在 workspace 根下(true)还是单根下(false)——N10 同一个 `targetFor` 的结论。 */
35
+ inWorkspace: boolean;
36
+ }
37
+ /** 记录 id → 该记录在磁盘上真实存在的投射文件(含 `.disable` 兄弟)。 */
38
+ export type FilesByRecord = ReadonlyMap<string, ProjectionFile[]>;
39
+ /** 归档搬运的结果(进清理响应,便于人核对"文件到底动没动")。 */
40
+ export interface ArchiveFileOutcome {
41
+ moved: number;
42
+ failed: number;
43
+ /** 该记录在磁盘上没有投射文件(没开过投射/从没导出过)。 */
44
+ absent: number;
45
+ }
46
+ /**
47
+ * 收集这批记录在磁盘上存在的投射文件。
48
+ *
49
+ * 只 `stat`,不读内容——归档要的是"把原物搬走",不是"按当前库重渲染一份"。
50
+ *
51
+ * @param records - 待清理的记录(已退场那批)。
52
+ * @param cfgRoot - `cfg.memExport.root`。
53
+ * @param dataDir - 插件数据目录。
54
+ * @param logger - 宿主日志接口。
55
+ * @returns `recordId → 文件[]`;没有任何文件时返回**空 Map**(调用方据此走旧行为)。
56
+ */
57
+ export declare function collectProjectionFiles(records: readonly MemoryRecord[], cfgRoot: string | undefined, dataDir: string, logger?: MemoryLogger): Promise<FilesByRecord>;
58
+ /**
59
+ * 渲染归档正文:按 `type` 分片的单一 Markdown,每条一节。
60
+ *
61
+ * frontmatter 与投射文件同构(同一份 `renderRecord`),只往里插两行退场信息——这样
62
+ * "归档里读到的"和"当初磁盘上的"是同一个形状,人不需要在两套元数据格式之间换算。
63
+ */
64
+ export declare function renderArchiveMarkdown(records: readonly MemoryRecord[], meta: ArchiveMeta): string;
65
+ /**
66
+ * 写归档正文。失败**抛出**:正文是这批数据的唯一人可读物,写不成得让调用方知道
67
+ * (搬运失败则相反——那只影响便利度,不影响事实)。
68
+ */
69
+ export declare function writeArchiveMarkdown(dir: string, records: readonly MemoryRecord[], meta: ArchiveMeta): Promise<string>;
70
+ /**
71
+ * 把这批记录的投射文件搬进归档目录。
72
+ *
73
+ * 目标形状 `<归档根>/files/<ws|home>/<type>/<原文件名>`(自顶向下建目录)。失败只计数并
74
+ * 留痕——绝不因为文件搬不动而回滚库删除(不对称的方向是"文件可重生成,库删了不可逆")。
75
+ *
76
+ * @param dir - 归档根(= 快照目录)。
77
+ * @param records - 该批记录(用 id 与 `files` 配对)。
78
+ * @param files - `collectProjectionFiles` 的产物。
79
+ * @param logger - 只需要 warn(清理侧的 logger 形状比 MemoryLogger 狭,这里不装宽)。
80
+ * @returns 搬运计数。
81
+ */
82
+ export declare function moveProjectionFiles(dir: string, records: readonly MemoryRecord[], files: FilesByRecord, logger?: {
83
+ warn: (m: string) => void;
84
+ }): Promise<ArchiveFileOutcome>;
@@ -0,0 +1,155 @@
1
+ /**
2
+ * P3 归档面:退场批次被物理删除时,把它的人类可读归档正文与投射文件一起收进**快照目录**。
3
+ *
4
+ * 两个刻意的决定:
5
+ *
6
+ * ① **归档不另起目录树,就住在快照目录里**(`<dataDir>/snapshots/<name>/`)。计划里原本写
7
+ * `<dataDir>/archives/<name>/`,但那样一次清理就有两个"批次"事实源(快照一份、归档一份),
8
+ * 名字对不上时没人能回答"这批到底删了什么"。住在一起之后:`manifest.json` 是库内容快照、
9
+ * `retired.md` 是给人读的归档正文、`files/` 是搬进来的投射文件——同一个批次名寻址,
10
+ * `snapshot-restore` 天然还能用得上。名字白名单也因此**直接继承** `isSnapshotName`
11
+ * (长度上限 / 固定前缀 / 禁 `/ \ ..`),不需要再造一个 `isArchiveName`,更不吃调用方的绝对路径。
12
+ *
13
+ * ② **搬运在物理删除之后**,且失败只 warn。库删了是既成事实,文件留在原地是可修的不对称
14
+ * (下一次导出会按 `retired` 把它们重写回去);反过来先搬文件再删库,一旦删除中止就变成
15
+ * "记录还活着、文件已经不见了",那才是真的说不清。
16
+ */
17
+ import { mkdir, rename, stat, writeFile } from 'node:fs/promises';
18
+ import { basename, join } from 'node:path';
19
+ import { disablePathOf, renderRecord, targetFor } from './memory-md.js';
20
+ /** 归档正文文件名(人在归档目录里第一个该看见的东西)。 */
21
+ export const ARCHIVE_MD = 'retired.md';
22
+ /** 投射文件在归档里的落点子目录。 */
23
+ export const ARCHIVE_FILES_DIR = 'files';
24
+ /** 换行符(取字符码,避开任何转义写法)。 */
25
+ const NL = String.fromCharCode(10);
26
+ /**
27
+ * 收集这批记录在磁盘上存在的投射文件。
28
+ *
29
+ * 只 `stat`,不读内容——归档要的是"把原物搬走",不是"按当前库重渲染一份"。
30
+ *
31
+ * @param records - 待清理的记录(已退场那批)。
32
+ * @param cfgRoot - `cfg.memExport.root`。
33
+ * @param dataDir - 插件数据目录。
34
+ * @param logger - 宿主日志接口。
35
+ * @returns `recordId → 文件[]`;没有任何文件时返回**空 Map**(调用方据此走旧行为)。
36
+ */
37
+ export async function collectProjectionFiles(records, cfgRoot, dataDir, logger) {
38
+ const map = new Map();
39
+ for (const r of records) {
40
+ const target = targetFor(r, cfgRoot, dataDir, logger);
41
+ if (target.absPath === undefined)
42
+ continue;
43
+ // `.disable` 优先:退场记录在文件侧的标准形态就是它(N11)。两个都在就都搬。
44
+ const candidates = [
45
+ { absPath: disablePathOf(target.absPath), inWorkspace: target.inWorkspace },
46
+ { absPath: target.absPath, inWorkspace: target.inWorkspace },
47
+ ];
48
+ const found = [];
49
+ for (const c of candidates) {
50
+ try {
51
+ const st = await stat(c.absPath);
52
+ if (st.isFile())
53
+ found.push(c);
54
+ }
55
+ catch {
56
+ // 没文件是常态(没开过投射、或这条从没导出过):静默跳过,不刷日志
57
+ }
58
+ }
59
+ if (found.length > 0)
60
+ map.set(r.id, found);
61
+ }
62
+ return map;
63
+ }
64
+ /**
65
+ * 渲染归档正文:按 `type` 分片的单一 Markdown,每条一节。
66
+ *
67
+ * frontmatter 与投射文件同构(同一份 `renderRecord`),只往里插两行退场信息——这样
68
+ * "归档里读到的"和"当初磁盘上的"是同一个形状,人不需要在两套元数据格式之间换算。
69
+ */
70
+ export function renderArchiveMarkdown(records, meta) {
71
+ const head = [
72
+ `# 退场记忆归档(${records.length} 条)`,
73
+ '',
74
+ `- 批次原因:${meta.reason}`,
75
+ `- 归档时刻:${meta.at.toISOString()}`,
76
+ ];
77
+ if (meta.note !== undefined && meta.note.trim().length > 0)
78
+ head.push(`- 备注:${meta.note.trim()}`);
79
+ head.push('');
80
+ const byType = new Map();
81
+ for (const r of records) {
82
+ const key = r.type.length > 0 ? r.type : 'unsorted';
83
+ const bucket = byType.get(key);
84
+ if (bucket === undefined)
85
+ byType.set(key, [r]);
86
+ else
87
+ bucket.push(r);
88
+ }
89
+ const sections = [];
90
+ for (const type of [...byType.keys()].sort()) {
91
+ const rows = [`## ${type}`, ''];
92
+ for (const r of byType.get(type) ?? []) {
93
+ const retiredAt = r.validTo === undefined ? meta.at.toISOString() : new Date(r.validTo).toISOString();
94
+ // 只往开栏后插两行,闭栏与正文一律不动:插进闭栏之后就成了正文的一部分。
95
+ // 这里刻意不用转义写法拼字符串(同一份产物里换行被展开过一次,正则字面量直接断法),
96
+ // 改成找第一个换行位置做切片——形状依赖的是文件真实结构,不是写法。
97
+ const { text } = renderRecord(r);
98
+ const at = text.indexOf(NL);
99
+ const extra = `retiredAt: ${JSON.stringify(retiredAt)}${NL}retireReason: ${JSON.stringify(meta.reason)}`;
100
+ rows.push(at < 0 ? text : `${text.slice(0, at)}${NL}${extra}${text.slice(at)}`);
101
+ rows.push('');
102
+ }
103
+ sections.push(rows.join('\n'));
104
+ }
105
+ return `${head.join('\n')}\n${sections.join('\n')}`;
106
+ }
107
+ /**
108
+ * 写归档正文。失败**抛出**:正文是这批数据的唯一人可读物,写不成得让调用方知道
109
+ * (搬运失败则相反——那只影响便利度,不影响事实)。
110
+ */
111
+ export async function writeArchiveMarkdown(dir, records, meta) {
112
+ const path = join(dir, ARCHIVE_MD);
113
+ await mkdir(dir, { recursive: true });
114
+ await writeFile(path, renderArchiveMarkdown(records, meta), 'utf8');
115
+ return path;
116
+ }
117
+ /**
118
+ * 把这批记录的投射文件搬进归档目录。
119
+ *
120
+ * 目标形状 `<归档根>/files/<ws|home>/<type>/<原文件名>`(自顶向下建目录)。失败只计数并
121
+ * 留痕——绝不因为文件搬不动而回滚库删除(不对称的方向是"文件可重生成,库删了不可逆")。
122
+ *
123
+ * @param dir - 归档根(= 快照目录)。
124
+ * @param records - 该批记录(用 id 与 `files` 配对)。
125
+ * @param files - `collectProjectionFiles` 的产物。
126
+ * @param logger - 只需要 warn(清理侧的 logger 形状比 MemoryLogger 狭,这里不装宽)。
127
+ * @returns 搬运计数。
128
+ */
129
+ export async function moveProjectionFiles(dir, records, files, logger) {
130
+ const out = { moved: 0, failed: 0, absent: 0 };
131
+ if (files.size === 0)
132
+ return out;
133
+ for (const r of records) {
134
+ const entry = files.get(r.id);
135
+ if (entry === undefined) {
136
+ out.absent += 1;
137
+ continue;
138
+ }
139
+ const type = r.type.length > 0 ? r.type : 'unsorted';
140
+ for (const f of entry) {
141
+ const side = f.inWorkspace ? 'ws' : 'home';
142
+ const toDir = join(dir, ARCHIVE_FILES_DIR, side, type);
143
+ try {
144
+ await mkdir(toDir, { recursive: true });
145
+ await rename(f.absPath, join(toDir, basename(f.absPath)));
146
+ out.moved += 1;
147
+ }
148
+ catch (err) {
149
+ out.failed += 1;
150
+ logger?.warn(`[memory] 归档搬运失败(库已删,文件留在原地): ${f.absPath} - ${err instanceof Error ? err.message : String(err)}`);
151
+ }
152
+ }
153
+ }
154
+ return out;
155
+ }
@@ -0,0 +1,74 @@
1
+ import { isRetiredRecord } from './memory-md.js';
2
+ import type { SuffixReport } from './memory-md-types.js';
3
+ import type { MdLedgerStore } from './memory-md-ledger.js';
4
+ import type { MemoryLogger, MemoryRecord } from '../types.js';
5
+ /** 文件侧的结果,原样进端点响应(面板看得见"到底动没动文件")。 */
6
+ export type DisableOutcome = 'renamed' | 'created' | 'already' | 'no-workspace' | 'failed';
7
+ /**
8
+ * 退场判据的单一真源在 `memory-md.ts`(导出侧与这里必须共用一个),本模块只转发,
9
+ * 免得既有调用方拿不到。另起一套(比如再叠上 `persistence`)只会让"列表说它退了、
10
+ * 文件却没后缀"重新变成常态。
11
+ */
12
+ export { isRetiredRecord };
13
+ export interface DisableDeps {
14
+ /**
15
+ * 刚被退场的那条记录。调用方应传**退场后**的快照(带 `validTo`),这样渲染出的
16
+ * frontmatter 自然包含 `retired: true`——凭证自己就说清了它为什么存在。
17
+ */
18
+ record: MemoryRecord;
19
+ /** `cfg.memExport.root`(只管无 workspace 那批,这里用不到但落点函数要它)。 */
20
+ cfgRoot: string | undefined;
21
+ dataDir: string;
22
+ ledger: MdLedgerStore;
23
+ logger?: MemoryLogger;
24
+ }
25
+ /**
26
+ * 账本跟着换键。
27
+ *
28
+ * 键是绝对路径(N10),所以改名必须"删旧键、记新键"——只改文件不改账本,下一轮对账
29
+ * 会同时报"旧键文件缺失"和"新文件是外人加的",两边都错。
30
+ * 没有旧条目(人从没导出过)时只记新键,不补造旧键。
31
+ *
32
+ * ⚠️ `fileDigest` **取磁盘上的真实字节**,不取我们手上那份渲染结果:改名分支里
33
+ * 旧文件是“未退场时导出的那一版”(不带 `retired`),拿新渲染的摘要当基线,
34
+ * 下一轮对账就会把“我们自己改的文件”报成“人改过文件”。
35
+ *
36
+ * @param persist - 默认 true(单条失效联动:写完就该落盘)。**批量收敛必须传 false**:
37
+ * 账本是整本 JSON 的原子重写,每条都 `flush()` 就把「窗口内只记脏、收尾落一次」
38
+ * 的设计退化成 O(n²)(真机 912 条回填会被墙钟 `timeBudgetMs` 拦成好几次点击)。
39
+ *
40
+ * 导出给 `memory-md-sync.ts` 的改名跟随用——「取磁盘真实字节当基线」这条纪律只该有一份。
41
+ */
42
+ export declare function moveLedger(ledger: MdLedgerStore, fromAbs: string, toAbs: string, r: MemoryRecord, fallbackText: string, persist?: boolean): Promise<void>;
43
+ /**
44
+ * 把一条已退场记忆的投射文件标成 `.disable`。
45
+ *
46
+ * @param deps - 记录、根配置、数据目录、账本与日志。
47
+ * @returns 文件侧结果;**任何异常都收敛成 `'failed'`,不抛**。
48
+ */
49
+ export declare function disableProjectionFile(deps: DisableDeps): Promise<DisableOutcome>;
50
+ export interface SuffixConvergeDeps {
51
+ /** 全量记录(含退场的)——收敛是库→盘的形状对齐,不是增量事件。 */
52
+ records: readonly MemoryRecord[];
53
+ cfgRoot: string | undefined;
54
+ dataDir: string;
55
+ ledger: MdLedgerStore;
56
+ /** 与 `exportRecords` 同口径:只约束**真碰了磁盘**的动作数,判为“形状已对”的不吃配额。 */
57
+ maxRecords: number;
58
+ timeBudgetMs: number;
59
+ logger?: MemoryLogger;
60
+ /** 注入时钟(测试用)。 */
61
+ now?: () => number;
62
+ }
63
+ /**
64
+ * 后缀不变式收敛:把「库里的有/无效」与「文件的 `.memory.md` / `.memory.md.disable`」对齐。
65
+ *
66
+ * 为什么挂在导出后面而不是各个退场调用点:那 8 处 `.retire()` 分布在手动软删、批量退场、
67
+ * 合并取代、冲突判负、slot 连带等路径上,事件挂钩注定会漏(真机实测:920 条退场记录
68
+ * 只有 8 个 `.disable`,全部来自召回面板那一条路径)。收敛器只依赖三个可观测事实:
69
+ * 文件存在性、当前后缀、指纹一致性。
70
+ *
71
+ * 护栏与导出同一套(`maxRecords` 只数碰盘动作 + `timeBudgetMs` 墙钟),所以第一次点可能只
72
+ * 收敛一部分,`capped`/`remaining` 会告诉你还要点几次。
73
+ */
74
+ export declare function convergeDisabledSuffix(deps: SuffixConvergeDeps): Promise<SuffixReport>;
@@ -0,0 +1,302 @@
1
+ /**
2
+ * 失效联动(N11 / P1.5):人在 brief 上点「声明过时」时,除了退场库记录,还要动到投射文件。
3
+ *
4
+ * 为什么这件事在服务端而不是在 brief:brief 只是发一个 RPC,它既不知道记录住在哪个
5
+ * workspace,也不该拿到文件系统的写权限。**库是事实源,文件是投影**——所以退场先成立,
6
+ * 文件侧只是把"人已处置"这件事也写到磁盘上。
7
+ *
8
+ * 后缀不变式(作者口径 2026-10-14):文件后缀**只跟「这条记录在库里是否有效」有关**,
9
+ * `name` 字段无感,「是谁把它退场的」也无感。所以不能只在一个退场按钮后面挂一刀——
10
+ * 全仓有 8 处 `.retire()` 调用点(手动判过时 / 批量软删 / 合并取代 / 冲突判负 / slot 连带…),
11
+ * 事件挂钩注定会漏。`convergeDisabledSuffix` 就是这个不变式的收敛器:每次导出后按
12
+ * 「文件存在性 + 当前后缀 + 指纹一致性」三项检查重算一遍,与退场路径无关。
13
+ *
14
+ * 点火已由 `L1Store` 的汇流口接管(`store/memory-md-sync.ts`),本模块只供收敛器与
15
+ * 单条凭证两个原件——曾经在这里的 `makeSuffixSync` 点火器已退役,不留第二事实源。
16
+ *
17
+ * 单点失效联动(`disableProjectionFile`)的三种落点:
18
+ * - 盘上有 `.memory.md`(**不再要求有 workspace**:无归属那条也要改,它的文件本来就在单根)→ **改名**成 `.memory.md.disable`;
19
+ * - 盘上什么都没有、但**有** workspace → **创建** `.disable`(内容就是渲染结果,带 `retired: true`),
20
+ * 人不点导出也有一份磁盘凭证,P2 与人都看得见;
21
+ * - 盘上什么都没有、且没有 workspace → **什么都不写**。宁缺勿猜:为一个归属不明的记忆在单根凭空造文件,
22
+ * 等于把"没路由上"伪装成"它属于默认根"。
23
+ *
24
+ * 三条硬约束:
25
+ * 1. **绝不抛**:退场已经成功,文件侧失败不能把端点变成 500(那会让人以为标注没生效)。
26
+ * 2. **幂等**:`.disable` 已在就返回 `already`,再点一次 `-1` 不会长出第二个凭证。
27
+ * 3. **改名而不是删除**:`.disable` 是凭证,删了 P2 与人都无从对齐。
28
+ */
29
+ import { dirname } from 'node:path';
30
+ import { readFile, rename, rm, stat } from 'node:fs/promises';
31
+ import { atomicWriteText, ensureDir } from '../util/io.js';
32
+ import { disablePathOf, digest, isRetiredRecord, recordDigest, renderRecord, targetFor } from './memory-md.js';
33
+ /**
34
+ * 退场判据的单一真源在 `memory-md.ts`(导出侧与这里必须共用一个),本模块只转发,
35
+ * 免得既有调用方拿不到。另起一套(比如再叠上 `persistence`)只会让"列表说它退了、
36
+ * 文件却没后缀"重新变成常态。
37
+ */
38
+ export { isRetiredRecord };
39
+ /** 存在性判定:一次 `stat`,任何错误都当"不存在"(权限/竞态都不该让失效动作失败)。 */
40
+ async function exists(p) {
41
+ try {
42
+ await stat(p);
43
+ return true;
44
+ }
45
+ catch {
46
+ return false;
47
+ }
48
+ }
49
+ /**
50
+ * 账本跟着换键。
51
+ *
52
+ * 键是绝对路径(N10),所以改名必须"删旧键、记新键"——只改文件不改账本,下一轮对账
53
+ * 会同时报"旧键文件缺失"和"新文件是外人加的",两边都错。
54
+ * 没有旧条目(人从没导出过)时只记新键,不补造旧键。
55
+ *
56
+ * ⚠️ `fileDigest` **取磁盘上的真实字节**,不取我们手上那份渲染结果:改名分支里
57
+ * 旧文件是“未退场时导出的那一版”(不带 `retired`),拿新渲染的摘要当基线,
58
+ * 下一轮对账就会把“我们自己改的文件”报成“人改过文件”。
59
+ *
60
+ * @param persist - 默认 true(单条失效联动:写完就该落盘)。**批量收敛必须传 false**:
61
+ * 账本是整本 JSON 的原子重写,每条都 `flush()` 就把「窗口内只记脏、收尾落一次」
62
+ * 的设计退化成 O(n²)(真机 912 条回填会被墙钟 `timeBudgetMs` 拦成好几次点击)。
63
+ *
64
+ * 导出给 `memory-md-sync.ts` 的改名跟随用——「取磁盘真实字节当基线」这条纪律只该有一份。
65
+ */
66
+ export async function moveLedger(ledger, fromAbs, toAbs, r, fallbackText, persist = true) {
67
+ const prev = ledger.get(fromAbs);
68
+ const st = await stat(toAbs).catch(() => undefined);
69
+ const actual = await readFile(toAbs, 'utf8').catch(() => fallbackText);
70
+ ledger.set(toAbs, {
71
+ id: r.id,
72
+ extKey: r.extKey ?? '',
73
+ root: prev?.root ?? '',
74
+ relPath: prev?.relPath ?? '',
75
+ dbDigest: recordDigest(r),
76
+ fileDigest: digest(actual),
77
+ fileMtimeMs: st?.mtimeMs ?? 0,
78
+ fileSize: st?.size ?? 0,
79
+ syncedAt: new Date().toISOString(),
80
+ });
81
+ if (prev !== undefined)
82
+ ledger.delete(fromAbs);
83
+ if (persist)
84
+ await ledger.flush();
85
+ }
86
+ /**
87
+ * 把一条已退场记忆的投射文件标成 `.disable`。
88
+ *
89
+ * @param deps - 记录、根配置、数据目录、账本与日志。
90
+ * @returns 文件侧结果;**任何异常都收敛成 `'failed'`,不抛**。
91
+ */
92
+ export async function disableProjectionFile(deps) {
93
+ const { record: r, ledger, logger } = deps;
94
+ const ws = (r.workspaceId ?? '').trim();
95
+ try {
96
+ const target = targetFor(r, deps.cfgRoot, deps.dataDir, logger);
97
+ const abs = target.absPath;
98
+ if (abs === undefined) {
99
+ logger?.warn(`[memory] 失效联动跳过(路径越界): ${r.id} → ${target.relPath}`);
100
+ return 'failed';
101
+ }
102
+ const dis = disablePathOf(abs);
103
+ const { text } = renderRecord(r);
104
+ if (await exists(dis)) {
105
+ // 已经是 .disable:仍要确保账本认得它(上一轮可能改完文件就崩了)。
106
+ if (ledger.get(dis) === undefined)
107
+ await moveLedger(ledger, abs, dis, r, text);
108
+ return 'already';
109
+ }
110
+ if (await exists(abs)) {
111
+ try {
112
+ await rename(abs, dis);
113
+ }
114
+ catch (err) {
115
+ // 竞态兜底:存在性检查和改名之间,收口挂钩可能已经把这个文件改走了(同一批次
116
+ // 双触发时两条路径都会走到这里)。改成功过的文件再改名报 ENOENT——盘上已经是
117
+ // 想要的那份,该报 `already`,不该刷 warn(真机日志里噪声会淹掉真失败)。
118
+ if (err instanceof Error && 'code' in err && err.code === 'ENOENT' && await exists(dis)) {
119
+ if (ledger.get(dis) === undefined)
120
+ await moveLedger(ledger, abs, dis, r, text);
121
+ return 'already';
122
+ }
123
+ throw err;
124
+ }
125
+ await moveLedger(ledger, abs, dis, r, text);
126
+ logger?.info(`[memory] 声明过时 → 投射文件已改名: ${dis}`);
127
+ return 'renamed';
128
+ }
129
+ // 盘上什么都没有:无归属时不凭空在单根造文件(原「宁缺勿猜」红线只保留在这一分支)。
130
+ // 旧写法是在函数入头就早退,于是「导出能写进单根、退场却改不了名」——写得出收不回。
131
+ if (ws.length === 0)
132
+ return 'no-workspace';
133
+ await ensureDir(dirname(dis));
134
+ await atomicWriteText(dis, text);
135
+ await moveLedger(ledger, abs, dis, r, text);
136
+ logger?.info(`[memory] 声明过时 → 已创建失效凭证(此前未导出过): ${dis}`);
137
+ return 'created';
138
+ }
139
+ catch (err) {
140
+ logger?.warn(`[memory] 失效联动文件侧失败(退场已完成,不回滚): ${r.id} - ${err instanceof Error ? err.message : String(err)}`);
141
+ return 'failed';
142
+ }
143
+ }
144
+ /** 让出事件循环一次:收敛是长任务,不能独占 tick(面板会僵)。 */
145
+ function tick() {
146
+ return new Promise((resolveTick) => { setImmediate(resolveTick); });
147
+ }
148
+ /**
149
+ * 盘上这份是不是被人改过:拿真实字节与账本的 `fileDigest` 比。
150
+ *
151
+ * 没账(从没导出过 / 首轮基线未建)时返回 false——“没基线”不等于“被改过”。
152
+ * 注意这里只诊断不阻断:改名只动后缀、不动字节,所以人改的内容跟着文件一起走,
153
+ * 丢不了;计数与重新记账是为了让下一遍对账能把这件事说清。
154
+ */
155
+ async function looksEdited(ledger, abs) {
156
+ const prev = ledger.get(abs);
157
+ if (prev === undefined)
158
+ return false;
159
+ const actual = await readFile(abs, 'utf8').catch(() => undefined);
160
+ return actual !== undefined && digest(actual) !== prev.fileDigest;
161
+ }
162
+ /**
163
+ * 后缀不变式收敛:把「库里的有/无效」与「文件的 `.memory.md` / `.memory.md.disable`」对齐。
164
+ *
165
+ * 为什么挂在导出后面而不是各个退场调用点:那 8 处 `.retire()` 分布在手动软删、批量退场、
166
+ * 合并取代、冲突判负、slot 连带等路径上,事件挂钩注定会漏(真机实测:920 条退场记录
167
+ * 只有 8 个 `.disable`,全部来自召回面板那一条路径)。收敛器只依赖三个可观测事实:
168
+ * 文件存在性、当前后缀、指纹一致性。
169
+ *
170
+ * 护栏与导出同一套(`maxRecords` 只数碰盘动作 + `timeBudgetMs` 墙钟),所以第一次点可能只
171
+ * 收敛一部分,`capped`/`remaining` 会告诉你还要点几次。
172
+ */
173
+ export async function convergeDisabledSuffix(deps) {
174
+ const { records, ledger, logger } = deps;
175
+ const startedAt = (deps.now ?? Date.now)();
176
+ const rep = {
177
+ scanned: 0, renamed: 0, revived: 0, edited: 0, already: 0, both: 0, absent: 0, skipped: 0, remaining: 0, capped: false,
178
+ };
179
+ let worked = 0;
180
+ for (let i = 0; i < records.length; i++) {
181
+ // 两条护栏都在循环顶部查:写在末尾会被 `continue` 跳过(exportRecords 自家测试抓过)。
182
+ if (worked >= deps.maxRecords || (deps.now ?? Date.now)() - startedAt >= deps.timeBudgetMs) {
183
+ rep.capped = true;
184
+ rep.remaining = records.length - i;
185
+ break;
186
+ }
187
+ const r = records[i];
188
+ if (r === undefined)
189
+ continue;
190
+ rep.scanned++;
191
+ const target = targetFor(r, deps.cfgRoot, deps.dataDir, logger);
192
+ const abs = target.absPath;
193
+ if (abs === undefined) {
194
+ rep.skipped++;
195
+ logger?.warn(`[memory] 后缀收敛跳过(路径越界): ${r.id} → ${target.relPath}`);
196
+ continue;
197
+ }
198
+ const dis = disablePathOf(abs);
199
+ try {
200
+ const hasDis = await exists(dis);
201
+ const hasAbs = await exists(abs);
202
+ if (isRetiredRecord(r)) {
203
+ if (hasDis && hasAbs) {
204
+ // 两份都在:可能是上一遍改名中途崩了,也可能是人复制了一份。删哪份都是不可逆判断,
205
+ // 所以只报账,交孤儿归档/人裁决。
206
+ rep.both++;
207
+ continue;
208
+ }
209
+ if (hasDis) {
210
+ // 后缀已对上;仍要保证账本认得它(改完文件就崩的残留态)。
211
+ if (ledger.get(dis) === undefined) {
212
+ await moveLedger(ledger, abs, dis, r, renderRecord(r).text, false);
213
+ worked++;
214
+ }
215
+ rep.already++;
216
+ continue;
217
+ }
218
+ if (!hasAbs) {
219
+ rep.absent++;
220
+ continue;
221
+ }
222
+ if (await looksEdited(ledger, abs))
223
+ rep.edited++;
224
+ try {
225
+ await rename(abs, dis);
226
+ }
227
+ catch (err) {
228
+ // 与 `disableProjectionFile` 同一条竞态兜底:双触发时另一条路径可能已把这份改走了。
229
+ // 盘上已是想要的那份 → 算 `already`,不刷 warn(否则噪声会淹掉真失败)。
230
+ if (err instanceof Error && 'code' in err && err.code === 'ENOENT' && await exists(dis)) {
231
+ if (ledger.get(dis) === undefined)
232
+ await moveLedger(ledger, abs, dis, r, renderRecord(r).text, false);
233
+ rep.already++;
234
+ continue;
235
+ }
236
+ throw err;
237
+ }
238
+ await moveLedger(ledger, abs, dis, r, renderRecord(r).text, false);
239
+ rep.renamed++;
240
+ worked++;
241
+ await tick();
242
+ continue;
243
+ }
244
+ // 有效记录:后缀应该是 `.memory.md`。
245
+ if (hasDis && !hasAbs) {
246
+ const entry = ledger.get(dis);
247
+ if (entry === undefined || entry.id !== r.id) {
248
+ // 人手造的 `.disable` 是文件侧退场凭证(recall-feedback 的文件侧对偶)。
249
+ // P2 关着时库里不知道这件事,自动改回去等于揉掉人的裁决 → 只报账不动盘。
250
+ rep.both++;
251
+ continue;
252
+ }
253
+ await rename(dis, abs);
254
+ await moveLedger(ledger, dis, abs, r, renderRecord(r).text, false);
255
+ rep.revived++;
256
+ worked++;
257
+ await tick();
258
+ continue;
259
+ }
260
+ if (hasDis && hasAbs) {
261
+ // 有效记录两份都在。多数不是人手复制,而是「恢复后导出补写了非退场路径,
262
+ // `.disable` 那份还留着」——不收这一格,恢复一轮就会永久多出双份。
263
+ // 删得同时满足三个条件才算“机器自己造的陈年标记”,不丢任何信息:
264
+ // ① 凭证确实是我们给**这条记录**写的(账本同 id);② 它自我们写下后没被人改过
265
+ //(磁盘字节 == 账本 fileDigest);③ `.memory.md` 这份就是我们当前渲染的那份
266
+ //(人手放的一份不会正好等于渲染结果)。任一不成立 → 只报账,交人裁决。
267
+ const entry = ledger.get(dis);
268
+ const disText = await readFile(dis, 'utf8').catch(() => undefined);
269
+ const absText = await readFile(abs, 'utf8').catch(() => undefined);
270
+ if (entry !== undefined && entry.id === r.id
271
+ && disText !== undefined && digest(disText) === entry.fileDigest
272
+ && absText !== undefined && absText === renderRecord(r).text) {
273
+ await rm(dis);
274
+ ledger.delete(dis);
275
+ rep.revived++;
276
+ worked++;
277
+ await tick();
278
+ continue;
279
+ }
280
+ rep.both++;
281
+ continue;
282
+ }
283
+ if (hasAbs) {
284
+ rep.already++;
285
+ continue;
286
+ }
287
+ rep.absent++;
288
+ }
289
+ catch (err) {
290
+ // 单条失败不阻断整批:下一遍还会重算(收敛是无状态的)。
291
+ rep.skipped++;
292
+ logger?.warn(`[memory] 后缀收敛失败(下轮重试): ${abs} - ${err instanceof Error ? err.message : String(err)}`);
293
+ }
294
+ }
295
+ // 收尾落一次账:循环里只记脏(`moveLedger` 的 `persist=false`)。中途崩了就丢本轮账,
296
+ // 但收敛器无状态且幂等——下一遍重算即可,所以不值得为它每条写一次整本 JSON。
297
+ await ledger.flush();
298
+ if (rep.renamed + rep.revived + rep.both + rep.edited > 0) {
299
+ logger?.info(`[memory] 后缀收敛:改名 ${rep.renamed}(其中人改过 ${rep.edited})· 改回 ${rep.revived} · 已对 ${rep.already} · 冲突 ${rep.both} · 无文件 ${rep.absent}${rep.capped ? ` · 剩余 ${rep.remaining}` : ''}`);
300
+ }
301
+ return rep;
302
+ }
@@ -0,0 +1,50 @@
1
+ import { safeJoin, resolveMemoriesRoot } from './memory-md.js';
2
+ import type { MdLedgerStore } from './memory-md-ledger.js';
3
+ import type { MemExportReport, MemExportResponse, ReconcileCounts, ReconcileReport, ReconcileSample } from './memory-md-types.js';
4
+ import type { MemoryLogger, MemoryRecord } from '../types.js';
5
+ export type { MemExportReport, MemExportResponse, ReconcileCounts, ReconcileReport, ReconcileSample };
6
+ export { resolveMemoriesRoot, safeJoin };
7
+ /** 导出报告等形状见 `memory-md-types.ts`(与契约层共用)。 */
8
+ export interface MemExportDeps {
9
+ /** 待投射的记录(含退场行——退场也要有投影,否则文件树里看不到它)。 */
10
+ records: readonly MemoryRecord[];
11
+ /** `cfg.memExport.root`(空 = 默认单根);workspace 优先时它只管无归属那批。 */
12
+ cfgRoot: string | undefined;
13
+ /** 插件数据目录:算默认单根用。 */
14
+ dataDir: string;
15
+ ledger: MdLedgerStore;
16
+ maxRecords: number;
17
+ timeBudgetMs: number;
18
+ logger?: MemoryLogger;
19
+ /** 注入时钟(测试用)。 */
20
+ now?: () => number;
21
+ }
22
+ /**
23
+ * 单向导出:库 → 文件 + 账本。
24
+ *
25
+ * 每次**全量重写**该写的:增量 patch 与内容指纹判据互相打脸(patch 的前提是"我知道
26
+ * 差在哪",而那正是指纹要回答的)。指纹一致就跳过,不刷 mtime——刷了会让下一轮
27
+ * 对账把"我们没动过"误判成"文件被改过"。
28
+ */
29
+ export declare function exportRecords(deps: MemExportDeps): Promise<MemExportReport>;
30
+ /** 四态计数与报告形状见 `memory-md-types.ts`。 */
31
+ export interface ReconcileDeps {
32
+ records: readonly MemoryRecord[];
33
+ /** `cfg.memExport.root`(空 = 默认单根);与 `dataDir` 一起交给 `targetFor`。 */
34
+ cfgRoot: string | undefined;
35
+ dataDir: string;
36
+ ledger: MdLedgerStore;
37
+ maxRecords: number;
38
+ timeBudgetMs: number;
39
+ logger?: MemoryLogger;
40
+ sampleCap?: number;
41
+ now?: () => number;
42
+ }
43
+ /**
44
+ * 只读对账:按账本基线判每条记录落在哪个象限。
45
+ *
46
+ * 判据是**三方**的(基线 = 账本里的两个 digest):
47
+ * `dbDigest ≠ 现在库内容` → 库动过;`fileDigest ≠ 现在文件内容` → 动过文件。
48
+ * 两方比较必吃一边,这就是账本要同时存两个 digest 的原因。
49
+ */
50
+ export declare function reconcileLedger(deps: ReconcileDeps): Promise<ReconcileReport>;