@yandy0725/pi-memory 2.0.0 → 2.1.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 +52 -55
- package/README.zh.md +52 -55
- package/index.ts +141 -96
- package/package.json +3 -3
- package/src/agent-runner.ts +8 -7
- package/src/config.ts +50 -0
- package/src/dream.ts +3 -5
- package/src/extract.ts +52 -15
- package/src/index-source.ts +57 -11
- package/src/inject.ts +9 -5
- package/src/memory-store.ts +12 -14
- package/src/memory-tool.ts +21 -4
- package/src/model-resolver.ts +1 -1
- package/src/process-lock.ts +1 -1
- package/src/migrate.ts +0 -303
package/src/migrate.ts
DELETED
|
@@ -1,303 +0,0 @@
|
|
|
1
|
-
import { cp, mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { isEntryType } from "./entry-file";
|
|
4
|
-
import { BACKUP_DIR, INDEX_FILE, LOCK_FILE, unlinkStrict, type MemoryStore } from "./memory-store";
|
|
5
|
-
|
|
6
|
-
/** 迁移完成标记。写在**最后一步** —— 中途失败时它不存在,下次 session_start 会重试(spec §15.4)。 */
|
|
7
|
-
export const MIGRATED_FILE = ".migrated";
|
|
8
|
-
|
|
9
|
-
/** spec §15.3 步骤 1:迁移的整轮锁超时是 30s(要在锁内逐条重写整个目录)。 */
|
|
10
|
-
export const MIGRATE_LOCK_TIMEOUT_MS = 30_000;
|
|
11
|
-
|
|
12
|
-
export interface MigrationResult {
|
|
13
|
-
/** 被拆开并删除的 legacy topic 文件数。 */
|
|
14
|
-
files: number;
|
|
15
|
-
/** 生成的 entry 数。 */
|
|
16
|
-
entries: number;
|
|
17
|
-
/** 回滚点目录(`.backups/migrate-<ts>/`),原 topic 文件另存于其下的 `originals/`。 */
|
|
18
|
-
backupDir: string;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
interface MigrationMarker extends MigrationResult {
|
|
22
|
-
migratedAt: string;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export interface LegacyEntry {
|
|
26
|
-
title: string;
|
|
27
|
-
content: string;
|
|
28
|
-
type: string;
|
|
29
|
-
updated: string;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** CRLF(以及老 Mac 的单独 CR)会让下面每一个正则都失配:`line === "---"` 不成立、
|
|
33
|
-
* `^## (.+)$` 里的 `.` 不匹配 `\r`。归一只在解析入口做一次。 */
|
|
34
|
-
function normalizeEol(raw: string): string {
|
|
35
|
-
return raw.includes("\r") ? raw.replace(/\r\n?/g, "\n") : raw;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const FIELD_RE = /^([A-Za-z_][A-Za-z0-9_]*):[ \t]*(.*)$/;
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* 拆分文件开头的 frontmatter:返回字段表与正文。
|
|
42
|
-
*
|
|
43
|
-
* v1 的 `parseEntries` 把每一行 `---` 都当作 frontmatter 开关:正文里只要出现一行 `---`
|
|
44
|
-
* (markdown 常见的分隔线),其后所有内容(包括下一个 `## ` 段)都会被当成 frontmatter 静默
|
|
45
|
-
* 吞掉。这里**有意收紧**:frontmatter 只在文件开头识别一次,找到第一个闭合的 `---` 行后,
|
|
46
|
-
* 其后整体视为正文,正文扫描期间不再切换状态。理由:迁移会 `unlink` 原文件(只留 `.backups/`
|
|
47
|
-
* 里的副本),宽松解析吞掉的数据用户不会再看见。
|
|
48
|
-
*
|
|
49
|
-
* 没有 frontmatter(不以 `---\n` 开头)或没有闭合行时 `fields` 为空、`body` 为原文 ——
|
|
50
|
-
* 无 frontmatter 的 legacy 文件同样合法(spec §15.2 的第二个触发条件是「≥2 个 `## ` 段」)。
|
|
51
|
-
*/
|
|
52
|
-
function splitFrontmatter(text: string): { fields: Record<string, string>; body: string } {
|
|
53
|
-
if (!text.startsWith("---\n")) return { fields: {}, body: text };
|
|
54
|
-
const lines = text.split("\n");
|
|
55
|
-
let end = -1;
|
|
56
|
-
for (let i = 1; i < lines.length; i++) {
|
|
57
|
-
if (lines[i] === "---") {
|
|
58
|
-
end = i;
|
|
59
|
-
break;
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
if (end === -1) return { fields: {}, body: text };
|
|
63
|
-
const fields: Record<string, string> = {};
|
|
64
|
-
for (const line of lines.slice(1, end)) {
|
|
65
|
-
const m = line.match(FIELD_RE);
|
|
66
|
-
if (m) fields[m[1]] = m[2].trim();
|
|
67
|
-
}
|
|
68
|
-
return { fields, body: lines.slice(end + 1).join("\n") };
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* 按 `## ` 切段(算法迁自 v1 `topic-file.ts#parseEntries`,但按 Finding I1 收紧:调用方必须
|
|
73
|
-
* 先用 `splitFrontmatter` 剥掉开头的 frontmatter,正文里的 `---` 不再是分隔符)。
|
|
74
|
-
*/
|
|
75
|
-
function parseLegacySections(body: string): Array<{ title: string; content: string }> {
|
|
76
|
-
const entries: Array<{ title: string; content: string }> = [];
|
|
77
|
-
let currentTitle = "";
|
|
78
|
-
let currentContent: string[] = [];
|
|
79
|
-
let inEntry = false;
|
|
80
|
-
|
|
81
|
-
for (const line of body.split("\n")) {
|
|
82
|
-
const h2 = line.match(/^## (.+)$/);
|
|
83
|
-
if (h2) {
|
|
84
|
-
if (inEntry) entries.push({ title: currentTitle, content: currentContent.join("\n").trim() });
|
|
85
|
-
currentTitle = h2[1];
|
|
86
|
-
currentContent = [];
|
|
87
|
-
inEntry = true;
|
|
88
|
-
continue;
|
|
89
|
-
}
|
|
90
|
-
if (inEntry) currentContent.push(line);
|
|
91
|
-
}
|
|
92
|
-
if (inEntry) entries.push({ title: currentTitle, content: currentContent.join("\n").trim() });
|
|
93
|
-
return entries;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* 这个文件是不是 v1 的 topic 文件(spec §15.2)?
|
|
98
|
-
*
|
|
99
|
-
* 判据:`MEMORY.md` 之外的 `.md`,且(frontmatter 含旧字段 `updated` **或** 含 ≥2 个 `## ` 段)。
|
|
100
|
-
*
|
|
101
|
-
* **必须先排除含 `modified` 的文件**:v2 的 entry 文件正文里完全可能有多个 `## ` 小标题,
|
|
102
|
-
* 若不排除,一条正常的 v2 记忆会被当成 legacy topic 再拆一次 —— 正文被切碎、原文件被删。
|
|
103
|
-
*/
|
|
104
|
-
export function isLegacyTopicFile(raw: string): boolean {
|
|
105
|
-
const { fields, body } = splitFrontmatter(normalizeEol(raw));
|
|
106
|
-
if (fields.modified !== undefined) return false;
|
|
107
|
-
if (fields.updated !== undefined) return true;
|
|
108
|
-
return parseLegacySections(body).length >= 2;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/** 拆出 legacy topic 文件里的全部 `## ` 段,并把该文件 frontmatter 的 `type` / `updated` 附到每一段上。 */
|
|
112
|
-
export function parseLegacyEntries(raw: string): LegacyEntry[] {
|
|
113
|
-
const { fields, body } = splitFrontmatter(normalizeEol(raw));
|
|
114
|
-
return parseLegacySections(body).map((section) => ({
|
|
115
|
-
title: section.title,
|
|
116
|
-
content: section.content,
|
|
117
|
-
type: fields.type ?? "",
|
|
118
|
-
updated: fields.updated ?? "",
|
|
119
|
-
}));
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* 把 legacy 标题变成合法的 entry `name`。
|
|
124
|
-
*
|
|
125
|
-
* `](` 必须中和:`MemoryStore.#validateName` 会拒绝它(它能伪造索引行的 name/file 分组),
|
|
126
|
-
* 而迁移**不能**因为一条标题里有个 markdown 链接就整轮失败。替换成 `] (` 保住可读性。
|
|
127
|
-
*/
|
|
128
|
-
function sanitizeName(title: string): string {
|
|
129
|
-
return title
|
|
130
|
-
.replace(/[\r\n]+/g, " ")
|
|
131
|
-
.replaceAll("](", "] (")
|
|
132
|
-
.trim();
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* 给一条 legacy 段落挑一个不与其它记忆冲突的 `name`(spec §15.3 的后缀规则)。
|
|
137
|
-
*
|
|
138
|
-
* 候选按 `base`、`base (2)`、`base (3)`…… 依次探测(`name` 精确匹配,与 store 的语义一致):
|
|
139
|
-
* - 未被占用 → 采用。
|
|
140
|
-
* - 已被占用,但磁盘上同名条目的正文与这一段相同 → **复用**这个名字:`addEntry` 对同名条目
|
|
141
|
-
* 是幂等覆盖,且保留磁盘上的 `created`。迁移中途失败后重跑时,上一轮已经写成功的条目走的正是
|
|
142
|
-
* 这条路 —— 不会产出 ` (2)` 影子副本(spec §15.4 的重跑安全)。
|
|
143
|
-
* - 已被占用、正文不同 → 试下一个后缀:迁移前就存在的同名用户条目必须被保护。
|
|
144
|
-
*/
|
|
145
|
-
function pickName(used: Set<string>, bodies: Map<string, string>, base: string, body: string): string {
|
|
146
|
-
const wanted = body.trim();
|
|
147
|
-
let candidate = base;
|
|
148
|
-
for (let n = 2; used.has(candidate) && bodies.get(candidate)?.trim() !== wanted; n++) {
|
|
149
|
-
candidate = `${base} (${n})`;
|
|
150
|
-
}
|
|
151
|
-
return candidate;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/** 旧 `updated` 归一为 store 接受的 `YYYY-MM-DD`;解析不出来就用今天(`created` 是必填字段)。 */
|
|
155
|
-
function normalizeDate(updated: string, now: Date): string {
|
|
156
|
-
const iso = updated.match(/(\d{4})-(\d{2})-(\d{2})/);
|
|
157
|
-
if (iso) return `${iso[1]}-${iso[2]}-${iso[3]}`;
|
|
158
|
-
const parsed = updated ? new Date(updated) : null;
|
|
159
|
-
if (parsed && !Number.isNaN(parsed.getTime())) return parsed.toISOString().slice(0, 10);
|
|
160
|
-
return now.toISOString().slice(0, 10);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
async function isFile(path: string): Promise<boolean> {
|
|
164
|
-
return stat(path).then(
|
|
165
|
-
(info) => info.isFile(),
|
|
166
|
-
() => false,
|
|
167
|
-
);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/** 只把 ENOENT 当作「文件不在了」;其余读取错误(EISDIR/EACCES/EIO)一律上抛(spec §15.4)。 */
|
|
171
|
-
async function readIfExists(path: string): Promise<string | null> {
|
|
172
|
-
try {
|
|
173
|
-
return await readFile(path, "utf8");
|
|
174
|
-
} catch (e) {
|
|
175
|
-
if ((e as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
176
|
-
throw e;
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* 自建回滚点 `.backups/migrate-<ts>/`(spec §15.3 步骤 2)。
|
|
182
|
-
*
|
|
183
|
-
* **不能用 `createSnapshot`**:它恒产出 `<ts>-<label>`,不可能以 `migrate-` 开头,
|
|
184
|
-
* 而 `pruneSnapshots` 只豁免 `migrate-` 前缀 —— 用它建的回滚点会在后续任何一次写入时
|
|
185
|
-
* 被 `snapshotKeep`(默认 5)裁掉,用户就再也回不去了(spec §19 风险表明列了这一条)。
|
|
186
|
-
*
|
|
187
|
-
* 布局:`migrate-<ts>/` = memoryDir 下所有普通文件的副本;`migrate-<ts>/originals/` = 待迁移的
|
|
188
|
-
* topic 文件副本(手工回滚时从这里恢复,见 spec §15.5)。
|
|
189
|
-
*/
|
|
190
|
-
async function createRollbackPoint(memoryDir: string, legacyFiles: string[], now: Date): Promise<string> {
|
|
191
|
-
const stamp = now.toISOString().replace(/[:.]/g, "-");
|
|
192
|
-
const backupDir = join(memoryDir, BACKUP_DIR, `migrate-${stamp}`);
|
|
193
|
-
await mkdir(join(backupDir, "originals"), { recursive: true });
|
|
194
|
-
|
|
195
|
-
// memoryDir 的存在性已在调用前检查过;列表失败(EACCES/EIO)不能吞:那会返回一个只有
|
|
196
|
-
// `originals/` 的不完整回滚点,而它随后就被写进 `.migrated` —— 用户再也回不去(spec §15.4)。
|
|
197
|
-
const entries = await readdir(memoryDir, { withFileTypes: true });
|
|
198
|
-
for (const entry of entries) {
|
|
199
|
-
// 只复制普通文件、跳过锁记录;`.backups` 与 `sessions/` 是目录,天然被排除。
|
|
200
|
-
// `.migrated` 此刻还不存在(它在最后一步才写),所以不需要显式排除。
|
|
201
|
-
if (!entry.isFile() || entry.name === LOCK_FILE) continue;
|
|
202
|
-
await cp(join(memoryDir, entry.name), join(backupDir, entry.name));
|
|
203
|
-
}
|
|
204
|
-
for (const file of legacyFiles) {
|
|
205
|
-
await cp(join(memoryDir, file), join(backupDir, "originals", file));
|
|
206
|
-
}
|
|
207
|
-
return backupDir;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
async function writeMarker(path: string, marker: MigrationMarker): Promise<void> {
|
|
211
|
-
await writeFile(path, `${JSON.stringify(marker, null, 2)}\n`, "utf8");
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* 首次 `session_start` 时把 v1 的 topic 布局迁移到 v2 的 per-entry 布局(spec §15 / D10)。
|
|
216
|
-
*
|
|
217
|
-
* 返回 `null` 表示「本次没有迁移任何东西」(已迁移过、目录不存在、或没有 legacy 文件)。
|
|
218
|
-
* 失败时**不写** `.migrated`、保留备份、把错误原样上抛(spec §15.4):下次 session_start 重试,
|
|
219
|
-
* 而重跑是安全的 —— `addEntry` 对同名条目幂等,且候选名只在「磁盘上同名条目的正文与当前段落不同」
|
|
220
|
-
* 时才追加后缀(正文相同即复用,见 `pickName`)。
|
|
221
|
-
*/
|
|
222
|
-
export async function migrateIfNeeded(store: MemoryStore, options?: { now?: Date }): Promise<MigrationResult | null> {
|
|
223
|
-
const memoryDir = store.cfg.memoryDir;
|
|
224
|
-
const markerPath = join(memoryDir, MIGRATED_FILE);
|
|
225
|
-
if (await isFile(markerPath)) return null;
|
|
226
|
-
|
|
227
|
-
// 目录还不存在:没有 legacy 数据,也不在这里替 store 建目录(首次写入时它自己会建)。
|
|
228
|
-
const names = await readdir(memoryDir).catch(() => null);
|
|
229
|
-
if (names === null) return null;
|
|
230
|
-
|
|
231
|
-
const now = options?.now ?? new Date();
|
|
232
|
-
const nothing: MigrationMarker = { migratedAt: now.toISOString(), entries: 0, files: 0, backupDir: "" };
|
|
233
|
-
|
|
234
|
-
// 触发条件之一是「MEMORY.md 存在」(spec §15.2)。不存在就没有旧索引可迁,写标记以免每次
|
|
235
|
-
// session_start 都全目录 readFile。
|
|
236
|
-
if (!names.includes(INDEX_FILE)) {
|
|
237
|
-
await writeMarker(markerPath, nothing);
|
|
238
|
-
return null;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
const candidates = names.filter((n) => n.endsWith(".md") && n !== INDEX_FILE && !n.startsWith(".")).sort();
|
|
242
|
-
const legacyFiles: string[] = [];
|
|
243
|
-
for (const file of candidates) {
|
|
244
|
-
// 只容忍 ENOENT(readdir 与 read 之间文件消失 → 当作非 legacy)。EISDIR/EACCES/EIO 必须上抛:
|
|
245
|
-
// 把不可读的候选归为「非 legacy」会在它是唯一候选时写下 0/0 标记,重试永久不再发生(spec §15.4)。
|
|
246
|
-
const raw = await readIfExists(join(memoryDir, file));
|
|
247
|
-
if (raw !== null && isLegacyTopicFile(raw)) legacyFiles.push(file);
|
|
248
|
-
}
|
|
249
|
-
if (legacyFiles.length === 0) {
|
|
250
|
-
await writeMarker(markerPath, nothing);
|
|
251
|
-
return null;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
return store.withLogicalLock(async () => {
|
|
255
|
-
const backupDir = await createRollbackPoint(memoryDir, legacyFiles, now);
|
|
256
|
-
|
|
257
|
-
// `name` 唯一性集合的初值来自磁盘:这让「迁移中途失败后重跑」不会产出 ` (2)` 影子副本 ——
|
|
258
|
-
// 上一轮已经写成功的条目会在 listEntries 里,正文与当前段落相同的候选会被复用(addEntry 幂等)。
|
|
259
|
-
const summaries = await store.listEntries();
|
|
260
|
-
const usedNames = new Set(summaries.map((summary) => summary.name));
|
|
261
|
-
// 候选探测还需要「同名的正文」。一次 `searchEntries("")` 拿走全部正文(空 needle 对
|
|
262
|
-
// 任何字符串都是 includes 命中),避免旧实现里在摘要循环内反复 `readEntry`(每次都重扫
|
|
263
|
-
// 目录 → O(N²))。同名多条目时以 listEntries 的顺序为准(后者覆盖前者),与现状一致。
|
|
264
|
-
const bodies = new Map<string, string>();
|
|
265
|
-
for (const entry of await store.searchEntries("")) bodies.set(entry.name, entry.body);
|
|
266
|
-
let entries = 0;
|
|
267
|
-
|
|
268
|
-
for (const file of legacyFiles) {
|
|
269
|
-
const raw = await readFile(join(memoryDir, file), "utf8");
|
|
270
|
-
for (const legacy of parseLegacyEntries(raw)) {
|
|
271
|
-
const name = pickName(usedNames, bodies, sanitizeName(legacy.title), legacy.content);
|
|
272
|
-
// 空标题或空正文的段落不生成 entry:store 会拒绝("name is required" / "body is required"),
|
|
273
|
-
// 而让整轮迁移因为一个空 `## ` 段失败是更差的取舍。原文件在 originals/ 里,信息没有丢。
|
|
274
|
-
if (!name || !legacy.content) continue;
|
|
275
|
-
usedNames.add(name);
|
|
276
|
-
await store.addEntry(
|
|
277
|
-
{
|
|
278
|
-
name,
|
|
279
|
-
description: name,
|
|
280
|
-
type: isEntryType(legacy.type) ? legacy.type : "feedback",
|
|
281
|
-
body: legacy.content,
|
|
282
|
-
created: normalizeDate(legacy.updated, now),
|
|
283
|
-
},
|
|
284
|
-
{ skipLogicalLock: true, skipSnapshot: true },
|
|
285
|
-
);
|
|
286
|
-
entries++;
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
await store.rebuildIndex({ skipLogicalLock: true, skipSnapshot: true });
|
|
291
|
-
|
|
292
|
-
// 原 topic 文件从 memory 目录移除,否则 rebuildIndex 之后它们还会作为「无法解析的 .md」
|
|
293
|
-
// 留在目录里(spec §15.3 步骤 5)。它们已经保留在 migrate-<ts>/originals/。
|
|
294
|
-
// 删除失败必须上抛(只有 ENOENT 视为已删除):吞掉的话 `.migrated` 会在文件仍留在目录里的
|
|
295
|
-
// 情况下被写下,重试永久不再发生(spec §15.4)。
|
|
296
|
-
for (const file of legacyFiles) {
|
|
297
|
-
await unlinkStrict(join(memoryDir, file));
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
await writeMarker(markerPath, { migratedAt: now.toISOString(), entries, files: legacyFiles.length, backupDir });
|
|
301
|
-
return { files: legacyFiles.length, entries, backupDir };
|
|
302
|
-
}, MIGRATE_LOCK_TIMEOUT_MS);
|
|
303
|
-
}
|