dsh-prime-memory 0.22.1 → 0.24.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 (121) hide show
  1. package/CHANGELOG.en.md +401 -0
  2. package/CHANGELOG.ja.md +401 -0
  3. package/CHANGELOG.ko.md +401 -0
  4. package/CHANGELOG.md +100 -0
  5. package/dist/client.js +1951 -1188
  6. package/dist/config.d.ts +176 -1240
  7. package/dist/config.js +113 -2
  8. package/dist/conflict-service.d.ts +12 -0
  9. package/dist/conflict-service.js +24 -0
  10. package/dist/contract.d.ts +327 -0
  11. package/dist/domain-gate.d.ts +19 -0
  12. package/dist/domain-gate.js +45 -0
  13. package/dist/graph/constraints.d.ts +4 -2
  14. package/dist/graph/constraints.js +4 -2
  15. package/dist/hooks/obsolete-notice.d.ts +26 -0
  16. package/dist/hooks/obsolete-notice.js +64 -0
  17. package/dist/hooks/recall.d.ts +41 -0
  18. package/dist/hooks/recall.js +127 -28
  19. package/dist/hooks/skill-recall.d.ts +25 -0
  20. package/dist/hooks/skill-recall.js +72 -0
  21. package/dist/index.d.ts +1 -1029
  22. package/dist/index.js +55 -2
  23. package/dist/memory-types.d.ts +69 -0
  24. package/dist/memory-types.js +165 -0
  25. package/dist/memport/format.d.ts +75 -0
  26. package/dist/memport/format.js +345 -0
  27. package/dist/memport/prefilter.d.ts +62 -0
  28. package/dist/memport/prefilter.js +132 -0
  29. package/dist/memport/scan.d.ts +13 -0
  30. package/dist/memport/scan.js +100 -0
  31. package/dist/metadata-validators.d.ts +22 -0
  32. package/dist/metadata-validators.js +45 -0
  33. package/dist/pipeline/l1.d.ts +44 -2
  34. package/dist/pipeline/l1.js +177 -9
  35. package/dist/pipeline/md-sync.d.ts +65 -0
  36. package/dist/pipeline/md-sync.js +456 -0
  37. package/dist/pipeline/ruminate.d.ts +46 -1
  38. package/dist/pipeline/ruminate.js +150 -1
  39. package/dist/pipeline/runner.d.ts +10 -0
  40. package/dist/pipeline/runner.js +7 -1
  41. package/dist/pipeline/slot-migrate.d.ts +45 -0
  42. package/dist/pipeline/slot-migrate.js +45 -0
  43. package/dist/pipeline/workspace-relink.d.ts +60 -0
  44. package/dist/pipeline/workspace-relink.js +73 -0
  45. package/dist/projection/recall.d.ts +112 -0
  46. package/dist/projection/recall.js +183 -0
  47. package/dist/projection/slots.d.ts +5 -0
  48. package/dist/prompts/examples.d.ts +73 -0
  49. package/dist/prompts/examples.js +178 -0
  50. package/dist/prompts/graph-projection.js +3 -0
  51. package/dist/prompts/l1-dedup.d.ts +9 -3
  52. package/dist/prompts/l1-dedup.js +34 -13
  53. package/dist/prompts/l1-extraction.d.ts +9 -4
  54. package/dist/prompts/l1-extraction.js +76 -8
  55. package/dist/settings.d.ts +8 -0
  56. package/dist/settings.js +15 -1
  57. package/dist/skills/crystallize.d.ts +41 -0
  58. package/dist/skills/crystallize.js +75 -0
  59. package/dist/stats.d.ts +30 -1
  60. package/dist/stats.js +504 -13
  61. package/dist/store/embedding-source.d.ts +8 -0
  62. package/dist/store/embedding-source.js +39 -2
  63. package/dist/store/file-versions.d.ts +4 -0
  64. package/dist/store/file-versions.js +4 -0
  65. package/dist/store/graph-store.d.ts +14 -0
  66. package/dist/store/graph-store.js +35 -0
  67. package/dist/store/l1-snapshot.d.ts +26 -1
  68. package/dist/store/l1-snapshot.js +53 -8
  69. package/dist/store/l1.d.ts +77 -3
  70. package/dist/store/l1.js +128 -3
  71. package/dist/store/memory-md-archive.d.ts +84 -0
  72. package/dist/store/memory-md-archive.js +155 -0
  73. package/dist/store/memory-md-disable.d.ts +23 -0
  74. package/dist/store/memory-md-disable.js +106 -0
  75. package/dist/store/memory-md-export.d.ts +50 -0
  76. package/dist/store/memory-md-export.js +200 -0
  77. package/dist/store/memory-md-ledger.d.ts +94 -0
  78. package/dist/store/memory-md-ledger.js +243 -0
  79. package/dist/store/memory-md-orphan.d.ts +27 -0
  80. package/dist/store/memory-md-orphan.js +153 -0
  81. package/dist/store/memory-md-parse.d.ts +35 -0
  82. package/dist/store/memory-md-parse.js +85 -0
  83. package/dist/store/memory-md-skeleton.d.ts +18 -0
  84. package/dist/store/memory-md-skeleton.js +61 -0
  85. package/dist/store/memory-md-types.d.ts +174 -0
  86. package/dist/store/memory-md-types.js +10 -0
  87. package/dist/store/memory-md.d.ts +108 -0
  88. package/dist/store/memory-md.js +219 -0
  89. package/dist/store/memory-sources.d.ts +59 -0
  90. package/dist/store/memory-sources.js +214 -0
  91. package/dist/store/obsolete-notices.d.ts +39 -0
  92. package/dist/store/obsolete-notices.js +153 -0
  93. package/dist/store/recall-feedback.d.ts +75 -0
  94. package/dist/store/recall-feedback.js +213 -0
  95. package/dist/store/recall-usage.d.ts +11 -0
  96. package/dist/store/recall-usage.js +29 -0
  97. package/dist/store/session-recalled.d.ts +60 -0
  98. package/dist/store/session-recalled.js +310 -0
  99. package/dist/store/skill-store.d.ts +99 -0
  100. package/dist/store/skill-store.js +330 -0
  101. package/dist/store/sqlite.d.ts +71 -0
  102. package/dist/store/sqlite.js +259 -15
  103. package/dist/store/supersede.d.ts +1 -1
  104. package/dist/store/supersede.js +1 -1
  105. package/dist/tools/index.d.ts +4 -13
  106. package/dist/tools/index.js +80 -12
  107. package/dist/tools/skills.d.ts +13 -0
  108. package/dist/tools/skills.js +130 -0
  109. package/dist/types.d.ts +30 -7
  110. package/dist/types.js +10 -2
  111. package/dist/util/maturity.d.ts +44 -0
  112. package/dist/util/maturity.js +43 -0
  113. package/dist/util/md-name.d.ts +129 -0
  114. package/dist/util/md-name.js +366 -0
  115. package/dist/util/md-normalize.d.ts +23 -0
  116. package/dist/util/md-normalize.js +131 -0
  117. package/dist/wing-backfill.js +7 -4
  118. package/dist/workspace-route.d.ts +22 -0
  119. package/dist/workspace-route.js +66 -0
  120. package/dsh.plugin.json +1 -1
  121. 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,23 @@
1
+ import type { MdLedgerStore } from './memory-md-ledger.js';
2
+ import type { MemoryLogger, MemoryRecord } from '../types.js';
3
+ /** 文件侧的结果,原样进端点响应(面板看得见"到底动没动文件")。 */
4
+ export type DisableOutcome = 'renamed' | 'created' | 'already' | 'no-workspace' | 'failed';
5
+ export interface DisableDeps {
6
+ /**
7
+ * 刚被退场的那条记录。调用方应传**退场后**的快照(带 `validTo`),这样渲染出的
8
+ * frontmatter 自然包含 `retired: true`——凭证自己就说清了它为什么存在。
9
+ */
10
+ record: MemoryRecord;
11
+ /** `cfg.memExport.root`(只管无 workspace 那批,这里用不到但落点函数要它)。 */
12
+ cfgRoot: string | undefined;
13
+ dataDir: string;
14
+ ledger: MdLedgerStore;
15
+ logger?: MemoryLogger;
16
+ }
17
+ /**
18
+ * 把一条已退场记忆的投射文件标成 `.disable`。
19
+ *
20
+ * @param deps - 记录、根配置、数据目录、账本与日志。
21
+ * @returns 文件侧结果;**任何异常都收敛成 `'failed'`,不抛**。
22
+ */
23
+ export declare function disableProjectionFile(deps: DisableDeps): Promise<DisableOutcome>;
@@ -0,0 +1,106 @@
1
+ /**
2
+ * 失效联动(N11 / P1.5):人在 brief 上点「声明过时」时,除了退场库记录,还要动到投射文件。
3
+ *
4
+ * 为什么这件事在服务端而不是在 brief:brief 只是发一个 RPC,它既不知道记录住在哪个
5
+ * workspace,也不该拿到文件系统的写权限。**库是事实源,文件是投影**——所以退场先成立,
6
+ * 文件侧只是把"人已处置"这件事也写到磁盘上。
7
+ *
8
+ * 三种落点(用户口径):
9
+ * - 有 workspace 且 `.memory.md` 存在 → **改名**成 `.memory.md.disable`;
10
+ * - 有 workspace 但文件还没导出过 → **创建** `.disable`(内容就是渲染结果,带 `retired: true`),
11
+ * 人不点导出也有一份磁盘凭证,P2 与人都看得见;
12
+ * - 没有 workspace → **什么都不写**。宁缺勿猜:为一个归属不明的记忆在单根凭空造文件,
13
+ * 等于把"没路由上"伪装成"它属于默认根"。
14
+ *
15
+ * 三条硬约束:
16
+ * 1. **绝不抛**:退场已经成功,文件侧失败不能把端点变成 500(那会让人以为标注没生效)。
17
+ * 2. **幂等**:`.disable` 已在就返回 `already`,再点一次 `-1` 不会长出第二个凭证。
18
+ * 3. **改名而不是删除**:`.disable` 是凭证,删了 P2 与人都无从对齐。
19
+ */
20
+ import { dirname } from 'node:path';
21
+ import { readFile, rename, stat } from 'node:fs/promises';
22
+ import { atomicWriteText, ensureDir } from '../util/io.js';
23
+ import { disablePathOf, digest, recordDigest, renderRecord, targetFor } from './memory-md.js';
24
+ /** 存在性判定:一次 `stat`,任何错误都当"不存在"(权限/竞态都不该让失效动作失败)。 */
25
+ async function exists(p) {
26
+ try {
27
+ await stat(p);
28
+ return true;
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }
34
+ /**
35
+ * 账本跟着换键。
36
+ *
37
+ * 键是绝对路径(N10),所以改名必须"删旧键、记新键"——只改文件不改账本,下一轮对账
38
+ * 会同时报"旧键文件缺失"和"新文件是外人加的",两边都错。
39
+ * 没有旧条目(人从没导出过)时只记新键,不补造旧键。
40
+ *
41
+ * ⚠️ `fileDigest` **取磁盘上的真实字节**,不取我们手上那份渲染结果:改名分支里
42
+ * 旧文件是“未退场时导出的那一版”(不带 `retired`),拿新渲染的摘要当基线,
43
+ * 下一轮对账就会把“我们自己改的文件”报成“人改过文件”。
44
+ */
45
+ async function moveLedger(ledger, fromAbs, toAbs, r, fallbackText) {
46
+ const prev = ledger.get(fromAbs);
47
+ const st = await stat(toAbs).catch(() => undefined);
48
+ const actual = await readFile(toAbs, 'utf8').catch(() => fallbackText);
49
+ ledger.set(toAbs, {
50
+ id: r.id,
51
+ extKey: r.extKey ?? '',
52
+ root: prev?.root ?? '',
53
+ relPath: prev?.relPath ?? '',
54
+ dbDigest: recordDigest(r),
55
+ fileDigest: digest(actual),
56
+ fileMtimeMs: st?.mtimeMs ?? 0,
57
+ fileSize: st?.size ?? 0,
58
+ syncedAt: new Date().toISOString(),
59
+ });
60
+ if (prev !== undefined)
61
+ ledger.delete(fromAbs);
62
+ await ledger.flush();
63
+ }
64
+ /**
65
+ * 把一条已退场记忆的投射文件标成 `.disable`。
66
+ *
67
+ * @param deps - 记录、根配置、数据目录、账本与日志。
68
+ * @returns 文件侧结果;**任何异常都收敛成 `'failed'`,不抛**。
69
+ */
70
+ export async function disableProjectionFile(deps) {
71
+ const { record: r, ledger, logger } = deps;
72
+ const ws = (r.workspaceId ?? '').trim();
73
+ if (ws.length === 0)
74
+ return 'no-workspace';
75
+ try {
76
+ const target = targetFor(r, deps.cfgRoot, deps.dataDir, logger);
77
+ const abs = target.absPath;
78
+ if (abs === undefined) {
79
+ logger?.warn(`[memory] 失效联动跳过(路径越界): ${r.id} → ${target.relPath}`);
80
+ return 'failed';
81
+ }
82
+ const dis = disablePathOf(abs);
83
+ const { text } = renderRecord(r);
84
+ if (await exists(dis)) {
85
+ // 已经是 .disable:仍要确保账本认得它(上一轮可能改完文件就崩了)。
86
+ if (ledger.get(dis) === undefined)
87
+ await moveLedger(ledger, abs, dis, r, text);
88
+ return 'already';
89
+ }
90
+ if (await exists(abs)) {
91
+ await rename(abs, dis);
92
+ await moveLedger(ledger, abs, dis, r, text);
93
+ logger?.info(`[memory] 声明过时 → 投射文件已改名: ${dis}`);
94
+ return 'renamed';
95
+ }
96
+ await ensureDir(dirname(dis));
97
+ await atomicWriteText(dis, text);
98
+ await moveLedger(ledger, abs, dis, r, text);
99
+ logger?.info(`[memory] 声明过时 → 已创建失效凭证(此前未导出过): ${dis}`);
100
+ return 'created';
101
+ }
102
+ catch (err) {
103
+ logger?.warn(`[memory] 失效联动文件侧失败(退场已完成,不回滚): ${r.id} - ${err instanceof Error ? err.message : String(err)}`);
104
+ return 'failed';
105
+ }
106
+ }
@@ -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>;
@@ -0,0 +1,200 @@
1
+ /**
2
+ * md 投射的导出与对账(P1 / N1 + N9)。
3
+ *
4
+ * 两个函数共享同一套路径纪律,但**权限面完全不同**:
5
+ * - `exportRecords` 写文件、写账本,**一字不改库**(导出前后 `hashRecords` 必须相同,
6
+ * 用例钉住这条);
7
+ * - `reconcileLedger` 什么都不写,只按账本与磁盘现状出四态报告——"看看差在哪"
8
+ * 不该需要高权限,`memoryMutate` 的门留给 P2 的真同步。
9
+ *
10
+ * 有界性照 `wing-backfill` / `workspace-relink` 的既有口径,但**配额数的是「动作」不是「遍历」**:
11
+ * `maxRecords` 只约束真碰了磁盘的条数(写入 / 补账 / 失败),判为「未变且已记账」的不吃配额。
12
+ * 原来的写法把未变也计入配额,于是**第二次点击扫的还是同一批前 N 条**,`remaining` 恒等于
13
+ * 同一个数——真机实测「已写 9 · 未变 291 · 剩余 866」点多少次都停在 300。扫描总量改由
14
+ * `timeBudgetMs` 兜底(只数条数不看时间,一条超大记录就能把一次点击挂死,这条理由不变),
15
+ * 剩余条数照旧如实回报 `remaining`。
16
+ */
17
+ import { dirname } from 'node:path';
18
+ import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
19
+ import { digest, recordDigest, renderRecord, safeJoin, targetFor, resolveMemoriesRoot } from './memory-md.js';
20
+ // 路径纪律已收到 `memory-md.ts`(N10 后导出/对账/失效/归档四处共用);这里再导一次
21
+ // 只为不拆已有调用方(stats.ts 与用例都从本模块拿它们)。
22
+ export { resolveMemoriesRoot, safeJoin };
23
+ /** 让出事件循环一次:投射是长任务,不能独占 tick(面板会僵)。 */
24
+ function tick() {
25
+ return new Promise((resolveTick) => { setImmediate(resolveTick); });
26
+ }
27
+ /**
28
+ * 单向导出:库 → 文件 + 账本。
29
+ *
30
+ * 每次**全量重写**该写的:增量 patch 与内容指纹判据互相打脸(patch 的前提是"我知道
31
+ * 差在哪",而那正是指纹要回答的)。指纹一致就跳过,不刷 mtime——刷了会让下一轮
32
+ * 对账把"我们没动过"误判成"文件被改过"。
33
+ */
34
+ export async function exportRecords(deps) {
35
+ const { records, cfgRoot, dataDir, ledger, logger } = deps;
36
+ const startedAt = (deps.now ?? Date.now)();
37
+ const root = resolveMemoriesRoot(cfgRoot, dataDir, logger);
38
+ const report = { root, inWorkspace: 0, scanned: 0, written: 0, unchanged: 0, skipped: 0, remaining: 0, capped: false };
39
+ // 吃配额的只有**碰过磁盘**的条数:写入、未变但补账、以及失败的那条(它已经碰过盘)。
40
+ let worked = 0;
41
+ for (let i = 0; i < records.length; i++) {
42
+ // 两条护栏都在**循环顶部**查:写在末尾会被 `continue` 跳过(自家测试抓过这个 bug)。
43
+ if (worked >= deps.maxRecords || (deps.now ?? Date.now)() - startedAt >= deps.timeBudgetMs) {
44
+ report.capped = true;
45
+ report.remaining = records.length - i;
46
+ break;
47
+ }
48
+ const r = records[i];
49
+ // 防御:`records` 来自 `store.all()`,类型上是满的,但 noUncheckedIndexedAccess
50
+ // 下的 client 程序会把它当 `| undefined`——这里不猜,直接跳过。
51
+ if (r === undefined) {
52
+ report.skipped++;
53
+ continue;
54
+ }
55
+ report.scanned++;
56
+ // N10:落点由 `targetFor` 一个函数定(workspace 优先),账本键 = 绝对路径。
57
+ const target = targetFor(r, cfgRoot, dataDir, logger);
58
+ if (target.absPath === undefined) {
59
+ report.skipped++;
60
+ logger?.warn(`[memory] md 投射跳过(路径越界): ${r.id} → ${target.relPath}`);
61
+ continue;
62
+ }
63
+ const { text } = renderRecord(r);
64
+ const abs = target.absPath;
65
+ if (target.inWorkspace)
66
+ report.inWorkspace++;
67
+ const dbDg = recordDigest(r);
68
+ const fileDg = digest(text);
69
+ try {
70
+ const existing = await readFile(abs, 'utf8').catch(() => undefined);
71
+ if (existing !== undefined && digest(existing) === fileDg) {
72
+ report.unchanged++;
73
+ // 内容没变也要补账:首轮导出前账本是空的,不补就永远判不出"文件被改过"。
74
+ // 补账要吃配额(它写盘),纯未变不吃——否则「再点一次」永远在同一批上打转。
75
+ if (ledger.get(abs) === undefined) {
76
+ try {
77
+ await stamp(ledger, r, target.root, target.relPath, abs, dbDg, fileDg);
78
+ worked++;
79
+ }
80
+ catch (err) {
81
+ // 文件与库都没动,只是没记上账:不能算 skipped(它没失败),也不能反回退成未计。
82
+ logger?.warn(`[memory] md 投射补账失败(下轮仍会重补): ${abs} - ${err instanceof Error ? err.message : String(err)}`);
83
+ }
84
+ }
85
+ await tick();
86
+ continue;
87
+ }
88
+ await mkdir(dirname(abs), { recursive: true });
89
+ await writeFile(abs, text, 'utf8');
90
+ report.written++;
91
+ worked++;
92
+ // 盘已经写成功了,之后记账失败**不能再算成 skipped**:同一条既 written 又 skipped
93
+ // 会让三个计数加起来大于 scanned(自家基准测出 1200 条同时报「写 1200 跳 1200」),
94
+ // 而报告是给人判断「这批要不要重来」的依据,自洽性比分类精细更要紧。
95
+ try {
96
+ await stamp(ledger, r, target.root, target.relPath, abs, dbDg, fileDg);
97
+ }
98
+ catch (err) {
99
+ logger?.warn(`[memory] md 投射已落盘但记账失败(下轮仍会重写这条): ${abs} - ${err instanceof Error ? err.message : String(err)}`);
100
+ }
101
+ }
102
+ catch (err) {
103
+ report.skipped++;
104
+ worked++;
105
+ logger?.warn(`[memory] md 投射单条失败(跳过,不影响其余): ${abs} - ${err instanceof Error ? err.message : String(err)}`);
106
+ }
107
+ await tick();
108
+ }
109
+ await ledger.flush();
110
+ return report;
111
+ }
112
+ /** 写账:文件侧字段取**落盘后**的真实 stat,不取内存值(否则对账基线就是假的)。 */
113
+ async function stamp(ledger, r, root, relPath, absPath, dbDg, fileDg) {
114
+ const st = await stat(absPath).catch(() => undefined);
115
+ const entry = {
116
+ id: r.id,
117
+ extKey: r.extKey ?? '',
118
+ root,
119
+ relPath,
120
+ dbDigest: dbDg,
121
+ fileDigest: fileDg,
122
+ fileMtimeMs: st?.mtimeMs ?? 0,
123
+ fileSize: st?.size ?? 0,
124
+ syncedAt: new Date().toISOString(),
125
+ };
126
+ ledger.set(absPath, entry);
127
+ }
128
+ /**
129
+ * 只读对账:按账本基线判每条记录落在哪个象限。
130
+ *
131
+ * 判据是**三方**的(基线 = 账本里的两个 digest):
132
+ * `dbDigest ≠ 现在库内容` → 库动过;`fileDigest ≠ 现在文件内容` → 动过文件。
133
+ * 两方比较必吃一边,这就是账本要同时存两个 digest 的原因。
134
+ */
135
+ export async function reconcileLedger(deps) {
136
+ const { records, cfgRoot, dataDir, ledger, logger } = deps;
137
+ const startedAt = (deps.now ?? Date.now)();
138
+ const root = resolveMemoriesRoot(cfgRoot, dataDir, logger);
139
+ const counts = { firstTime: 0, same: 0, dbOnly: 0, fileOnly: 0, both: 0, missingFile: 0 };
140
+ const samples = [];
141
+ const sampleCap = deps.sampleCap ?? 10;
142
+ let scanned = 0;
143
+ // 与导出同口径:`maxRecords` 数的是**需要人处理差异**的条数,`same` 不吃配额。
144
+ // 否则一个已经收敛的库会把配额全花在“确认没差”上,真正要看的差异反而被护到窗外。
145
+ let worked = 0;
146
+ let capped = false;
147
+ for (let i = 0; i < records.length; i++) {
148
+ if (worked >= deps.maxRecords || (deps.now ?? Date.now)() - startedAt >= deps.timeBudgetMs) {
149
+ capped = true;
150
+ break;
151
+ }
152
+ const r = records[i];
153
+ scanned++;
154
+ if (r === undefined)
155
+ continue;
156
+ // 与导出同一个 `targetFor`:否则“库侧算出的落点”与“导出时写的落点”会不一致,
157
+ // 对账就只会报“文件缺失”。
158
+ const target = targetFor(r, cfgRoot, dataDir, logger);
159
+ const abs = target.absPath;
160
+ const entry = abs === undefined ? undefined : ledger.get(abs);
161
+ const push = (state) => {
162
+ counts[state]++;
163
+ if (state !== 'same')
164
+ worked++;
165
+ if (samples.length < sampleCap)
166
+ samples.push({ relPath: target.relPath, id: r.id, state });
167
+ };
168
+ if (abs === undefined) {
169
+ push('missingFile');
170
+ logger?.warn(`[memory] 对账遇到越界路径: ${target.relPath}`);
171
+ await tick();
172
+ continue;
173
+ }
174
+ if (entry === undefined) {
175
+ push('firstTime');
176
+ await tick();
177
+ continue;
178
+ }
179
+ const existing = await readFile(abs, 'utf8').catch(() => undefined);
180
+ if (existing === undefined) {
181
+ push('missingFile');
182
+ await tick();
183
+ continue;
184
+ }
185
+ // 两个“变了”都是**相对账本基线**判的,不能拿“与现在会渲染出的文本不同”当依据:
186
+ // 库一变渲染就变,那会把纯粹的 dbOnly 误判成“文件也被人改了”,象限表全盘跑偏。
187
+ const dbChanged = entry.dbDigest !== recordDigest(r);
188
+ const fileChanged = digest(existing) !== entry.fileDigest;
189
+ if (dbChanged && fileChanged)
190
+ push('both');
191
+ else if (dbChanged)
192
+ push('dbOnly');
193
+ else if (fileChanged)
194
+ push('fileOnly');
195
+ else
196
+ push('same');
197
+ await tick();
198
+ }
199
+ return { root, capped, scanned, ...counts, samples };
200
+ }