pi-memoria 0.2.0 → 0.3.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/AGENTS.md +53 -0
- package/README.md +126 -166
- package/index.ts +299 -204
- package/package.json +28 -69
- package/src/aliases.ts +104 -0
- package/src/config.ts +22 -184
- package/src/database.ts +301 -0
- package/src/errors.ts +55 -0
- package/src/files.ts +29 -0
- package/src/hot-memory.ts +231 -0
- package/src/results.ts +264 -0
- package/src/sessions.ts +246 -843
- package/src/text.ts +48 -0
- package/skills/memoria/SKILL.md +0 -77
- package/skills/memoria-sessions/SKILL.md +0 -69
- package/src/commands.ts +0 -398
- package/src/frontmatter.ts +0 -318
- package/src/index-engine.ts +0 -1071
- package/src/recall.ts +0 -211
- package/src/rerank.ts +0 -85
- package/src/runtime.ts +0 -1469
- package/src/search.ts +0 -499
- package/src/similarity.ts +0 -374
- package/src/store.ts +0 -1724
- package/src/synonyms.ts +0 -105
- package/src/timeexpr.ts +0 -145
- package/src/tokenize.ts +0 -295
- package/src/tools.ts +0 -813
- package/src/types.ts +0 -541
- package/src/usage.ts +0 -188
- package/src/util.ts +0 -141
package/src/store.ts
DELETED
|
@@ -1,1724 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Filesystem layer: reading, writing and organizing markdown memories.
|
|
3
|
-
*
|
|
4
|
-
* All documented behavior lives here so the index engine and search layers stay
|
|
5
|
-
* pure and testable.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { mkdir, readdir, readFile, realpath, rm, stat } from "node:fs/promises";
|
|
9
|
-
import { basename, join, relative, resolve } from "node:path";
|
|
10
|
-
import { parseDateValue, splitFrontmatter, stringifyWithFrontmatter } from "./frontmatter.ts";
|
|
11
|
-
import { accumulateTokens, stem, tokenize, tokenizeRaw } from "./tokenize.ts";
|
|
12
|
-
import { atomicWriteFile, moveToTrash, newMemoryId, normalizePath, oneLine, readFileOrUndefined, shortHash, truncateChars } from "./util.ts";
|
|
13
|
-
import { HOT_FILE, INDEX_DIR, LIBRARY_DIR, TRASH_DIR } from "./config.ts";
|
|
14
|
-
import type { Confidence, MemoryDoc, MemoryFrontmatter, Priority } from "./types.ts";
|
|
15
|
-
|
|
16
|
-
const PRIORITIES: Priority[] = ["low", "normal", "high", "critical"];
|
|
17
|
-
const CONFIDENCES: Confidence[] = ["low", "medium", "high"];
|
|
18
|
-
/** File name of generated table-of-contents files, which are never indexed. */
|
|
19
|
-
export const GENERATED_INDEX = "INDEX.md";
|
|
20
|
-
|
|
21
|
-
/** Field weights used to build the weighted term-frequency map. */
|
|
22
|
-
export const FIELD_WEIGHTS = {
|
|
23
|
-
title: 4,
|
|
24
|
-
tags: 3,
|
|
25
|
-
aliases: 3,
|
|
26
|
-
summary: 2,
|
|
27
|
-
related: 1.5,
|
|
28
|
-
supersedes: 1.5,
|
|
29
|
-
category: 1.5,
|
|
30
|
-
body: 1,
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
export function normalizePriority(value: unknown): Priority {
|
|
34
|
-
if (typeof value === "string" && (PRIORITIES as string[]).includes(value.toLowerCase())) return value.toLowerCase() as Priority;
|
|
35
|
-
return "normal";
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function normalizeConfidence(value: unknown): Confidence {
|
|
39
|
-
if (typeof value === "string" && (CONFIDENCES as string[]).includes(value.toLowerCase())) return value.toLowerCase() as Confidence;
|
|
40
|
-
return "medium";
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export function priorityRank(priority: Priority): number {
|
|
44
|
-
return PRIORITIES.indexOf(priority);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** Slugify a title into a file name fragment. */
|
|
48
|
-
export function slugify(title: string): string {
|
|
49
|
-
const slug = title
|
|
50
|
-
.toLowerCase()
|
|
51
|
-
.normalize("NFKD")
|
|
52
|
-
.replace(/[\u0300-\u036f]/g, "")
|
|
53
|
-
// Possessives join rather than split: "John's father" -> "johns-father",
|
|
54
|
-
// not "john-s-father".
|
|
55
|
-
.replace(/['\u2019\u02bc]+/g, "")
|
|
56
|
-
.replace(/[^a-z0-9\u3040-\u30ff\u4e00-\u9fff\uac00-\ud7af]+/g, "-")
|
|
57
|
-
.replace(/^-+|-+$/g, "")
|
|
58
|
-
.slice(0, 64)
|
|
59
|
-
.replace(/-+$/g, "");
|
|
60
|
-
return slug || "memory";
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** Upper bound on how many alternative names one note may carry. */
|
|
64
|
-
export const MAX_ALIASES = 24;
|
|
65
|
-
/** Upper bound on `related` / `supersedes` links per note. */
|
|
66
|
-
export const MAX_LINKS = 16;
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Normalize `related` / `supersedes` links: trimmed, deduplicated
|
|
70
|
-
* case-insensitively, capped. Entries are ids (`mem_...`), relative paths or
|
|
71
|
-
* note titles; resolution happens against the live index.
|
|
72
|
-
*/
|
|
73
|
-
export function normalizeLinks(values: Iterable<unknown> | undefined): string[] {
|
|
74
|
-
const out: string[] = [];
|
|
75
|
-
const seen = new Set<string>();
|
|
76
|
-
for (const value of values ?? []) {
|
|
77
|
-
const link = oneLine(String(value ?? ""), 160).replace(/^[-*]+\s*/, "").trim();
|
|
78
|
-
if (!link) continue;
|
|
79
|
-
const key = link.toLowerCase();
|
|
80
|
-
if (seen.has(key)) continue;
|
|
81
|
-
seen.add(key);
|
|
82
|
-
out.push(link);
|
|
83
|
-
if (out.length >= MAX_LINKS) break;
|
|
84
|
-
}
|
|
85
|
-
return out;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Normalize a list of alternative names: one line each, trimmed, deduplicated
|
|
90
|
-
* case-insensitively, capped. Order is preserved (first wins), so callers can
|
|
91
|
-
* put the most canonical name first.
|
|
92
|
-
*/
|
|
93
|
-
export function normalizeAliases(values: Iterable<unknown> | undefined): string[] {
|
|
94
|
-
const out: string[] = [];
|
|
95
|
-
const seen = new Set<string>();
|
|
96
|
-
for (const value of values ?? []) {
|
|
97
|
-
const alias = oneLine(String(value ?? ""), 60).replace(/^[-*]+\s*/, "").trim();
|
|
98
|
-
if (!alias) continue;
|
|
99
|
-
const key = alias.toLowerCase();
|
|
100
|
-
if (seen.has(key)) continue;
|
|
101
|
-
seen.add(key);
|
|
102
|
-
out.push(alias);
|
|
103
|
-
if (out.length >= MAX_ALIASES) break;
|
|
104
|
-
}
|
|
105
|
-
return out;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/** Parse `aliases`/`tags`-style frontmatter values (array or separated string). */
|
|
109
|
-
export function parseStringList(value: unknown): string[] {
|
|
110
|
-
if (Array.isArray(value)) return value.map((entry) => String(entry).trim()).filter(Boolean);
|
|
111
|
-
if (typeof value === "string") return value.split(/[,\n]+/).map((entry) => entry.trim()).filter(Boolean);
|
|
112
|
-
return [];
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/** Derive the category (first folder under library/) from a relative path. */
|
|
116
|
-
export function categoryFromRelPath(relPath: string): string {
|
|
117
|
-
const normalized = normalizePath(relPath);
|
|
118
|
-
const prefix = `${LIBRARY_DIR}/`;
|
|
119
|
-
if (!normalized.startsWith(prefix)) return "";
|
|
120
|
-
const rest = normalized.slice(prefix.length);
|
|
121
|
-
const slash = rest.indexOf("/");
|
|
122
|
-
if (slash === -1) return "";
|
|
123
|
-
return rest.slice(0, slash);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/** Extract a one-line summary from frontmatter or the first body paragraph. */
|
|
127
|
-
export function deriveSummary(body: string, provided?: unknown): string {
|
|
128
|
-
if (typeof provided === "string" && provided.trim()) return oneLine(provided, 240);
|
|
129
|
-
const lines = body.split(/\r?\n/);
|
|
130
|
-
for (const line of lines) {
|
|
131
|
-
const trimmed = line.trim();
|
|
132
|
-
if (!trimmed) continue;
|
|
133
|
-
if (trimmed.startsWith("#") || trimmed.startsWith("<!--") || trimmed.startsWith("```") || trimmed.startsWith("-")) continue;
|
|
134
|
-
return oneLine(trimmed, 240);
|
|
135
|
-
}
|
|
136
|
-
return "";
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/** Derive a title from frontmatter, the first H1, or the file name. */
|
|
140
|
-
export function deriveTitle(frontmatter: MemoryFrontmatter, body: string, relPath: string): string {
|
|
141
|
-
if (typeof frontmatter.title === "string" && frontmatter.title.trim()) return oneLine(frontmatter.title, 160);
|
|
142
|
-
const h1 = body.match(/^\s*#\s+(.+)$/m);
|
|
143
|
-
if (h1) return oneLine(h1[1], 160);
|
|
144
|
-
const file = basename(relPath).replace(/\.md$/i, "");
|
|
145
|
-
return oneLine(file.replace(/[-_]+/g, " "), 160);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function stripLeadingTitle(body: string, title: string): string {
|
|
149
|
-
const match = body.match(/^\s*#\s+(.+?)\s*$/m);
|
|
150
|
-
if (!match) return body;
|
|
151
|
-
if (oneLine(match[1], 160).toLowerCase() !== title.toLowerCase()) return body;
|
|
152
|
-
return body.slice(match[0].length).replace(/^\n+/, "");
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function computeTokens(
|
|
156
|
-
title: string,
|
|
157
|
-
category: string,
|
|
158
|
-
tags: string[],
|
|
159
|
-
aliases: string[],
|
|
160
|
-
links: string[],
|
|
161
|
-
summary: string,
|
|
162
|
-
body: string,
|
|
163
|
-
): { tokens: Record<string, number>; tokenCount: number } {
|
|
164
|
-
const tokens: Record<string, number> = {};
|
|
165
|
-
accumulateTokens(title, FIELD_WEIGHTS.title, tokens);
|
|
166
|
-
accumulateTokens(tags.join(" "), FIELD_WEIGHTS.tags, tokens);
|
|
167
|
-
// Aliases are indexed at tag weight: finding a note by any of its names must
|
|
168
|
-
// not depend on the file name that happened to win.
|
|
169
|
-
accumulateTokens(aliases.join(" "), FIELD_WEIGHTS.aliases, tokens);
|
|
170
|
-
// Linked note names are searchable too, so following a relationship works
|
|
171
|
-
// from either end.
|
|
172
|
-
accumulateTokens(links.join(" "), FIELD_WEIGHTS.related, tokens);
|
|
173
|
-
accumulateTokens(category, FIELD_WEIGHTS.category, tokens);
|
|
174
|
-
accumulateTokens(summary, FIELD_WEIGHTS.summary, tokens);
|
|
175
|
-
const bodyWithoutTitle = stripLeadingTitle(body, title);
|
|
176
|
-
accumulateTokens(bodyWithoutTitle, FIELD_WEIGHTS.body, tokens);
|
|
177
|
-
let tokenCount = 0;
|
|
178
|
-
for (const value of Object.values(tokens)) tokenCount += value;
|
|
179
|
-
return { tokens, tokenCount: Math.max(1, Math.round(tokenCount)) };
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
export interface ParseDocInput {
|
|
183
|
-
source: string;
|
|
184
|
-
root: string;
|
|
185
|
-
path: string;
|
|
186
|
-
relPath: string;
|
|
187
|
-
mtimeMs: number;
|
|
188
|
-
size: number;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/** Parse a markdown file into a MemoryDoc. Missing ids are synthesized deterministically. */
|
|
192
|
-
export function parseMemoryDoc(input: ParseDocInput): MemoryDoc {
|
|
193
|
-
const { source, root, path, relPath } = input;
|
|
194
|
-
const split = splitFrontmatter(source);
|
|
195
|
-
const fm = split.frontmatter as MemoryFrontmatter;
|
|
196
|
-
const body = split.body;
|
|
197
|
-
const title = deriveTitle(fm, body, relPath);
|
|
198
|
-
const topic = typeof fm.topic === "string" && fm.topic.trim() ? oneLine(fm.topic, 120) : title;
|
|
199
|
-
const category = typeof fm.category === "string" && fm.category.trim() ? oneLine(fm.category, 80) : categoryFromRelPath(relPath);
|
|
200
|
-
const tags = Array.isArray(fm.tags)
|
|
201
|
-
? fm.tags.map((tag) => String(tag).trim()).filter(Boolean)
|
|
202
|
-
: typeof fm.tags === "string"
|
|
203
|
-
? fm.tags.split(/[,\s]+/).filter(Boolean)
|
|
204
|
-
: [];
|
|
205
|
-
const aliases = normalizeAliases(parseStringList(fm.aliases));
|
|
206
|
-
const related = normalizeLinks(parseStringList(fm.related));
|
|
207
|
-
const supersedes = normalizeLinks(parseStringList(fm.supersedes));
|
|
208
|
-
const summary = deriveSummary(body, fm.summary);
|
|
209
|
-
const id = typeof fm.id === "string" && fm.id.trim() ? oneLine(fm.id, 120) : `mem_${shortHash(`${relPath}:${source.length}`)}`;
|
|
210
|
-
const created = parseDateValue(fm.created) || input.mtimeMs;
|
|
211
|
-
const updated = parseDateValue(fm.updated) || input.mtimeMs;
|
|
212
|
-
const lastUsed = parseDateValue(fm.last_used) || updated;
|
|
213
|
-
const { tokens, tokenCount } = computeTokens(title, category, tags, aliases, [...related, ...supersedes], summary, body);
|
|
214
|
-
const wordCount = body.split(/\s+/).filter(Boolean).length;
|
|
215
|
-
return {
|
|
216
|
-
id,
|
|
217
|
-
root,
|
|
218
|
-
path,
|
|
219
|
-
relPath: normalizePath(relPath),
|
|
220
|
-
title,
|
|
221
|
-
topic,
|
|
222
|
-
category,
|
|
223
|
-
tags,
|
|
224
|
-
aliases,
|
|
225
|
-
related,
|
|
226
|
-
supersedes,
|
|
227
|
-
lastUsed,
|
|
228
|
-
summary,
|
|
229
|
-
priority: normalizePriority(fm.priority),
|
|
230
|
-
confidence: normalizeConfidence(fm.confidence),
|
|
231
|
-
created,
|
|
232
|
-
updated,
|
|
233
|
-
mtimeMs: input.mtimeMs,
|
|
234
|
-
size: input.size,
|
|
235
|
-
wordCount,
|
|
236
|
-
tokens,
|
|
237
|
-
tokenCount,
|
|
238
|
-
body,
|
|
239
|
-
frontmatter: fm,
|
|
240
|
-
unfiled: category === "inbox" || relPath === `${LIBRARY_DIR}/inbox`,
|
|
241
|
-
};
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/** Read and parse a memory file from disk. */
|
|
245
|
-
export async function readMemoryDoc(root: string, path: string): Promise<MemoryDoc | undefined> {
|
|
246
|
-
try {
|
|
247
|
-
const [source, stats] = await Promise.all([readFile(path, "utf8"), stat(path)]);
|
|
248
|
-
const relPath = normalizePath(relative(root, path));
|
|
249
|
-
return parseMemoryDoc({ source, root, path, relPath, mtimeMs: stats.mtimeMs, size: stats.size });
|
|
250
|
-
} catch (error) {
|
|
251
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
252
|
-
throw error;
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
/** Whether a relative path should be indexed. */
|
|
257
|
-
export function isIndexableRelPath(relPath: string, exclude: string[] = []): boolean {
|
|
258
|
-
const normalized = normalizePath(relPath);
|
|
259
|
-
if (!normalized.startsWith(`${LIBRARY_DIR}/`)) return false;
|
|
260
|
-
if (!normalized.toLowerCase().endsWith(".md")) return false;
|
|
261
|
-
if (basename(normalized) === GENERATED_INDEX) return false;
|
|
262
|
-
if (normalized.split("/").some((segment) => segment.startsWith("."))) return false;
|
|
263
|
-
for (const fragment of exclude) {
|
|
264
|
-
if (fragment && normalized.includes(fragment)) return false;
|
|
265
|
-
}
|
|
266
|
-
return true;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
export interface ScannedFile {
|
|
270
|
-
relPath: string;
|
|
271
|
-
path: string;
|
|
272
|
-
mtimeMs: number;
|
|
273
|
-
size: number;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
/** Recursively scan the library for markdown memories. */
|
|
277
|
-
export async function scanLibrary(root: string, exclude: string[] = []): Promise<Map<string, ScannedFile>> {
|
|
278
|
-
const out = new Map<string, ScannedFile>();
|
|
279
|
-
const libraryRoot = join(root, LIBRARY_DIR);
|
|
280
|
-
const walk = async (dir: string): Promise<void> => {
|
|
281
|
-
let entries;
|
|
282
|
-
try {
|
|
283
|
-
entries = await readdir(dir, { withFileTypes: true });
|
|
284
|
-
} catch (error) {
|
|
285
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
286
|
-
throw error;
|
|
287
|
-
}
|
|
288
|
-
for (const entry of entries) {
|
|
289
|
-
if (entry.name.startsWith(".")) continue;
|
|
290
|
-
const full = join(dir, entry.name);
|
|
291
|
-
if (entry.isDirectory()) {
|
|
292
|
-
await walk(full);
|
|
293
|
-
continue;
|
|
294
|
-
}
|
|
295
|
-
if (!entry.isFile()) continue;
|
|
296
|
-
const relPath = normalizePath(relative(root, full));
|
|
297
|
-
if (!isIndexableRelPath(relPath, exclude)) continue;
|
|
298
|
-
try {
|
|
299
|
-
const stats = await stat(full);
|
|
300
|
-
out.set(relPath, { relPath, path: full, mtimeMs: stats.mtimeMs, size: stats.size });
|
|
301
|
-
} catch {
|
|
302
|
-
// File disappeared mid-scan: ignore.
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
};
|
|
306
|
-
await walk(libraryRoot);
|
|
307
|
-
return out;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
export interface CreateMemoryInput {
|
|
311
|
-
/** The fact(s) to store, as markdown. Appended to the topic note when it exists. */
|
|
312
|
-
content: string;
|
|
313
|
-
/** Broad grouping name; becomes the file name and the note title. */
|
|
314
|
-
topic?: string;
|
|
315
|
-
/**
|
|
316
|
-
* Legacy alias for `topic`. Kept because models often reach for "title".
|
|
317
|
-
* Fact-like values are still rejected and filed under the category.
|
|
318
|
-
*/
|
|
319
|
-
title?: string;
|
|
320
|
-
/** Short bold label for this specific fact inside the topic note, e.g. "Drinks". */
|
|
321
|
-
label?: string;
|
|
322
|
-
category?: string;
|
|
323
|
-
tags?: string[];
|
|
324
|
-
/**
|
|
325
|
-
* Other names this note answers to (nickname, full name, former topic,
|
|
326
|
-
* relation). Indexed, so any of them recalls the note.
|
|
327
|
-
*/
|
|
328
|
-
aliases?: string[];
|
|
329
|
-
/** Related notes (ids, relative paths or titles). */
|
|
330
|
-
related?: string[];
|
|
331
|
-
/** Notes this one replaces (ids, relative paths or titles). */
|
|
332
|
-
supersedes?: string[];
|
|
333
|
-
summary?: string;
|
|
334
|
-
priority?: string;
|
|
335
|
-
confidence?: string;
|
|
336
|
-
source?: string;
|
|
337
|
-
session?: string;
|
|
338
|
-
/** When set, replace the body of this note instead of adding to a topic note. */
|
|
339
|
-
id?: string;
|
|
340
|
-
/** Pre-resolved document for `id` updates (avoids a filesystem scan). */
|
|
341
|
-
existingDoc?: MemoryDoc;
|
|
342
|
-
/** Explicit file name override (without extension). Bypasses topic resolution. */
|
|
343
|
-
slug?: string;
|
|
344
|
-
/**
|
|
345
|
-
* auto (default): add the fact to the topic note, creating it when missing.
|
|
346
|
-
* new: always create a new sibling file.
|
|
347
|
-
* replace: overwrite the topic note body (destroys other facts in it).
|
|
348
|
-
*/
|
|
349
|
-
mode?: "auto" | "new" | "replace";
|
|
350
|
-
/** Overwrite an existing file at the target path (used with `slug`). */
|
|
351
|
-
allowOverwrite?: boolean;
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
export interface CreateMemoryResult {
|
|
355
|
-
doc: MemoryDoc;
|
|
356
|
-
/** True when an existing note was updated. */
|
|
357
|
-
updated: boolean;
|
|
358
|
-
/** True when the fact was appended to an existing topic note. */
|
|
359
|
-
merged: boolean;
|
|
360
|
-
/** True when the fact was already present and nothing changed. */
|
|
361
|
-
duplicate: boolean;
|
|
362
|
-
/** Resolved broad topic (may differ from the requested one). */
|
|
363
|
-
topic: string;
|
|
364
|
-
/** True when a fact-like topic was rejected in favor of the category. */
|
|
365
|
-
topicAdjusted: boolean;
|
|
366
|
-
/** Explanation for `topicAdjusted`. */
|
|
367
|
-
topicNote?: string;
|
|
368
|
-
path: string;
|
|
369
|
-
relPath: string;
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
/* ------------------------------------------------------------------ */
|
|
373
|
-
/* Topics: file names always stay broad */
|
|
374
|
-
/* ------------------------------------------------------------------ */
|
|
375
|
-
|
|
376
|
-
/** Subjects that make a phrase read like a personal fact, not a topic. */
|
|
377
|
-
const ATOMIC_SUBJECTS = new Set([
|
|
378
|
-
"i",
|
|
379
|
-
"we",
|
|
380
|
-
"you",
|
|
381
|
-
"they",
|
|
382
|
-
"he",
|
|
383
|
-
"she",
|
|
384
|
-
"it",
|
|
385
|
-
"me",
|
|
386
|
-
"us",
|
|
387
|
-
"them",
|
|
388
|
-
"my",
|
|
389
|
-
"our",
|
|
390
|
-
"your",
|
|
391
|
-
"their",
|
|
392
|
-
"his",
|
|
393
|
-
"her",
|
|
394
|
-
"this",
|
|
395
|
-
"that",
|
|
396
|
-
"user",
|
|
397
|
-
"users",
|
|
398
|
-
"the user",
|
|
399
|
-
"someone",
|
|
400
|
-
"everyone",
|
|
401
|
-
"people",
|
|
402
|
-
]);
|
|
403
|
-
|
|
404
|
-
/**
|
|
405
|
-
* Unambiguous personal-fact verbs. Deliberately excludes noun/verb homographs
|
|
406
|
-
* ("start", "work", "plan", "call", "result") so noun-phrase topics such as
|
|
407
|
-
* "cold start optimization" are not mistaken for facts.
|
|
408
|
-
*/
|
|
409
|
-
const PERSONAL_VERBS = new Set([
|
|
410
|
-
"like",
|
|
411
|
-
"likes",
|
|
412
|
-
"love",
|
|
413
|
-
"loves",
|
|
414
|
-
"prefer",
|
|
415
|
-
"prefers",
|
|
416
|
-
"hate",
|
|
417
|
-
"hates",
|
|
418
|
-
"dislike",
|
|
419
|
-
"dislikes",
|
|
420
|
-
"enjoy",
|
|
421
|
-
"enjoys",
|
|
422
|
-
"want",
|
|
423
|
-
"wants",
|
|
424
|
-
"need",
|
|
425
|
-
"needs",
|
|
426
|
-
"drink",
|
|
427
|
-
"drinks",
|
|
428
|
-
"eat",
|
|
429
|
-
"eats",
|
|
430
|
-
"speak",
|
|
431
|
-
"speaks",
|
|
432
|
-
"live",
|
|
433
|
-
"lives",
|
|
434
|
-
"read",
|
|
435
|
-
"reads",
|
|
436
|
-
"watch",
|
|
437
|
-
"watches",
|
|
438
|
-
"play",
|
|
439
|
-
"plays",
|
|
440
|
-
"drive",
|
|
441
|
-
"drives",
|
|
442
|
-
"own",
|
|
443
|
-
"owns",
|
|
444
|
-
"study",
|
|
445
|
-
"studies",
|
|
446
|
-
"learn",
|
|
447
|
-
"learns",
|
|
448
|
-
"learned",
|
|
449
|
-
"moved",
|
|
450
|
-
"moves",
|
|
451
|
-
"visit",
|
|
452
|
-
"visits",
|
|
453
|
-
"use",
|
|
454
|
-
"uses",
|
|
455
|
-
"used",
|
|
456
|
-
"is",
|
|
457
|
-
"are",
|
|
458
|
-
"was",
|
|
459
|
-
"were",
|
|
460
|
-
"am",
|
|
461
|
-
"has",
|
|
462
|
-
"have",
|
|
463
|
-
"had",
|
|
464
|
-
"can",
|
|
465
|
-
"will",
|
|
466
|
-
"would",
|
|
467
|
-
"should",
|
|
468
|
-
"must",
|
|
469
|
-
"does",
|
|
470
|
-
"do",
|
|
471
|
-
"did",
|
|
472
|
-
"believes",
|
|
473
|
-
"believe",
|
|
474
|
-
"thinks",
|
|
475
|
-
"think",
|
|
476
|
-
"decided",
|
|
477
|
-
"decides",
|
|
478
|
-
"happen",
|
|
479
|
-
"happens",
|
|
480
|
-
"happened",
|
|
481
|
-
"occur",
|
|
482
|
-
"occurs",
|
|
483
|
-
"occurred",
|
|
484
|
-
"exists",
|
|
485
|
-
"exist",
|
|
486
|
-
"existed",
|
|
487
|
-
"depends",
|
|
488
|
-
"depend",
|
|
489
|
-
"differs",
|
|
490
|
-
"differ",
|
|
491
|
-
"matters",
|
|
492
|
-
"matter",
|
|
493
|
-
"spoke",
|
|
494
|
-
"said",
|
|
495
|
-
"says",
|
|
496
|
-
"told",
|
|
497
|
-
"tells",
|
|
498
|
-
]);
|
|
499
|
-
|
|
500
|
-
/**
|
|
501
|
-
* Relation and role nouns. A topic built from one of these names describes a
|
|
502
|
-
* *relationship* rather than a subject: "johns-father", "Alice's manager",
|
|
503
|
-
* "dad". Those hold only until the person has a name, so they are facts
|
|
504
|
-
* wearing a file name. "family", "friends" and team/org names are not here —
|
|
505
|
-
* those are genuinely broad.
|
|
506
|
-
*/
|
|
507
|
-
const RELATION_NOUNS = new Set([
|
|
508
|
-
"dad",
|
|
509
|
-
"daddy",
|
|
510
|
-
"father",
|
|
511
|
-
"mom",
|
|
512
|
-
"mum",
|
|
513
|
-
"mommy",
|
|
514
|
-
"mother",
|
|
515
|
-
"parent",
|
|
516
|
-
"parents",
|
|
517
|
-
"son",
|
|
518
|
-
"daughter",
|
|
519
|
-
"child",
|
|
520
|
-
"children",
|
|
521
|
-
"kid",
|
|
522
|
-
"kids",
|
|
523
|
-
"brother",
|
|
524
|
-
"sister",
|
|
525
|
-
"sibling",
|
|
526
|
-
"siblings",
|
|
527
|
-
"wife",
|
|
528
|
-
"husband",
|
|
529
|
-
"spouse",
|
|
530
|
-
"partner",
|
|
531
|
-
"fiance",
|
|
532
|
-
"fiancee",
|
|
533
|
-
"boyfriend",
|
|
534
|
-
"girlfriend",
|
|
535
|
-
"uncle",
|
|
536
|
-
"aunt",
|
|
537
|
-
"cousin",
|
|
538
|
-
"nephew",
|
|
539
|
-
"niece",
|
|
540
|
-
"grandfather",
|
|
541
|
-
"grandmother",
|
|
542
|
-
"grandpa",
|
|
543
|
-
"grandma",
|
|
544
|
-
"grandparent",
|
|
545
|
-
"grandparents",
|
|
546
|
-
"grandson",
|
|
547
|
-
"granddaughter",
|
|
548
|
-
"roommate",
|
|
549
|
-
"neighbor",
|
|
550
|
-
"landlord",
|
|
551
|
-
"employer",
|
|
552
|
-
"employee",
|
|
553
|
-
"manager",
|
|
554
|
-
"boss",
|
|
555
|
-
"supervisor",
|
|
556
|
-
"colleague",
|
|
557
|
-
"coworker",
|
|
558
|
-
"teammate",
|
|
559
|
-
"mentor",
|
|
560
|
-
"mentee",
|
|
561
|
-
"assistant",
|
|
562
|
-
]);
|
|
563
|
-
|
|
564
|
-
/** `John's`, `Johns'`, `John’s` — a possessive makes the phrase relational. */
|
|
565
|
-
const POSSESSIVE = /[^\s]['\u2019]s?($|\s)|['\u2019]s\b/;
|
|
566
|
-
|
|
567
|
-
/**
|
|
568
|
-
* True when a string reads like a single fact rather than a broad grouping.
|
|
569
|
-
* `"I like apples"`, `"likes apples"`, `"Alice prefers oat milk"` and
|
|
570
|
-
* `"John's father"` are facts; `"Dietary preferences"`, `"Deployment process"`,
|
|
571
|
-
* `"Alice"` and `"Family"` are topics.
|
|
572
|
-
*/
|
|
573
|
-
export function looksAtomicTopic(requested: string): boolean {
|
|
574
|
-
const cleaned = oneLine(requested, 160);
|
|
575
|
-
if (!cleaned) return true;
|
|
576
|
-
if (/[!?;]$/.test(cleaned) || cleaned.includes(",")) return true;
|
|
577
|
-
const tokens = tokenizeRaw(cleaned, { stopwords: false, stem: false });
|
|
578
|
-
if (tokens.length === 0) return true;
|
|
579
|
-
const isVerb = (token: string) => PERSONAL_VERBS.has(token) || PERSONAL_VERBS.has(stem(token));
|
|
580
|
-
/**
|
|
581
|
-
* A live predicate rather than a noun. Gerunds are excluded on purpose:
|
|
582
|
-
* "Machine learning notebook" and "Budget planning" are topics even though
|
|
583
|
-
* "learning" and "planning" look verbish, while "Alice prefers oat milk" and
|
|
584
|
-
* "likes apples" are facts.
|
|
585
|
-
*/
|
|
586
|
-
const isPredicate = (token: string) => isVerb(token) && !token.endsWith("ing");
|
|
587
|
-
if (isPredicate(tokens[0])) return true;
|
|
588
|
-
const subject = tokens[0] === "the" && tokens[1] ? `the ${tokens[1]}` : tokens[0];
|
|
589
|
-
if (ATOMIC_SUBJECTS.has(subject)) return true;
|
|
590
|
-
// A relation is a fact about someone else: "John's father" today, a name
|
|
591
|
-
// tomorrow. The relationship belongs in the fact text; the note lives under
|
|
592
|
-
// a broad topic (the person's name, or the category as a fallback).
|
|
593
|
-
if (tokens.some((token) => RELATION_NOUNS.has(token) || RELATION_NOUNS.has(stem(token)))) return true;
|
|
594
|
-
if (tokens.length > 1 && POSSESSIVE.test(cleaned)) return true;
|
|
595
|
-
// Short phrases containing a personal verb read like facts: "Alice prefers
|
|
596
|
-
// oat milk", "alice prefers oat milk", "Project Nightingale uses Postgres".
|
|
597
|
-
// Rejecting a noun-phrase topic only costs specificity (it falls back to the
|
|
598
|
-
// category); accepting a fact-shaped topic produces a bad file name, so this
|
|
599
|
-
// check is deliberately aggressive.
|
|
600
|
-
if (tokens.length <= 5 && tokens.some(isPredicate)) return true;
|
|
601
|
-
return false;
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
export interface TopicResolution {
|
|
605
|
-
/** Display form of the resolved topic. */
|
|
606
|
-
topic: string;
|
|
607
|
-
/** File-name base derived from the topic. */
|
|
608
|
-
slug: string;
|
|
609
|
-
/** True when the requested topic was rejected or missing. */
|
|
610
|
-
adjusted: boolean;
|
|
611
|
-
/** Human-readable explanation when adjusted. */
|
|
612
|
-
note?: string;
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
function titleCaseTopic(topic: string): string {
|
|
616
|
-
const trimmed = oneLine(topic, 160);
|
|
617
|
-
if (!trimmed) return trimmed;
|
|
618
|
-
return trimmed.charAt(0).toUpperCase() + trimmed.slice(1);
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
/**
|
|
622
|
-
* Resolve a requested topic into a broad, file-safe topic.
|
|
623
|
-
*
|
|
624
|
-
* The fallback is the category name, so even a missing or fact-like topic can
|
|
625
|
-
* never produce a file such as `likes-apples.md`.
|
|
626
|
-
*/
|
|
627
|
-
export function resolveTopic(
|
|
628
|
-
requested: string | undefined,
|
|
629
|
-
category: string,
|
|
630
|
-
defaultCategory: string,
|
|
631
|
-
maxWords = 6,
|
|
632
|
-
): TopicResolution {
|
|
633
|
-
const fallbackBase = category || defaultCategory;
|
|
634
|
-
const fallback: TopicResolution = {
|
|
635
|
-
topic: titleCaseTopic(fallbackBase),
|
|
636
|
-
slug: slugify(fallbackBase),
|
|
637
|
-
adjusted: false,
|
|
638
|
-
};
|
|
639
|
-
const cleaned = oneLine(requested ?? "", 120);
|
|
640
|
-
if (!cleaned) return fallback;
|
|
641
|
-
if (looksAtomicTopic(cleaned)) {
|
|
642
|
-
return {
|
|
643
|
-
...fallback,
|
|
644
|
-
adjusted: true,
|
|
645
|
-
note: `"${cleaned}" names a single fact or relationship, not a broad topic; filed under "${fallback.topic}" instead. Put the fact in the note body and use a broad topic (e.g. the person's name) for the file`,
|
|
646
|
-
};
|
|
647
|
-
}
|
|
648
|
-
const tokens = tokenizeRaw(cleaned, { stopwords: false, stem: false });
|
|
649
|
-
if (tokens.length > maxWords) {
|
|
650
|
-
return {
|
|
651
|
-
...fallback,
|
|
652
|
-
adjusted: true,
|
|
653
|
-
note: `"${cleaned}" is too long for a topic name (${tokens.length} words, max ${maxWords}); filed under "${fallback.topic}" instead`,
|
|
654
|
-
};
|
|
655
|
-
}
|
|
656
|
-
return { topic: titleCaseTopic(cleaned), slug: slugify(cleaned), adjusted: false };
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
export interface AppendFactResult {
|
|
660
|
-
body: string;
|
|
661
|
-
changed: boolean;
|
|
662
|
-
duplicate: boolean;
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
/**
|
|
666
|
-
* Append one fact to a topic note body.
|
|
667
|
-
*
|
|
668
|
-
* Facts live under a single `## Facts` section: single-line facts become
|
|
669
|
-
* bullets (optionally with a bold label), multi-line facts become `### label`
|
|
670
|
-
* blocks. Appending an already-present fact is a no-op.
|
|
671
|
-
*/
|
|
672
|
-
export function appendFact(body: string, label: string | undefined, content: string): AppendFactResult {
|
|
673
|
-
const trimmed = content.replace(/^\n+/, "").replace(/\s+$/, "").trim();
|
|
674
|
-
if (!trimmed) return { body, changed: false, duplicate: false };
|
|
675
|
-
const singleLine = !trimmed.includes("\n");
|
|
676
|
-
const plain = singleLine ? oneLine(trimmed, 2000).replace(/^[-*]\s*/, "") : "";
|
|
677
|
-
const probe = oneLine(singleLine ? plain : trimmed, 80).toLowerCase();
|
|
678
|
-
if (probe.length >= 12 && body.toLowerCase().includes(probe)) {
|
|
679
|
-
return { body, changed: false, duplicate: true };
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
const labelText = label && label.trim() ? oneLine(label.trim(), 80) : undefined;
|
|
683
|
-
let entry: string;
|
|
684
|
-
if (singleLine) {
|
|
685
|
-
entry = labelText ? `- **${labelText}** — ${plain}` : `- ${plain}`;
|
|
686
|
-
} else {
|
|
687
|
-
entry = labelText ? `### ${labelText}\n\n${trimmed}` : trimmed;
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
const base = body.replace(/\s+$/, "");
|
|
691
|
-
const header = /^##\s+Facts\s*$/m.exec(base);
|
|
692
|
-
if (!header) {
|
|
693
|
-
const separator = base.length > 0 ? "\n\n" : "";
|
|
694
|
-
return { body: `${base}${separator}## Facts\n\n${entry}\n`, changed: true, duplicate: false };
|
|
695
|
-
}
|
|
696
|
-
const sectionStart = header.index + header[0].length;
|
|
697
|
-
const nextHeading = base.slice(sectionStart).search(/\n##\s/);
|
|
698
|
-
const insertAt = nextHeading === -1 ? base.length : sectionStart + nextHeading;
|
|
699
|
-
const before = base.slice(0, insertAt).replace(/\s+$/, "");
|
|
700
|
-
const after = base.slice(insertAt).replace(/^\s+/, "");
|
|
701
|
-
const joined = before.length > 0 ? `${before}\n${entry}` : entry;
|
|
702
|
-
return { body: after ? `${joined}\n\n${after}\n` : `${joined}\n`, changed: true, duplicate: false };
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
/** Extend a topic note's summary with a new fact summary, without duplication. */
|
|
706
|
-
export function extendSummary(existing: string, addition: string, maxChars = 240): string {
|
|
707
|
-
const add = oneLine(addition, maxChars);
|
|
708
|
-
if (!add) return oneLine(existing, maxChars);
|
|
709
|
-
const current = oneLine(existing, maxChars);
|
|
710
|
-
if (!current) return add;
|
|
711
|
-
const probe = add.toLowerCase().slice(0, Math.min(24, add.length));
|
|
712
|
-
if (probe.length >= 8 && current.toLowerCase().includes(probe)) return current;
|
|
713
|
-
if (current.length + add.length + 2 > maxChars) return current;
|
|
714
|
-
return `${current.replace(/[.;]+$/, "")}; ${add}`;
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
/** First free `<slug>.md` (or `<slug>-N.md`) inside a store directory. */
|
|
718
|
-
export async function uniquePath(root: string, relDir: string, slug: string): Promise<string> {
|
|
719
|
-
const dir = join(root, relDir);
|
|
720
|
-
await mkdir(dir, { recursive: true });
|
|
721
|
-
let candidate = `${slug}.md`;
|
|
722
|
-
let counter = 2;
|
|
723
|
-
while (true) {
|
|
724
|
-
const full = join(dir, candidate);
|
|
725
|
-
try {
|
|
726
|
-
await stat(full);
|
|
727
|
-
candidate = `${slug}-${counter}.md`;
|
|
728
|
-
counter += 1;
|
|
729
|
-
if (counter > 500) throw new Error(`Unable to find a free file name for ${slug}`);
|
|
730
|
-
} catch (error) {
|
|
731
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") return full;
|
|
732
|
-
throw error;
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
function resolveCategoryPath(category: string | undefined, defaultCategory: string): { relDir: string; category: string } {
|
|
738
|
-
const clean = oneLine(category ?? defaultCategory, 80)
|
|
739
|
-
.replace(/[\\:]+/g, "/")
|
|
740
|
-
.replace(/\.\./g, "")
|
|
741
|
-
.replace(/^\/+|\/+$/g, "")
|
|
742
|
-
.trim();
|
|
743
|
-
if (!clean) return { relDir: `${LIBRARY_DIR}/${defaultCategory}`, category: defaultCategory };
|
|
744
|
-
const first = clean.split("/")[0];
|
|
745
|
-
return { relDir: `${LIBRARY_DIR}/${clean}`, category: first };
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
export interface CreateMemoryOptions {
|
|
749
|
-
/** Maximum words allowed in a topic name before falling back to the category. */
|
|
750
|
-
topicMaxWords?: number;
|
|
751
|
-
/** A topic note larger than this spills into a numbered sibling. */
|
|
752
|
-
topicMaxChars?: number;
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
/**
|
|
756
|
-
* Add a memory to the store.
|
|
757
|
-
*
|
|
758
|
-
* Without an `id` this adds a fact to a **topic note**: the file name is the
|
|
759
|
-
* broad topic slug, never the fact. Existing topic notes are appended to, so
|
|
760
|
-
* related facts accumulate in one place instead of creating `likes-apples.md`.
|
|
761
|
-
*/
|
|
762
|
-
export async function createMemory(
|
|
763
|
-
root: string,
|
|
764
|
-
input: CreateMemoryInput,
|
|
765
|
-
defaultCategory = "inbox",
|
|
766
|
-
options: CreateMemoryOptions = {},
|
|
767
|
-
): Promise<CreateMemoryResult> {
|
|
768
|
-
const now = new Date();
|
|
769
|
-
const nowIso = now.toISOString();
|
|
770
|
-
const nowMs = now.getTime();
|
|
771
|
-
const maxChars = Math.max(500, options.topicMaxChars ?? 8000);
|
|
772
|
-
if (input.id) {
|
|
773
|
-
const existing = input.existingDoc ?? (await findDocById(root, input.id));
|
|
774
|
-
if (!existing) throw new Error(`Memory not found: ${input.id}`);
|
|
775
|
-
const updated = await updateMemory(root, existing, {
|
|
776
|
-
content: input.content,
|
|
777
|
-
tags: input.tags,
|
|
778
|
-
related: input.related,
|
|
779
|
-
supersedes: input.supersedes,
|
|
780
|
-
summary: input.summary,
|
|
781
|
-
priority: input.priority,
|
|
782
|
-
confidence: input.confidence,
|
|
783
|
-
category: input.category,
|
|
784
|
-
source: input.source,
|
|
785
|
-
});
|
|
786
|
-
return {
|
|
787
|
-
doc: updated,
|
|
788
|
-
updated: true,
|
|
789
|
-
merged: false,
|
|
790
|
-
duplicate: false,
|
|
791
|
-
topic: typeof updated.frontmatter.topic === "string" ? updated.frontmatter.topic : updated.title,
|
|
792
|
-
topicAdjusted: false,
|
|
793
|
-
path: updated.path,
|
|
794
|
-
relPath: updated.relPath,
|
|
795
|
-
};
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
const { relDir, category } = resolveCategoryPath(input.category, defaultCategory);
|
|
799
|
-
const resolution = input.slug
|
|
800
|
-
? { topic: titleCaseTopic(oneLine(input.slug, 120)), slug: slugify(input.slug), adjusted: false }
|
|
801
|
-
: resolveTopic(input.topic ?? input.title, category, defaultCategory, options.topicMaxWords ?? 6);
|
|
802
|
-
const mode = input.mode ?? "auto";
|
|
803
|
-
const inputAliases = normalizeAliases(input.aliases);
|
|
804
|
-
|
|
805
|
-
// Find the topic note to merge into, or the first free sibling slot.
|
|
806
|
-
let mergeTarget: MemoryDoc | undefined;
|
|
807
|
-
let firstFreeBase: string | undefined;
|
|
808
|
-
if (mode !== "new") {
|
|
809
|
-
for (let n = 1; n <= 100; n += 1) {
|
|
810
|
-
const base = n === 1 ? resolution.slug : `${resolution.slug}-${n}`;
|
|
811
|
-
const candidate = await readMemoryDoc(root, join(root, relDir, `${base}.md`));
|
|
812
|
-
if (!candidate) {
|
|
813
|
-
if (!firstFreeBase) firstFreeBase = base;
|
|
814
|
-
break;
|
|
815
|
-
}
|
|
816
|
-
if (!mergeTarget && candidate.size <= maxChars) mergeTarget = candidate;
|
|
817
|
-
}
|
|
818
|
-
}
|
|
819
|
-
|
|
820
|
-
if (mergeTarget && mode === "auto") {
|
|
821
|
-
const appended = appendFact(mergeTarget.body, input.label, input.content);
|
|
822
|
-
if (!appended.changed) {
|
|
823
|
-
return {
|
|
824
|
-
doc: mergeTarget,
|
|
825
|
-
updated: false,
|
|
826
|
-
merged: true,
|
|
827
|
-
duplicate: true,
|
|
828
|
-
topic: typeof mergeTarget.frontmatter.topic === "string" ? mergeTarget.frontmatter.topic : mergeTarget.title,
|
|
829
|
-
topicAdjusted: resolution.adjusted,
|
|
830
|
-
topicNote: resolution.note,
|
|
831
|
-
path: mergeTarget.path,
|
|
832
|
-
relPath: mergeTarget.relPath,
|
|
833
|
-
};
|
|
834
|
-
}
|
|
835
|
-
const tags = input.tags ? [...new Set([...mergeTarget.tags, ...input.tags.map((tag) => oneLine(tag, 40)).filter(Boolean)])] : mergeTarget.tags;
|
|
836
|
-
const aliases = inputAliases.length > 0 ? normalizeAliases([...mergeTarget.aliases, ...inputAliases]) : mergeTarget.aliases;
|
|
837
|
-
const related = normalizeLinks([...mergeTarget.related, ...(input.related ?? [])]);
|
|
838
|
-
const supersedes = normalizeLinks([...mergeTarget.supersedes, ...(input.supersedes ?? [])]);
|
|
839
|
-
const priority = priorityRank(normalizePriority(input.priority)) > priorityRank(mergeTarget.priority)
|
|
840
|
-
? normalizePriority(input.priority)
|
|
841
|
-
: mergeTarget.priority;
|
|
842
|
-
const summary = extendSummary(mergeTarget.summary, input.summary ?? deriveSummary(input.content));
|
|
843
|
-
const merged = await updateMemory(root, mergeTarget, {
|
|
844
|
-
content: appended.body,
|
|
845
|
-
tags,
|
|
846
|
-
aliases,
|
|
847
|
-
related: input.related ? related : undefined,
|
|
848
|
-
supersedes: input.supersedes ? supersedes : undefined,
|
|
849
|
-
summary,
|
|
850
|
-
priority,
|
|
851
|
-
confidence: input.confidence,
|
|
852
|
-
source: input.source ?? "agent",
|
|
853
|
-
});
|
|
854
|
-
return {
|
|
855
|
-
doc: merged,
|
|
856
|
-
updated: true,
|
|
857
|
-
merged: true,
|
|
858
|
-
duplicate: false,
|
|
859
|
-
topic: resolution.topic,
|
|
860
|
-
topicAdjusted: resolution.adjusted,
|
|
861
|
-
topicNote: resolution.note,
|
|
862
|
-
path: merged.path,
|
|
863
|
-
relPath: merged.relPath,
|
|
864
|
-
};
|
|
865
|
-
}
|
|
866
|
-
|
|
867
|
-
const path = mode === "replace" && firstFreeBase === resolution.slug && !mergeTarget
|
|
868
|
-
? join(root, relDir, `${resolution.slug}.md`)
|
|
869
|
-
: firstFreeBase
|
|
870
|
-
? join(root, relDir, `${firstFreeBase}.md`)
|
|
871
|
-
: await uniquePath(root, relDir, resolution.slug);
|
|
872
|
-
const relPath = normalizePath(relative(root, path));
|
|
873
|
-
const existingAtPath = mode === "replace" ? await readMemoryDoc(root, path) : undefined;
|
|
874
|
-
const frontmatter: MemoryFrontmatter = {
|
|
875
|
-
id: existingAtPath?.id ?? newMemoryId(nowMs),
|
|
876
|
-
title: resolution.topic,
|
|
877
|
-
topic: resolution.topic,
|
|
878
|
-
category,
|
|
879
|
-
tags: input.tags?.map((tag) => oneLine(tag, 40)).filter(Boolean) ?? [],
|
|
880
|
-
created: existingAtPath?.frontmatter.created ?? nowIso,
|
|
881
|
-
updated: nowIso,
|
|
882
|
-
last_used: nowMs,
|
|
883
|
-
source: input.source ?? "agent",
|
|
884
|
-
confidence: normalizeConfidence(input.confidence),
|
|
885
|
-
priority: normalizePriority(input.priority),
|
|
886
|
-
};
|
|
887
|
-
const aliases = normalizeAliases([...parseStringList(existingAtPath?.frontmatter.aliases), ...inputAliases]);
|
|
888
|
-
if (aliases.length > 0) frontmatter.aliases = aliases;
|
|
889
|
-
const related = normalizeLinks([...parseStringList(existingAtPath?.frontmatter.related), ...(input.related ?? [])]);
|
|
890
|
-
if (related.length > 0) frontmatter.related = related;
|
|
891
|
-
const supersedes = normalizeLinks([...parseStringList(existingAtPath?.frontmatter.supersedes), ...(input.supersedes ?? [])]);
|
|
892
|
-
if (supersedes.length > 0) frontmatter.supersedes = supersedes;
|
|
893
|
-
const summary = oneLine(input.summary ?? deriveSummary(input.content), 240);
|
|
894
|
-
if (summary) frontmatter.summary = summary;
|
|
895
|
-
if (input.session) frontmatter.session = input.session;
|
|
896
|
-
const contentBody = input.content.replace(/^\n+/, "").replace(/\s+$/, "");
|
|
897
|
-
let body: string;
|
|
898
|
-
if (mode === "replace" && /^\s*#\s+/.test(contentBody)) {
|
|
899
|
-
// Full document supplied: keep its structure.
|
|
900
|
-
body = contentBody;
|
|
901
|
-
} else {
|
|
902
|
-
const heading = `# ${resolution.topic}`;
|
|
903
|
-
body = appendFact(heading, input.label, contentBody).body;
|
|
904
|
-
}
|
|
905
|
-
await atomicWriteFile(path, stringifyWithFrontmatter(frontmatter as Record<string, unknown>, body));
|
|
906
|
-
const doc = await readMemoryDoc(root, path);
|
|
907
|
-
if (!doc) throw new Error(`Failed to read back memory at ${path}`);
|
|
908
|
-
return {
|
|
909
|
-
doc,
|
|
910
|
-
updated: Boolean(existingAtPath),
|
|
911
|
-
merged: false,
|
|
912
|
-
duplicate: false,
|
|
913
|
-
topic: resolution.topic,
|
|
914
|
-
topicAdjusted: resolution.adjusted,
|
|
915
|
-
topicNote: resolution.note,
|
|
916
|
-
path,
|
|
917
|
-
relPath,
|
|
918
|
-
};
|
|
919
|
-
}
|
|
920
|
-
|
|
921
|
-
/**
|
|
922
|
-
* Find a memory by id anywhere under the library.
|
|
923
|
-
*
|
|
924
|
-
* When the caller already has index metadata, pass it in `candidates` to skip
|
|
925
|
-
* the full directory scan (the common case inside tools).
|
|
926
|
-
*/
|
|
927
|
-
export async function findDocById(
|
|
928
|
-
root: string,
|
|
929
|
-
id: string,
|
|
930
|
-
candidates?: Array<{ id: string; path: string }>,
|
|
931
|
-
): Promise<MemoryDoc | undefined> {
|
|
932
|
-
if (candidates) {
|
|
933
|
-
const match = candidates.find((entry) => entry.id === id);
|
|
934
|
-
return match ? readMemoryDoc(root, match.path) : undefined;
|
|
935
|
-
}
|
|
936
|
-
const scanned = await scanLibrary(root);
|
|
937
|
-
for (const entry of scanned.values()) {
|
|
938
|
-
const doc = await readMemoryDoc(root, entry.path);
|
|
939
|
-
if (doc && doc.id === id) return doc;
|
|
940
|
-
}
|
|
941
|
-
return undefined;
|
|
942
|
-
}
|
|
943
|
-
|
|
944
|
-
export interface UpdateMemoryInput {
|
|
945
|
-
title?: string;
|
|
946
|
-
content?: string;
|
|
947
|
-
tags?: string[];
|
|
948
|
-
aliases?: string[];
|
|
949
|
-
/** Replace the note's outgoing `related` links. */
|
|
950
|
-
related?: string[];
|
|
951
|
-
/** Replace the note's `supersedes` links. */
|
|
952
|
-
supersedes?: string[];
|
|
953
|
-
summary?: string;
|
|
954
|
-
priority?: string;
|
|
955
|
-
confidence?: string;
|
|
956
|
-
category?: string;
|
|
957
|
-
/** Replace only if the current body matches this hash prefix (optimistic concurrency). */
|
|
958
|
-
expectHash?: string;
|
|
959
|
-
source?: string;
|
|
960
|
-
}
|
|
961
|
-
|
|
962
|
-
/** Update an existing memory in place, moving it when the category changes. */
|
|
963
|
-
export async function updateMemory(root: string, doc: MemoryDoc, input: UpdateMemoryInput): Promise<MemoryDoc> {
|
|
964
|
-
if (input.expectHash && !shortHash(doc.body).startsWith(input.expectHash)) {
|
|
965
|
-
throw new Error(`Memory ${doc.id} changed since it was read; re-read and retry.`);
|
|
966
|
-
}
|
|
967
|
-
const frontmatter: MemoryFrontmatter = { ...doc.frontmatter };
|
|
968
|
-
if (input.title !== undefined) frontmatter.title = oneLine(input.title, 160);
|
|
969
|
-
if (input.tags !== undefined) frontmatter.tags = input.tags.map((tag) => oneLine(tag, 40)).filter(Boolean);
|
|
970
|
-
if (input.aliases !== undefined) {
|
|
971
|
-
const aliases = normalizeAliases(input.aliases);
|
|
972
|
-
if (aliases.length > 0) frontmatter.aliases = aliases;
|
|
973
|
-
else delete frontmatter.aliases;
|
|
974
|
-
}
|
|
975
|
-
if (input.related !== undefined) {
|
|
976
|
-
const related = normalizeLinks(input.related);
|
|
977
|
-
if (related.length > 0) frontmatter.related = related;
|
|
978
|
-
else delete frontmatter.related;
|
|
979
|
-
}
|
|
980
|
-
if (input.supersedes !== undefined) {
|
|
981
|
-
const supersedes = normalizeLinks(input.supersedes);
|
|
982
|
-
if (supersedes.length > 0) frontmatter.supersedes = supersedes;
|
|
983
|
-
else delete frontmatter.supersedes;
|
|
984
|
-
}
|
|
985
|
-
if (input.summary !== undefined) frontmatter.summary = oneLine(input.summary, 240);
|
|
986
|
-
if (input.priority !== undefined) frontmatter.priority = normalizePriority(input.priority);
|
|
987
|
-
if (input.confidence !== undefined) frontmatter.confidence = normalizeConfidence(input.confidence);
|
|
988
|
-
if (input.source !== undefined) frontmatter.source = oneLine(input.source, 120);
|
|
989
|
-
if (input.category !== undefined) {
|
|
990
|
-
const { category } = resolveCategoryPath(input.category, doc.category || "inbox");
|
|
991
|
-
frontmatter.category = category;
|
|
992
|
-
}
|
|
993
|
-
frontmatter.updated = new Date().toISOString();
|
|
994
|
-
// Writing is using: keep the persisted recency signal fresh without a
|
|
995
|
-
// separate pass. Reads are updated lazily by the usage tracker instead.
|
|
996
|
-
frontmatter.last_used = Date.now();
|
|
997
|
-
|
|
998
|
-
const body = input.content !== undefined ? input.content.replace(/^\n+/, "").replace(/\s+$/, "") : doc.body.replace(/\s+$/, "");
|
|
999
|
-
if (!input.summary && input.content !== undefined) {
|
|
1000
|
-
const derived = deriveSummary(body);
|
|
1001
|
-
if (derived) frontmatter.summary = derived;
|
|
1002
|
-
}
|
|
1003
|
-
if (!frontmatter.id) frontmatter.id = doc.id;
|
|
1004
|
-
if (!frontmatter.title) frontmatter.title = doc.title;
|
|
1005
|
-
|
|
1006
|
-
const targetCategory = typeof frontmatter.category === "string" && frontmatter.category ? frontmatter.category : doc.category;
|
|
1007
|
-
let targetPath = doc.path;
|
|
1008
|
-
if (input.category !== undefined && targetCategory !== doc.category) {
|
|
1009
|
-
const fileSlug = basename(doc.relPath).replace(/\.md$/i, "");
|
|
1010
|
-
targetPath = await uniquePath(root, `${LIBRARY_DIR}/${targetCategory}`, fileSlug);
|
|
1011
|
-
}
|
|
1012
|
-
const raw = stringifyWithFrontmatter(frontmatter as Record<string, unknown>, body);
|
|
1013
|
-
if (targetPath !== doc.path) {
|
|
1014
|
-
// Journal the move so a crash between the write and the trash can be
|
|
1015
|
-
// repaired on the next start (otherwise both files exist with one id).
|
|
1016
|
-
await writeJournal(root, { op: "move", from: doc.relPath, to: normalizePath(relative(root, targetPath)), at: Date.now() });
|
|
1017
|
-
}
|
|
1018
|
-
await atomicWriteFile(targetPath, raw);
|
|
1019
|
-
if (targetPath !== doc.path) {
|
|
1020
|
-
const trashed = await moveToTrash(doc.path, join(root, TRASH_DIR, "moved")).then(
|
|
1021
|
-
() => true,
|
|
1022
|
-
() => false,
|
|
1023
|
-
);
|
|
1024
|
-
if (trashed) await clearJournal(root).catch(() => {});
|
|
1025
|
-
}
|
|
1026
|
-
const updated = await readMemoryDoc(root, targetPath);
|
|
1027
|
-
if (!updated) throw new Error(`Failed to read back memory at ${targetPath}`);
|
|
1028
|
-
return updated;
|
|
1029
|
-
}
|
|
1030
|
-
|
|
1031
|
-
/** Remove a memory file, returning the trash path. */
|
|
1032
|
-
export async function deleteMemory(root: string, doc: MemoryDoc): Promise<string> {
|
|
1033
|
-
return moveToTrash(doc.path, join(root, TRASH_DIR));
|
|
1034
|
-
}
|
|
1035
|
-
|
|
1036
|
-
/* ------------------------------------------------------------------ */
|
|
1037
|
-
/* Consolidation: re-file a note under a better broad topic */
|
|
1038
|
-
/* ------------------------------------------------------------------ */
|
|
1039
|
-
|
|
1040
|
-
/**
|
|
1041
|
-
* Break a note body into appendable fact units.
|
|
1042
|
-
*
|
|
1043
|
-
* `- **Label** — text` bullets keep their label; `### Label` blocks attach the
|
|
1044
|
-
* label to the following block; anything else becomes one prose unit.
|
|
1045
|
-
*/
|
|
1046
|
-
export function extractFactUnits(body: string): Array<{ label?: string; text: string }> {
|
|
1047
|
-
const units: Array<{ label?: string; text: string }> = [];
|
|
1048
|
-
const withoutTitle = body.replace(/^\s*#\s+[^\n]*\n?/, "");
|
|
1049
|
-
let buffer: string[] = [];
|
|
1050
|
-
let pendingLabel: string | undefined;
|
|
1051
|
-
const flush = (): void => {
|
|
1052
|
-
const text = buffer.join("\n").trim();
|
|
1053
|
-
buffer = [];
|
|
1054
|
-
if (!text) return;
|
|
1055
|
-
const bullet = text.match(/^-\s+\*\*(.+?)\*\*\s*[\u2014\u2013-]\s*([\s\S]*)$/);
|
|
1056
|
-
if (bullet) {
|
|
1057
|
-
units.push({ label: bullet[1].trim(), text: bullet[2].trim() });
|
|
1058
|
-
pendingLabel = undefined;
|
|
1059
|
-
return;
|
|
1060
|
-
}
|
|
1061
|
-
const plain = text.match(/^-\s+([\s\S]*)$/);
|
|
1062
|
-
if (plain) {
|
|
1063
|
-
units.push({ label: pendingLabel, text: plain[1].replace(/\s+/g, " ").trim() });
|
|
1064
|
-
pendingLabel = undefined;
|
|
1065
|
-
return;
|
|
1066
|
-
}
|
|
1067
|
-
units.push({ label: pendingLabel, text });
|
|
1068
|
-
pendingLabel = undefined;
|
|
1069
|
-
};
|
|
1070
|
-
for (const raw of withoutTitle.split(/\r?\n/)) {
|
|
1071
|
-
const line = raw.trimEnd();
|
|
1072
|
-
if (/^\s*##\s+Facts\s*$/.test(line)) continue;
|
|
1073
|
-
const heading = line.match(/^\s*###\s+(.+?)\s*$/);
|
|
1074
|
-
if (heading) {
|
|
1075
|
-
flush();
|
|
1076
|
-
pendingLabel = heading[1];
|
|
1077
|
-
continue;
|
|
1078
|
-
}
|
|
1079
|
-
if (/^\s*[-*]\s+/.test(line)) flush();
|
|
1080
|
-
buffer.push(line);
|
|
1081
|
-
}
|
|
1082
|
-
flush();
|
|
1083
|
-
return units;
|
|
1084
|
-
}
|
|
1085
|
-
|
|
1086
|
-
export interface MoveMemoryInput {
|
|
1087
|
-
/** Target broad topic; becomes the file name and the note title. */
|
|
1088
|
-
topic: string;
|
|
1089
|
-
/** Target category folder. Defaults to the note's current category. */
|
|
1090
|
-
category?: string;
|
|
1091
|
-
/**
|
|
1092
|
-
* When the target topic already exists: true explicitly merges this note into
|
|
1093
|
-
* it; omitted or false refuses and reports the conflict.
|
|
1094
|
-
*/
|
|
1095
|
-
merge?: boolean;
|
|
1096
|
-
/** Record the note's previous names as aliases of the target (default true). */
|
|
1097
|
-
keepAlias?: boolean;
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
export interface MoveMemoryResult {
|
|
1101
|
-
doc: MemoryDoc;
|
|
1102
|
-
/** Previous relative path. */
|
|
1103
|
-
from: string;
|
|
1104
|
-
moved: boolean;
|
|
1105
|
-
/** True when the facts were folded into an existing note. */
|
|
1106
|
-
merged: boolean;
|
|
1107
|
-
/** Explanation when nothing changed. */
|
|
1108
|
-
reason?: string;
|
|
1109
|
-
/** Aliases added to the target by this move. */
|
|
1110
|
-
aliasesAdded: string[];
|
|
1111
|
-
topicAdjusted: boolean;
|
|
1112
|
-
topicNote?: string;
|
|
1113
|
-
/** Where the old file went (always `.trash/`). */
|
|
1114
|
-
trashPath?: string;
|
|
1115
|
-
}
|
|
1116
|
-
|
|
1117
|
-
/**
|
|
1118
|
-
* Re-file a note under a better broad topic.
|
|
1119
|
-
*
|
|
1120
|
-
* This is the escape hatch for names that only became knowable later: a note
|
|
1121
|
-
* filed as "John's father" when the person is really "Bob". The previous topic
|
|
1122
|
-
* (and the previous file name) are kept as aliases, so nothing that referred to
|
|
1123
|
-
* the old name stops working, and an existing target note is merged into rather
|
|
1124
|
-
* than clobbered.
|
|
1125
|
-
*/
|
|
1126
|
-
export async function moveMemory(
|
|
1127
|
-
root: string,
|
|
1128
|
-
doc: MemoryDoc,
|
|
1129
|
-
input: MoveMemoryInput,
|
|
1130
|
-
defaultCategory = "inbox",
|
|
1131
|
-
options: CreateMemoryOptions = {},
|
|
1132
|
-
): Promise<MoveMemoryResult> {
|
|
1133
|
-
const { relDir, category } = resolveCategoryPath(input.category, doc.category || defaultCategory);
|
|
1134
|
-
const resolution = resolveTopic(input.topic, category, category || defaultCategory, options.topicMaxWords ?? 6);
|
|
1135
|
-
const targetPath = join(root, relDir, `${resolution.slug}.md`);
|
|
1136
|
-
const targetRelPath = normalizePath(relative(root, targetPath));
|
|
1137
|
-
const keepAlias = input.keepAlias !== false;
|
|
1138
|
-
const base = {
|
|
1139
|
-
from: doc.relPath,
|
|
1140
|
-
aliasesAdded: [] as string[],
|
|
1141
|
-
topicAdjusted: resolution.adjusted,
|
|
1142
|
-
topicNote: resolution.note,
|
|
1143
|
-
};
|
|
1144
|
-
if (targetRelPath === doc.relPath) {
|
|
1145
|
-
return { ...base, doc, moved: false, merged: false, reason: `Already filed under "${resolution.topic}".` };
|
|
1146
|
-
}
|
|
1147
|
-
|
|
1148
|
-
const target = await readMemoryDoc(root, targetPath);
|
|
1149
|
-
if (target && input.merge !== true) {
|
|
1150
|
-
throw new Error(
|
|
1151
|
-
`${targetRelPath} already exists (topic "${target.topic}"), so moving ${doc.relPath} there would either overwrite or mix two subjects. Re-run with merge: true to combine them, or pick a different topic.`,
|
|
1152
|
-
);
|
|
1153
|
-
}
|
|
1154
|
-
const previousAliases = target ? normalizeAliases([...target.aliases, ...parseStringList(target.frontmatter.aliases)]) : [];
|
|
1155
|
-
const historicalIds = normalizeAliases([...(target ? [doc.id] : []), ...previousAliases, ...doc.aliases].filter((alias) => alias.startsWith("mem_")));
|
|
1156
|
-
if (target && historicalIds.length >= MAX_ALIASES) {
|
|
1157
|
-
throw new Error(`Cannot merge ${doc.relPath}: the target has reached the ${MAX_ALIASES}-alias limit for historical ids.`);
|
|
1158
|
-
}
|
|
1159
|
-
const aliases = normalizeAliases([
|
|
1160
|
-
...historicalIds,
|
|
1161
|
-
...(target ? [doc.id] : []),
|
|
1162
|
-
...previousAliases,
|
|
1163
|
-
...doc.aliases,
|
|
1164
|
-
...(keepAlias ? [doc.topic, doc.title, basename(doc.relPath).replace(/\.md$/i, "")] : []),
|
|
1165
|
-
]);
|
|
1166
|
-
const requiredAliases = [
|
|
1167
|
-
...(target ? [doc.id] : []), ...previousAliases, ...doc.aliases,
|
|
1168
|
-
...(keepAlias ? [doc.topic, doc.title, basename(doc.relPath).replace(/\.md$/i, "")] : []),
|
|
1169
|
-
];
|
|
1170
|
-
if (requiredAliases.some((alias) => {
|
|
1171
|
-
const normalized = normalizeAliases([alias])[0];
|
|
1172
|
-
return normalized && !aliases.some((saved) => saved.toLowerCase() === normalized.toLowerCase());
|
|
1173
|
-
})) {
|
|
1174
|
-
throw new Error(`Cannot move ${doc.relPath}: keeping its former names would exceed the ${MAX_ALIASES}-alias limit.`);
|
|
1175
|
-
}
|
|
1176
|
-
const aliasesAdded = aliases.filter((alias) => !previousAliases.some((previous) => previous.toLowerCase() === alias.toLowerCase()));
|
|
1177
|
-
|
|
1178
|
-
if (target) {
|
|
1179
|
-
// Merge: fold the facts in, ignore duplicates, keep the oldest identity.
|
|
1180
|
-
let body = target.body.replace(/\s+$/, "");
|
|
1181
|
-
let mergedFacts = 0;
|
|
1182
|
-
for (const unit of extractFactUnits(doc.body)) {
|
|
1183
|
-
const appended = appendFact(body, unit.label, unit.text);
|
|
1184
|
-
if (appended.changed) {
|
|
1185
|
-
body = appended.body;
|
|
1186
|
-
mergedFacts += 1;
|
|
1187
|
-
}
|
|
1188
|
-
}
|
|
1189
|
-
const tags = [...new Set([...target.tags, ...doc.tags])];
|
|
1190
|
-
const priority = priorityRank(doc.priority) > priorityRank(target.priority) ? doc.priority : target.priority;
|
|
1191
|
-
const related = normalizeLinks([...target.related, ...doc.related]);
|
|
1192
|
-
const supersedes = normalizeLinks([...target.supersedes, ...doc.supersedes]);
|
|
1193
|
-
const linksFit = (required: string[], saved: string[]): boolean => required.every((link) => {
|
|
1194
|
-
const normalized = normalizeLinks([link])[0];
|
|
1195
|
-
return !normalized || saved.some((entry) => entry.toLowerCase() === normalized.toLowerCase());
|
|
1196
|
-
});
|
|
1197
|
-
if (!linksFit([...target.related, ...doc.related], related) || !linksFit([...target.supersedes, ...doc.supersedes], supersedes)) {
|
|
1198
|
-
throw new Error(`Cannot merge ${doc.relPath}: keeping its links would exceed the ${MAX_LINKS}-link limit.`);
|
|
1199
|
-
}
|
|
1200
|
-
// The journal must exist before the target changes. Recovery only removes
|
|
1201
|
-
// the source after it verifies that the target contains all its facts and
|
|
1202
|
-
// its former id; an interrupted target write therefore cannot lose data.
|
|
1203
|
-
await writeJournal(root, { op: "move", from: doc.relPath, to: target.relPath, merge: true, at: Date.now() });
|
|
1204
|
-
const updated = await updateMemory(root, target, {
|
|
1205
|
-
content: body,
|
|
1206
|
-
tags,
|
|
1207
|
-
aliases,
|
|
1208
|
-
related,
|
|
1209
|
-
supersedes,
|
|
1210
|
-
summary: extendSummary(target.summary, doc.summary),
|
|
1211
|
-
priority,
|
|
1212
|
-
});
|
|
1213
|
-
const trashPath = await deleteMemory(root, doc);
|
|
1214
|
-
await clearJournal(root).catch(() => {});
|
|
1215
|
-
return { ...base, doc: updated, moved: true, merged: mergedFacts > 0, aliasesAdded, trashPath };
|
|
1216
|
-
}
|
|
1217
|
-
|
|
1218
|
-
// Pure rename: the note keeps its id, so ids read earlier stay valid.
|
|
1219
|
-
const frontmatter: MemoryFrontmatter = {
|
|
1220
|
-
...doc.frontmatter,
|
|
1221
|
-
id: doc.id,
|
|
1222
|
-
title: resolution.topic,
|
|
1223
|
-
topic: resolution.topic,
|
|
1224
|
-
category,
|
|
1225
|
-
updated: new Date().toISOString(),
|
|
1226
|
-
};
|
|
1227
|
-
if (frontmatter.created === undefined) frontmatter.created = new Date(doc.created).toISOString();
|
|
1228
|
-
if (aliases.length > 0) frontmatter.aliases = aliases;
|
|
1229
|
-
else delete frontmatter.aliases;
|
|
1230
|
-
const body = stripLeadingTitle(doc.body, doc.title).replace(/^\n+/, "").replace(/\s+$/, "");
|
|
1231
|
-
const newBody = /^\s*#\s+/.test(body) ? body : `# ${resolution.topic}\n\n${body}`;
|
|
1232
|
-
await writeJournal(root, { op: "move", from: doc.relPath, to: targetRelPath, at: Date.now() });
|
|
1233
|
-
await atomicWriteFile(targetPath, stringifyWithFrontmatter(frontmatter as Record<string, unknown>, newBody));
|
|
1234
|
-
const renamed = await readMemoryDoc(root, targetPath);
|
|
1235
|
-
if (!renamed) throw new Error(`Failed to read back memory at ${targetPath}`);
|
|
1236
|
-
const trashPath = await deleteMemory(root, doc);
|
|
1237
|
-
await clearJournal(root).catch(() => {});
|
|
1238
|
-
return { ...base, doc: renamed, moved: true, merged: false, aliasesAdded, trashPath };
|
|
1239
|
-
}
|
|
1240
|
-
|
|
1241
|
-
/**
|
|
1242
|
-
* Refresh only the `last_used` frontmatter field of a note.
|
|
1243
|
-
*
|
|
1244
|
-
* Kept separate from `updateMemory` on purpose: a recall must not change
|
|
1245
|
-
* `updated`, or reading a note would make it look recently edited and corrupt
|
|
1246
|
-
* recency ranking. Returns the re-parsed doc, or undefined when unchanged.
|
|
1247
|
-
*/
|
|
1248
|
-
export async function touchLastUsed(root: string, doc: MemoryDoc, usedAt = Date.now()): Promise<MemoryDoc | undefined> {
|
|
1249
|
-
const lastUsed = parseDateValue(doc.frontmatter.last_used) || 0;
|
|
1250
|
-
if (usedAt - lastUsed < 1000) return undefined;
|
|
1251
|
-
const frontmatter: MemoryFrontmatter = { ...doc.frontmatter, last_used: usedAt };
|
|
1252
|
-
if (!frontmatter.id) frontmatter.id = doc.id;
|
|
1253
|
-
if (!frontmatter.title) frontmatter.title = doc.title;
|
|
1254
|
-
const raw = stringifyWithFrontmatter(frontmatter as Record<string, unknown>, doc.body.replace(/\s+$/, ""));
|
|
1255
|
-
await atomicWriteFile(doc.path, raw);
|
|
1256
|
-
return readMemoryDoc(root, doc.path);
|
|
1257
|
-
}
|
|
1258
|
-
|
|
1259
|
-
/** Count the fact units in a note body (bullets and labeled blocks). */
|
|
1260
|
-
export function countFacts(body: string): number {
|
|
1261
|
-
return extractFactUnits(body).length;
|
|
1262
|
-
}
|
|
1263
|
-
|
|
1264
|
-
/* ------------------------------------------------------------------ */
|
|
1265
|
-
/* Crash-safe moves (journal) */
|
|
1266
|
-
/* ------------------------------------------------------------------ */
|
|
1267
|
-
|
|
1268
|
-
export interface MoveJournalEntry {
|
|
1269
|
-
op: "move";
|
|
1270
|
-
/** A merge keeps the target id and must be verified before source removal. */
|
|
1271
|
-
merge?: boolean;
|
|
1272
|
-
/** Relative path of the source file. */
|
|
1273
|
-
from: string;
|
|
1274
|
-
/** Relative path of the destination file. */
|
|
1275
|
-
to: string;
|
|
1276
|
-
at: number;
|
|
1277
|
-
}
|
|
1278
|
-
|
|
1279
|
-
function journalPath(root: string): string {
|
|
1280
|
-
return join(root, INDEX_DIR, "journal.json");
|
|
1281
|
-
}
|
|
1282
|
-
|
|
1283
|
-
/** Record an in-progress move before it becomes visible on disk. */
|
|
1284
|
-
export async function writeJournal(root: string, entry: MoveJournalEntry): Promise<void> {
|
|
1285
|
-
await atomicWriteFile(journalPath(root), JSON.stringify(entry));
|
|
1286
|
-
}
|
|
1287
|
-
|
|
1288
|
-
export async function readJournal(root: string): Promise<MoveJournalEntry | undefined> {
|
|
1289
|
-
const raw = await readFileOrUndefined(journalPath(root));
|
|
1290
|
-
if (!raw) return undefined;
|
|
1291
|
-
try {
|
|
1292
|
-
const parsed = JSON.parse(raw) as MoveJournalEntry;
|
|
1293
|
-
const safe = (path: string): boolean => isIndexableRelPath(path)
|
|
1294
|
-
&& !path.split("/").some((part) => part === "." || part === ".." || !part);
|
|
1295
|
-
if (parsed && parsed.op === "move" && typeof parsed.from === "string" && typeof parsed.to === "string"
|
|
1296
|
-
&& safe(parsed.from) && safe(parsed.to)) return parsed;
|
|
1297
|
-
} catch {
|
|
1298
|
-
// A corrupt journal is not worth failing over; the files themselves are
|
|
1299
|
-
// authoritative and doctor reports duplicate ids.
|
|
1300
|
-
}
|
|
1301
|
-
return undefined;
|
|
1302
|
-
}
|
|
1303
|
-
|
|
1304
|
-
export async function clearJournal(root: string): Promise<void> {
|
|
1305
|
-
await rm(journalPath(root), { force: true });
|
|
1306
|
-
}
|
|
1307
|
-
|
|
1308
|
-
/**
|
|
1309
|
-
* Finish an interrupted move: when the journal says `from` was moved to `to`
|
|
1310
|
-
* and both files exist, trash the source. Safe to call on every start.
|
|
1311
|
-
*/
|
|
1312
|
-
export async function recoverJournal(root: string): Promise<string | undefined> {
|
|
1313
|
-
const entry = await readJournal(root);
|
|
1314
|
-
if (!entry) return undefined;
|
|
1315
|
-
const fromPath = await safeMemoryPath(root, entry.from);
|
|
1316
|
-
const toPath = await safeMemoryPath(root, entry.to);
|
|
1317
|
-
let recovered: string | undefined;
|
|
1318
|
-
if (fromPath && toPath) {
|
|
1319
|
-
const [source, target] = await Promise.all([readMemoryDoc(root, fromPath), readMemoryDoc(root, toPath)]);
|
|
1320
|
-
const complete = source && target && (entry.merge
|
|
1321
|
-
? target.aliases.includes(source.id) && extractFactUnits(source.body).every((unit) => !appendFact(target.body, unit.label, unit.text).changed)
|
|
1322
|
-
: source.id === target.id);
|
|
1323
|
-
if (!complete) return `interrupted move kept source ${entry.from}: target is incomplete`;
|
|
1324
|
-
await moveToTrash(fromPath, join(root, TRASH_DIR, "moved")).then(
|
|
1325
|
-
() => {
|
|
1326
|
-
recovered = `completed interrupted move ${entry.from} -> ${entry.to}`;
|
|
1327
|
-
},
|
|
1328
|
-
() => undefined,
|
|
1329
|
-
);
|
|
1330
|
-
if (!recovered) return undefined;
|
|
1331
|
-
}
|
|
1332
|
-
await clearJournal(root).catch(() => {});
|
|
1333
|
-
return recovered;
|
|
1334
|
-
}
|
|
1335
|
-
|
|
1336
|
-
/* ------------------------------------------------------------------ */
|
|
1337
|
-
/* MEMORY.md (the always-injected hot cache) */
|
|
1338
|
-
/* ------------------------------------------------------------------ */
|
|
1339
|
-
|
|
1340
|
-
export interface HotState {
|
|
1341
|
-
path: string;
|
|
1342
|
-
content: string;
|
|
1343
|
-
chars: number;
|
|
1344
|
-
limit: number;
|
|
1345
|
-
over: boolean;
|
|
1346
|
-
exists: boolean;
|
|
1347
|
-
}
|
|
1348
|
-
|
|
1349
|
-
/** Read MEMORY.md with budget accounting. */
|
|
1350
|
-
export async function readHot(root: string, limit: number): Promise<HotState> {
|
|
1351
|
-
const path = join(root, HOT_FILE);
|
|
1352
|
-
let content: string;
|
|
1353
|
-
try {
|
|
1354
|
-
content = await readFile(path, "utf8");
|
|
1355
|
-
} catch (error) {
|
|
1356
|
-
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
1357
|
-
return { path, content: "", chars: 0, limit, over: false, exists: false };
|
|
1358
|
-
}
|
|
1359
|
-
const chars = content.length;
|
|
1360
|
-
return { path, content, chars, limit, over: chars > limit, exists: true };
|
|
1361
|
-
}
|
|
1362
|
-
|
|
1363
|
-
/** Validate the complete briefing, without silently cutting or removing facts.
|
|
1364
|
-
* Semantic eligibility and correction checks remain the calling agent's job.
|
|
1365
|
-
* The legacy empty/template title is accepted and removed during migration.
|
|
1366
|
-
*/
|
|
1367
|
-
export function validateHot(content: string, limit = 5000): string {
|
|
1368
|
-
const prose = content.replace(/^# Memory[ \t]*(?:\r?\n|$)/, "").trim();
|
|
1369
|
-
if (/^\s*(?:#{1,6}\s|[-*+]\s|\d+[.)]\s|>|```|~~~|\|)/m.test(prose) || /<!--|-->/.test(prose)) {
|
|
1370
|
-
throw new Error("MEMORY.md must be plain prose: no headings, lists, tables, code fences or annotations.");
|
|
1371
|
-
}
|
|
1372
|
-
const normalized = prose ? `${prose}\n` : "";
|
|
1373
|
-
if (normalized.length > limit) throw new Error(`MEMORY.md exceeds its ${limit}-character budget. Rewrite more tightly; no content was saved or truncated.`);
|
|
1374
|
-
const seen = new Set<string>();
|
|
1375
|
-
for (const paragraph of normalized.split(/\n\s*\n/)) for (const unit of splitHotUnits(paragraph)) {
|
|
1376
|
-
const key = unit.toLowerCase().replace(/\s+/g, " ").trim();
|
|
1377
|
-
if (key && seen.has(key)) throw new Error("MEMORY.md contains a repeated sentence. Consolidate it before saving.");
|
|
1378
|
-
seen.add(key);
|
|
1379
|
-
}
|
|
1380
|
-
return normalized;
|
|
1381
|
-
}
|
|
1382
|
-
|
|
1383
|
-
/** All supported briefing writes validate first and preserve the previous version. */
|
|
1384
|
-
export async function writeHot(root: string, content: string, limit = 5000): Promise<void> {
|
|
1385
|
-
const next = validateHot(content, limit);
|
|
1386
|
-
const path = join(root, HOT_FILE);
|
|
1387
|
-
const previous = await readFileOrUndefined(path);
|
|
1388
|
-
if (previous === next) return;
|
|
1389
|
-
if (previous !== undefined) {
|
|
1390
|
-
await atomicWriteFile(join(root, ".history", `${newMemoryId()}-MEMORY.md`), previous);
|
|
1391
|
-
}
|
|
1392
|
-
await atomicWriteFile(path, next);
|
|
1393
|
-
}
|
|
1394
|
-
|
|
1395
|
-
/**
|
|
1396
|
-
* MEMORY.md is a plain-prose briefing: a short introduction to the user plus the
|
|
1397
|
-
* standing context that is worth loading into every single session. It reads as
|
|
1398
|
-
* normal paragraphs — no headings, no bullet lists, no annotations, no key/value
|
|
1399
|
-
* or "Topic — fact" markers. Every character here is paid for on every request,
|
|
1400
|
-
* so only the highest-leverage facts belong in it.
|
|
1401
|
-
*/
|
|
1402
|
-
export const HOT_TITLE = "# Memory";
|
|
1403
|
-
|
|
1404
|
-
export function hotTemplate(): string {
|
|
1405
|
-
return `${HOT_TITLE}\n`;
|
|
1406
|
-
}
|
|
1407
|
-
|
|
1408
|
-
/** Drop HTML comments (including the legacy annotation block). */
|
|
1409
|
-
export function stripHotComments(content: string): string {
|
|
1410
|
-
return content.replace(/<!--[\s\S]*?-->/g, "");
|
|
1411
|
-
}
|
|
1412
|
-
|
|
1413
|
-
/** Blank-line-separated paragraph blocks. */
|
|
1414
|
-
export function splitHotParagraphs(content: string): string[] {
|
|
1415
|
-
return stripHotComments(content)
|
|
1416
|
-
.split(/\n\s*\n/)
|
|
1417
|
-
.map((block) => block.trim())
|
|
1418
|
-
.filter(Boolean);
|
|
1419
|
-
}
|
|
1420
|
-
|
|
1421
|
-
/** The prose body of MEMORY.md, without the optional title or comments. */
|
|
1422
|
-
export function hotBody(content: string): string {
|
|
1423
|
-
return splitHotParagraphs(content)
|
|
1424
|
-
.filter((block) => !/^#\s+/.test(block))
|
|
1425
|
-
.join("\n\n")
|
|
1426
|
-
.trim();
|
|
1427
|
-
}
|
|
1428
|
-
|
|
1429
|
-
/** True when MEMORY.md holds no facts (only a title, comments or whitespace). */
|
|
1430
|
-
export function hotIsEmpty(content: string): boolean {
|
|
1431
|
-
return hotBody(content).length === 0;
|
|
1432
|
-
}
|
|
1433
|
-
|
|
1434
|
-
function isTitleBlock(block: string): boolean {
|
|
1435
|
-
return /^#\s+\S/.test(block);
|
|
1436
|
-
}
|
|
1437
|
-
|
|
1438
|
-
/** Split a paragraph into addressable units: sentences and bullets. */
|
|
1439
|
-
export function splitHotUnits(paragraph: string): string[] {
|
|
1440
|
-
return paragraph
|
|
1441
|
-
.split(/\n+(?=\s*[-*]\s)/)
|
|
1442
|
-
.flatMap((chunk) => chunk.split(/(?<=[.!?])\s+(?=[A-Z0-9"“'(\[])/))
|
|
1443
|
-
.map((unit) => unit.trim())
|
|
1444
|
-
.filter(Boolean);
|
|
1445
|
-
}
|
|
1446
|
-
|
|
1447
|
-
function renderHot(title: string | undefined, body: string[]): string {
|
|
1448
|
-
const parts = [...(title ? [title] : []), ...body.filter(Boolean)];
|
|
1449
|
-
return `${parts.join("\n\n").replace(/\s+$/, "")}\n`;
|
|
1450
|
-
}
|
|
1451
|
-
|
|
1452
|
-
/**
|
|
1453
|
-
* Choose the paragraph a new sentence belongs to, by token overlap with the
|
|
1454
|
-
* caller's topic hint. Returns -1 when nothing is close enough, in which case
|
|
1455
|
-
* the sentence becomes a new paragraph.
|
|
1456
|
-
*
|
|
1457
|
-
* There is no marker syntax in the file: MEMORY.md is prose, so placement is
|
|
1458
|
-
* inferred from content rather than from anchors.
|
|
1459
|
-
*/
|
|
1460
|
-
function findParagraphForTopic(body: string[], topic: string): number {
|
|
1461
|
-
const topicTokens = new Set(tokenize(topic, { stopwords: true }));
|
|
1462
|
-
if (topicTokens.size === 0) return -1;
|
|
1463
|
-
let best = -1;
|
|
1464
|
-
let bestScore = 0;
|
|
1465
|
-
for (let i = 0; i < body.length; i += 1) {
|
|
1466
|
-
const paragraphTokens = new Set(tokenize(body[i], { stopwords: true }));
|
|
1467
|
-
if (paragraphTokens.size === 0) continue;
|
|
1468
|
-
let overlap = 0;
|
|
1469
|
-
for (const token of topicTokens) if (paragraphTokens.has(token)) overlap += 1;
|
|
1470
|
-
const score = overlap / topicTokens.size;
|
|
1471
|
-
if (score > bestScore) {
|
|
1472
|
-
bestScore = score;
|
|
1473
|
-
best = i;
|
|
1474
|
-
}
|
|
1475
|
-
}
|
|
1476
|
-
return bestScore >= 0.5 ? best : -1;
|
|
1477
|
-
}
|
|
1478
|
-
|
|
1479
|
-
export interface HotAddResult {
|
|
1480
|
-
content: string;
|
|
1481
|
-
changed: boolean;
|
|
1482
|
-
/** The paragraph the fact landed in. */
|
|
1483
|
-
paragraph: string;
|
|
1484
|
-
/** True when a new anchored paragraph was created. */
|
|
1485
|
-
created: boolean;
|
|
1486
|
-
}
|
|
1487
|
-
|
|
1488
|
-
/**
|
|
1489
|
-
* Append a sentence (or several) to MEMORY.md.
|
|
1490
|
-
*
|
|
1491
|
-
* With `topic`, the text joins the paragraph with the strongest token overlap,
|
|
1492
|
-
* so related facts stay together; otherwise it becomes a new paragraph. No
|
|
1493
|
-
* marker or anchor text is ever written into the file.
|
|
1494
|
-
*/
|
|
1495
|
-
export function addHotEntry(content: string, text: string, topic?: string): HotAddResult {
|
|
1496
|
-
validateHot(text, Number.MAX_SAFE_INTEGER);
|
|
1497
|
-
const sentence = oneLine(text).trim();
|
|
1498
|
-
if (!sentence) return { content, changed: false, paragraph: "", created: false };
|
|
1499
|
-
const blocks = splitHotParagraphs(content);
|
|
1500
|
-
const title = blocks.find(isTitleBlock);
|
|
1501
|
-
const body = blocks.filter((block) => !isTitleBlock(block));
|
|
1502
|
-
const cleanTopic = topic && topic.trim() ? oneLine(topic, 80) : "";
|
|
1503
|
-
if (cleanTopic) {
|
|
1504
|
-
const index = findParagraphForTopic(body, cleanTopic);
|
|
1505
|
-
if (index >= 0) {
|
|
1506
|
-
body[index] = `${body[index].replace(/\s+$/, "")} ${sentence}`;
|
|
1507
|
-
return { content: renderHot(title, body), changed: true, paragraph: body[index], created: false };
|
|
1508
|
-
}
|
|
1509
|
-
}
|
|
1510
|
-
body.push(sentence);
|
|
1511
|
-
return { content: renderHot(title, body), changed: true, paragraph: sentence, created: true };
|
|
1512
|
-
}
|
|
1513
|
-
|
|
1514
|
-
/**
|
|
1515
|
-
* Remove every sentence or bullet containing `pattern`.
|
|
1516
|
-
* Returns the new content and the number of units removed.
|
|
1517
|
-
*/
|
|
1518
|
-
export function removeHotMatches(content: string, pattern: string): [string, number] {
|
|
1519
|
-
const needle = pattern.trim().toLowerCase();
|
|
1520
|
-
if (!needle) return [content, 0];
|
|
1521
|
-
const blocks = splitHotParagraphs(content);
|
|
1522
|
-
const title = blocks.find(isTitleBlock);
|
|
1523
|
-
const body: string[] = [];
|
|
1524
|
-
let removed = 0;
|
|
1525
|
-
for (const block of blocks) {
|
|
1526
|
-
if (isTitleBlock(block)) continue;
|
|
1527
|
-
const kept = splitHotUnits(block).filter((unit) => {
|
|
1528
|
-
if (unit.toLowerCase().includes(needle)) {
|
|
1529
|
-
removed += 1;
|
|
1530
|
-
return false;
|
|
1531
|
-
}
|
|
1532
|
-
return true;
|
|
1533
|
-
});
|
|
1534
|
-
if (kept.length > 0) body.push(kept.join(" "));
|
|
1535
|
-
}
|
|
1536
|
-
return [renderHot(title, body), removed];
|
|
1537
|
-
}
|
|
1538
|
-
|
|
1539
|
-
export interface HotTrimResult {
|
|
1540
|
-
content: string;
|
|
1541
|
-
/** Text that no longer fits, so callers can report or re-file it. */
|
|
1542
|
-
removed: string;
|
|
1543
|
-
trimmed: boolean;
|
|
1544
|
-
}
|
|
1545
|
-
|
|
1546
|
-
/**
|
|
1547
|
-
* Trim MEMORY.md to the limit, dropping paragraphs from the bottom and cutting
|
|
1548
|
-
* the last paragraph at a sentence boundary. The removed text is returned
|
|
1549
|
-
* instead of being written into the file as a marker.
|
|
1550
|
-
*/
|
|
1551
|
-
export function trimHot(content: string, limit: number): HotTrimResult {
|
|
1552
|
-
if (content.length <= limit) return { content, removed: "", trimmed: false };
|
|
1553
|
-
const blocks = splitHotParagraphs(content);
|
|
1554
|
-
const title = blocks.find(isTitleBlock);
|
|
1555
|
-
const body = blocks.filter((block) => !isTitleBlock(block));
|
|
1556
|
-
const kept: string[] = [];
|
|
1557
|
-
const dropped: string[] = [];
|
|
1558
|
-
let used = title ? title.length + 2 : 0;
|
|
1559
|
-
for (let i = 0; i < body.length; i += 1) {
|
|
1560
|
-
const block = body[i];
|
|
1561
|
-
if (used + block.length + 2 <= limit) {
|
|
1562
|
-
kept.push(block);
|
|
1563
|
-
used += block.length + 2;
|
|
1564
|
-
continue;
|
|
1565
|
-
}
|
|
1566
|
-
const units = splitHotUnits(block);
|
|
1567
|
-
const partial: string[] = [];
|
|
1568
|
-
const rest: string[] = [];
|
|
1569
|
-
for (const unit of units) {
|
|
1570
|
-
const trial = [...partial, unit].join(" ");
|
|
1571
|
-
if (used + trial.length + 2 <= limit) partial.push(unit);
|
|
1572
|
-
else rest.push(unit);
|
|
1573
|
-
}
|
|
1574
|
-
if (partial.length > 0) kept.push(partial.join(" "));
|
|
1575
|
-
if (rest.length > 0) dropped.push(rest.join(" "));
|
|
1576
|
-
for (let j = i + 1; j < body.length; j += 1) dropped.push(body[j]);
|
|
1577
|
-
break;
|
|
1578
|
-
}
|
|
1579
|
-
return { content: renderHot(title, kept), removed: dropped.join("\n\n"), trimmed: true };
|
|
1580
|
-
}
|
|
1581
|
-
|
|
1582
|
-
/* ------------------------------------------------------------------ */
|
|
1583
|
-
/* Generated library indexes */
|
|
1584
|
-
/* ------------------------------------------------------------------ */
|
|
1585
|
-
|
|
1586
|
-
export interface IndexListingDoc {
|
|
1587
|
-
relPath: string;
|
|
1588
|
-
title: string;
|
|
1589
|
-
id: string;
|
|
1590
|
-
category: string;
|
|
1591
|
-
tags: string[];
|
|
1592
|
-
summary: string;
|
|
1593
|
-
priority: Priority;
|
|
1594
|
-
updated: number;
|
|
1595
|
-
}
|
|
1596
|
-
|
|
1597
|
-
function sortEntries(entries: IndexListingDoc[]): IndexListingDoc[] {
|
|
1598
|
-
return entries.slice().sort((a, b) => b.updated - a.updated || a.title.localeCompare(b.title));
|
|
1599
|
-
}
|
|
1600
|
-
|
|
1601
|
-
function entryLines(entry: IndexListingDoc, link: string): string[] {
|
|
1602
|
-
const tags = entry.tags.length > 0 ? ` · tags: ${entry.tags.join(", ")}` : "";
|
|
1603
|
-
const priority = entry.priority !== "normal" ? ` · priority: ${entry.priority}` : "";
|
|
1604
|
-
const summary = entry.summary ? ` — ${entry.summary}` : "";
|
|
1605
|
-
return [`- [${entry.title}](${link})${summary}`, ` - id: \`${entry.id}\`${tags}${priority}`];
|
|
1606
|
-
}
|
|
1607
|
-
|
|
1608
|
-
/** Render `library/INDEX.md` plus one index per category. */
|
|
1609
|
-
export function renderLibraryIndexes(docs: IndexListingDoc[], generatedAt = new Date()): Map<string, string> {
|
|
1610
|
-
const byCategory = new Map<string, IndexListingDoc[]>();
|
|
1611
|
-
for (const doc of docs) {
|
|
1612
|
-
const key = doc.category || "(root)";
|
|
1613
|
-
const list = byCategory.get(key) ?? [];
|
|
1614
|
-
list.push(doc);
|
|
1615
|
-
byCategory.set(key, list);
|
|
1616
|
-
}
|
|
1617
|
-
const stamp = generatedAt.toISOString();
|
|
1618
|
-
const categories = [...byCategory.keys()].sort((a, b) => a.localeCompare(b));
|
|
1619
|
-
const out = new Map<string, string>();
|
|
1620
|
-
|
|
1621
|
-
const rootLines: string[] = [
|
|
1622
|
-
"# Memory Library Index",
|
|
1623
|
-
"",
|
|
1624
|
-
`<!-- Generated by memoria at ${stamp}. Do not edit; run /memoria index to regenerate. -->`,
|
|
1625
|
-
"",
|
|
1626
|
-
`${docs.length} memories in ${categories.length} categories.`,
|
|
1627
|
-
"",
|
|
1628
|
-
"## Categories",
|
|
1629
|
-
"",
|
|
1630
|
-
];
|
|
1631
|
-
for (const category of categories) {
|
|
1632
|
-
rootLines.push(`- \`${category}\` — ${byCategory.get(category)!.length}`);
|
|
1633
|
-
}
|
|
1634
|
-
if (categories.length === 0) rootLines.push("_(empty)_");
|
|
1635
|
-
rootLines.push("");
|
|
1636
|
-
for (const category of categories) {
|
|
1637
|
-
rootLines.push(`## ${category}`, "");
|
|
1638
|
-
for (const entry of sortEntries(byCategory.get(category)!)) {
|
|
1639
|
-
rootLines.push(...entryLines(entry, encodeURI(entry.relPath)));
|
|
1640
|
-
}
|
|
1641
|
-
rootLines.push("");
|
|
1642
|
-
}
|
|
1643
|
-
out.set("INDEX.md", `${rootLines.join("\n").trimEnd()}\n`);
|
|
1644
|
-
|
|
1645
|
-
for (const category of categories) {
|
|
1646
|
-
const dirName = category === "(root)" ? "" : category;
|
|
1647
|
-
const lines = [`# ${category}`, "", `<!-- Generated by memoria at ${stamp}. Do not edit. -->`, ""];
|
|
1648
|
-
for (const entry of sortEntries(byCategory.get(category)!)) {
|
|
1649
|
-
const fileName = entry.relPath.split("/").pop() ?? "";
|
|
1650
|
-
lines.push(...entryLines(entry, encodeURI(fileName)));
|
|
1651
|
-
}
|
|
1652
|
-
lines.push("");
|
|
1653
|
-
out.set(dirName ? `${dirName}/INDEX.md` : "INDEX.md", `${lines.join("\n").trimEnd()}\n`);
|
|
1654
|
-
}
|
|
1655
|
-
return out;
|
|
1656
|
-
}
|
|
1657
|
-
|
|
1658
|
-
/** Build the category map used by the system prompt. */
|
|
1659
|
-
export function categorySummary(docs: Array<{ category: string }>): Array<[string, number]> {
|
|
1660
|
-
const counts = new Map<string, number>();
|
|
1661
|
-
for (const doc of docs) {
|
|
1662
|
-
const key = doc.category || "(root)";
|
|
1663
|
-
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
1664
|
-
}
|
|
1665
|
-
return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
|
1666
|
-
}
|
|
1667
|
-
|
|
1668
|
-
/** Create the store skeleton on disk (idempotent). */
|
|
1669
|
-
export async function ensureStore(root: string, hotLimit: number, defaultCategory = "inbox"): Promise<void> {
|
|
1670
|
-
await mkdir(join(root, LIBRARY_DIR, defaultCategory), { recursive: true });
|
|
1671
|
-
await mkdir(join(root, INDEX_DIR), { recursive: true });
|
|
1672
|
-
const hot = await readHot(root, hotLimit);
|
|
1673
|
-
if (!hot.exists) await writeHot(root, hotTemplate(), hotLimit);
|
|
1674
|
-
}
|
|
1675
|
-
|
|
1676
|
-
/** Resolve a user-supplied memory reference (id, alias, relative path, or absolute path). */
|
|
1677
|
-
export async function resolveMemoryRef(
|
|
1678
|
-
root: string,
|
|
1679
|
-
ref: string,
|
|
1680
|
-
candidates?: Array<{ id: string; path: string; aliases?: string[] }>,
|
|
1681
|
-
): Promise<MemoryDoc | undefined> {
|
|
1682
|
-
const trimmed = ref.trim();
|
|
1683
|
-
if (!trimmed) return undefined;
|
|
1684
|
-
if (trimmed.endsWith(".md")) {
|
|
1685
|
-
const candidate = await safeMemoryPath(root, trimmed);
|
|
1686
|
-
const direct = candidate ? await readMemoryDoc(root, candidate) : undefined;
|
|
1687
|
-
if (direct) return direct;
|
|
1688
|
-
}
|
|
1689
|
-
const needle = trimmed.toLowerCase();
|
|
1690
|
-
const matchesRef = (doc: { id: string; aliases: string[] }) => doc.id === trimmed || doc.aliases.some((alias) => alias.toLowerCase() === needle);
|
|
1691
|
-
if (candidates) {
|
|
1692
|
-
const match = candidates.find((entry) => entry.id === trimmed || entry.aliases?.some((alias) => alias.toLowerCase() === needle));
|
|
1693
|
-
return match ? readMemoryDoc(root, match.path) : undefined;
|
|
1694
|
-
}
|
|
1695
|
-
for (const entry of (await scanLibrary(root)).values()) {
|
|
1696
|
-
const doc = await readMemoryDoc(root, entry.path);
|
|
1697
|
-
if (doc && matchesRef(doc)) return doc;
|
|
1698
|
-
}
|
|
1699
|
-
return undefined;
|
|
1700
|
-
}
|
|
1701
|
-
|
|
1702
|
-
/** Resolve only note files inside this store, including through symlinks. */
|
|
1703
|
-
async function safeMemoryPath(root: string, ref: string): Promise<string | undefined> {
|
|
1704
|
-
const target = resolve(root, ref);
|
|
1705
|
-
if (!isIndexableRelPath(normalizePath(relative(root, target)))) return undefined;
|
|
1706
|
-
try {
|
|
1707
|
-
const [realRoot, realTarget] = await Promise.all([realpath(root), realpath(target)]);
|
|
1708
|
-
if (!isIndexableRelPath(normalizePath(relative(realRoot, realTarget)))) return undefined;
|
|
1709
|
-
return target;
|
|
1710
|
-
} catch (error) {
|
|
1711
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
1712
|
-
throw error;
|
|
1713
|
-
}
|
|
1714
|
-
}
|
|
1715
|
-
|
|
1716
|
-
/** Read a memory file inside this store, guarding traversal and symlinks. */
|
|
1717
|
-
export async function readStoreFile(root: string, relOrAbs: string, maxChars = 40_000): Promise<{ path: string; content: string; truncated: boolean }> {
|
|
1718
|
-
const target = await safeMemoryPath(root, relOrAbs);
|
|
1719
|
-
if (!target) {
|
|
1720
|
-
throw new Error(`Refusing to read outside the memoria root: ${relOrAbs}`);
|
|
1721
|
-
}
|
|
1722
|
-
const content = await readFile(target, "utf8");
|
|
1723
|
-
return { path: target, content: truncateChars(content, maxChars), truncated: content.length > maxChars };
|
|
1724
|
-
}
|