pi-memoria 0.1.3 → 0.2.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 +120 -589
- package/index.ts +14 -79
- package/package.json +1 -1
- package/skills/memoria/SKILL.md +14 -2
- package/src/commands.ts +2 -79
- package/src/config.ts +3 -11
- package/src/index-engine.ts +2 -1
- package/src/recall.ts +63 -38
- package/src/runtime.ts +11 -220
- package/src/store.ts +33 -5
- package/src/tools.ts +9 -134
- package/src/types.ts +2 -10
- package/src/learn.ts +0 -299
- package/src/transfer.ts +0 -180
package/src/learn.ts
DELETED
|
@@ -1,299 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Session learning: extract durable memories from a conversation and merge
|
|
3
|
-
* them into the library.
|
|
4
|
-
*
|
|
5
|
-
* The pure functions (`buildTranscriptText`, `buildLearnPrompt`,
|
|
6
|
-
* `parseLearnResponse`, `mergeStrategy`) are testable without the pi runtime;
|
|
7
|
-
* `harvestSession` wires them to the current model.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
11
|
-
import type { MemoriaRuntime } from "./runtime.ts";
|
|
12
|
-
import { oneLine, truncateChars } from "./util.ts";
|
|
13
|
-
import { tokenizeRaw } from "./tokenize.ts";
|
|
14
|
-
import type { Priority } from "./types.ts";
|
|
15
|
-
|
|
16
|
-
export interface LearnedMemory {
|
|
17
|
-
/** Broad grouping name; becomes the file name and note title. */
|
|
18
|
-
topic: string;
|
|
19
|
-
/** Optional short label for this fact inside the topic note. */
|
|
20
|
-
label: string;
|
|
21
|
-
content: string;
|
|
22
|
-
category: string;
|
|
23
|
-
tags: string[];
|
|
24
|
-
summary: string;
|
|
25
|
-
priority: Priority;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export const LEARN_CATEGORIES = ["people", "projects", "preferences", "decisions", "knowledge", "workflows", "inbox"] as const;
|
|
29
|
-
|
|
30
|
-
const MAX_TRANSCRIPT_CHARS = 48_000;
|
|
31
|
-
const MAX_MESSAGE_CHARS = 1_600;
|
|
32
|
-
/** Upper bound on model calls per harvest. */
|
|
33
|
-
const MAX_CHUNKS = 4;
|
|
34
|
-
|
|
35
|
-
interface MessageEntryLike {
|
|
36
|
-
type?: string;
|
|
37
|
-
message?: {
|
|
38
|
-
role?: string;
|
|
39
|
-
content?: unknown;
|
|
40
|
-
};
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function blockText(content: unknown): string {
|
|
44
|
-
if (typeof content === "string") return content;
|
|
45
|
-
if (!Array.isArray(content)) return "";
|
|
46
|
-
const parts: string[] = [];
|
|
47
|
-
for (const block of content) {
|
|
48
|
-
if (!block || typeof block !== "object") continue;
|
|
49
|
-
const typed = block as { type?: string; text?: string; name?: string };
|
|
50
|
-
if (typed.type === "text" && typeof typed.text === "string") parts.push(typed.text);
|
|
51
|
-
}
|
|
52
|
-
return parts.join("\n");
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** Flatten session entries into a compact, role-tagged transcript. */
|
|
56
|
-
export function buildTranscriptText(entries: ReadonlyArray<MessageEntryLike>, maxChars = MAX_TRANSCRIPT_CHARS): string {
|
|
57
|
-
const lines: string[] = [];
|
|
58
|
-
for (const entry of entries) {
|
|
59
|
-
if (entry.type !== "message" || !entry.message) continue;
|
|
60
|
-
const role = entry.message.role;
|
|
61
|
-
if (role !== "user" && role !== "assistant") continue;
|
|
62
|
-
const text = truncateChars(oneLine(blockText(entry.message.content), MAX_MESSAGE_CHARS), MAX_MESSAGE_CHARS).trim();
|
|
63
|
-
if (!text) continue;
|
|
64
|
-
lines.push(`${role === "user" ? "USER" : "ASSISTANT"}: ${text}`);
|
|
65
|
-
}
|
|
66
|
-
const joined = lines.join("\n");
|
|
67
|
-
// Keep the tail of the conversation when it does not fit; recent context is
|
|
68
|
-
// usually where durable facts appear.
|
|
69
|
-
return joined.length > maxChars ? joined.slice(joined.length - maxChars) : joined;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Split a transcript into chunks at line boundaries.
|
|
74
|
-
*
|
|
75
|
-
* Long sessions are harvested chunk by chunk so one huge prompt neither
|
|
76
|
-
* truncates silently nor blocks the UI for the whole extraction. The tail is
|
|
77
|
-
* kept when the transcript exceeds `maxChunks * chunkChars`, because that is
|
|
78
|
-
* where the recent facts are. At most `maxChunks` chunks are returned; if line
|
|
79
|
-
* rounding would produce more, the leading ones are folded together.
|
|
80
|
-
*/
|
|
81
|
-
export function chunkTranscript(transcript: string, chunkChars = 12_000, maxChunks = MAX_CHUNKS): string[] {
|
|
82
|
-
const size = Math.max(1000, chunkChars);
|
|
83
|
-
const bounded = transcript.length > size * maxChunks ? transcript.slice(transcript.length - size * maxChunks) : transcript;
|
|
84
|
-
const lines = bounded.split("\n");
|
|
85
|
-
const chunks: string[] = [];
|
|
86
|
-
let current: string[] = [];
|
|
87
|
-
let length = 0;
|
|
88
|
-
for (const line of lines) {
|
|
89
|
-
if (length + line.length + 1 > size && current.length > 0) {
|
|
90
|
-
chunks.push(current.join("\n"));
|
|
91
|
-
current = [];
|
|
92
|
-
length = 0;
|
|
93
|
-
}
|
|
94
|
-
current.push(line);
|
|
95
|
-
length += line.length + 1;
|
|
96
|
-
}
|
|
97
|
-
if (current.length > 0) chunks.push(current.join("\n"));
|
|
98
|
-
// Line-boundary rounding can produce one extra chunk; fold the leading
|
|
99
|
-
// extras into the first one so the caller can rely on the cap.
|
|
100
|
-
if (chunks.length > maxChunks) {
|
|
101
|
-
const overflow = chunks.length - maxChunks + 1;
|
|
102
|
-
const merged = chunks.slice(0, overflow).join("\n");
|
|
103
|
-
chunks.splice(0, overflow, merged);
|
|
104
|
-
}
|
|
105
|
-
return chunks;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
export function buildLearnPrompt(transcript: string, knownTitles: string[] = []): string {
|
|
109
|
-
const known = knownTitles.length > 0 ? `\nAlready stored (do not duplicate; update mentally instead):\n${knownTitles.slice(0, 60).map((title) => `- ${title}`).join("\n")}\n` : "";
|
|
110
|
-
return [
|
|
111
|
-
"You extract durable long-term memories from a conversation for a memory system.",
|
|
112
|
-
"",
|
|
113
|
-
"Memories are grouped by BROAD TOPIC, never by individual fact. The topic becomes the",
|
|
114
|
-
"file name, so it must be a short noun phrase that many related facts could share.",
|
|
115
|
-
"",
|
|
116
|
-
"Return ONLY a JSON array (no prose, no code fences). Each element:",
|
|
117
|
-
'{"topic": string (broad grouping, 1-4 words, a noun phrase), "label": string (short label for this fact, e.g. "Drinks"), "content": string (markdown, 1-6 sentences), "category": one of people|projects|preferences|decisions|knowledge|workflows|inbox, "tags": string[] (lowercase), "summary": string (one line), "priority": "low"|"normal"|"high"|"critical"}',
|
|
118
|
-
"",
|
|
119
|
-
"Topic rules (most important):",
|
|
120
|
-
'- GOOD topics: "Dietary preferences", "Alice", "Deployment conventions", "Project Nightingale", "On-call rotation".',
|
|
121
|
-
'- BAD topics (fact-shaped, will be rejected): "likes apples", "Alice prefers oat milk", "deploys happen on Thursdays".',
|
|
122
|
-
"- Never invent a topic per fact. If several facts belong together, emit multiple elements with the SAME topic and different labels.",
|
|
123
|
-
"",
|
|
124
|
-
"Other rules:",
|
|
125
|
-
"- Keep durable, reusable facts: user preferences, personal/professional context, project facts, decisions and their rationale, conventions, recurring workflows, lessons learned.",
|
|
126
|
-
"- Skip transient task state, code that already lives in the repository, secrets, credentials, and anything the user asked to forget.",
|
|
127
|
-
"- Do not record the assistant's unaccepted suggestions.",
|
|
128
|
-
"- Prefer few high-quality notes over many trivial ones. Merge related facts into a single topic.",
|
|
129
|
-
"- Use the user's own terminology. Write content that will still make sense months later without the conversation.",
|
|
130
|
-
"- If nothing is worth remembering, return [].",
|
|
131
|
-
known,
|
|
132
|
-
"<conversation>",
|
|
133
|
-
transcript,
|
|
134
|
-
"</conversation>",
|
|
135
|
-
].join("\n");
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function coercePriority(value: unknown): Priority {
|
|
139
|
-
if (value === "low" || value === "high" || value === "critical") return value;
|
|
140
|
-
return "normal";
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/** Parse the model's JSON array, tolerating code fences and stray prose. */
|
|
144
|
-
export function parseLearnResponse(text: string): LearnedMemory[] {
|
|
145
|
-
const cleaned = text.replace(/```(?:json)?/gi, "").trim();
|
|
146
|
-
const start = cleaned.indexOf("[");
|
|
147
|
-
const end = cleaned.lastIndexOf("]");
|
|
148
|
-
if (start === -1 || end === -1 || end <= start) return [];
|
|
149
|
-
let parsed: unknown;
|
|
150
|
-
try {
|
|
151
|
-
parsed = JSON.parse(cleaned.slice(start, end + 1));
|
|
152
|
-
} catch {
|
|
153
|
-
return [];
|
|
154
|
-
}
|
|
155
|
-
if (!Array.isArray(parsed)) return [];
|
|
156
|
-
const out: LearnedMemory[] = [];
|
|
157
|
-
for (const item of parsed) {
|
|
158
|
-
if (!item || typeof item !== "object") continue;
|
|
159
|
-
const raw = item as Record<string, unknown>;
|
|
160
|
-
// `title` is accepted as a legacy alias for `topic`.
|
|
161
|
-
const topic = typeof raw.topic === "string" ? raw.topic : typeof raw.title === "string" ? raw.title : "";
|
|
162
|
-
if (!topic || typeof raw.content !== "string") continue;
|
|
163
|
-
const label = typeof raw.label === "string" ? oneLine(raw.label, 80) : "";
|
|
164
|
-
const content = raw.content.trim();
|
|
165
|
-
if (!content) continue;
|
|
166
|
-
const category = typeof raw.category === "string" && (LEARN_CATEGORIES as readonly string[]).includes(raw.category) ? raw.category : "inbox";
|
|
167
|
-
const tags = Array.isArray(raw.tags) ? raw.tags.map((tag) => oneLine(String(tag), 40).toLowerCase()).filter(Boolean).slice(0, 12) : [];
|
|
168
|
-
const summary = typeof raw.summary === "string" && raw.summary.trim() ? oneLine(raw.summary, 240) : oneLine(content, 240);
|
|
169
|
-
out.push({ topic: oneLine(topic, 120), label, content, category, tags, summary, priority: coercePriority(raw.priority) });
|
|
170
|
-
if (out.length >= 25) break;
|
|
171
|
-
}
|
|
172
|
-
return out;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* Decide whether a learned memory should update an existing one.
|
|
177
|
-
* Returns the memory id to update, or undefined to create a new note.
|
|
178
|
-
*/
|
|
179
|
-
export function mergeTarget(
|
|
180
|
-
candidate: LearnedMemory,
|
|
181
|
-
existing: Array<{ id: string; title: string; category: string; score: number }>,
|
|
182
|
-
): string | undefined {
|
|
183
|
-
const candidateTokens = new Set(tokenizeRaw(candidate.topic, { stopwords: true }));
|
|
184
|
-
for (const entry of existing) {
|
|
185
|
-
if (entry.category !== candidate.category) continue;
|
|
186
|
-
const entryTokens = tokenizeRaw(entry.title, { stopwords: true });
|
|
187
|
-
const overlap = entryTokens.filter((token) => candidateTokens.has(token)).length;
|
|
188
|
-
const denominator = Math.max(1, Math.min(candidateTokens.size, entryTokens.length));
|
|
189
|
-
const ratio = overlap / denominator;
|
|
190
|
-
if (ratio >= 0.6 && entry.score > 3) return entry.id;
|
|
191
|
-
if (entry.score > 14) return entry.id;
|
|
192
|
-
}
|
|
193
|
-
return undefined;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
export interface HarvestResult {
|
|
197
|
-
created: string[];
|
|
198
|
-
updated: string[];
|
|
199
|
-
skipped: number;
|
|
200
|
-
error?: string;
|
|
201
|
-
/** Number of extraction calls made. */
|
|
202
|
-
chunks?: number;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
export interface HarvestOptions {
|
|
206
|
-
/** Characters per extraction chunk. */
|
|
207
|
-
chunkChars?: number;
|
|
208
|
-
/** Maximum extraction calls. */
|
|
209
|
-
maxChunks?: number;
|
|
210
|
-
/** Progress callback, used to keep the UI informed during long extractions. */
|
|
211
|
-
onProgress?: (update: { phase: "extracting" | "merging" | "done"; index: number; total: number }) => void;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* Ask the current model to extract memories from the session and persist them.
|
|
216
|
-
* Merges into near-duplicate existing notes instead of creating clones.
|
|
217
|
-
*/
|
|
218
|
-
export async function harvestSession(
|
|
219
|
-
ctx: ExtensionContext,
|
|
220
|
-
runtime: MemoriaRuntime,
|
|
221
|
-
entries: ReadonlyArray<MessageEntryLike>,
|
|
222
|
-
options: HarvestOptions = {},
|
|
223
|
-
): Promise<HarvestResult> {
|
|
224
|
-
const result: HarvestResult = { created: [], updated: [], skipped: 0, chunks: 0 };
|
|
225
|
-
const transcript = buildTranscriptText(entries);
|
|
226
|
-
if (transcript.length < 200) {
|
|
227
|
-
return { ...result, error: "Conversation is too short to learn from." };
|
|
228
|
-
}
|
|
229
|
-
const model = ctx.model;
|
|
230
|
-
if (!model || !ctx.modelRegistry.hasConfiguredAuth(model)) {
|
|
231
|
-
return { ...result, error: "No authenticated model available for extraction." };
|
|
232
|
-
}
|
|
233
|
-
const chunks = chunkTranscript(transcript, options.chunkChars ?? 12_000, options.maxChunks ?? MAX_CHUNKS);
|
|
234
|
-
const knownTitles = (await runtime.listEntries("all")).map((entry) => entry.meta.title);
|
|
235
|
-
const candidates: LearnedMemory[] = [];
|
|
236
|
-
const seenCandidates = new Set<string>();
|
|
237
|
-
for (let index = 0; index < chunks.length; index += 1) {
|
|
238
|
-
options.onProgress?.({ phase: "extracting", index: index + 1, total: chunks.length });
|
|
239
|
-
const prompt = buildLearnPrompt(chunks[index], knownTitles);
|
|
240
|
-
const response = await ctx.modelRegistry.complete(
|
|
241
|
-
model,
|
|
242
|
-
{ messages: [{ role: "user", content: [{ type: "text", text: prompt }], timestamp: Date.now() }] },
|
|
243
|
-
{ maxTokens: 4096, cacheRetention: "none" },
|
|
244
|
-
);
|
|
245
|
-
result.chunks = index + 1;
|
|
246
|
-
const text = response.content
|
|
247
|
-
.filter((block): block is { type: "text"; text: string } => block.type === "text")
|
|
248
|
-
.map((block) => block.text)
|
|
249
|
-
.join("\n");
|
|
250
|
-
for (const candidate of parseLearnResponse(text)) {
|
|
251
|
-
const key = `${candidate.category}:${candidate.topic.toLowerCase()}::${candidate.content.toLowerCase().slice(0, 60)}`;
|
|
252
|
-
if (seenCandidates.has(key)) continue;
|
|
253
|
-
seenCandidates.add(key);
|
|
254
|
-
candidates.push(candidate);
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
if (candidates.length === 0) {
|
|
258
|
-
return { ...result, error: "The model found nothing durable to store." };
|
|
259
|
-
}
|
|
260
|
-
options.onProgress?.({ phase: "merging", index: chunks.length, total: chunks.length });
|
|
261
|
-
const seen = new Set<string>();
|
|
262
|
-
for (const candidate of candidates) {
|
|
263
|
-
const dedupeKey = `${candidate.category}:${candidate.topic.toLowerCase()}`;
|
|
264
|
-
const search = await runtime.search(`${candidate.topic} ${candidate.tags.join(" ")}`, { limit: 3, scope: "all" });
|
|
265
|
-
const existing = search.hits.map((hit) => ({ id: hit.doc.id, title: hit.doc.title, category: hit.doc.category, score: hit.score }));
|
|
266
|
-
const target = seen.has(dedupeKey) ? undefined : mergeTarget(candidate, existing);
|
|
267
|
-
if (target) {
|
|
268
|
-
const appended = await runtime.appendToNote(target, {
|
|
269
|
-
content: candidate.content,
|
|
270
|
-
label: candidate.label,
|
|
271
|
-
tags: candidate.tags,
|
|
272
|
-
summary: candidate.summary,
|
|
273
|
-
priority: candidate.priority,
|
|
274
|
-
source: "session-extract",
|
|
275
|
-
});
|
|
276
|
-
if (appended?.duplicate) result.skipped += 1;
|
|
277
|
-
else if (appended) result.updated.push(appended.doc.id);
|
|
278
|
-
else result.skipped += 1;
|
|
279
|
-
seen.add(dedupeKey);
|
|
280
|
-
continue;
|
|
281
|
-
}
|
|
282
|
-
const written = await runtime.write({
|
|
283
|
-
topic: candidate.topic,
|
|
284
|
-
content: candidate.content,
|
|
285
|
-
label: candidate.label,
|
|
286
|
-
category: candidate.category,
|
|
287
|
-
tags: candidate.tags,
|
|
288
|
-
summary: candidate.summary,
|
|
289
|
-
priority: candidate.priority,
|
|
290
|
-
source: "session-extract",
|
|
291
|
-
});
|
|
292
|
-
if (written.result.duplicate) result.skipped += 1;
|
|
293
|
-
else if (written.result.updated) result.updated.push(written.result.doc.id);
|
|
294
|
-
else result.created.push(written.result.doc.id);
|
|
295
|
-
seen.add(dedupeKey);
|
|
296
|
-
}
|
|
297
|
-
options.onProgress?.({ phase: "done", index: chunks.length, total: chunks.length });
|
|
298
|
-
return result;
|
|
299
|
-
}
|
package/src/transfer.ts
DELETED
|
@@ -1,180 +0,0 @@
|
|
|
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
|
-
}
|