@ryan_nookpi/pi-extension-memory-layer 0.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/storage.ts ADDED
@@ -0,0 +1,680 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import type { MemoryScope } from "./types.ts";
6
+
7
+ // ── Paths ────────────────────────────────────────────────────────────────────
8
+
9
+ const MEMORY_BASE = path.join(os.homedir(), ".pi", "memory");
10
+ const USER_DIR = path.join(MEMORY_BASE, "user");
11
+ const PROJECTS_DIR = path.join(MEMORY_BASE, "projects");
12
+
13
+ function scopeDir(scope: MemoryScope, projectId?: string): string {
14
+ if (scope === "project") {
15
+ if (!projectId) {
16
+ throw new Error("project scope requires projectId");
17
+ }
18
+ const safe = projectId.replace(/[^a-zA-Z0-9_-]/g, "-");
19
+ return path.join(PROJECTS_DIR, safe);
20
+ }
21
+ return USER_DIR;
22
+ }
23
+
24
+ // ── P1-2: Topic Sanitization & Path Confinement ─────────────────────────────
25
+
26
+ /**
27
+ * Sanitize a topic name into a safe filesystem slug.
28
+ * Strips path traversal sequences, path separators, and non-slug characters.
29
+ * Throws on empty result.
30
+ */
31
+ export function sanitizeTopic(topic: string): string {
32
+ const slug = topic
33
+ .replace(/\.\./g, "") // strip traversal
34
+ .replace(/[/\\]/g, "") // strip path separators
35
+ .toLowerCase()
36
+ .replace(/[^a-z0-9\uAC00-\uD7AF\u3131-\u3163-]/g, "-")
37
+ .replace(/-+/g, "-")
38
+ .replace(/^-|-$/g, "")
39
+ .slice(0, 50);
40
+
41
+ if (!slug) {
42
+ throw new Error(`Invalid topic name: "${topic}"`);
43
+ }
44
+ return slug;
45
+ }
46
+
47
+ function indexPath(scope: MemoryScope, projectId?: string): string {
48
+ return path.join(scopeDir(scope, projectId), "MEMORY.md");
49
+ }
50
+
51
+ function topicPath(scope: MemoryScope, projectId: string | undefined, topic: string): string {
52
+ const safe = sanitizeTopic(topic);
53
+ const dir = scopeDir(scope, projectId);
54
+ const resolved = path.resolve(dir, `${safe}.md`);
55
+
56
+ // Belt-and-suspenders: verify resolved path stays inside scope directory
57
+ const normalizedDir = path.resolve(dir);
58
+ if (!resolved.startsWith(`${normalizedDir}${path.sep}`)) {
59
+ throw new Error("Path confinement violation: topic escapes scope directory");
60
+ }
61
+
62
+ return resolved;
63
+ }
64
+
65
+ // ── Directory Setup ──────────────────────────────────────────────────────────
66
+
67
+ export async function ensureDir(): Promise<void> {
68
+ await fs.mkdir(USER_DIR, { recursive: true });
69
+ await fs.mkdir(PROJECTS_DIR, { recursive: true });
70
+ }
71
+
72
+ /**
73
+ * P1-1: Ensure the specific scope directory exists.
74
+ * Must be called before acquiring locks on scope-specific files.
75
+ */
76
+ async function ensureScopeDir(scope: MemoryScope, projectId?: string): Promise<void> {
77
+ const dir = scopeDir(scope, projectId);
78
+ await fs.mkdir(dir, { recursive: true });
79
+ }
80
+
81
+ // ── File Locking (per-scope, keyed on MEMORY.md) ────────────────────────────
82
+
83
+ const LOCK_TTL_MS = 30_000;
84
+ const LOCK_RETRY_MS = 50;
85
+ const LOCK_MAX_RETRIES = 10;
86
+
87
+ async function acquireLock(fp: string): Promise<() => Promise<void>> {
88
+ const lp = `${fp}.lock`;
89
+ for (let attempt = 0; attempt < LOCK_MAX_RETRIES; attempt++) {
90
+ try {
91
+ const handle = await fs.open(lp, "wx");
92
+ try {
93
+ await handle.writeFile(JSON.stringify({ pid: process.pid, ts: new Date().toISOString() }), "utf8");
94
+ await handle.close();
95
+ } catch (writeErr) {
96
+ await handle.close().catch(() => {});
97
+ await fs.unlink(lp).catch(() => {});
98
+ throw writeErr;
99
+ }
100
+ return async () => {
101
+ await fs.unlink(lp).catch(() => {});
102
+ };
103
+ } catch (err: unknown) {
104
+ if ((err as NodeJS.ErrnoException)?.code !== "EEXIST") {
105
+ throw new Error(`Lock acquire failed: ${err instanceof Error ? err.message : "unknown"}`);
106
+ }
107
+ const stats = await fs.stat(lp).catch(() => null);
108
+ if (!stats || Date.now() - stats.mtimeMs > LOCK_TTL_MS) {
109
+ await fs.unlink(lp).catch(() => {});
110
+ continue;
111
+ }
112
+ await new Promise((r) => setTimeout(r, LOCK_RETRY_MS));
113
+ }
114
+ }
115
+ throw new Error("Memory lock timeout after retries");
116
+ }
117
+
118
+ async function withScopeLock<T>(scope: MemoryScope, projectId: string | undefined, fn: () => Promise<T>): Promise<T> {
119
+ // P1-1: Ensure scope directory exists before creating the lock file
120
+ await ensureScopeDir(scope, projectId);
121
+
122
+ const fp = indexPath(scope, projectId);
123
+ const release = await acquireLock(fp);
124
+ try {
125
+ return await fn();
126
+ } finally {
127
+ await release();
128
+ }
129
+ }
130
+
131
+ // ── Atomic Write ─────────────────────────────────────────────────────────────
132
+
133
+ async function atomicWrite(fp: string, content: string): Promise<void> {
134
+ const dir = path.dirname(fp);
135
+ await fs.mkdir(dir, { recursive: true });
136
+ const tmp = path.join(dir, `.tmp_${crypto.randomBytes(4).toString("hex")}`);
137
+ try {
138
+ await fs.writeFile(tmp, content, "utf8");
139
+ await fs.rename(tmp, fp);
140
+ } catch (err) {
141
+ await fs.unlink(tmp).catch(() => {});
142
+ throw err;
143
+ }
144
+ }
145
+
146
+ // ── Read Helper ──────────────────────────────────────────────────────────────
147
+
148
+ async function readOrEmpty(fp: string): Promise<string> {
149
+ try {
150
+ return await fs.readFile(fp, "utf8");
151
+ } catch {
152
+ return "";
153
+ }
154
+ }
155
+
156
+ // ── MEMORY.md Index Parsing / Building ───────────────────────────────────────
157
+
158
+ export interface IndexSection {
159
+ topic: string; // filename without .md
160
+ entries: string[]; // memory titles (bullets)
161
+ }
162
+
163
+ export function parseIndex(content: string): IndexSection[] {
164
+ const sections: IndexSection[] = [];
165
+ let currentTopic: string | null = null;
166
+ let currentEntries: string[] = [];
167
+
168
+ for (const line of content.split("\n")) {
169
+ const topicMatch = line.match(/^## (.+)\.md\s*$/);
170
+ if (topicMatch) {
171
+ if (currentTopic) sections.push({ topic: currentTopic, entries: currentEntries });
172
+ currentTopic = topicMatch[1];
173
+ currentEntries = [];
174
+ continue;
175
+ }
176
+ const bullet = line.match(/^- (.+)$/);
177
+ if (bullet && currentTopic) {
178
+ currentEntries.push(bullet[1]);
179
+ }
180
+ }
181
+
182
+ if (currentTopic) sections.push({ topic: currentTopic, entries: currentEntries });
183
+ return sections;
184
+ }
185
+
186
+ function buildIndex(sections: IndexSection[]): string {
187
+ const lines = ["# Memory Index", ""];
188
+ for (const section of sections) {
189
+ lines.push(`## ${section.topic}.md`);
190
+ for (const entry of section.entries) {
191
+ lines.push(`- ${entry}`);
192
+ }
193
+ lines.push("");
194
+ }
195
+ return lines.join("\n");
196
+ }
197
+
198
+ // ── P1-3: Entry Marker Format ────────────────────────────────────────────────
199
+ // New format uses base64-encoded title markers to avoid ## heading collisions.
200
+ // Legacy ## format is auto-detected and supported for reading.
201
+
202
+ const ENTRY_MARKER_PREFIX = "<!-- @entry: ";
203
+ const ENTRY_MARKER_SUFFIX = " -->";
204
+
205
+ function encodeEntryTitle(title: string): string {
206
+ return Buffer.from(title, "utf8").toString("base64");
207
+ }
208
+
209
+ function decodeEntryTitle(encoded: string): string {
210
+ return Buffer.from(encoded, "base64").toString("utf8");
211
+ }
212
+
213
+ function isNewEntryFormat(raw: string): boolean {
214
+ return raw.includes(ENTRY_MARKER_PREFIX);
215
+ }
216
+
217
+ // ── Topic File Parsing / Building ────────────────────────────────────────────
218
+
219
+ export interface TopicEntry {
220
+ title: string;
221
+ content: string;
222
+ }
223
+
224
+ /** Parse topic file using new marker format. */
225
+ function parseTopicFileMarker(raw: string): { heading: string; entries: TopicEntry[] } {
226
+ const lines = raw.split("\n");
227
+ let heading = "";
228
+ const entries: TopicEntry[] = [];
229
+ let curTitle: string | null = null;
230
+ let curBody: string[] = [];
231
+
232
+ for (const line of lines) {
233
+ // Parse heading (first # line only)
234
+ if (!heading) {
235
+ const h1 = line.match(/^# (.+)$/);
236
+ if (h1) {
237
+ heading = h1[1];
238
+ continue;
239
+ }
240
+ }
241
+
242
+ // Parse entry marker
243
+ if (line.startsWith(ENTRY_MARKER_PREFIX) && line.endsWith(ENTRY_MARKER_SUFFIX)) {
244
+ if (curTitle !== null) {
245
+ entries.push({ title: curTitle, content: curBody.join("\n").trim() });
246
+ }
247
+ const b64 = line.slice(ENTRY_MARKER_PREFIX.length, -ENTRY_MARKER_SUFFIX.length).trim();
248
+ try {
249
+ curTitle = decodeEntryTitle(b64);
250
+ } catch {
251
+ curTitle = b64; // fallback: use raw if decode fails
252
+ }
253
+ curBody = [];
254
+ continue;
255
+ }
256
+
257
+ if (curTitle !== null) curBody.push(line);
258
+ }
259
+
260
+ if (curTitle !== null) entries.push({ title: curTitle, content: curBody.join("\n").trim() });
261
+ return { heading, entries };
262
+ }
263
+
264
+ /** Parse topic file using legacy ## heading format (backward compatibility). */
265
+ function parseTopicFileLegacy(raw: string): { heading: string; entries: TopicEntry[] } {
266
+ const lines = raw.split("\n");
267
+ let heading = "";
268
+ const entries: TopicEntry[] = [];
269
+ let curTitle: string | null = null;
270
+ let curBody: string[] = [];
271
+ let headingResolved = false;
272
+
273
+ for (const line of lines) {
274
+ // Issue 2 fix: only the first non-empty line's H1 is the document heading
275
+ if (!headingResolved) {
276
+ if (line.trim() === "") continue; // skip leading blank lines
277
+ const h1 = line.match(/^# (.+)$/);
278
+ if (h1) {
279
+ heading = h1[1];
280
+ headingResolved = true;
281
+ continue;
282
+ }
283
+ headingResolved = true; // first non-empty line is not H1 — stop looking
284
+ }
285
+
286
+ const h2 = line.match(/^## (.+)$/);
287
+ if (h2) {
288
+ if (curTitle) entries.push({ title: curTitle, content: curBody.join("\n").trim() });
289
+ curTitle = h2[1];
290
+ curBody = [];
291
+ continue;
292
+ }
293
+ if (curTitle !== null) curBody.push(line);
294
+ }
295
+ if (curTitle) entries.push({ title: curTitle, content: curBody.join("\n").trim() });
296
+
297
+ return { heading, entries };
298
+ }
299
+
300
+ /**
301
+ * Parse a topic file, auto-detecting format.
302
+ * New marker format takes priority; falls back to legacy ## format.
303
+ */
304
+ export function parseTopicFile(raw: string): { heading: string; entries: TopicEntry[] } {
305
+ if (isNewEntryFormat(raw)) return parseTopicFileMarker(raw);
306
+ return parseTopicFileLegacy(raw);
307
+ }
308
+
309
+ /** Build topic file always using new marker format (## safe). */
310
+ function buildTopicFile(heading: string, entries: TopicEntry[]): string {
311
+ const lines = [`# ${heading}`, ""];
312
+ for (const entry of entries) {
313
+ lines.push(`${ENTRY_MARKER_PREFIX}${encodeEntryTitle(entry.title)}${ENTRY_MARKER_SUFFIX}`);
314
+ lines.push(entry.content);
315
+ lines.push("");
316
+ }
317
+ return lines.join("\n");
318
+ }
319
+
320
+ // ── Public API: Save ─────────────────────────────────────────────────────────
321
+
322
+ export async function saveMemory(
323
+ scope: MemoryScope,
324
+ projectId: string | undefined,
325
+ topic: string,
326
+ topicHeading: string,
327
+ title: string,
328
+ content: string,
329
+ ): Promise<void> {
330
+ const safeTopic = sanitizeTopic(topic);
331
+ const iFp = indexPath(scope, projectId);
332
+ const tFp = topicPath(scope, projectId, safeTopic);
333
+
334
+ await withScopeLock(scope, projectId, async () => {
335
+ // 1) append to topic file
336
+ const raw = await readOrEmpty(tFp);
337
+ const parsed = raw ? parseTopicFile(raw) : { heading: topicHeading, entries: [] };
338
+ parsed.entries.push({ title, content });
339
+ await atomicWrite(tFp, buildTopicFile(parsed.heading, parsed.entries));
340
+
341
+ // 2) update index
342
+ const idxRaw = await readOrEmpty(iFp);
343
+ const sections = parseIndex(idxRaw);
344
+ let sec = sections.find((s) => s.topic === safeTopic);
345
+ if (!sec) {
346
+ sec = { topic: safeTopic, entries: [] };
347
+ sections.push(sec);
348
+ }
349
+ sec.entries.push(title);
350
+ await atomicWrite(iFp, buildIndex(sections));
351
+ });
352
+ }
353
+
354
+ // ── Public API: Remove ───────────────────────────────────────────────────────
355
+
356
+ export async function removeMemory(
357
+ scope: MemoryScope,
358
+ projectId: string | undefined,
359
+ topic: string,
360
+ title: string,
361
+ ): Promise<boolean> {
362
+ const safeTopic = sanitizeTopic(topic);
363
+ const iFp = indexPath(scope, projectId);
364
+ const tFp = topicPath(scope, projectId, safeTopic);
365
+
366
+ return withScopeLock(scope, projectId, async () => {
367
+ // 1) remove from topic file
368
+ const raw = await readOrEmpty(tFp);
369
+ if (!raw) return false;
370
+
371
+ const parsed = parseTopicFile(raw);
372
+ const idx = parsed.entries.findIndex((e) => e.title === title);
373
+ if (idx === -1) return false;
374
+
375
+ parsed.entries.splice(idx, 1);
376
+ if (parsed.entries.length === 0) {
377
+ await fs.unlink(tFp).catch(() => {});
378
+ } else {
379
+ await atomicWrite(tFp, buildTopicFile(parsed.heading, parsed.entries));
380
+ }
381
+
382
+ // 2) update index — remove only ONE matching entry (Issue 3 fix)
383
+ const idxRaw = await readOrEmpty(iFp);
384
+ const sections = parseIndex(idxRaw);
385
+ const secIdx = sections.findIndex((s) => s.topic === safeTopic);
386
+ if (secIdx !== -1) {
387
+ const sec = sections[secIdx];
388
+ const entryIdx = sec.entries.indexOf(title);
389
+ if (entryIdx !== -1) sec.entries.splice(entryIdx, 1);
390
+ if (sec.entries.length === 0) sections.splice(secIdx, 1);
391
+ }
392
+ await atomicWrite(iFp, buildIndex(sections));
393
+ return true;
394
+ });
395
+ }
396
+
397
+ // ── Public API: Check Existence (P2-2) ───────────────────────────────────────
398
+
399
+ /**
400
+ * Check if a memory entry exists in a specific scope (without lock).
401
+ * Used for forget ambiguity detection.
402
+ */
403
+ export async function memoryExistsInScope(
404
+ scope: MemoryScope,
405
+ projectId: string | undefined,
406
+ topic: string,
407
+ title: string,
408
+ ): Promise<boolean> {
409
+ try {
410
+ const entries = await loadTopicEntries(scope, projectId, topic);
411
+ return entries.some((e) => e.title === title);
412
+ } catch {
413
+ return false;
414
+ }
415
+ }
416
+
417
+ // ── Public API: Read ─────────────────────────────────────────────────────────
418
+
419
+ export async function loadIndex(scope: MemoryScope, projectId?: string): Promise<IndexSection[]> {
420
+ const raw = await readOrEmpty(indexPath(scope, projectId));
421
+ return parseIndex(raw);
422
+ }
423
+
424
+ export async function readMemoryMd(scope: MemoryScope, projectId?: string): Promise<string> {
425
+ return readOrEmpty(indexPath(scope, projectId));
426
+ }
427
+
428
+ export async function readTopicFile(scope: MemoryScope, projectId: string | undefined, topic: string): Promise<string> {
429
+ return readOrEmpty(topicPath(scope, projectId, topic));
430
+ }
431
+
432
+ export async function loadTopicEntries(
433
+ scope: MemoryScope,
434
+ projectId: string | undefined,
435
+ topic: string,
436
+ ): Promise<TopicEntry[]> {
437
+ const raw = await readTopicFile(scope, projectId, topic);
438
+ if (!raw) return [];
439
+ return parseTopicFile(raw).entries;
440
+ }
441
+
442
+ export async function listTopics(scope: MemoryScope, projectId?: string): Promise<string[]> {
443
+ const dir = scopeDir(scope, projectId);
444
+ try {
445
+ const files = await fs.readdir(dir);
446
+ return files.filter((f) => f.endsWith(".md") && f !== "MEMORY.md").map((f) => f.replace(/\.md$/, ""));
447
+ } catch {
448
+ return [];
449
+ }
450
+ }
451
+
452
+ // ── Search ───────────────────────────────────────────────────────────────────
453
+
454
+ export interface SearchResult {
455
+ scope: MemoryScope;
456
+ projectId?: string;
457
+ topic: string;
458
+ title: string;
459
+ content: string;
460
+ }
461
+
462
+ export function memoryEntryId(
463
+ scope: MemoryScope,
464
+ projectId: string | undefined,
465
+ topic: string,
466
+ title: string,
467
+ content: string,
468
+ ): string {
469
+ const key = `${scope}:${projectId ?? ""}:${topic}:${title}:${content}`;
470
+ return crypto.createHash("sha256").update(key).digest("hex").slice(0, 12);
471
+ }
472
+
473
+ export async function findMemoryById(id: string, projectId?: string): Promise<SearchResult | null> {
474
+ const scopes: Array<{ scope: MemoryScope; pid?: string }> = [
475
+ { scope: "user" },
476
+ ...(projectId ? [{ scope: "project" as MemoryScope, pid: projectId }] : []),
477
+ ];
478
+ for (const { scope, pid } of scopes) {
479
+ const topics = await listTopics(scope, pid);
480
+ for (const topic of topics) {
481
+ const entries = await loadTopicEntries(scope, pid, topic);
482
+ for (const entry of entries) {
483
+ if (memoryEntryId(scope, pid, topic, entry.title, entry.content) === id) {
484
+ return { scope, projectId: pid, topic, title: entry.title, content: entry.content };
485
+ }
486
+ }
487
+ }
488
+ }
489
+ return null;
490
+ }
491
+
492
+ function tokenizeSearchQuery(query: string): string[] {
493
+ const normalized = query.toLowerCase().trim();
494
+ if (!normalized) return [];
495
+ const splitTokens = normalized
496
+ .split(/\s+/)
497
+ .map((token) => token.trim())
498
+ .filter(Boolean);
499
+ const filtered = splitTokens.filter((token) => token.length >= 2);
500
+ const tokens = filtered.length > 0 ? filtered : splitTokens;
501
+ return [...new Set(tokens)];
502
+ }
503
+
504
+ export function scoreMemorySearchMatch(
505
+ query: string,
506
+ target: { topic: string; title: string; content: string },
507
+ ): number {
508
+ const normalizedQuery = query.toLowerCase().trim();
509
+ if (!normalizedQuery) return 0;
510
+
511
+ const topic = target.topic.toLowerCase();
512
+ const title = target.title.toLowerCase();
513
+ const content = target.content.toLowerCase();
514
+ const tokens = tokenizeSearchQuery(normalizedQuery);
515
+ let score = 0;
516
+
517
+ if (title.includes(normalizedQuery)) score += 10;
518
+ if (topic.includes(normalizedQuery)) score += 8;
519
+ if (content.includes(normalizedQuery)) score += 6;
520
+
521
+ for (const token of tokens) {
522
+ if (title.includes(token)) score += 3;
523
+ if (topic.includes(token)) score += 2;
524
+ if (content.includes(token)) score += 1;
525
+ }
526
+
527
+ return score;
528
+ }
529
+
530
+ export async function searchMemories(query: string, projectId?: string): Promise<SearchResult[]> {
531
+ const results: Array<SearchResult & { score: number }> = [];
532
+
533
+ const scopes: Array<{ scope: MemoryScope; pid?: string }> = [
534
+ { scope: "user" },
535
+ ...(projectId ? [{ scope: "project" as MemoryScope, pid: projectId }] : []),
536
+ ];
537
+
538
+ for (const { scope, pid } of scopes) {
539
+ const topics = await listTopics(scope, pid);
540
+ for (const topic of topics) {
541
+ const entries = await loadTopicEntries(scope, pid, topic);
542
+ for (const entry of entries) {
543
+ const score = scoreMemorySearchMatch(query, { topic, title: entry.title, content: entry.content });
544
+ if (score > 0) {
545
+ results.push({ scope, projectId: pid, topic, title: entry.title, content: entry.content, score });
546
+ }
547
+ }
548
+ }
549
+ }
550
+
551
+ results.sort((a, b) => b.score - a.score || a.topic.localeCompare(b.topic) || a.title.localeCompare(b.title));
552
+ return results.map(({ score: _score, ...result }) => result);
553
+ }
554
+
555
+ // ── Count Helper for Migration Dedup ─────────────────────────────────────────
556
+
557
+ /**
558
+ * Normalize text for consistent key generation.
559
+ * Trims whitespace and canonicalizes line endings (\r\n → \n).
560
+ */
561
+ function normalizeText(s: string): string {
562
+ return s.replace(/\r\n/g, "\n").trim();
563
+ }
564
+
565
+ /**
566
+ * Build a dedup key from title + content with consistent normalization.
567
+ * Both source (raw legacy) and existing (parsed) entries must go through
568
+ * this function to guarantee idempotent comparison.
569
+ */
570
+ function makeEntryKey(title: string, content: string): string {
571
+ return `${normalizeText(title)}\0${normalizeText(content)}`;
572
+ }
573
+
574
+ /** Count occurrences of each key in an array. */
575
+ function countByKey(keys: string[]): Map<string, number> {
576
+ const map = new Map<string, number>();
577
+ for (const key of keys) {
578
+ map.set(key, (map.get(key) ?? 0) + 1);
579
+ }
580
+ return map;
581
+ }
582
+
583
+ // ── P1-4: Migration from JSON (idempotent, atomic rename) ────────────────────
584
+
585
+ interface LegacyRecord {
586
+ title: string;
587
+ content: string;
588
+ scope: MemoryScope;
589
+ projectId?: string;
590
+ status: string;
591
+ }
592
+
593
+ type MigrationTarget = {
594
+ scope: MemoryScope;
595
+ projectId?: string;
596
+ filePath: string;
597
+ errorPrefix: string;
598
+ };
599
+
600
+ async function migrateLegacyRecords(target: MigrationTarget, records: LegacyRecord[], errors: string[]) {
601
+ const activeRecords = records.filter((r) => r.status === "active");
602
+ const existingEntries = await loadTopicEntries(target.scope, target.projectId, "general");
603
+ const sourceCounts = countByKey(activeRecords.map((r) => makeEntryKey(r.title, r.content)));
604
+ const existingCounts = countByKey(existingEntries.map((e) => makeEntryKey(e.title, e.content)));
605
+
606
+ let fileAllSucceeded = true;
607
+ let fileMigrated = 0;
608
+
609
+ for (const [key, srcCount] of sourceCounts) {
610
+ const needed = srcCount - (existingCounts.get(key) ?? 0);
611
+ if (needed <= 0) continue;
612
+ const sepIdx = key.indexOf("\0");
613
+ const title = key.slice(0, sepIdx);
614
+ const content = key.slice(sepIdx + 1);
615
+
616
+ for (let i = 0; i < needed; i++) {
617
+ try {
618
+ await saveMemory(target.scope, target.projectId, "general", "General", title, content);
619
+ fileMigrated++;
620
+ } catch (error) {
621
+ fileAllSucceeded = false;
622
+ errors.push(`${target.errorPrefix} "${title}": ${error instanceof Error ? error.message : "unknown"}`);
623
+ }
624
+ }
625
+ }
626
+
627
+ if (fileAllSucceeded) {
628
+ await fs.rename(target.filePath, `${target.filePath}.bak`);
629
+ }
630
+
631
+ return { migrated: fileMigrated, fileAllSucceeded };
632
+ }
633
+
634
+ async function migrateLegacyFile(target: MigrationTarget, errors: string[]) {
635
+ const raw = await fs.readFile(target.filePath, "utf8");
636
+ const records: LegacyRecord[] = JSON.parse(raw);
637
+ return migrateLegacyRecords(target, records, errors);
638
+ }
639
+
640
+ export async function migrateFromJson(): Promise<{ migrated: number; errors: string[] }> {
641
+ let migrated = 0;
642
+ const errors: string[] = [];
643
+
644
+ const userJson = path.join(MEMORY_BASE, "user.json");
645
+ try {
646
+ const result = await migrateLegacyFile({ scope: "user", filePath: userJson, errorPrefix: "user" }, errors);
647
+ migrated += result.migrated;
648
+ } catch (err) {
649
+ if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") {
650
+ errors.push(`user.json: ${err instanceof Error ? err.message : "unknown"}`);
651
+ }
652
+ }
653
+
654
+ try {
655
+ const projectFiles = await fs.readdir(PROJECTS_DIR);
656
+ for (const file of projectFiles) {
657
+ if (!file.endsWith(".json")) continue;
658
+ const projectId = file.replace(/\.json$/, "");
659
+ const filePath = path.join(PROJECTS_DIR, file);
660
+ try {
661
+ const result = await migrateLegacyFile(
662
+ {
663
+ scope: "project",
664
+ projectId,
665
+ filePath,
666
+ errorPrefix: `project "${projectId}"`,
667
+ },
668
+ errors,
669
+ );
670
+ migrated += result.migrated;
671
+ } catch (error) {
672
+ errors.push(`${file}: ${error instanceof Error ? error.message : "unknown"}`);
673
+ }
674
+ }
675
+ } catch {
676
+ // projects dir might not exist yet
677
+ }
678
+
679
+ return { migrated, errors };
680
+ }