pi-memoria 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,180 @@
1
+ /**
2
+ * JSONL export/import.
3
+ *
4
+ * One JSON object per line so the format is appendable, diffable and readable
5
+ * without a parser dependency. `memoria_export` writes it, `memoria_import`
6
+ * reads it; the same format is what a future migration tool would use.
7
+ *
8
+ * A record is either a memory note (`type: "memory"`) or the always-loaded
9
+ * briefing (`type: "hot"`). Unknown fields are ignored on import, and unknown
10
+ * record types are reported instead of silently dropped.
11
+ */
12
+
13
+ import { basename, join } from "node:path";
14
+ import { LIBRARY_DIR } from "./config.ts";
15
+ import {
16
+ normalizeAliases,
17
+ normalizeConfidence,
18
+ normalizeLinks,
19
+ normalizePriority,
20
+ parseStringList,
21
+ readMemoryDoc,
22
+ slugify,
23
+ uniquePath,
24
+ } from "./store.ts";
25
+ import { stringifyWithFrontmatter } from "./frontmatter.ts";
26
+ import { atomicWriteFile, newMemoryId, normalizePath, oneLine, truncateChars } from "./util.ts";
27
+ import type { MemoryDoc, MemoryFrontmatter } from "./types.ts";
28
+
29
+ /** Upper bound on an imported note body, to keep a bad file from filling the store. */
30
+ export const MAX_IMPORT_BODY_CHARS = 400_000;
31
+
32
+ export interface ImportedNote {
33
+ id: string;
34
+ relPath: string;
35
+ title: string;
36
+ category: string;
37
+ tags: string[];
38
+ aliases: string[];
39
+ related: string[];
40
+ supersedes: string[];
41
+ summary: string;
42
+ priority: string;
43
+ confidence: string;
44
+ created?: number;
45
+ updated?: number;
46
+ body: string;
47
+ }
48
+
49
+ export type ExportRecord =
50
+ | { type: "hot"; root?: string; content: string }
51
+ | ({ type: "memory"; root?: string } & ImportedNote);
52
+
53
+ /** Serialize one record as a single JSONL line. */
54
+ export function encodeJsonl(record: ExportRecord): string {
55
+ return JSON.stringify(record);
56
+ }
57
+
58
+ /**
59
+ * Validate and normalize a relative path from an import file.
60
+ *
61
+ * Import files are data, not code: a path is only accepted when it stays inside
62
+ * `library/`, ends in `.md`, and contains no traversal segments.
63
+ */
64
+ export function sanitizeRelPath(relPath: string): string | undefined {
65
+ const cleaned = normalizePath(String(relPath ?? "").trim()).replace(/^\.\//, "");
66
+ if (!cleaned || cleaned.startsWith("/") || /^[a-zA-Z]:/.test(cleaned)) return undefined;
67
+ const segments = cleaned.split("/");
68
+ if (segments.some((segment) => segment === ".." || segment === "." || segment === "")) return undefined;
69
+ if (segments.some((segment) => segment.startsWith("."))) return undefined;
70
+ if (!cleaned.startsWith(`${LIBRARY_DIR}/`)) return undefined;
71
+ if (!cleaned.toLowerCase().endsWith(".md")) return undefined;
72
+ if (basename(cleaned).toLowerCase() === "index.md") return undefined;
73
+ return cleaned;
74
+ }
75
+
76
+ /** Parse JSONL text into records, collecting per-line errors instead of throwing. */
77
+ export function decodeJsonl(text: string): { records: ExportRecord[]; errors: string[] } {
78
+ const records: ExportRecord[] = [];
79
+ const errors: string[] = [];
80
+ const lines = text.split(/\r?\n/);
81
+ for (let i = 0; i < lines.length; i += 1) {
82
+ const line = lines[i].trim();
83
+ if (!line) continue;
84
+ let parsed: unknown;
85
+ try {
86
+ parsed = JSON.parse(line);
87
+ } catch {
88
+ errors.push(`line ${i + 1}: not valid JSON`);
89
+ continue;
90
+ }
91
+ if (!parsed || typeof parsed !== "object") {
92
+ errors.push(`line ${i + 1}: not an object`);
93
+ continue;
94
+ }
95
+ const raw = parsed as Record<string, unknown>;
96
+ if (raw.type === "hot") {
97
+ if (typeof raw.content !== "string") {
98
+ errors.push(`line ${i + 1}: hot record without content`);
99
+ continue;
100
+ }
101
+ records.push({ type: "hot", content: truncateChars(raw.content, MAX_IMPORT_BODY_CHARS) });
102
+ continue;
103
+ }
104
+ if (raw.type !== "memory") {
105
+ errors.push(`line ${i + 1}: unsupported record type ${JSON.stringify(raw.type)}`);
106
+ continue;
107
+ }
108
+ if (typeof raw.body !== "string" || typeof raw.relPath !== "string") {
109
+ errors.push(`line ${i + 1}: memory record needs relPath and body`);
110
+ continue;
111
+ }
112
+ const relPath = sanitizeRelPath(raw.relPath);
113
+ if (!relPath) {
114
+ errors.push(`line ${i + 1}: unsafe or non-library path ${JSON.stringify(raw.relPath)}`);
115
+ continue;
116
+ }
117
+ const category = typeof raw.category === "string" && raw.category.trim() ? oneLine(raw.category, 80) : relPath.split("/")[1] ?? "inbox";
118
+ const title = typeof raw.title === "string" && raw.title.trim() ? oneLine(raw.title, 160) : basename(relPath).replace(/\.md$/i, "");
119
+ records.push({
120
+ type: "memory",
121
+ id: typeof raw.id === "string" && raw.id.trim() ? oneLine(raw.id, 120) : newMemoryId(),
122
+ relPath,
123
+ title,
124
+ category,
125
+ tags: parseStringList(raw.tags).map((tag) => oneLine(tag, 40).toLowerCase()).filter(Boolean).slice(0, 24),
126
+ aliases: normalizeAliases(parseStringList(raw.aliases)),
127
+ related: normalizeLinks(parseStringList(raw.related)),
128
+ supersedes: normalizeLinks(parseStringList(raw.supersedes)),
129
+ summary: typeof raw.summary === "string" ? oneLine(raw.summary, 240) : "",
130
+ priority: normalizePriority(raw.priority),
131
+ confidence: normalizeConfidence(raw.confidence),
132
+ created: typeof raw.created === "number" && Number.isFinite(raw.created) ? raw.created : undefined,
133
+ updated: typeof raw.updated === "number" && Number.isFinite(raw.updated) ? raw.updated : undefined,
134
+ body: truncateChars(raw.body, MAX_IMPORT_BODY_CHARS),
135
+ });
136
+ }
137
+ return { records, errors };
138
+ }
139
+
140
+ /**
141
+ * Write an imported note to disk, preserving its id.
142
+ *
143
+ * When the target path is already taken by a different note the file is written
144
+ * to a free sibling (`topic-2.md`) instead of overwriting anything.
145
+ */
146
+ export async function writeImportedNote(root: string, note: ImportedNote): Promise<MemoryDoc> {
147
+ const relPath = sanitizeRelPath(note.relPath) ?? `${LIBRARY_DIR}/inbox/${slugify(note.title)}.md`;
148
+ const target = join(root, relPath);
149
+ const occupant = await readMemoryDoc(root, target);
150
+ const path = occupant && occupant.id !== note.id ? await uniquePath(root, relPath.split("/").slice(0, -1).join("/"), basename(relPath).replace(/\.md$/i, "")) : target;
151
+ const nowIso = new Date().toISOString();
152
+ const body = ensureHeading(note.body, note.title);
153
+ const frontmatter: MemoryFrontmatter = {
154
+ id: note.id,
155
+ title: note.title,
156
+ topic: note.title,
157
+ category: note.category,
158
+ tags: note.tags,
159
+ created: note.created ? new Date(note.created).toISOString() : nowIso,
160
+ updated: note.updated ? new Date(note.updated).toISOString() : nowIso,
161
+ last_used: note.updated ?? Date.now(),
162
+ source: "import",
163
+ confidence: normalizeConfidence(note.confidence),
164
+ priority: normalizePriority(note.priority),
165
+ };
166
+ if (note.summary) frontmatter.summary = oneLine(note.summary, 240);
167
+ if (note.aliases.length > 0) frontmatter.aliases = note.aliases;
168
+ if (note.related.length > 0) frontmatter.related = note.related;
169
+ if (note.supersedes.length > 0) frontmatter.supersedes = note.supersedes;
170
+ await atomicWriteFile(path, stringifyWithFrontmatter(frontmatter as Record<string, unknown>, body));
171
+ const doc = await readMemoryDoc(root, path);
172
+ if (!doc) throw new Error(`Failed to read back imported memory at ${path}`);
173
+ return doc;
174
+ }
175
+
176
+ function ensureHeading(body: string, title: string): string {
177
+ const trimmed = body.replace(/^\n+/, "").replace(/\s+$/, "");
178
+ if (/^\s*#\s+/.test(trimmed)) return trimmed;
179
+ return `# ${title}\n\n${trimmed}`;
180
+ }