@yandy0725/pi-memory 1.4.0 → 2.0.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.
- package/README.md +248 -140
- package/README.zh.md +266 -156
- package/index.ts +393 -95
- package/package.json +1 -1
- package/src/agent-runner.ts +17 -2
- package/src/config.ts +31 -6
- package/src/dream.ts +98 -56
- package/src/entry-file.ts +81 -0
- package/src/entry-index.ts +112 -0
- package/src/extract.ts +338 -57
- package/src/filename.ts +48 -0
- package/src/fs-lock.ts +251 -0
- package/src/index-source.ts +140 -0
- package/src/inject.ts +121 -73
- package/src/memory-store.ts +498 -0
- package/src/memory-tool.ts +244 -326
- package/src/migrate.ts +303 -0
- package/src/paths.ts +1 -14
- package/src/process-lock.ts +122 -0
- package/src/sanitize.ts +36 -0
- package/src/snapshot.ts +68 -0
- package/src/index-file.ts +0 -91
- package/src/topic-file.ts +0 -119
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
import { mkdir, readdir, readFile, stat, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { deriveDescription, type EntryType, parseEntryFile, serializeEntryFile } from "./entry-file";
|
|
5
|
+
import { formatIndexLine, indexCapacity, parseEntryIndex, removeIndexLine, upsertIndexLine } from "./entry-index";
|
|
6
|
+
import { entryFileName, resolveUniqueFileName } from "./filename";
|
|
7
|
+
import { withLock } from "./fs-lock";
|
|
8
|
+
import { isProcessLockActive, tryWithProcessLock, withProcessLock } from "./process-lock";
|
|
9
|
+
import { createSnapshot } from "./snapshot";
|
|
10
|
+
|
|
11
|
+
export const INDEX_FILE = "MEMORY.md";
|
|
12
|
+
export const LOCK_FILE = ".lock";
|
|
13
|
+
export const BACKUP_DIR = ".backups";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 删除文件;ENOENT 视为成功,其余错误一律上抛(与快照的 fail-closed 策略一致)。
|
|
17
|
+
*
|
|
18
|
+
* 吞错会让调用方拿到「删除成功」的假信号:removeEntry 已删索引行、已失效缓存但文件还在,
|
|
19
|
+
* 下次 rebuildIndex(dream 会常规调用)会把它加回来 —— 删除被静默回滚。
|
|
20
|
+
* 生产调用点:`MemoryStore` 的 removeEntry / replaceEntry,以及 `migrate.ts` 删除已迁移的
|
|
21
|
+
* legacy topic 文件。导出也供测试直接覆盖:这条语义无法在 Linux 上经由 MemoryStore 的公开
|
|
22
|
+
* API 触发(锁的临时文件与 entry 文件同目录,目录不可写时会在获取锁阶段先失败)。
|
|
23
|
+
*/
|
|
24
|
+
export async function unlinkStrict(path: string): Promise<void> {
|
|
25
|
+
try {
|
|
26
|
+
await unlink(path);
|
|
27
|
+
} catch (e) {
|
|
28
|
+
if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 两个路径是否指向同一个 inode。同样导出仅为测试。
|
|
34
|
+
*
|
|
35
|
+
* 大小写不敏感 / 会对 Unicode 做规范化的文件系统上,`file !== current.file` 只是字符串比较:
|
|
36
|
+
* writeFile("b.md") 可能已经写穿了 "B.md" 的 inode,紧接着 unlink("B.md") 会把刚写入的文件删掉。
|
|
37
|
+
*/
|
|
38
|
+
export async function sameFile(a: string, b: string): Promise<boolean> {
|
|
39
|
+
const [left, right] = await Promise.all([stat(a).catch(() => null), stat(b).catch(() => null)]);
|
|
40
|
+
return left !== null && right !== null && left.dev === right.dev && left.ino === right.ino;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 写原语的调用选项。
|
|
45
|
+
*
|
|
46
|
+
* `skipLogicalLock` 供**整轮持有者**使用:dream / 迁移先用 `withLogicalLock()` 包住整轮,
|
|
47
|
+
* 它内部再调用原语时若还去抢同一把进程内锁就会自锁。默认值(不传)= 自己拿锁,这对
|
|
48
|
+
* 「一次调用一个作用域」的调用方(主 agent 工具、extract)是安全的默认。
|
|
49
|
+
*
|
|
50
|
+
* `skipSnapshot` 供「整轮已经自己拍过一次全目录快照」的持有者使用:dream 进入时快照整个目录
|
|
51
|
+
* (spec §6)、迁移自建 `.backups/migrate-<ts>/`(spec §15.3)。它们内部每个原语再各拍一次,
|
|
52
|
+
* 会让同一批变更产生 N 份重复备份,并把 `snapshotKeep`(默认 5)的名额挤光 —— 用户还想用来做
|
|
53
|
+
* 崩溃恢复的 `write` 快照会被 `pruneSnapshots` 删掉。
|
|
54
|
+
*/
|
|
55
|
+
export interface WriteOptions {
|
|
56
|
+
skipLogicalLock?: boolean;
|
|
57
|
+
skipSnapshot?: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface StoreConfig {
|
|
61
|
+
memoryDir: string;
|
|
62
|
+
indexMaxLines: number;
|
|
63
|
+
indexMaxBytes: number;
|
|
64
|
+
/** 跨进程 `.lock` 的等待上限。注意这里**没有 ttl** —— 锁只在毫秒级的物理写入期间持有,
|
|
65
|
+
* 且永不自动回收(见 fs-lock.ts);dream 的整轮互斥由 process-lock.ts 的进程内队列承担。 */
|
|
66
|
+
lock: { timeoutMs: number; snapshotKeep: number };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface EntrySummary {
|
|
70
|
+
file: string;
|
|
71
|
+
name: string;
|
|
72
|
+
description: string;
|
|
73
|
+
type: EntryType;
|
|
74
|
+
modified: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface Entry extends EntrySummary {
|
|
78
|
+
created: string;
|
|
79
|
+
body: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface CacheRow {
|
|
83
|
+
mtimeMs: number;
|
|
84
|
+
summary: EntrySummary;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function compareSummaries(a: EntrySummary, b: EntrySummary): number {
|
|
88
|
+
return a.modified === b.modified ? a.file.localeCompare(b.file) : a.modified.localeCompare(b.modified);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 进程级唯一写入通道。所有对 memory 目录的修改必须经它。
|
|
93
|
+
*
|
|
94
|
+
* 已知崩溃窗口(由写前快照兜底,消费方属 Plan B 的恢复/迁移工具):`replaceEntry` 改名时
|
|
95
|
+
* 先写新文件、再删旧文件、最后重写索引;若在「删旧文件」与「重写索引」之间进程退出,
|
|
96
|
+
* `MEMORY.md` 会残留一条指向已删文件的死链。`upsertIndexLine` 以 file 为键,因此这条死链
|
|
97
|
+
* 不会被后续写入自动覆盖 —— 只能从 `.backups/` 快照恢复。
|
|
98
|
+
*
|
|
99
|
+
* 锁契约(Plan B 的 dream / 迁移必须遵守):
|
|
100
|
+
* - **两级锁,作用域不同**:进程内**逻辑锁**(`process-lock.ts`,按 `memoryDir` 分键)承担
|
|
101
|
+
* 「逻辑作用域」(单次原语 = 该次调用;dream / 迁移 = 整轮);跨进程 `.lock` 承担「毫秒级的
|
|
102
|
+
* 物理写入」。因此 `.lock` 永远只被持有一瞬间,不需要 TTL / 续约 / 接管(见 fs-lock.ts)。
|
|
103
|
+
* - 不做整轮的调用方(主 agent 工具、extract)**不必传任何选项** —— 默认就会自取逻辑锁。
|
|
104
|
+
* - 需要整轮独占时:用 `withLogicalLock()` 包住整轮,且**其内部调用必须传
|
|
105
|
+
* `{ skipLogicalLock: true }`**,否则会去抢自己已持有的锁而自锁到 `timeoutMs`。
|
|
106
|
+
* 启动前用 `logicalLockActive()` 自检 —— 忘了包锁会静默失去整轮互斥。
|
|
107
|
+
* - `#savingQueue`(进程内 mutation 队列)**没有**超时;它是毫秒级的,且已在逻辑锁之内,
|
|
108
|
+
* 所以不会把超时语义弄糊。
|
|
109
|
+
*/
|
|
110
|
+
export class MemoryStore {
|
|
111
|
+
readonly #cache = new Map<string, CacheRow>();
|
|
112
|
+
|
|
113
|
+
constructor(readonly cfg: StoreConfig) {}
|
|
114
|
+
|
|
115
|
+
async #entryFiles(): Promise<string[]> {
|
|
116
|
+
const names = await readdir(this.cfg.memoryDir).catch(() => []);
|
|
117
|
+
return names.filter((n) => n.endsWith(".md") && n !== INDEX_FILE && !n.startsWith(".")).sort();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** 已被占用的文件名:磁盘上的条目文件 + 索引文件本身。
|
|
121
|
+
* 索引必须计入 —— 它被 #entryFiles 过滤掉(它不是条目),但绝不能作为条目文件名被写入:
|
|
122
|
+
* 一次合法的 addEntry({ name: "MEMORY" }) 会解析出 MEMORY.md 并写穿它,销毁手写头部与全部索引行。
|
|
123
|
+
* ".MEMORY"(entryFileName 会剥掉前置句点)同样命中,因此这里按派生后的文件名而不是原始 name 判断。 */
|
|
124
|
+
async #takenFileNames(): Promise<Set<string>> {
|
|
125
|
+
const used = new Set(await this.#entryFiles());
|
|
126
|
+
used.add(INDEX_FILE);
|
|
127
|
+
return used;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** 解析目标文件名:先按字符串占用集取名,再对磁盘探测一次。
|
|
131
|
+
* 大小写不敏感、或会对 Unicode 做规范化的文件系统上,"b.md" 与 "B.md" / NFC 与 NFD
|
|
132
|
+
* 可能指向同一个 inode —— 纯字符串比较会让我们以为名字空闲,写穿别人的文件。
|
|
133
|
+
* `exclude` 是调用方自己的现有文件(改名时的自碰撞应复用原文件,而不是产生 A-B-2.md)。 */
|
|
134
|
+
async #resolveTargetFile(name: string, exclude?: string): Promise<string> {
|
|
135
|
+
const used = await this.#takenFileNames();
|
|
136
|
+
if (exclude) used.delete(exclude);
|
|
137
|
+
let candidate = resolveUniqueFileName(used, entryFileName(name));
|
|
138
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
139
|
+
if (candidate === exclude) return candidate;
|
|
140
|
+
const taken = await stat(join(this.cfg.memoryDir, candidate)).then(
|
|
141
|
+
() => true,
|
|
142
|
+
() => false,
|
|
143
|
+
);
|
|
144
|
+
if (!taken) return candidate;
|
|
145
|
+
used.add(candidate);
|
|
146
|
+
candidate = resolveUniqueFileName(used, entryFileName(name));
|
|
147
|
+
}
|
|
148
|
+
throw new Error(`Unable to find a free file name for "${name}"`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
#indexPath(): string {
|
|
152
|
+
return join(this.cfg.memoryDir, INDEX_FILE);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 进程内串行;跨进程安全由 #locked 负责。写路径都必须先保证目录存在 ——
|
|
156
|
+
* 锁的临时文件就落在该目录下,目录不存在会在获取锁时报出与 memory 无关的 ENOENT。 */
|
|
157
|
+
async #savingQueue<T>(fn: () => Promise<T>): Promise<T> {
|
|
158
|
+
await mkdir(this.cfg.memoryDir, { recursive: true });
|
|
159
|
+
return withFileMutationQueue(this.#indexPath(), fn);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async #locked<T>(op: string, timeoutMs: number, fn: () => Promise<T>): Promise<T> {
|
|
163
|
+
return withLock(join(this.cfg.memoryDir, LOCK_FILE), op, { timeoutMs }, fn);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* 进程内逻辑锁的 key。与跨进程锁同源(同一个 memoryDir),因此「同一目录的多个 store 实例」
|
|
168
|
+
* 共享同一把逻辑锁。注意 key 用配置里已展开的路径字符串:若同一个目录以不同写法(`..`、符号链接)
|
|
169
|
+
* 配置给两个 store,会得到两把锁 —— 目前所有调用方都走同一条 `resolveMemoryDir` 路径。
|
|
170
|
+
*/
|
|
171
|
+
get #logicalKey(): string {
|
|
172
|
+
return this.#indexPath();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* 写管线(所有原语的唯一入口):
|
|
177
|
+
* 进程内逻辑锁(毫秒级:本调用) → 进程内 mutation 队列 → 跨进程 `.lock`(毫秒级) → 快照 → 读改。
|
|
178
|
+
* 两级锁的顺序固定(逻辑锁在外),因此不存在锁序反转。
|
|
179
|
+
*/
|
|
180
|
+
async #pipeline<T>(op: string, options: WriteOptions | undefined, fn: () => Promise<T>): Promise<T> {
|
|
181
|
+
const run = () => this.#savingQueue(() => this.#locked(op, this.cfg.lock.timeoutMs, fn));
|
|
182
|
+
if (options?.skipLogicalLock) return run();
|
|
183
|
+
return withProcessLock(this.#logicalKey, this.cfg.lock.timeoutMs, run);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* 在「进程内逻辑锁」下跑一整轮(dream / 迁移)。持有期间调用原语必须传
|
|
188
|
+
* `{ skipLogicalLock: true }`,否则会自锁到超时。
|
|
189
|
+
*
|
|
190
|
+
* 整轮互斥放进程内、而不是让跨进程 `.lock` 持整轮,是刻意的:`.lock` 只承担毫秒级的物理写入,
|
|
191
|
+
* 因而不需要 TTL / 续约 / 接管(见 fs-lock.ts);而 dream 自己的原语调用也不会撞上自己的锁。
|
|
192
|
+
*
|
|
193
|
+
* `timeoutMs` 缺省为 `cfg.lock.timeoutMs`(5s)。整轮持有者可以显式放宽 —— 迁移用 30s
|
|
194
|
+
* (spec §15.3 步骤 1),因为它要在锁内逐条重写整个目录。
|
|
195
|
+
*/
|
|
196
|
+
async withLogicalLock<T>(fn: () => Promise<T>, timeoutMs?: number): Promise<T> {
|
|
197
|
+
return withProcessLock(this.#logicalKey, timeoutMs ?? this.cfg.lock.timeoutMs, fn);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* 只在逻辑锁空闲时执行 `fn`;有人持有或排队则**立刻**返回 `null`,绝不等待。
|
|
202
|
+
*
|
|
203
|
+
* extract 用它实现「锁忙就跳过本轮」(spec §5.2):extract 在每次 `agent_end` 都触发,
|
|
204
|
+
* 若让它排队等 dream(分钟级),一批又一批的提取会堆在进程内队列里,等 dream 结束后
|
|
205
|
+
* 依次重放早已过时的对话。跳过是无损的 —— 下一轮 `agent_end` 会再来。
|
|
206
|
+
*/
|
|
207
|
+
async tryWithLogicalLock<T>(fn: () => Promise<T>): Promise<T | null> {
|
|
208
|
+
return tryWithProcessLock(this.#logicalKey, fn);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** 整轮持有者启动前的自检:忘了包 `withLogicalLock` 会静默失去整轮互斥。 */
|
|
212
|
+
logicalLockActive(): boolean {
|
|
213
|
+
return isProcessLockActive(this.#logicalKey);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async #snapshot(label: string, files: string[]): Promise<void> {
|
|
217
|
+
await createSnapshot(join(this.cfg.memoryDir, BACKUP_DIR), label, files, this.cfg.memoryDir, {
|
|
218
|
+
keep: this.cfg.lock.snapshotKeep,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** `skipSnapshot` 的门。四个写原语的快照调用一律走它,避免「某一个原语漏改」。 */
|
|
223
|
+
async #maybeSnapshot(options: WriteOptions | undefined, label: string, files: string[]): Promise<void> {
|
|
224
|
+
if (options?.skipSnapshot) return;
|
|
225
|
+
await this.#snapshot(label, files);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
#capacityWarning(raw: string): string | undefined {
|
|
229
|
+
const cap = indexCapacity(raw, this.cfg.indexMaxLines, this.cfg.indexMaxBytes);
|
|
230
|
+
if (cap.ok) return undefined;
|
|
231
|
+
return (
|
|
232
|
+
`MEMORY.md is over its limit: ${cap.lineCount}/${this.cfg.indexMaxLines} lines, ` +
|
|
233
|
+
`${cap.byteLength}/${this.cfg.indexMaxBytes} bytes. The write succeeded, but everything past ` +
|
|
234
|
+
"the limit is dropped on the next load. Rewrite it now: keep one line per entry, merge or drop " +
|
|
235
|
+
"stale entries, and move detail into entry bodies rather than the index."
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** 扫描目录并返回按 (modified, file) 排序的条目标量;mtime 未变时复用缓存的 frontmatter。 */
|
|
240
|
+
async listEntries(): Promise<EntrySummary[]> {
|
|
241
|
+
const files = await this.#entryFiles();
|
|
242
|
+
const out: EntrySummary[] = [];
|
|
243
|
+
const seen = new Set<string>();
|
|
244
|
+
|
|
245
|
+
for (const file of files) {
|
|
246
|
+
const info = await stat(join(this.cfg.memoryDir, file)).catch(() => null);
|
|
247
|
+
if (!info?.isFile()) continue;
|
|
248
|
+
seen.add(file);
|
|
249
|
+
|
|
250
|
+
const cached = this.#cache.get(file);
|
|
251
|
+
if (cached && cached.mtimeMs === info.mtimeMs) {
|
|
252
|
+
out.push(cached.summary);
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const parsed = parseEntryFile(await readFile(join(this.cfg.memoryDir, file), "utf8").catch(() => ""));
|
|
257
|
+
if (!parsed) {
|
|
258
|
+
this.#cache.delete(file);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const summary: EntrySummary = { file, ...parsed.meta };
|
|
263
|
+
this.#cache.set(file, { mtimeMs: info.mtimeMs, summary });
|
|
264
|
+
out.push(summary);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
for (const file of [...this.#cache.keys()]) if (!seen.has(file)) this.#cache.delete(file);
|
|
268
|
+
return out.sort(compareSummaries);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** 读单个 entry 的正文;调用方自己提供已扫描到的 summary,避免重复扫目录。 */
|
|
272
|
+
async #readBody(summary: EntrySummary): Promise<Entry | null> {
|
|
273
|
+
const parsed = parseEntryFile(await readFile(join(this.cfg.memoryDir, summary.file), "utf8").catch(() => ""));
|
|
274
|
+
return parsed ? { ...summary, created: parsed.meta.created, body: parsed.body } : null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** 按文件名或 name 定位一个 entry。 */
|
|
278
|
+
async readEntry(ref: string): Promise<Entry | null> {
|
|
279
|
+
const summaries = await this.listEntries();
|
|
280
|
+
const summary = summaries.find((s) => s.file === ref) ?? summaries.find((s) => s.name === ref);
|
|
281
|
+
if (!summary) return null;
|
|
282
|
+
return this.#readBody(summary);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async readIndex(): Promise<string> {
|
|
286
|
+
return readFile(join(this.cfg.memoryDir, INDEX_FILE), "utf8").catch(() => "");
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async searchEntries(query: string): Promise<Entry[]> {
|
|
290
|
+
const needle = query.toLowerCase();
|
|
291
|
+
const out: Entry[] = [];
|
|
292
|
+
// 必须走 #readBody 而不是 readEntry:readEntry 会再调一次 listEntries(= 再一轮 readdir +
|
|
293
|
+
// 每文件一次 stat),在 N 条目上就会变成 N+1 次目录扫描、约 N² 次 stat。
|
|
294
|
+
for (const summary of await this.listEntries()) {
|
|
295
|
+
const entry = await this.#readBody(summary);
|
|
296
|
+
if (!entry) continue;
|
|
297
|
+
const haystack = [entry.name, entry.description, entry.body].map((f) => f.toLowerCase());
|
|
298
|
+
if (haystack.some((f) => f.includes(needle))) out.push(entry);
|
|
299
|
+
}
|
|
300
|
+
return out;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** 清空并重建缓存(手工编辑过目录后用)。 */
|
|
304
|
+
async refreshCache(): Promise<void> {
|
|
305
|
+
this.#cache.clear();
|
|
306
|
+
await this.listEntries();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** name 校验:非空、单行、不含 `](`。add 与 replace 共用,否则改名就成了绕过入口。 */
|
|
310
|
+
#validateName(name: string): void {
|
|
311
|
+
if (!name) throw new Error("name is required");
|
|
312
|
+
if (/[\r\n]/.test(name)) throw new Error("name must be a single line");
|
|
313
|
+
// `](` 会伪造索引行的 name/file 分组:formatIndexLine("A](x.md) — fake", "real.md", "d") 产出
|
|
314
|
+
// `- [A](x.md) — fake](real.md) — d`,解析回 { name: "A", file: "x.md" }。于是 removeIndexLine
|
|
315
|
+
// 删掉的是这一行,真正那条 x.md 留下(删 X 报成功却留下死链),而 real.md 变成无索引的孤儿。
|
|
316
|
+
// description 里出现 `](` 无害(该组是到行尾的 `(.*)`),只有 name 危险。
|
|
317
|
+
if (name.includes("](")) throw new Error("name must not contain ']('");
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
#validateDescription(value: string | undefined): void {
|
|
321
|
+
if (value && /[\r\n]/.test(value)) throw new Error("description must be a single line");
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** `created` 只接受 `YYYY-MM-DD`(spec §3.1)。迁移会把旧 frontmatter 的 `updated` 归一到这个
|
|
325
|
+
* 形状再传进来;非法值写出去会让 `parseEntryFile` 返回 null,整条记忆对 store 静默不可见。 */
|
|
326
|
+
#validateCreated(value: string | undefined): void {
|
|
327
|
+
if (value === undefined) return;
|
|
328
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) throw new Error("created must be YYYY-MM-DD");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async addEntry(
|
|
332
|
+
input: { name: string; description?: string; type?: EntryType; body: string; created?: string },
|
|
333
|
+
options?: WriteOptions,
|
|
334
|
+
): Promise<{ file: string; capacityWarning?: string }> {
|
|
335
|
+
const name = input.name.trim();
|
|
336
|
+
this.#validateName(name);
|
|
337
|
+
const requestedDescription = input.description?.trim();
|
|
338
|
+
this.#validateDescription(requestedDescription);
|
|
339
|
+
const requestedCreated = input.created?.trim();
|
|
340
|
+
this.#validateCreated(requestedCreated);
|
|
341
|
+
const body = input.body.trim();
|
|
342
|
+
if (!body) throw new Error("body is required");
|
|
343
|
+
|
|
344
|
+
return this.#pipeline("add", options, async () => {
|
|
345
|
+
const summaries = await this.listEntries();
|
|
346
|
+
const existing = summaries.find((s) => s.name === name);
|
|
347
|
+
const file = existing?.file ?? (await this.#resolveTargetFile(name));
|
|
348
|
+
// 覆盖同名 entry 时**永远**保留磁盘上的 created(迁移重跑幂等的关键);
|
|
349
|
+
// 只有新建时才接受调用方传入的值,缺省是今天。
|
|
350
|
+
const created = existing
|
|
351
|
+
? ((await this.readEntry(existing.file))?.created ?? isoDate(new Date()))
|
|
352
|
+
: (requestedCreated || isoDate(new Date()));
|
|
353
|
+
// `|| name` 是必需的:deriveDescription 可能返回 "",而空 description 会让该 entry 对侧查询不可见。
|
|
354
|
+
const description = requestedDescription || deriveDescription(body) || name;
|
|
355
|
+
const now = new Date();
|
|
356
|
+
|
|
357
|
+
await this.#maybeSnapshot(options, "write", [INDEX_FILE, file]);
|
|
358
|
+
await writeFile(
|
|
359
|
+
join(this.cfg.memoryDir, file),
|
|
360
|
+
serializeEntryFile(
|
|
361
|
+
{
|
|
362
|
+
name,
|
|
363
|
+
description,
|
|
364
|
+
type: input.type ?? existing?.type ?? "feedback",
|
|
365
|
+
created,
|
|
366
|
+
modified: now.toISOString(),
|
|
367
|
+
},
|
|
368
|
+
body,
|
|
369
|
+
),
|
|
370
|
+
"utf8",
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
const next = upsertIndexLine(await this.readIndex(), { name, file, description });
|
|
374
|
+
await writeFile(this.#indexPath(), next, "utf8");
|
|
375
|
+
this.#cache.delete(file);
|
|
376
|
+
|
|
377
|
+
return { file, capacityWarning: this.#capacityWarning(next) };
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async replaceEntry(
|
|
382
|
+
ref: string,
|
|
383
|
+
patch: { name?: string; description?: string; type?: EntryType; body?: string },
|
|
384
|
+
options?: WriteOptions,
|
|
385
|
+
): Promise<{ file: string; capacityWarning?: string }> {
|
|
386
|
+
return this.#pipeline("replace", options, async () => {
|
|
387
|
+
const current = await this.readEntry(ref);
|
|
388
|
+
if (!current) throw new Error(`Entry "${ref}" not found`);
|
|
389
|
+
|
|
390
|
+
const name = (patch.name ?? current.name).trim();
|
|
391
|
+
this.#validateName(name);
|
|
392
|
+
const requestedDescription = patch.description?.trim();
|
|
393
|
+
this.#validateDescription(requestedDescription);
|
|
394
|
+
const body = patch.body === undefined ? current.body : patch.body.trim();
|
|
395
|
+
const description =
|
|
396
|
+
requestedDescription ||
|
|
397
|
+
(patch.body === undefined ? current.description : deriveDescription(body)) ||
|
|
398
|
+
current.description ||
|
|
399
|
+
name;
|
|
400
|
+
// 改名不得撞名。`resolveUniqueFileName` 只防**文件名**冲突,所以不在这里拦下的话,
|
|
401
|
+
// rename 到已存在的 name 会写出第二个同名 entry(索引里两行都写着 [A]),之后 readEntry /
|
|
402
|
+
// addEntry 都会命中其中较旧的那个 —— 「精确同名即幂等」的硬约束就断了。
|
|
403
|
+
// 选择报错而不是合并或自动加后缀:合并会静默覆盖对方的内容,加后缀则会篡改调用方给的 name,
|
|
404
|
+
// 两者都在用户没要求的地方动记忆。
|
|
405
|
+
if (name !== current.name && (await this.listEntries()).some((s) => s.name === name)) {
|
|
406
|
+
throw new Error(`Entry "${name}" already exists`);
|
|
407
|
+
}
|
|
408
|
+
// 自碰撞(如 "A B" → "A-B" 派生出同一个文件名)由 #resolveTargetFile 的 exclude 参数复用原文件
|
|
409
|
+
const file = name === current.name ? current.file : await this.#resolveTargetFile(name, current.file);
|
|
410
|
+
|
|
411
|
+
await this.#maybeSnapshot(options, "write", [INDEX_FILE, current.file, file]);
|
|
412
|
+
await writeFile(
|
|
413
|
+
join(this.cfg.memoryDir, file),
|
|
414
|
+
serializeEntryFile(
|
|
415
|
+
{
|
|
416
|
+
name,
|
|
417
|
+
description,
|
|
418
|
+
type: patch.type ?? current.type,
|
|
419
|
+
created: current.created,
|
|
420
|
+
modified: new Date().toISOString(),
|
|
421
|
+
},
|
|
422
|
+
body,
|
|
423
|
+
),
|
|
424
|
+
"utf8",
|
|
425
|
+
);
|
|
426
|
+
if (file !== current.file) {
|
|
427
|
+
// 大小写不敏感 / 做 Unicode 规范化的文件系统上,两个不同的字符串可能指向同一 inode;
|
|
428
|
+
// 那种情况下上面的 writeFile 已经写穿了原文件,再 unlink 会把刚写入的文件删掉。
|
|
429
|
+
const target = join(this.cfg.memoryDir, file);
|
|
430
|
+
const source = join(this.cfg.memoryDir, current.file);
|
|
431
|
+
if (!(await sameFile(target, source))) await unlinkStrict(source);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const raw = await this.readIndex();
|
|
435
|
+
// 目标文件名可能残留一条陈旧索引行(手工删了文件却没删行)。不先清掉的话,
|
|
436
|
+
// 下面的 atLineNo 覆盖会与它并存 → 同一个 file 出现两条索引行。
|
|
437
|
+
// 必须先清再重新解析行号:删除会让后面的行号前移。
|
|
438
|
+
// 仅在真正换文件时清理:未改名(含 "A B" → "A-B" 派生同名文件)时 file === current.file,
|
|
439
|
+
// removeIndexLine 会删掉我们要保留位置的那一行,它会被 upsert 追加到索引末尾。
|
|
440
|
+
const cleaned = file === current.file ? raw : removeIndexLine(raw, file);
|
|
441
|
+
const line = parseEntryIndex(cleaned).entries.find((e) => e.file === current.file);
|
|
442
|
+
const next = upsertIndexLine(
|
|
443
|
+
cleaned,
|
|
444
|
+
{ name, file, description },
|
|
445
|
+
line ? { atLineNo: line.lineNo } : undefined,
|
|
446
|
+
);
|
|
447
|
+
await writeFile(this.#indexPath(), next, "utf8");
|
|
448
|
+
this.#cache.delete(current.file);
|
|
449
|
+
this.#cache.delete(file);
|
|
450
|
+
|
|
451
|
+
return { file, capacityWarning: this.#capacityWarning(next) };
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async removeEntry(ref: string, options?: WriteOptions): Promise<void> {
|
|
456
|
+
await this.#pipeline("remove", options, async () => {
|
|
457
|
+
const current = await this.readEntry(ref);
|
|
458
|
+
if (!current) throw new Error(`Entry "${ref}" not found`);
|
|
459
|
+
|
|
460
|
+
await this.#maybeSnapshot(options, "write", [INDEX_FILE, current.file]);
|
|
461
|
+
await unlinkStrict(join(this.cfg.memoryDir, current.file));
|
|
462
|
+
await writeFile(this.#indexPath(), removeIndexLine(await this.readIndex(), current.file), "utf8");
|
|
463
|
+
this.#cache.delete(current.file);
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async renameEntry(ref: string, newName: string, options?: WriteOptions): Promise<{ file: string }> {
|
|
468
|
+
const { file } = await this.replaceEntry(ref, { name: newName }, options);
|
|
469
|
+
return { file };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** 从磁盘全量重建索引;保留第一条索引行之前的手写块,其余无法识别行丢弃。 */
|
|
473
|
+
async rebuildIndex(options?: WriteOptions): Promise<{ entries: number; headerLines: number }> {
|
|
474
|
+
return this.#pipeline("rebuild", options, async () => {
|
|
475
|
+
await this.#maybeSnapshot(options, "index", [INDEX_FILE]);
|
|
476
|
+
|
|
477
|
+
const summaries = await this.listEntries();
|
|
478
|
+
const { lines, entries } = parseEntryIndex(await this.readIndex());
|
|
479
|
+
|
|
480
|
+
const header = lines.slice(0, entries[0]?.lineNo ?? lines.length);
|
|
481
|
+
while (header.length > 0 && header[header.length - 1].trim() === "") header.pop();
|
|
482
|
+
const effectiveHeader = header.length > 0 ? header : ["# Memory Index", ""];
|
|
483
|
+
|
|
484
|
+
const rebuilt = [
|
|
485
|
+
...effectiveHeader,
|
|
486
|
+
...summaries.map((s) => formatIndexLine(s.name, s.file, s.description)),
|
|
487
|
+
];
|
|
488
|
+
await writeFile(this.#indexPath(), rebuilt.length === 0 ? "" : `${rebuilt.join("\n")}\n`, "utf8");
|
|
489
|
+
this.#cache.clear();
|
|
490
|
+
|
|
491
|
+
return { entries: summaries.length, headerLines: effectiveHeader.length };
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function isoDate(date: Date): string {
|
|
497
|
+
return date.toISOString().slice(0, 10);
|
|
498
|
+
}
|