pi-memoria 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +672 -0
- package/index.ts +260 -0
- package/package.json +71 -0
- package/skills/memoria/SKILL.md +65 -0
- package/skills/memoria-sessions/SKILL.md +66 -0
- package/src/commands.ts +472 -0
- package/src/config.ts +196 -0
- package/src/frontmatter.ts +318 -0
- package/src/index-engine.ts +1044 -0
- package/src/learn.ts +299 -0
- package/src/recall.ts +172 -0
- package/src/rerank.ts +85 -0
- package/src/runtime.ts +1633 -0
- package/src/search.ts +499 -0
- package/src/sessions.ts +592 -0
- package/src/similarity.ts +374 -0
- package/src/store.ts +1643 -0
- package/src/synonyms.ts +105 -0
- package/src/timeexpr.ts +145 -0
- package/src/tokenize.ts +295 -0
- package/src/tools.ts +935 -0
- package/src/transfer.ts +180 -0
- package/src/types.ts +533 -0
- package/src/usage.ts +174 -0
- package/src/util.ts +138 -0
package/src/commands.ts
ADDED
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User-facing `/memoria` command with subcommands and autocomplete.
|
|
3
|
+
*
|
|
4
|
+
* Command output is appended as a custom entry (never sent to the model) and
|
|
5
|
+
* rendered as markdown in interactive mode.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import { isAbsolute, resolve } from "node:path";
|
|
10
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { Markdown, Text } from "@earendil-works/pi-tui";
|
|
13
|
+
import type { MemoriaRuntime } from "./runtime.ts";
|
|
14
|
+
import type { RuntimeGetter } from "./tools.ts";
|
|
15
|
+
import { harvestSession } from "./learn.ts";
|
|
16
|
+
import { formatSessionTime } from "./sessions.ts";
|
|
17
|
+
import { describeAge } from "./usage.ts";
|
|
18
|
+
import { atomicWriteFile, oneLine } from "./util.ts";
|
|
19
|
+
import type { Scope } from "./types.ts";
|
|
20
|
+
|
|
21
|
+
export const COMMAND_ENTRY_TYPE = "memoria_command";
|
|
22
|
+
|
|
23
|
+
const SUBCOMMANDS: Array<{ value: string; label: string; description: string }> = [
|
|
24
|
+
{ value: "status", label: "status", description: "Store locations, index stats and MEMORY.md budget" },
|
|
25
|
+
{ value: "search", label: "search <query>", description: "Search long-term memory" },
|
|
26
|
+
{ value: "store", label: "store <text>", description: "Quickly save a memory to the inbox" },
|
|
27
|
+
{ value: "read", label: "read <id|path>", description: "Read a full memory note" },
|
|
28
|
+
{ value: "move", label: "move <ref> <topic>", description: "Re-file a note under a better broad topic" },
|
|
29
|
+
{ value: "hot", label: "hot", description: "Show MEMORY.md and its budget" },
|
|
30
|
+
{ value: "reindex", label: "reindex", description: "Rebuild the search index from disk" },
|
|
31
|
+
{ value: "index", label: "index", description: "Regenerate library/INDEX.md table of contents" },
|
|
32
|
+
{ value: "doctor", label: "doctor", description: "Check the store for consistency problems" },
|
|
33
|
+
{ value: "topics", label: "topics", description: "Review notes: size, facts, usage, merge candidates" },
|
|
34
|
+
{ value: "diff", label: "diff [days]", description: "Show memories created or updated recently" },
|
|
35
|
+
{ value: "sessions", label: "sessions <query>", description: "Search earlier conversations (raw transcripts)" },
|
|
36
|
+
{ value: "export", label: "export [file]", description: "Dump the store to JSONL" },
|
|
37
|
+
{ value: "import", label: "import <file>", description: "Restore memories from a JSONL export" },
|
|
38
|
+
{ value: "learn", label: "learn", description: "Extract durable memories from the current session" },
|
|
39
|
+
{ value: "paths", label: "paths", description: "Print store paths" },
|
|
40
|
+
{ value: "forget", label: "forget <id>", description: "Move a memory to .trash" },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
function emit(pi: ExtensionAPI, title: string, markdown: string): void {
|
|
44
|
+
pi.appendEntry(COMMAND_ENTRY_TYPE, { title, markdown });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function statusMarkdown(runtime: MemoriaRuntime): Promise<string> {
|
|
48
|
+
const { roots, hot, recovered } = await runtime.stats();
|
|
49
|
+
const lines: string[] = [];
|
|
50
|
+
lines.push(`**Store root:** \`${runtime.displayPath(runtime.roots.primary)}\` ${roots.length === 0 ? "_(not created yet — use /memoria store to initialize)_" : ""}`);
|
|
51
|
+
if (runtime.roots.project) lines.push(`**Project root:** \`${runtime.displayPath(runtime.roots.project)}\``);
|
|
52
|
+
if (runtime.roots.extras.length > 0) lines.push(`**Extra roots (read-only):** ${runtime.roots.extras.map((extra) => `\`${runtime.displayPath(extra)}\``).join(", ")}`);
|
|
53
|
+
if (recovered && recovered.length > 0) lines.push(`**Recovered:** ${recovered.join("; ")}`);
|
|
54
|
+
lines.push(`**MEMORY.md:** ${hot.chars}/${hot.limit} chars${hot.over ? " — **over budget**" : ""}`);
|
|
55
|
+
lines.push("");
|
|
56
|
+
if (roots.length === 0) {
|
|
57
|
+
lines.push("_No index loaded._");
|
|
58
|
+
return lines.join("\n");
|
|
59
|
+
}
|
|
60
|
+
lines.push("| root | notes | index built | load | last search | watcher |");
|
|
61
|
+
lines.push("| --- | ---: | --- | ---: | ---: | --- |");
|
|
62
|
+
for (const stats of roots) {
|
|
63
|
+
const built = stats.indexBuiltAt ? new Date(stats.indexBuiltAt).toISOString().replace("T", " ").slice(0, 19) : "-";
|
|
64
|
+
lines.push(
|
|
65
|
+
`| \`${runtime.displayPath(stats.root)}\` | ${stats.docs} | ${built} | ${stats.indexLoadMs}ms | ${stats.lastSearchMs}ms | ${stats.watcherActive ? "on" : "off"} |`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
const categories = new Map<string, number>();
|
|
69
|
+
for (const stats of roots) for (const [category, count] of Object.entries(stats.categories)) categories.set(category, (categories.get(category) ?? 0) + count);
|
|
70
|
+
if (categories.size > 0) {
|
|
71
|
+
lines.push("");
|
|
72
|
+
lines.push(`**Categories:** ${[...categories.entries()].sort((a, b) => b[1] - a[1]).map(([category, count]) => `${category} (${count})`).join(", ")}`);
|
|
73
|
+
}
|
|
74
|
+
lines.push("");
|
|
75
|
+
lines.push(`**Auto-recall:** ${runtime.config.autoRecall ? `on (limit ${runtime.config.autoRecallLimit}, min score ${runtime.config.autoRecallMinScore})` : "off"} | **auto-learn:** ${runtime.config.autoLearn}`);
|
|
76
|
+
lines.push(`**Index:** ${runtime.config.indexFormat} | **rerank:** ${runtime.config.rerank ? runtime.config.rerankModel || "session model" : "off"} | **synonym table:** ${Object.keys(runtime.config.synonyms).length} inline`);
|
|
77
|
+
lines.push(
|
|
78
|
+
`**Session recall:** ${runtime.config.sessionSearch ? (runtime.config.sessionFallback ? "on (falls back when memory is empty)" : "on (explicit only)") : "off"} | roots: ${runtime.sessionRoots().filter((root) => existsSync(root)).length}`,
|
|
79
|
+
);
|
|
80
|
+
return lines.join("\n");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function parseScopeFlag(args: string, fallback: Scope = "primary"): { scope: Scope; rest: string } {
|
|
84
|
+
const match = args.match(/(?:^|\s)--(primary|global|all|project)\b/);
|
|
85
|
+
if (!match) return { scope: fallback, rest: args };
|
|
86
|
+
return { scope: match[1] as Scope, rest: args.replace(match[0], " ") };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function registerMemoriaCommand(pi: ExtensionAPI, getRuntime: RuntimeGetter): void {
|
|
90
|
+
pi.registerEntryRenderer(COMMAND_ENTRY_TYPE, (entry, _options, theme) => {
|
|
91
|
+
const data = entry.data as { title?: string; markdown?: string } | undefined;
|
|
92
|
+
const markdown = data?.markdown ?? "";
|
|
93
|
+
const heading = data?.title ?? "memoria";
|
|
94
|
+
try {
|
|
95
|
+
return new Markdown(`**${heading}**\n\n${markdown}`, 1, 0, getMarkdownTheme());
|
|
96
|
+
} catch {
|
|
97
|
+
return new Text(theme.fg("accent", heading) + `\n${markdown}`, 1, 0);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
pi.registerCommand("memoria", {
|
|
102
|
+
description: "Long-term memory: status, search, store, reindex, doctor",
|
|
103
|
+
getArgumentCompletions: (prefix) => {
|
|
104
|
+
const trimmed = prefix.trimStart();
|
|
105
|
+
if (trimmed.includes(" ")) return null;
|
|
106
|
+
const filtered = SUBCOMMANDS.filter((entry) => entry.value.startsWith(trimmed));
|
|
107
|
+
return filtered.length > 0 ? filtered.map((entry) => ({ value: entry.value, label: entry.label, description: entry.description })) : null;
|
|
108
|
+
},
|
|
109
|
+
handler: async (rawArgs, ctx: ExtensionCommandContext) => {
|
|
110
|
+
const runtime = await getRuntime(ctx);
|
|
111
|
+
const args = rawArgs.trim();
|
|
112
|
+
// `"".split(/\s+/)` yields `[""]`, so default explicitly.
|
|
113
|
+
const parts = args.length > 0 ? args.split(/\s+/) : [];
|
|
114
|
+
const subcommand = parts[0] ?? "status";
|
|
115
|
+
const remainder = parts.slice(1).join(" ").trim();
|
|
116
|
+
await runtime.prepare(true);
|
|
117
|
+
switch (subcommand) {
|
|
118
|
+
case "status": {
|
|
119
|
+
emit(pi, "memoria status", await statusMarkdown(runtime));
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
case "search":
|
|
123
|
+
case "recall": {
|
|
124
|
+
if (!remainder) {
|
|
125
|
+
ctx.ui.notify("Usage: /memoria search <query>", "warning");
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const { scope, rest: query } = parseScopeFlag(remainder, "all");
|
|
129
|
+
const result = await runtime.recall(query || remainder, { limit: 12, scope });
|
|
130
|
+
if (result.hits.length === 0) {
|
|
131
|
+
emit(pi, `memoria search: ${oneLine(remainder, 80)}`, `No memories matched. ${result.total} candidates after filtering.`);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const lines = [`${result.hits.length} of ${result.total} matches in ${result.tookMs}ms${result.reranked ? " (model-reranked)" : ""}.`, ""];
|
|
135
|
+
if (result.timeWindow) lines.push(`_Time window: ${result.timeWindow.label}_`, "");
|
|
136
|
+
result.hits.forEach((hit, index) => {
|
|
137
|
+
const doc = hit.doc;
|
|
138
|
+
const flags = [hit.relatedTo ? "related" : "", hit.supersededBy ? "superseded" : ""].filter(Boolean).join(", ");
|
|
139
|
+
lines.push(`${index + 1}. **${doc.title}** — \`${doc.id}\` (score ${hit.score}${flags ? `, ${flags}` : ""})`);
|
|
140
|
+
lines.push(` \`${doc.relPath}\`${doc.tags.length > 0 ? ` · tags: ${doc.tags.join(", ")}` : ""}`);
|
|
141
|
+
lines.push(` ${oneLine(hit.snippet, 320)}`);
|
|
142
|
+
lines.push("");
|
|
143
|
+
});
|
|
144
|
+
emit(pi, `memoria search: ${oneLine(remainder, 80)}`, lines.join("\n"));
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
case "store": {
|
|
148
|
+
if (!remainder) {
|
|
149
|
+
ctx.ui.notify("Usage: /memoria store <text>", "warning");
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
// Quick captures accumulate in one broad, date-based inbox note rather
|
|
153
|
+
// than creating a file per thought. The label keeps them triageable.
|
|
154
|
+
const now = new Date();
|
|
155
|
+
const day = now.toISOString().slice(0, 10);
|
|
156
|
+
const time = now.toISOString().slice(11, 16);
|
|
157
|
+
const { result } = await runtime.write({
|
|
158
|
+
topic: `Inbox ${day}`,
|
|
159
|
+
label: time,
|
|
160
|
+
content: remainder,
|
|
161
|
+
category: "inbox",
|
|
162
|
+
tags: [],
|
|
163
|
+
summary: oneLine(remainder, 200),
|
|
164
|
+
source: "user-command",
|
|
165
|
+
});
|
|
166
|
+
ctx.ui.notify(`Saved to ${runtime.displayPath(result.path)} (${result.doc.id})`, "info");
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
case "read": {
|
|
170
|
+
if (!remainder) {
|
|
171
|
+
ctx.ui.notify("Usage: /memoria read <id|path>", "warning");
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const found = await runtime.readMemory(remainder, "all");
|
|
175
|
+
if (!found) {
|
|
176
|
+
ctx.ui.notify(`Memory not found: ${remainder}`, "error");
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const { doc } = found;
|
|
180
|
+
emit(pi, `${doc.title} (${doc.id})`, [`\`${doc.relPath}\``, "", doc.body].join("\n"));
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
case "move": {
|
|
184
|
+
const [ref, ...rest] = remainder.split(/\s+/).filter(Boolean);
|
|
185
|
+
const topic = rest.join(" ").trim();
|
|
186
|
+
if (!ref || !topic) {
|
|
187
|
+
ctx.ui.notify("Usage: /memoria move <id|path|alias> <new broad topic>", "warning");
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const moved = await runtime.move(ref, { topic }, "all");
|
|
191
|
+
if (!moved) {
|
|
192
|
+
ctx.ui.notify(`Memory not found: ${ref}`, "error");
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const { result } = moved;
|
|
196
|
+
const lines = [
|
|
197
|
+
result.moved
|
|
198
|
+
? result.merged
|
|
199
|
+
? `Merged \`${result.from}\` into the existing note **${result.doc.topic}** (\`${result.doc.relPath}\`).`
|
|
200
|
+
: `Re-filed \`${result.from}\` as **${result.doc.topic}** (\`${result.doc.relPath}\`).`
|
|
201
|
+
: `No change: ${result.reason ?? "already at the target"}`,
|
|
202
|
+
];
|
|
203
|
+
if (result.aliasesAdded.length > 0) lines.push(`Aliases added: ${result.aliasesAdded.map((alias) => `\`${alias}\``).join(", ")}`);
|
|
204
|
+
if (result.topicNote) lines.push(`Note: ${result.topicNote}`);
|
|
205
|
+
emit(pi, `memoria move: ${oneLine(result.doc.topic, 60)}`, lines.join("\n"));
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
case "hot": {
|
|
209
|
+
const hot = await runtime.hotState();
|
|
210
|
+
emit(
|
|
211
|
+
pi,
|
|
212
|
+
`MEMORY.md ${hot.chars}/${hot.limit} chars`,
|
|
213
|
+
hot.content.trim() || "_(empty)_",
|
|
214
|
+
);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
case "reindex": {
|
|
218
|
+
ctx.ui.notify("Rebuilding memoria indexes...", "info");
|
|
219
|
+
const results = await runtime.rebuild("all");
|
|
220
|
+
const lines = results.map((entry) => `- \`${runtime.displayPath(entry.root)}\`: ${entry.docs} notes in ${entry.tookMs}ms`);
|
|
221
|
+
emit(pi, "memoria reindex", lines.join("\n") || "_No roots to index._");
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
case "index": {
|
|
225
|
+
const results = await runtime.regenerateIndexes("all");
|
|
226
|
+
const lines = results.map((entry) => `- \`${runtime.displayPath(entry.root)}\`: ${entry.files} index files`);
|
|
227
|
+
emit(pi, "memoria index", lines.join("\n") || "_No roots to index._");
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
case "doctor": {
|
|
231
|
+
const report = await runtime.health();
|
|
232
|
+
if (report.findings.length === 0) {
|
|
233
|
+
emit(pi, "memoria doctor", "No problems found.");
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const sections: string[] = [report.findings.map((finding) => `- ${finding}`).join("\n")];
|
|
237
|
+
const extra: string[] = [];
|
|
238
|
+
if (report.merges.length > 0) extra.push(`_${report.merges.length} merge candidate pair(s)_`);
|
|
239
|
+
if (report.contradictions.length > 0) extra.push(`_${report.contradictions.length} possible contradiction(s)_`);
|
|
240
|
+
if (report.stale.length > 0) extra.push(`_${report.stale.length} stale note(s)_`);
|
|
241
|
+
if (report.promotions.length > 0) extra.push(`_${report.promotions.length} promotion candidate(s)_`);
|
|
242
|
+
if (extra.length > 0) sections.push(extra.join(" · ") + ". See /memoria topics for details.");
|
|
243
|
+
emit(pi, "memoria doctor", sections.join("\n\n"));
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
case "topics": {
|
|
247
|
+
const report = await runtime.topicsReport();
|
|
248
|
+
if (report.length === 0) {
|
|
249
|
+
emit(pi, "memoria topics", "_No memories yet._");
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const merges = await runtime.mergeSuggestions(8);
|
|
253
|
+
const lines: string[] = [];
|
|
254
|
+
lines.push(`${report.length} topic notes.`);
|
|
255
|
+
lines.push("");
|
|
256
|
+
lines.push("| topic | category | facts | size | updated | hits | last used |");
|
|
257
|
+
lines.push("| --- | --- | ---: | ---: | --- | ---: | --- |");
|
|
258
|
+
let totalFacts = 0;
|
|
259
|
+
for (const entry of report.slice(0, 200)) {
|
|
260
|
+
totalFacts += entry.facts;
|
|
261
|
+
const updated = entry.updated ? new Date(entry.updated).toISOString().slice(0, 10) : "-";
|
|
262
|
+
const lastUsed = entry.lastUsed ? describeAge(Date.now() - entry.lastUsed) + " ago" : "never";
|
|
263
|
+
lines.push(
|
|
264
|
+
`| ${entry.stale ? "**stale** " : ""}${entry.title} | ${entry.category || "(root)"} | ${entry.facts} | ${Math.round(entry.bytes / 1024)}k | ${updated} | ${entry.hits} | ${lastUsed} |`,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
lines.push("");
|
|
268
|
+
lines.push(`${totalFacts} facts in ${report.length} notes.`);
|
|
269
|
+
if (merges.length > 0) {
|
|
270
|
+
lines.push("");
|
|
271
|
+
lines.push("**Merge candidates** (same subject, two files):");
|
|
272
|
+
for (const merge of merges) {
|
|
273
|
+
lines.push(
|
|
274
|
+
`- \`${merge.a.relPath}\` ↔ \`${merge.b.relPath}\` (${Math.round(merge.score * 100)}% overlap: ${merge.shared.join(", ")}) → \`/memoria move ${merge.b.id} ${merge.a.title}\``,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
emit(pi, "memoria topics", lines.join("\n"));
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
case "sessions": {
|
|
282
|
+
const flags = remainder.split(/\s+/).filter(Boolean);
|
|
283
|
+
const readIndex = flags.indexOf("--read");
|
|
284
|
+
if (readIndex >= 0) {
|
|
285
|
+
const path = flags[readIndex + 1];
|
|
286
|
+
const line = Number.parseInt(flags[readIndex + 2] ?? "1", 10);
|
|
287
|
+
const windowFlag = flags.find((flag) => flag.startsWith("--window="));
|
|
288
|
+
const window = windowFlag ? Number.parseInt(windowFlag.slice(9), 10) : 6;
|
|
289
|
+
if (!path) {
|
|
290
|
+
ctx.ui.notify("Usage: /memoria sessions --read <path> <line> [--window=6]", "warning");
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
const found = await runtime.sessionRead(path, Number.isFinite(line) ? line : 1, Number.isFinite(window) ? window : 6);
|
|
294
|
+
if (!found) {
|
|
295
|
+
ctx.ui.notify("No transcript window there. Paths must come from a /memoria sessions search.", "error");
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const lines = [`\`${found.relPath}\` · project **${found.projectName}** · lines ${found.startLine}-${found.endLine}`, ""];
|
|
299
|
+
for (const message of found.messages) {
|
|
300
|
+
lines.push(`- **[${message.line}] ${message.role}** ${formatSessionTime(message.timestamp)}: ${oneLine(message.text, 1200)}`);
|
|
301
|
+
}
|
|
302
|
+
emit(pi, "memoria sessions: window", lines.join("\n"));
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const daysFlag = flags.find((flag) => flag.startsWith("--days="));
|
|
306
|
+
const projectFlag = flags.find((flag) => flag.startsWith("--project="));
|
|
307
|
+
const query = flags.filter((flag) => !flag.startsWith("--")).join(" ");
|
|
308
|
+
if (!query) {
|
|
309
|
+
ctx.ui.notify("Usage: /memoria sessions <query> [--days=N] [--project=name] [--tools] [--user]", "warning");
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const result = await runtime.sessionSearch(query, {
|
|
313
|
+
limit: 10,
|
|
314
|
+
sinceDays: daysFlag ? Number.parseInt(daysFlag.slice(7), 10) : undefined,
|
|
315
|
+
project: projectFlag ? projectFlag.slice(10) : undefined,
|
|
316
|
+
includeTools: flags.includes("--tools"),
|
|
317
|
+
userOnly: flags.includes("--user"),
|
|
318
|
+
});
|
|
319
|
+
const stats = result.stats;
|
|
320
|
+
const header = `${result.hits.length} match${result.hits.length === 1 ? "" : "es"} in ${stats.files} sessions / ${stats.messages} messages (${result.tookMs}ms${stats.partial ? ", partial scan" : ""}${stats.skipped > 0 ? `, ${stats.skipped} unreadable records skipped` : ""}).`;
|
|
321
|
+
if (result.hits.length === 0) {
|
|
322
|
+
emit(pi, `memoria sessions: ${oneLine(query, 60)}`, `${header}\n\nNo saved conversation matched. Try other wording, a name or a date.`);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const lines = [header, "", "_Evidence from raw transcripts, not curated memory._", ""];
|
|
326
|
+
for (const hit of result.hits) {
|
|
327
|
+
lines.push(`- **[${hit.role}] ${formatSessionTime(hit.timestamp)}** · ${hit.projectName} · score ${hit.score}${hit.exact ? " · exact" : ""}`);
|
|
328
|
+
lines.push(` ${oneLine(hit.excerpt, 400)}`);
|
|
329
|
+
lines.push(` \`${hit.path}:${hit.line}\` · matched: ${hit.matched.join(", ") || "-"}`);
|
|
330
|
+
}
|
|
331
|
+
lines.push("", `Verify context: \`/memoria sessions --read <path> <line>\``);
|
|
332
|
+
emit(pi, `memoria sessions: ${oneLine(query, 60)}`, lines.join("\n"));
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
case "diff": {
|
|
336
|
+
const days = remainder ? Number.parseInt(remainder, 10) : 7;
|
|
337
|
+
const report = await runtime.diffReport(Number.isFinite(days) ? days : 7);
|
|
338
|
+
const lines: string[] = [];
|
|
339
|
+
if (report.created.length === 0 && report.updated.length === 0) {
|
|
340
|
+
lines.push(`_No memories created or updated in the last ${Number.isFinite(days) ? days : 7} days._`);
|
|
341
|
+
} else {
|
|
342
|
+
if (report.created.length > 0) {
|
|
343
|
+
lines.push(`**New (${report.created.length})**`);
|
|
344
|
+
for (const entry of report.created.slice(0, 40)) lines.push(`- ${entry.title} \`${entry.relPath}\` (${entry.category || "(root)"})`);
|
|
345
|
+
}
|
|
346
|
+
if (report.updated.length > 0) {
|
|
347
|
+
lines.push("", `**Updated (${report.updated.length})**`);
|
|
348
|
+
for (const entry of report.updated.slice(0, 40)) lines.push(`- ${entry.title} \`${entry.relPath}\` (${entry.category || "(root)"})`);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
emit(pi, `memoria diff: last ${Number.isFinite(days) ? days : 7} days`, lines.join("\n"));
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
case "export": {
|
|
355
|
+
const flags = remainder.split(/\s+/).filter(Boolean);
|
|
356
|
+
// `--hot` exports MEMORY.md alone as a readable "about me" document.
|
|
357
|
+
if (flags.includes("--hot")) {
|
|
358
|
+
const fileArg = flags.find((flag) => !flag.startsWith("--"));
|
|
359
|
+
const target = fileArg
|
|
360
|
+
? isAbsolute(fileArg)
|
|
361
|
+
? fileArg
|
|
362
|
+
: resolve(ctx.cwd, fileArg)
|
|
363
|
+
: resolve(ctx.cwd, "about-me.md");
|
|
364
|
+
const hot = await runtime.hotState();
|
|
365
|
+
await atomicWriteFile(target, `${hot.content.trim()}\n`);
|
|
366
|
+
emit(pi, "memoria export", `Wrote MEMORY.md (${hot.chars} chars) to \`${target}\`.`);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
const fileArg = flags.find((flag) => !flag.startsWith("--"));
|
|
370
|
+
const { jsonl, notes } = await runtime.exportJsonl({ scope: "all" });
|
|
371
|
+
const target = fileArg
|
|
372
|
+
? isAbsolute(fileArg)
|
|
373
|
+
? fileArg
|
|
374
|
+
: resolve(ctx.cwd, fileArg)
|
|
375
|
+
: resolve(ctx.cwd, `memoria-export-${new Date().toISOString().slice(0, 10)}.jsonl`);
|
|
376
|
+
await atomicWriteFile(target, jsonl);
|
|
377
|
+
emit(pi, "memoria export", `Exported ${notes} note(s) to \`${target}\` (${jsonl.length} bytes).`);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
case "import": {
|
|
381
|
+
if (!remainder) {
|
|
382
|
+
ctx.ui.notify("Usage: /memoria import <file> [--merge|--replace|--skip] [--dry-run]", "warning");
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
const flags = remainder.split(/\s+/);
|
|
386
|
+
const fileArg = flags.find((flag) => !flag.startsWith("--"));
|
|
387
|
+
if (!fileArg) {
|
|
388
|
+
ctx.ui.notify("Usage: /memoria import <file> [--merge|--replace|--skip] [--dry-run]", "warning");
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
const mode = flags.includes("--replace") ? "replace" : flags.includes("--skip") ? "skip" : "merge";
|
|
392
|
+
const dryRun = flags.includes("--dry-run");
|
|
393
|
+
const target = isAbsolute(fileArg) ? fileArg : resolve(ctx.cwd, fileArg);
|
|
394
|
+
const { readFile } = await import("node:fs/promises");
|
|
395
|
+
const payload = await readFile(target, "utf8");
|
|
396
|
+
const result = await runtime.importJsonl(payload, { mode, dryRun });
|
|
397
|
+
const lines = [
|
|
398
|
+
`${dryRun ? "Dry run:" : "Imported"} ${result.created.length} new, ${result.updated.length} updated, ${result.skipped} skipped.`,
|
|
399
|
+
];
|
|
400
|
+
if (result.hotImported) lines.push("MEMORY.md restored.");
|
|
401
|
+
if (result.errors.length > 0) lines.push("", "**Skipped records:**", ...result.errors.slice(0, 20).map((error) => `- ${error}`));
|
|
402
|
+
emit(pi, `memoria import: ${oneLine(fileArg, 60)}`, lines.join("\n"));
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
case "learn": {
|
|
406
|
+
ctx.ui.notify("Extracting durable memories from this session...", "info");
|
|
407
|
+
const branch = ctx.sessionManager.getBranch() as ReadonlyArray<{ type?: string; message?: { role?: string; content?: unknown } }>;
|
|
408
|
+
const result = await harvestSession(ctx, runtime, branch, {
|
|
409
|
+
chunkChars: runtime.config.learnChunkChars,
|
|
410
|
+
onProgress: (update) => {
|
|
411
|
+
if (update.total > 1) {
|
|
412
|
+
setStatus(ctx, `memoria: extracting memories... ${update.index}/${update.total}`);
|
|
413
|
+
ctx.ui.notify(`Extracting memories (${update.index}/${update.total})...`, "info");
|
|
414
|
+
}
|
|
415
|
+
},
|
|
416
|
+
});
|
|
417
|
+
setStatus(ctx, undefined);
|
|
418
|
+
const lines: string[] = [];
|
|
419
|
+
if (result.error) lines.push(result.error);
|
|
420
|
+
if (result.created.length > 0) lines.push(`**Created ${result.created.length}:**\n${result.created.map((id) => `- \`${id}\``).join("\n")}`);
|
|
421
|
+
if (result.updated.length > 0) lines.push(`**Updated ${result.updated.length}:**\n${result.updated.map((id) => `- \`${id}\``).join("\n")}`);
|
|
422
|
+
if (result.skipped > 0) lines.push(`_${result.skipped} candidate(s) could not be merged._`);
|
|
423
|
+
emit(pi, "memoria learn", lines.join("\n\n") || "Nothing durable found in this session.");
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
case "paths": {
|
|
427
|
+
const paths = runtime.paths();
|
|
428
|
+
emit(
|
|
429
|
+
pi,
|
|
430
|
+
"memoria paths",
|
|
431
|
+
[
|
|
432
|
+
`- primary store: \`${runtime.displayPath(paths.primary)}\``,
|
|
433
|
+
...(paths.project ? [`- project store: \`${runtime.displayPath(paths.project)}\``] : []),
|
|
434
|
+
`- pi agent dir: \`${runtime.displayPath(paths.agentDir)}\``,
|
|
435
|
+
`- MEMORY.md: \`${runtime.displayPath(paths.hot)}\``,
|
|
436
|
+
`- library: \`${runtime.displayPath(paths.library)}\``,
|
|
437
|
+
`- index: \`${runtime.displayPath(paths.indexDir)}\``,
|
|
438
|
+
...paths.extras.map((extra) => `- extra root: \`${runtime.displayPath(extra)}\``),
|
|
439
|
+
...paths.sessions.map((root) => `- session root: \`${runtime.displayPath(root)}\`${existsSync(root) ? "" : " _(missing)_"}`),
|
|
440
|
+
].join("\n"),
|
|
441
|
+
);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
case "forget": {
|
|
445
|
+
if (!remainder) {
|
|
446
|
+
ctx.ui.notify("Usage: /memoria forget <id>", "warning");
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
const result = await runtime.forget(remainder, "all");
|
|
450
|
+
if (!result) {
|
|
451
|
+
ctx.ui.notify(`Memory not found: ${remainder}`, "error");
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
ctx.ui.notify(`Moved ${result.doc.id} to ${runtime.displayPath(result.trashPath)}`, "info");
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
default: {
|
|
458
|
+
ctx.ui.notify(`Unknown subcommand: ${subcommand}. Try: ${SUBCOMMANDS.map((entry) => entry.value).join(", ")}`, "warning");
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
},
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Update the footer status line. Safe in non-TUI modes (no-op). */
|
|
466
|
+
export function setStatus(ctx: ExtensionContext, text: string | undefined): void {
|
|
467
|
+
try {
|
|
468
|
+
ctx.ui.setStatus("memoria", text);
|
|
469
|
+
} catch {
|
|
470
|
+
// Status is best-effort; RPC/print modes may not support it.
|
|
471
|
+
}
|
|
472
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration and path resolution for memoria.
|
|
3
|
+
*
|
|
4
|
+
* A store root has this shape:
|
|
5
|
+
*
|
|
6
|
+
* memoria/
|
|
7
|
+
* ├── MEMORY.md curated memory injected into every session (<= hotLimit chars)
|
|
8
|
+
* ├── config.json optional config overrides
|
|
9
|
+
* ├── .index/ derived index data (never indexed)
|
|
10
|
+
* ├── .trash/ soft-deleted memories
|
|
11
|
+
* └── library/ categorized markdown memories
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
16
|
+
import { readFileOrUndefined } from "./util.ts";
|
|
17
|
+
import type { MemoriaConfig } from "./types.ts";
|
|
18
|
+
|
|
19
|
+
export const INDEX_DIR = ".index";
|
|
20
|
+
export const TRASH_DIR = ".trash";
|
|
21
|
+
export const LIBRARY_DIR = "library";
|
|
22
|
+
export const HOT_FILE = "MEMORY.md";
|
|
23
|
+
export const CONFIG_FILE = "config.json";
|
|
24
|
+
export const INDEX_FILE = "index.json";
|
|
25
|
+
export const INDEX_BIN_FILE = "index.bin";
|
|
26
|
+
export const USAGE_FILE = "usage.json";
|
|
27
|
+
export const SYNONYMS_FILE = "synonyms.json";
|
|
28
|
+
|
|
29
|
+
export const DEFAULT_CONFIG: MemoriaConfig = {
|
|
30
|
+
version: 1,
|
|
31
|
+
rootDir: "$AGENT_DIR/memoria",
|
|
32
|
+
projectRoot: "",
|
|
33
|
+
extraRoots: [],
|
|
34
|
+
hotLimit: 5000,
|
|
35
|
+
snippetChars: 280,
|
|
36
|
+
autoRecall: true,
|
|
37
|
+
autoRecallLimit: 6,
|
|
38
|
+
autoRecallMinScore: 1.4,
|
|
39
|
+
autoRecallMaxChars: 2400,
|
|
40
|
+
autoRecallLastTurns: 1,
|
|
41
|
+
autoRecallPriorWeight: 0.4,
|
|
42
|
+
bodyCacheBytes: 8 * 1024 * 1024,
|
|
43
|
+
scanIntervalMs: 2000,
|
|
44
|
+
exclude: [],
|
|
45
|
+
defaultCategory: "inbox",
|
|
46
|
+
topicMaxWords: 6,
|
|
47
|
+
topicMaxChars: 8000,
|
|
48
|
+
usageTracking: true,
|
|
49
|
+
lastUsedWriteIntervalMs: 21_600_000,
|
|
50
|
+
staleAfterDays: 365,
|
|
51
|
+
promoteAfterWrites: 3,
|
|
52
|
+
dedupeThreshold: 0.62,
|
|
53
|
+
relatedHits: 3,
|
|
54
|
+
relatedBoost: 0.35,
|
|
55
|
+
synonyms: {},
|
|
56
|
+
synonymWeight: 0.6,
|
|
57
|
+
timeHints: true,
|
|
58
|
+
searchCacheSize: 32,
|
|
59
|
+
rerank: false,
|
|
60
|
+
rerankModel: "",
|
|
61
|
+
rerankTopK: 12,
|
|
62
|
+
rerankTimeoutMs: 2500,
|
|
63
|
+
autoLearn: "off",
|
|
64
|
+
autoLearnMinTurns: 8,
|
|
65
|
+
autoLearnMinChars: 2000,
|
|
66
|
+
autoLearnCooldownMs: 21_600_000,
|
|
67
|
+
learnChunkChars: 12_000,
|
|
68
|
+
watcherSettleMs: 300,
|
|
69
|
+
indexFormat: "auto",
|
|
70
|
+
sessionSearch: true,
|
|
71
|
+
sessionRoots: [],
|
|
72
|
+
sessionFallback: true,
|
|
73
|
+
sessionScanMs: 1500,
|
|
74
|
+
sessionExcerptChars: 400,
|
|
75
|
+
sessionCacheBytes: 32 * 1024 * 1024,
|
|
76
|
+
sessionIncludeTools: false,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** Filesystem context used to resolve store paths. */
|
|
80
|
+
export interface PathContext {
|
|
81
|
+
home: string;
|
|
82
|
+
/** Pi's user config directory (`~/.pi/agent` by default, or `PI_CODING_AGENT_DIR`). */
|
|
83
|
+
agentDir: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface ResolvedRoots {
|
|
87
|
+
/** The main user-level store: `<agent dir>/memoria` by default. */
|
|
88
|
+
primary: string;
|
|
89
|
+
/** Optional per-project store (writable). Empty string when disabled. */
|
|
90
|
+
project: string;
|
|
91
|
+
/** Additional read-only store roots declared in config. */
|
|
92
|
+
extras: string[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Expand `$AGENT_DIR`, a leading `~`, absolute paths and cwd-relative paths.
|
|
97
|
+
* Returns undefined for an empty value.
|
|
98
|
+
*/
|
|
99
|
+
export function resolveStorePath(input: string, cwd: string, context: PathContext): string | undefined {
|
|
100
|
+
const value = (input ?? "").trim();
|
|
101
|
+
if (!value) return undefined;
|
|
102
|
+
let expanded = value.replace(/\$AGENT_DIR/g, context.agentDir);
|
|
103
|
+
if (expanded === "~") return context.home;
|
|
104
|
+
if (expanded.startsWith("~/") || expanded.startsWith("~\\")) expanded = join(context.home, expanded.slice(2));
|
|
105
|
+
return isAbsolute(expanded) ? expanded : resolve(cwd, expanded);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Resolve the store roots for a working directory. */
|
|
109
|
+
export function resolveRoots(cwd: string, config: MemoriaConfig = DEFAULT_CONFIG, context?: Partial<PathContext>): ResolvedRoots {
|
|
110
|
+
const home = context?.home ?? homedir();
|
|
111
|
+
const pathContext: PathContext = { home, agentDir: context?.agentDir ?? join(home, ".pi", "agent") };
|
|
112
|
+
const primary = resolveStorePath(config.rootDir, cwd, pathContext) ?? join(pathContext.agentDir, "memoria");
|
|
113
|
+
const project = resolveStorePath(config.projectRoot ?? "", cwd, pathContext) ?? "";
|
|
114
|
+
const extras = config.extraRoots
|
|
115
|
+
.map((entry) => resolveStorePath(entry, cwd, pathContext))
|
|
116
|
+
.filter((entry): entry is string => Boolean(entry) && entry !== primary && entry !== project);
|
|
117
|
+
return { primary, project, extras: [...new Set(extras)] };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function coerceConfig(raw: unknown): Partial<MemoriaConfig> {
|
|
121
|
+
if (!raw || typeof raw !== "object") return {};
|
|
122
|
+
const input = raw as Record<string, unknown>;
|
|
123
|
+
const out: Partial<MemoriaConfig> = {};
|
|
124
|
+
const number = (value: unknown): number | undefined => (typeof value === "number" && Number.isFinite(value) ? value : undefined);
|
|
125
|
+
const bool = (value: unknown): boolean | undefined => (typeof value === "boolean" ? value : undefined);
|
|
126
|
+
const str = (value: unknown): string | undefined => (typeof value === "string" && value.length > 0 ? value : undefined);
|
|
127
|
+
if (str(input.rootDir)) out.rootDir = input.rootDir as string;
|
|
128
|
+
if (Array.isArray(input.extraRoots)) out.extraRoots = input.extraRoots.filter((item): item is string => typeof item === "string");
|
|
129
|
+
if (Array.isArray(input.exclude)) out.exclude = input.exclude.filter((item): item is string => typeof item === "string");
|
|
130
|
+
if (number(input.hotLimit) !== undefined) out.hotLimit = Math.max(500, number(input.hotLimit)!);
|
|
131
|
+
if (number(input.snippetChars) !== undefined) out.snippetChars = Math.max(80, number(input.snippetChars)!);
|
|
132
|
+
if (bool(input.autoRecall) !== undefined) out.autoRecall = bool(input.autoRecall);
|
|
133
|
+
if (number(input.autoRecallLimit) !== undefined) out.autoRecallLimit = Math.max(0, Math.min(20, number(input.autoRecallLimit)!));
|
|
134
|
+
if (number(input.autoRecallMinScore) !== undefined) out.autoRecallMinScore = Math.max(0, number(input.autoRecallMinScore)!);
|
|
135
|
+
if (number(input.autoRecallMaxChars) !== undefined) out.autoRecallMaxChars = Math.max(200, number(input.autoRecallMaxChars)!);
|
|
136
|
+
if (number(input.autoRecallLastTurns) !== undefined) out.autoRecallLastTurns = Math.max(0, Math.min(5, number(input.autoRecallLastTurns)!));
|
|
137
|
+
if (number(input.bodyCacheBytes) !== undefined) out.bodyCacheBytes = Math.max(0, number(input.bodyCacheBytes)!);
|
|
138
|
+
if (number(input.scanIntervalMs) !== undefined) out.scanIntervalMs = Math.max(250, number(input.scanIntervalMs)!);
|
|
139
|
+
if (str(input.defaultCategory)) out.defaultCategory = input.defaultCategory as string;
|
|
140
|
+
if (number(input.topicMaxWords) !== undefined) out.topicMaxWords = Math.max(1, Math.min(12, number(input.topicMaxWords)!));
|
|
141
|
+
if (number(input.topicMaxChars) !== undefined) out.topicMaxChars = Math.max(500, number(input.topicMaxChars)!);
|
|
142
|
+
if (str(input.projectRoot)) out.projectRoot = input.projectRoot as string;
|
|
143
|
+
if (number(input.autoRecallPriorWeight) !== undefined) out.autoRecallPriorWeight = Math.max(0, Math.min(1, number(input.autoRecallPriorWeight)!));
|
|
144
|
+
if (bool(input.usageTracking) !== undefined) out.usageTracking = bool(input.usageTracking);
|
|
145
|
+
if (number(input.lastUsedWriteIntervalMs) !== undefined) out.lastUsedWriteIntervalMs = Math.max(60_000, number(input.lastUsedWriteIntervalMs)!);
|
|
146
|
+
if (number(input.staleAfterDays) !== undefined) out.staleAfterDays = Math.max(1, number(input.staleAfterDays)!);
|
|
147
|
+
if (number(input.promoteAfterWrites) !== undefined) out.promoteAfterWrites = Math.max(1, number(input.promoteAfterWrites)!);
|
|
148
|
+
if (number(input.dedupeThreshold) !== undefined) out.dedupeThreshold = Math.max(0.2, Math.min(0.98, number(input.dedupeThreshold)!));
|
|
149
|
+
if (number(input.relatedHits) !== undefined) out.relatedHits = Math.max(0, Math.min(10, number(input.relatedHits)!));
|
|
150
|
+
if (number(input.relatedBoost) !== undefined) out.relatedBoost = Math.max(0, Math.min(2, number(input.relatedBoost)!));
|
|
151
|
+
if (bool(input.timeHints) !== undefined) out.timeHints = bool(input.timeHints);
|
|
152
|
+
if (number(input.searchCacheSize) !== undefined) out.searchCacheSize = Math.max(0, Math.min(512, number(input.searchCacheSize)!));
|
|
153
|
+
if (bool(input.rerank) !== undefined) out.rerank = bool(input.rerank);
|
|
154
|
+
if (str(input.rerankModel)) out.rerankModel = input.rerankModel as string;
|
|
155
|
+
if (number(input.rerankTopK) !== undefined) out.rerankTopK = Math.max(1, Math.min(50, number(input.rerankTopK)!));
|
|
156
|
+
if (number(input.rerankTimeoutMs) !== undefined) out.rerankTimeoutMs = Math.max(250, Math.min(30_000, number(input.rerankTimeoutMs)!));
|
|
157
|
+
if (number(input.synonymWeight) !== undefined) out.synonymWeight = Math.max(0.1, Math.min(1, number(input.synonymWeight)!));
|
|
158
|
+
if (str(input.autoLearn) && ["off", "on-settle", "on-shutdown"].includes(input.autoLearn as string)) out.autoLearn = input.autoLearn as string;
|
|
159
|
+
if (number(input.autoLearnMinTurns) !== undefined) out.autoLearnMinTurns = Math.max(1, number(input.autoLearnMinTurns)!);
|
|
160
|
+
if (number(input.autoLearnMinChars) !== undefined) out.autoLearnMinChars = Math.max(200, number(input.autoLearnMinChars)!);
|
|
161
|
+
if (number(input.autoLearnCooldownMs) !== undefined) out.autoLearnCooldownMs = Math.max(0, number(input.autoLearnCooldownMs)!);
|
|
162
|
+
if (number(input.learnChunkChars) !== undefined) out.learnChunkChars = Math.max(2000, number(input.learnChunkChars)!);
|
|
163
|
+
if (number(input.watcherSettleMs) !== undefined) out.watcherSettleMs = Math.max(0, Math.min(5000, number(input.watcherSettleMs)!));
|
|
164
|
+
if (str(input.indexFormat) && ["auto", "json", "binary"].includes(input.indexFormat as string)) out.indexFormat = input.indexFormat as string;
|
|
165
|
+
if (bool(input.sessionSearch) !== undefined) out.sessionSearch = bool(input.sessionSearch);
|
|
166
|
+
if (bool(input.sessionFallback) !== undefined) out.sessionFallback = bool(input.sessionFallback);
|
|
167
|
+
if (bool(input.sessionIncludeTools) !== undefined) out.sessionIncludeTools = bool(input.sessionIncludeTools);
|
|
168
|
+
if (number(input.sessionScanMs) !== undefined) out.sessionScanMs = Math.max(50, Math.min(60_000, number(input.sessionScanMs)!));
|
|
169
|
+
if (number(input.sessionExcerptChars) !== undefined) out.sessionExcerptChars = Math.max(80, Math.min(4000, number(input.sessionExcerptChars)!));
|
|
170
|
+
if (number(input.sessionCacheBytes) !== undefined) out.sessionCacheBytes = Math.max(0, number(input.sessionCacheBytes)!);
|
|
171
|
+
if (Array.isArray(input.sessionRoots)) out.sessionRoots = input.sessionRoots.filter((item): item is string => typeof item === "string");
|
|
172
|
+
if (input.synonyms && typeof input.synonyms === "object" && !Array.isArray(input.synonyms)) {
|
|
173
|
+
const table: Record<string, string[]> = {};
|
|
174
|
+
for (const [key, value] of Object.entries(input.synonyms as Record<string, unknown>)) {
|
|
175
|
+
const term = key.trim().toLowerCase();
|
|
176
|
+
if (!term) continue;
|
|
177
|
+
const list = Array.isArray(value) ? value : typeof value === "string" ? value.split(/[,\s]+/) : [];
|
|
178
|
+
const cleaned = list.map((entry) => String(entry).trim().toLowerCase()).filter((entry) => entry && entry !== term);
|
|
179
|
+
if (cleaned.length > 0) table[term] = [...new Set(cleaned)].slice(0, 12);
|
|
180
|
+
}
|
|
181
|
+
out.synonyms = table;
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Load `<root>/config.json`, merged over defaults. Malformed files fall back to defaults. */
|
|
187
|
+
export async function loadConfig(root: string): Promise<MemoriaConfig> {
|
|
188
|
+
const raw = await readFileOrUndefined(join(root, CONFIG_FILE));
|
|
189
|
+
if (!raw) return { ...DEFAULT_CONFIG };
|
|
190
|
+
try {
|
|
191
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
192
|
+
return { ...DEFAULT_CONFIG, ...coerceConfig(parsed) };
|
|
193
|
+
} catch {
|
|
194
|
+
return { ...DEFAULT_CONFIG };
|
|
195
|
+
}
|
|
196
|
+
}
|