pi-memoria 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +121 -586
- package/index.ts +38 -98
- package/package.json +1 -1
- package/skills/memoria/SKILL.md +14 -2
- package/src/commands.ts +8 -84
- package/src/config.ts +3 -11
- package/src/index-engine.ts +34 -7
- package/src/recall.ts +63 -38
- package/src/runtime.ts +79 -249
- package/src/sessions.ts +24 -3
- package/src/store.ts +102 -21
- package/src/tools.ts +10 -135
- package/src/types.ts +2 -10
- package/src/usage.ts +15 -1
- package/src/util.ts +5 -2
- package/src/learn.ts +0 -299
- package/src/transfer.ts +0 -180
package/index.ts
CHANGED
|
@@ -25,49 +25,44 @@ import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
|
25
25
|
import { MemoriaRuntime } from "./src/runtime.ts";
|
|
26
26
|
import { registerMemoriaTools } from "./src/tools.ts";
|
|
27
27
|
import { registerMemoriaCommand, setStatus } from "./src/commands.ts";
|
|
28
|
-
import { RECALL_CUSTOM_TYPE, buildRecallParts, renderRecallBlock, renderStatusLine, renderSystemSection, ripgrepNotice } from "./src/recall.ts";
|
|
29
|
-
import { buildTranscriptText, harvestSession } from "./src/learn.ts";
|
|
28
|
+
import { RECALL_CUSTOM_TYPE, cleanRecallPrompt, selectAutoRecallHits, recallFingerprint, visibleRecallFingerprints, buildRecallParts, renderRecallBlock, renderStatusLine, renderSystemSection, ripgrepNotice } from "./src/recall.ts";
|
|
30
29
|
import { createModelReranker } from "./src/rerank.ts";
|
|
31
30
|
import { tokenizeRaw } from "./src/tokenize.ts";
|
|
32
|
-
import { oneLine } from "./src/util.ts";
|
|
31
|
+
import { KeyedMutex, oneLine } from "./src/util.ts";
|
|
33
32
|
|
|
34
33
|
const RECENT_PROMPT_LIMIT = 6;
|
|
35
34
|
|
|
36
35
|
export default function memoria(pi: ExtensionAPI) {
|
|
37
36
|
let runtime: MemoriaRuntime | undefined;
|
|
38
37
|
let runtimeCwd: string | undefined;
|
|
39
|
-
|
|
38
|
+
const runtimeLock = new KeyedMutex();
|
|
40
39
|
let hotCache: { content: string; chars: number; limit: number; over: boolean; mtimeMs: number } | undefined;
|
|
41
40
|
let recentPrompts: string[] = [];
|
|
42
41
|
/** The missing-ripgrep warning is shown once per session, not on every prompt. */
|
|
43
42
|
let ripgrepWarned = false;
|
|
44
|
-
/** Auto-learn bookkeeping: user turns since the last extraction, and when. */
|
|
45
|
-
let turnsSinceLearn = 0;
|
|
46
|
-
let lastLearnAt = 0;
|
|
47
|
-
let learnInFlight = false;
|
|
48
|
-
|
|
49
43
|
/** Resolve (and lazily initialize) the runtime for this working directory. */
|
|
50
|
-
const getRuntime =
|
|
44
|
+
const getRuntime = (ctx: ExtensionContext): Promise<MemoriaRuntime> => runtimeLock.run("runtime", async () => {
|
|
51
45
|
if (!runtime || runtimeCwd !== ctx.cwd) {
|
|
52
|
-
|
|
46
|
+
const previous = runtime;
|
|
47
|
+
runtime = undefined;
|
|
48
|
+
runtimeCwd = undefined;
|
|
49
|
+
await previous?.dispose().catch(() => {});
|
|
53
50
|
// Follow pi's own agent directory (honours PI_CODING_AGENT_DIR) so the
|
|
54
51
|
// store lives at `<agent dir>/memoria`.
|
|
55
|
-
|
|
52
|
+
const next = new MemoriaRuntime(ctx.cwd, { agentDir: getAgentDir() });
|
|
53
|
+
try {
|
|
54
|
+
await next.init(true);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
await next.dispose().catch(() => {});
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
runtime = next;
|
|
56
60
|
runtimeCwd = ctx.cwd;
|
|
57
61
|
hotCache = undefined;
|
|
58
62
|
recentPrompts = [];
|
|
59
|
-
turnsSinceLearn = 0;
|
|
60
|
-
lastLearnAt = 0;
|
|
61
|
-
learnInFlight = false;
|
|
62
|
-
initPromise = runtime.init(true).catch((error) => {
|
|
63
|
-
// Allow a later attempt to retry instead of caching the rejection forever.
|
|
64
|
-
initPromise = undefined;
|
|
65
|
-
throw error;
|
|
66
|
-
});
|
|
67
63
|
}
|
|
68
|
-
await initPromise;
|
|
69
64
|
return runtime;
|
|
70
|
-
};
|
|
65
|
+
});
|
|
71
66
|
|
|
72
67
|
/** Read MEMORY.md, reusing the cached copy until its mtime changes. */
|
|
73
68
|
const readHotCached = async (memoria: MemoriaRuntime): Promise<{ content: string; chars: number; limit: number; over: boolean }> => {
|
|
@@ -114,7 +109,7 @@ export default function memoria(pi: ExtensionAPI) {
|
|
|
114
109
|
|
|
115
110
|
// Layer 1: stable system-prompt section with MEMORY.md + library map.
|
|
116
111
|
const hot = await readHotCached(memoria_);
|
|
117
|
-
const overview = await memoria_.overview(
|
|
112
|
+
const overview = await memoria_.overview(0);
|
|
118
113
|
event.systemPromptOptions.sections.memoria = renderSystemSection({
|
|
119
114
|
root: memoria_.roots.primary,
|
|
120
115
|
hotContent: hot.over ? hot.content.slice(0, hot.limit) : hot.content,
|
|
@@ -124,16 +119,14 @@ export default function memoria(pi: ExtensionAPI) {
|
|
|
124
119
|
categories: overview.categories,
|
|
125
120
|
totalDocs: overview.docs,
|
|
126
121
|
indexTookMs: 0,
|
|
127
|
-
recent: overview.recent,
|
|
128
122
|
});
|
|
129
123
|
|
|
130
124
|
// Reranking is opt-in and best-effort; the lexical order is the fallback.
|
|
131
125
|
memoria_.setReranker(memoria_.config.rerank ? createModelReranker(ctx) : undefined);
|
|
132
126
|
|
|
133
127
|
// Layer 2: per-prompt recall block.
|
|
134
|
-
turnsSinceLearn += 1;
|
|
135
128
|
if (!memoria_.config.autoRecall) return undefined;
|
|
136
|
-
const prompt = event.prompt ?? "";
|
|
129
|
+
const prompt = cleanRecallPrompt(event.prompt ?? "");
|
|
137
130
|
if (prompt.trimStart().startsWith("/")) return undefined;
|
|
138
131
|
const tokens = tokenizeRaw(prompt);
|
|
139
132
|
if (tokens.length < 2 && !/\d/.test(prompt)) return undefined;
|
|
@@ -145,10 +138,15 @@ export default function memoria(pi: ExtensionAPI) {
|
|
|
145
138
|
ctx.ui.notify(`memoria: ${rgNotice}.`, "warning");
|
|
146
139
|
}
|
|
147
140
|
recentPrompts = [...recentPrompts, prompt].slice(-RECENT_PROMPT_LIMIT);
|
|
148
|
-
const
|
|
141
|
+
const tables = await Promise.all([...new Set(result.hits.map(hit => hit.doc.root))].map(root => memoria_.synonymsFor(root)));
|
|
142
|
+
const synonyms: Record<string, string[]> = {};
|
|
143
|
+
for (const table of tables) for (const [term, values] of Object.entries(table)) synonyms[term] = [...(synonyms[term] ?? []), ...values];
|
|
144
|
+
const relevant = selectAutoRecallHits(result.hits, prompt, memoria_.config.autoRecallMinScore, memoria_.config.autoRecallMinRatio, synonyms);
|
|
145
|
+
const seen = visibleRecallFingerprints(ctx.sessionManager.buildContextEntries?.() ?? ctx.sessionManager.getBranch());
|
|
146
|
+
const hits = relevant.filter(hit => !seen.has(recallFingerprint(hit)));
|
|
149
147
|
// Nothing in the library? Show what earlier conversations said instead
|
|
150
148
|
// of leaving the model with nothing to go on.
|
|
151
|
-
const sessionHits =
|
|
149
|
+
const sessionHits = relevant.length === 0 ? result.sessionHits : undefined;
|
|
152
150
|
if (hits.length === 0 && (!sessionHits || sessionHits.length === 0)) return undefined;
|
|
153
151
|
const rendered = renderRecallBlock({
|
|
154
152
|
query: oneLine(prompt, 160),
|
|
@@ -160,12 +158,14 @@ export default function memoria(pi: ExtensionAPI) {
|
|
|
160
158
|
sessionHits,
|
|
161
159
|
sessionStats: result.sessionStats,
|
|
162
160
|
});
|
|
161
|
+
if (!rendered) return undefined;
|
|
162
|
+
const included = hits.filter(hit => rendered.includes(`- [${hit.doc.id}]`));
|
|
163
163
|
return {
|
|
164
164
|
message: {
|
|
165
165
|
customType: RECALL_CUSTOM_TYPE,
|
|
166
166
|
content: rendered,
|
|
167
167
|
display: true,
|
|
168
|
-
details: { ids:
|
|
168
|
+
details: { ids: included.map((hit) => hit.doc.id), fingerprints: included.map(recallFingerprint), tookMs: result.tookMs, sessionHits: sessionHits?.length ?? 0 },
|
|
169
169
|
},
|
|
170
170
|
};
|
|
171
171
|
} catch (error) {
|
|
@@ -175,75 +175,15 @@ export default function memoria(pi: ExtensionAPI) {
|
|
|
175
175
|
}
|
|
176
176
|
});
|
|
177
177
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
const mode = runtime.config.autoLearn;
|
|
188
|
-
if (mode === "off") return;
|
|
189
|
-
if (mode === "on-settle" && trigger !== "settle") return;
|
|
190
|
-
if (mode === "on-shutdown" && trigger !== "shutdown") return;
|
|
191
|
-
if (turnsSinceLearn < runtime.config.autoLearnMinTurns) return;
|
|
192
|
-
const now = Date.now();
|
|
193
|
-
if (now - lastLearnAt < runtime.config.autoLearnCooldownMs) return;
|
|
194
|
-
const branch = ctx.sessionManager.getBranch() as ReadonlyArray<{ type?: string; message?: { role?: string; content?: unknown } }>;
|
|
195
|
-
// The character floor keeps a burst of tiny turns from triggering a model
|
|
196
|
-
// call that has nothing to extract from.
|
|
197
|
-
const transcriptChars = buildTranscriptText(branch, 40_000).length;
|
|
198
|
-
if (transcriptChars < runtime.config.autoLearnMinChars) return;
|
|
199
|
-
learnInFlight = true;
|
|
200
|
-
lastLearnAt = now;
|
|
201
|
-
turnsSinceLearn = 0;
|
|
202
|
-
try {
|
|
203
|
-
setStatus(ctx, "memoria: extracting memories...");
|
|
204
|
-
const result = await harvestSession(ctx, runtime, branch, {
|
|
205
|
-
chunkChars: runtime.config.learnChunkChars,
|
|
206
|
-
onProgress: (update) => {
|
|
207
|
-
if (update.total > 1) setStatus(ctx, `memoria: extracting memories... ${update.index}/${update.total}`);
|
|
208
|
-
},
|
|
209
|
-
});
|
|
210
|
-
const total = result.created.length + result.updated.length;
|
|
211
|
-
if (total > 0) {
|
|
212
|
-
ctx.ui.notify(`memoria: learned ${total} note${total === 1 ? "" : "s"} (${result.created.length} new, ${result.updated.length} updated)`, "info");
|
|
213
|
-
} else if (result.error) {
|
|
214
|
-
setStatus(ctx, undefined);
|
|
215
|
-
}
|
|
216
|
-
} catch (error) {
|
|
217
|
-
ctx.ui.notify(`memoria: auto-learn failed: ${(error as Error).message}`, "warning");
|
|
218
|
-
} finally {
|
|
219
|
-
learnInFlight = false;
|
|
220
|
-
try {
|
|
221
|
-
const stats = await runtime.stats();
|
|
222
|
-
const docs = stats.roots.reduce((sum, entry) => sum + entry.docs, 0);
|
|
223
|
-
setStatus(ctx, renderStatusLine(runtime.config, docs, stats.hot.chars, 0));
|
|
224
|
-
} catch {
|
|
225
|
-
setStatus(ctx, undefined);
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
};
|
|
229
|
-
|
|
230
|
-
pi.on("agent_end", async (_event, ctx) => {
|
|
231
|
-
await maybeAutoLearn(ctx, "settle").catch(() => {});
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
pi.on("session_shutdown", async (_event, ctx) => {
|
|
235
|
-
try {
|
|
236
|
-
await maybeAutoLearn(ctx, "shutdown");
|
|
237
|
-
} catch {
|
|
238
|
-
// Shutdown must never throw.
|
|
239
|
-
}
|
|
240
|
-
await runtime?.flushUsageNow().catch(() => {});
|
|
241
|
-
await runtime?.dispose().catch(() => {});
|
|
242
|
-
runtime = undefined;
|
|
243
|
-
runtimeCwd = undefined;
|
|
244
|
-
initPromise = undefined;
|
|
245
|
-
hotCache = undefined;
|
|
246
|
-
recentPrompts = [];
|
|
178
|
+
pi.on("session_shutdown", async (_event, _ctx) => {
|
|
179
|
+
await runtimeLock.run("runtime", async () => {
|
|
180
|
+
await runtime?.flushUsageNow().catch(() => {});
|
|
181
|
+
await runtime?.dispose().catch(() => {});
|
|
182
|
+
runtime = undefined;
|
|
183
|
+
runtimeCwd = undefined;
|
|
184
|
+
hotCache = undefined;
|
|
185
|
+
recentPrompts = [];
|
|
186
|
+
});
|
|
247
187
|
});
|
|
248
188
|
|
|
249
189
|
pi.registerMessageRenderer(RECALL_CUSTOM_TYPE, (message, options, theme) => {
|
package/package.json
CHANGED
package/skills/memoria/SKILL.md
CHANGED
|
@@ -52,8 +52,8 @@ bad file name, or consolidating notes that describe the same thing.
|
|
|
52
52
|
- **A note is wrong** — write the correction with `memoria_write` using the same
|
|
53
53
|
topic, or `supersedes` the old note when it replaces a decision.
|
|
54
54
|
- **Never something again** — `memoria_forget` (soft delete into `.trash/`).
|
|
55
|
-
- **Backup or migrate** —
|
|
56
|
-
|
|
55
|
+
- **Backup or migrate** — copy the store directory (`~/.pi/agent/memoria/`);
|
|
56
|
+
notes are plain markdown and `.index/` is derived.
|
|
57
57
|
- **Search feels weak** — add `synonyms.json`, or check `/memoria status`.
|
|
58
58
|
|
|
59
59
|
## Do not
|
|
@@ -63,3 +63,15 @@ bad file name, or consolidating notes that describe the same thing.
|
|
|
63
63
|
- Do not store secrets, credentials or tokens in the library.
|
|
64
64
|
- Do not create a note per fact, and do not inline a fact into MEMORY.md when a
|
|
65
65
|
library note would do.
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
## Before editing MEMORY.md
|
|
69
|
+
|
|
70
|
+
Read the current briefing first. Include only verified standing context useful
|
|
71
|
+
across most sessions. Consolidate existing statements rather than appending
|
|
72
|
+
paraphrases. Apply explicit corrections in place, ask about ambiguous conflicts,
|
|
73
|
+
and preserve unrelated standing facts. Keep dated history and redundant skill
|
|
74
|
+
instructions in the library/skills. Never use direct file edits to bypass validation.
|
|
75
|
+
Replace requires the complete intended briefing. Compact will not drop facts to
|
|
76
|
+
meet the budget; author a tighter replacement instead. Every changed supported
|
|
77
|
+
write preserves a previous version under `.history/`.
|
package/src/commands.ts
CHANGED
|
@@ -6,17 +6,15 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { existsSync } from "node:fs";
|
|
9
|
-
import { isAbsolute, resolve } from "node:path";
|
|
10
9
|
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
11
10
|
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
12
11
|
import { Markdown, Text } from "@earendil-works/pi-tui";
|
|
13
12
|
import type { MemoriaRuntime } from "./runtime.ts";
|
|
14
13
|
import type { RuntimeGetter } from "./tools.ts";
|
|
15
|
-
import { harvestSession } from "./learn.ts";
|
|
16
14
|
import { ripgrepNotice } from "./recall.ts";
|
|
17
15
|
import { formatSessionTime } from "./sessions.ts";
|
|
18
16
|
import { describeAge } from "./usage.ts";
|
|
19
|
-
import {
|
|
17
|
+
import { oneLine } from "./util.ts";
|
|
20
18
|
import type { Scope } from "./types.ts";
|
|
21
19
|
|
|
22
20
|
export const COMMAND_ENTRY_TYPE = "memoria_command";
|
|
@@ -26,7 +24,7 @@ const SUBCOMMANDS: Array<{ value: string; label: string; description: string }>
|
|
|
26
24
|
{ value: "search", label: "search <query>", description: "Search long-term memory" },
|
|
27
25
|
{ value: "store", label: "store <text>", description: "Quickly save a memory to the inbox" },
|
|
28
26
|
{ value: "read", label: "read <id|path>", description: "Read a full memory note" },
|
|
29
|
-
{ value: "move", label: "move <ref> <topic>", description: "Re-file a note under a better broad topic" },
|
|
27
|
+
{ value: "move", label: "move <ref> <topic> [--merge]", description: "Re-file a note under a better broad topic" },
|
|
30
28
|
{ value: "hot", label: "hot", description: "Show MEMORY.md and its budget" },
|
|
31
29
|
{ value: "reindex", label: "reindex", description: "Rebuild the search index from disk" },
|
|
32
30
|
{ value: "index", label: "index", description: "Regenerate library/INDEX.md table of contents" },
|
|
@@ -34,9 +32,6 @@ const SUBCOMMANDS: Array<{ value: string; label: string; description: string }>
|
|
|
34
32
|
{ value: "topics", label: "topics", description: "Review notes: size, facts, usage, merge candidates" },
|
|
35
33
|
{ value: "diff", label: "diff [days]", description: "Show memories created or updated recently" },
|
|
36
34
|
{ value: "sessions", label: "sessions <query>", description: "Search earlier conversations (raw transcripts)" },
|
|
37
|
-
{ value: "export", label: "export [file]", description: "Dump the store to JSONL" },
|
|
38
|
-
{ value: "import", label: "import <file>", description: "Restore memories from a JSONL export" },
|
|
39
|
-
{ value: "learn", label: "learn", description: "Extract durable memories from the current session" },
|
|
40
35
|
{ value: "paths", label: "paths", description: "Print store paths" },
|
|
41
36
|
{ value: "forget", label: "forget <id>", description: "Move a memory to .trash" },
|
|
42
37
|
];
|
|
@@ -73,7 +68,7 @@ async function statusMarkdown(runtime: MemoriaRuntime): Promise<string> {
|
|
|
73
68
|
lines.push(`**Categories:** ${[...categories.entries()].sort((a, b) => b[1] - a[1]).map(([category, count]) => `${category} (${count})`).join(", ")}`);
|
|
74
69
|
}
|
|
75
70
|
lines.push("");
|
|
76
|
-
lines.push(`**Auto-recall:** ${runtime.config.autoRecall ? `on (limit ${runtime.config.autoRecallLimit}, min score ${runtime.config.autoRecallMinScore})` : "off"}
|
|
71
|
+
lines.push(`**Auto-recall:** ${runtime.config.autoRecall ? `on (limit ${runtime.config.autoRecallLimit}, min score ${runtime.config.autoRecallMinScore})` : "off"}`);
|
|
77
72
|
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`);
|
|
78
73
|
lines.push(
|
|
79
74
|
`**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}`,
|
|
@@ -183,12 +178,13 @@ export function registerMemoriaCommand(pi: ExtensionAPI, getRuntime: RuntimeGett
|
|
|
183
178
|
}
|
|
184
179
|
case "move": {
|
|
185
180
|
const [ref, ...rest] = remainder.split(/\s+/).filter(Boolean);
|
|
186
|
-
const
|
|
181
|
+
const merge = rest.includes("--merge");
|
|
182
|
+
const topic = rest.filter((word) => word !== "--merge").join(" ").trim();
|
|
187
183
|
if (!ref || !topic) {
|
|
188
|
-
ctx.ui.notify("Usage: /memoria move <id|path|alias> <new broad topic>", "warning");
|
|
184
|
+
ctx.ui.notify("Usage: /memoria move <id|path|alias> <new broad topic> [--merge]", "warning");
|
|
189
185
|
return;
|
|
190
186
|
}
|
|
191
|
-
const moved = await runtime.move(ref, { topic }, "all");
|
|
187
|
+
const moved = await runtime.move(ref, { topic, merge }, "all");
|
|
192
188
|
if (!moved) {
|
|
193
189
|
ctx.ui.notify(`Memory not found: ${ref}`, "error");
|
|
194
190
|
return;
|
|
@@ -272,7 +268,7 @@ export function registerMemoriaCommand(pi: ExtensionAPI, getRuntime: RuntimeGett
|
|
|
272
268
|
lines.push("**Merge candidates** (same subject, two files):");
|
|
273
269
|
for (const merge of merges) {
|
|
274
270
|
lines.push(
|
|
275
|
-
`- \`${merge.a.relPath}\` ↔ \`${merge.b.relPath}\` (${Math.round(merge.score * 100)}% overlap: ${merge.shared.join(", ")}) → \`/memoria move ${merge.b.id} ${merge.a.title}\``,
|
|
271
|
+
`- \`${merge.a.relPath}\` ↔ \`${merge.b.relPath}\` (${Math.round(merge.score * 100)}% overlap: ${merge.shared.join(", ")}) → \`/memoria move ${merge.b.id} ${merge.a.title} --merge\``,
|
|
276
272
|
);
|
|
277
273
|
}
|
|
278
274
|
}
|
|
@@ -353,78 +349,6 @@ export function registerMemoriaCommand(pi: ExtensionAPI, getRuntime: RuntimeGett
|
|
|
353
349
|
emit(pi, `memoria diff: last ${Number.isFinite(days) ? days : 7} days`, lines.join("\n"));
|
|
354
350
|
return;
|
|
355
351
|
}
|
|
356
|
-
case "export": {
|
|
357
|
-
const flags = remainder.split(/\s+/).filter(Boolean);
|
|
358
|
-
// `--hot` exports MEMORY.md alone as a readable "about me" document.
|
|
359
|
-
if (flags.includes("--hot")) {
|
|
360
|
-
const fileArg = flags.find((flag) => !flag.startsWith("--"));
|
|
361
|
-
const target = fileArg
|
|
362
|
-
? isAbsolute(fileArg)
|
|
363
|
-
? fileArg
|
|
364
|
-
: resolve(ctx.cwd, fileArg)
|
|
365
|
-
: resolve(ctx.cwd, "about-me.md");
|
|
366
|
-
const hot = await runtime.hotState();
|
|
367
|
-
await atomicWriteFile(target, `${hot.content.trim()}\n`);
|
|
368
|
-
emit(pi, "memoria export", `Wrote MEMORY.md (${hot.chars} chars) to \`${target}\`.`);
|
|
369
|
-
return;
|
|
370
|
-
}
|
|
371
|
-
const fileArg = flags.find((flag) => !flag.startsWith("--"));
|
|
372
|
-
const { jsonl, notes } = await runtime.exportJsonl({ scope: "all" });
|
|
373
|
-
const target = fileArg
|
|
374
|
-
? isAbsolute(fileArg)
|
|
375
|
-
? fileArg
|
|
376
|
-
: resolve(ctx.cwd, fileArg)
|
|
377
|
-
: resolve(ctx.cwd, `memoria-export-${new Date().toISOString().slice(0, 10)}.jsonl`);
|
|
378
|
-
await atomicWriteFile(target, jsonl);
|
|
379
|
-
emit(pi, "memoria export", `Exported ${notes} note(s) to \`${target}\` (${jsonl.length} bytes).`);
|
|
380
|
-
return;
|
|
381
|
-
}
|
|
382
|
-
case "import": {
|
|
383
|
-
if (!remainder) {
|
|
384
|
-
ctx.ui.notify("Usage: /memoria import <file> [--merge|--replace|--skip] [--dry-run]", "warning");
|
|
385
|
-
return;
|
|
386
|
-
}
|
|
387
|
-
const flags = remainder.split(/\s+/);
|
|
388
|
-
const fileArg = flags.find((flag) => !flag.startsWith("--"));
|
|
389
|
-
if (!fileArg) {
|
|
390
|
-
ctx.ui.notify("Usage: /memoria import <file> [--merge|--replace|--skip] [--dry-run]", "warning");
|
|
391
|
-
return;
|
|
392
|
-
}
|
|
393
|
-
const mode = flags.includes("--replace") ? "replace" : flags.includes("--skip") ? "skip" : "merge";
|
|
394
|
-
const dryRun = flags.includes("--dry-run");
|
|
395
|
-
const target = isAbsolute(fileArg) ? fileArg : resolve(ctx.cwd, fileArg);
|
|
396
|
-
const { readFile } = await import("node:fs/promises");
|
|
397
|
-
const payload = await readFile(target, "utf8");
|
|
398
|
-
const result = await runtime.importJsonl(payload, { mode, dryRun });
|
|
399
|
-
const lines = [
|
|
400
|
-
`${dryRun ? "Dry run:" : "Imported"} ${result.created.length} new, ${result.updated.length} updated, ${result.skipped} skipped.`,
|
|
401
|
-
];
|
|
402
|
-
if (result.hotImported) lines.push("MEMORY.md restored.");
|
|
403
|
-
if (result.errors.length > 0) lines.push("", "**Skipped records:**", ...result.errors.slice(0, 20).map((error) => `- ${error}`));
|
|
404
|
-
emit(pi, `memoria import: ${oneLine(fileArg, 60)}`, lines.join("\n"));
|
|
405
|
-
return;
|
|
406
|
-
}
|
|
407
|
-
case "learn": {
|
|
408
|
-
ctx.ui.notify("Extracting durable memories from this session...", "info");
|
|
409
|
-
const branch = ctx.sessionManager.getBranch() as ReadonlyArray<{ type?: string; message?: { role?: string; content?: unknown } }>;
|
|
410
|
-
const result = await harvestSession(ctx, runtime, branch, {
|
|
411
|
-
chunkChars: runtime.config.learnChunkChars,
|
|
412
|
-
onProgress: (update) => {
|
|
413
|
-
if (update.total > 1) {
|
|
414
|
-
setStatus(ctx, `memoria: extracting memories... ${update.index}/${update.total}`);
|
|
415
|
-
ctx.ui.notify(`Extracting memories (${update.index}/${update.total})...`, "info");
|
|
416
|
-
}
|
|
417
|
-
},
|
|
418
|
-
});
|
|
419
|
-
setStatus(ctx, undefined);
|
|
420
|
-
const lines: string[] = [];
|
|
421
|
-
if (result.error) lines.push(result.error);
|
|
422
|
-
if (result.created.length > 0) lines.push(`**Created ${result.created.length}:**\n${result.created.map((id) => `- \`${id}\``).join("\n")}`);
|
|
423
|
-
if (result.updated.length > 0) lines.push(`**Updated ${result.updated.length}:**\n${result.updated.map((id) => `- \`${id}\``).join("\n")}`);
|
|
424
|
-
if (result.skipped > 0) lines.push(`_${result.skipped} candidate(s) could not be merged._`);
|
|
425
|
-
emit(pi, "memoria learn", lines.join("\n\n") || "Nothing durable found in this session.");
|
|
426
|
-
return;
|
|
427
|
-
}
|
|
428
352
|
case "paths": {
|
|
429
353
|
const paths = runtime.paths();
|
|
430
354
|
emit(
|
package/src/config.ts
CHANGED
|
@@ -34,7 +34,8 @@ export const DEFAULT_CONFIG: MemoriaConfig = {
|
|
|
34
34
|
hotLimit: 5000,
|
|
35
35
|
snippetChars: 280,
|
|
36
36
|
autoRecall: true,
|
|
37
|
-
autoRecallLimit:
|
|
37
|
+
autoRecallLimit: 3,
|
|
38
|
+
autoRecallMinRatio: 0.3,
|
|
38
39
|
autoRecallMinScore: 1.4,
|
|
39
40
|
autoRecallMaxChars: 2400,
|
|
40
41
|
autoRecallLastTurns: 1,
|
|
@@ -60,11 +61,6 @@ export const DEFAULT_CONFIG: MemoriaConfig = {
|
|
|
60
61
|
rerankModel: "",
|
|
61
62
|
rerankTopK: 12,
|
|
62
63
|
rerankTimeoutMs: 2500,
|
|
63
|
-
autoLearn: "off",
|
|
64
|
-
autoLearnMinTurns: 8,
|
|
65
|
-
autoLearnMinChars: 2000,
|
|
66
|
-
autoLearnCooldownMs: 21_600_000,
|
|
67
|
-
learnChunkChars: 12_000,
|
|
68
64
|
watcherSettleMs: 300,
|
|
69
65
|
indexFormat: "auto",
|
|
70
66
|
sessionSearch: true,
|
|
@@ -132,6 +128,7 @@ function coerceConfig(raw: unknown): Partial<MemoriaConfig> {
|
|
|
132
128
|
if (number(input.snippetChars) !== undefined) out.snippetChars = Math.max(80, number(input.snippetChars)!);
|
|
133
129
|
if (bool(input.autoRecall) !== undefined) out.autoRecall = bool(input.autoRecall);
|
|
134
130
|
if (number(input.autoRecallLimit) !== undefined) out.autoRecallLimit = Math.max(0, Math.min(20, number(input.autoRecallLimit)!));
|
|
131
|
+
if (number(input.autoRecallMinRatio) !== undefined) out.autoRecallMinRatio = Math.max(0, Math.min(1, number(input.autoRecallMinRatio)!));
|
|
135
132
|
if (number(input.autoRecallMinScore) !== undefined) out.autoRecallMinScore = Math.max(0, number(input.autoRecallMinScore)!);
|
|
136
133
|
if (number(input.autoRecallMaxChars) !== undefined) out.autoRecallMaxChars = Math.max(200, number(input.autoRecallMaxChars)!);
|
|
137
134
|
if (number(input.autoRecallLastTurns) !== undefined) out.autoRecallLastTurns = Math.max(0, Math.min(5, number(input.autoRecallLastTurns)!));
|
|
@@ -156,11 +153,6 @@ function coerceConfig(raw: unknown): Partial<MemoriaConfig> {
|
|
|
156
153
|
if (number(input.rerankTopK) !== undefined) out.rerankTopK = Math.max(1, Math.min(50, number(input.rerankTopK)!));
|
|
157
154
|
if (number(input.rerankTimeoutMs) !== undefined) out.rerankTimeoutMs = Math.max(250, Math.min(30_000, number(input.rerankTimeoutMs)!));
|
|
158
155
|
if (number(input.synonymWeight) !== undefined) out.synonymWeight = Math.max(0.1, Math.min(1, number(input.synonymWeight)!));
|
|
159
|
-
if (str(input.autoLearn) && ["off", "on-settle", "on-shutdown"].includes(input.autoLearn as string)) out.autoLearn = input.autoLearn as string;
|
|
160
|
-
if (number(input.autoLearnMinTurns) !== undefined) out.autoLearnMinTurns = Math.max(1, number(input.autoLearnMinTurns)!);
|
|
161
|
-
if (number(input.autoLearnMinChars) !== undefined) out.autoLearnMinChars = Math.max(200, number(input.autoLearnMinChars)!);
|
|
162
|
-
if (number(input.autoLearnCooldownMs) !== undefined) out.autoLearnCooldownMs = Math.max(0, number(input.autoLearnCooldownMs)!);
|
|
163
|
-
if (number(input.learnChunkChars) !== undefined) out.learnChunkChars = Math.max(2000, number(input.learnChunkChars)!);
|
|
164
156
|
if (number(input.watcherSettleMs) !== undefined) out.watcherSettleMs = Math.max(0, Math.min(5000, number(input.watcherSettleMs)!));
|
|
165
157
|
if (str(input.indexFormat) && ["auto", "json", "binary"].includes(input.indexFormat as string)) out.indexFormat = input.indexFormat as string;
|
|
166
158
|
if (bool(input.sessionSearch) !== undefined) out.sessionSearch = bool(input.sessionSearch);
|
package/src/index-engine.ts
CHANGED
|
@@ -42,6 +42,7 @@ export function isIgnoredWatchPath(relName: string): boolean {
|
|
|
42
42
|
const name = normalizeRelPath(relName);
|
|
43
43
|
if (!name) return true;
|
|
44
44
|
if (name === INDEX_DIR || name.startsWith(`${INDEX_DIR}/`)) return true;
|
|
45
|
+
if (name === ".history" || name.startsWith(".history/")) return true;
|
|
45
46
|
if (name === TRASH_DIR || name.startsWith(`${TRASH_DIR}/`)) return true;
|
|
46
47
|
if (basename(name) === GENERATED_INDEX) return true;
|
|
47
48
|
return false;
|
|
@@ -150,6 +151,8 @@ export class MemoryIndex {
|
|
|
150
151
|
|
|
151
152
|
private termIds = new Map<string, number>();
|
|
152
153
|
private idToIdx = new Map<string, number>();
|
|
154
|
+
/** Built only for lookups that miss canonical ids; aliases are not the hot path. */
|
|
155
|
+
private aliasToIdx: Map<string, number> | undefined;
|
|
153
156
|
private relPathToIdx = new Map<string, number>();
|
|
154
157
|
private bodyCache: BodyCache;
|
|
155
158
|
private trigramIndex: Map<string, number[]> | undefined;
|
|
@@ -237,7 +240,20 @@ export class MemoryIndex {
|
|
|
237
240
|
}
|
|
238
241
|
|
|
239
242
|
idxForId(id: string): number | undefined {
|
|
240
|
-
|
|
243
|
+
const direct = this.idToIdx.get(id);
|
|
244
|
+
if (direct !== undefined) return direct;
|
|
245
|
+
// Imports may have ids that do not use the mem_ prefix. A merge records the
|
|
246
|
+
// retired id as an alias, so every former id must remain usable.
|
|
247
|
+
if (!this.aliasToIdx) {
|
|
248
|
+
this.aliasToIdx = new Map();
|
|
249
|
+
for (let idx = 0; idx < this.docs.length; idx += 1) {
|
|
250
|
+
if (!this.alive[idx]) continue;
|
|
251
|
+
for (const alias of this.docs[idx].aliases) {
|
|
252
|
+
if (!this.aliasToIdx.has(alias)) this.aliasToIdx.set(alias, idx);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return this.aliasToIdx.get(id);
|
|
241
257
|
}
|
|
242
258
|
|
|
243
259
|
idxForRelPath(relPath: string): number {
|
|
@@ -323,6 +339,7 @@ export class MemoryIndex {
|
|
|
323
339
|
this.docTfs.push(tfs);
|
|
324
340
|
this.alive.push(true);
|
|
325
341
|
this.idToIdx.set(doc.id, idx);
|
|
342
|
+
this.aliasToIdx = undefined;
|
|
326
343
|
this.relPathToIdx.set(meta.relPath, idx);
|
|
327
344
|
this.bodyCache.set(doc.path, doc.body);
|
|
328
345
|
this.linkCache = undefined;
|
|
@@ -335,6 +352,7 @@ export class MemoryIndex {
|
|
|
335
352
|
const doc = this.docs[idx];
|
|
336
353
|
this.alive[idx] = false;
|
|
337
354
|
this.idToIdx.delete(doc.id);
|
|
355
|
+
this.aliasToIdx = undefined;
|
|
338
356
|
this.relPathToIdx.delete(doc.relPath);
|
|
339
357
|
this.bodyCache.delete(doc.path);
|
|
340
358
|
const termIds = this.docTerms[idx] ?? [];
|
|
@@ -414,6 +432,7 @@ export class MemoryIndex {
|
|
|
414
432
|
this.docTfs = newDocTfs;
|
|
415
433
|
this.alive = new Array(newDocs.length).fill(true);
|
|
416
434
|
this.idToIdx = new Map();
|
|
435
|
+
this.aliasToIdx = undefined;
|
|
417
436
|
this.relPathToIdx = new Map();
|
|
418
437
|
for (let i = 0; i < newDocs.length; i += 1) {
|
|
419
438
|
this.idToIdx.set(newDocs[i].id, i);
|
|
@@ -437,6 +456,7 @@ export class MemoryIndex {
|
|
|
437
456
|
this.alive = [];
|
|
438
457
|
this.termIds = new Map();
|
|
439
458
|
this.idToIdx = new Map();
|
|
459
|
+
this.aliasToIdx = undefined;
|
|
440
460
|
this.relPathToIdx = new Map();
|
|
441
461
|
this.trigramIndex = undefined;
|
|
442
462
|
this.fuzzyCache.clear();
|
|
@@ -505,10 +525,8 @@ export class MemoryIndex {
|
|
|
505
525
|
const resolve = (ref: string): number | undefined => {
|
|
506
526
|
const trimmed = ref.trim();
|
|
507
527
|
if (!trimmed) return undefined;
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
if (idx !== undefined) return idx;
|
|
511
|
-
}
|
|
528
|
+
const byId = this.idxForId(trimmed);
|
|
529
|
+
if (byId !== undefined) return byId;
|
|
512
530
|
const byPath = this.relPathToIdx.get(normalizeRelPath(trimmed));
|
|
513
531
|
if (byPath !== undefined) return byPath;
|
|
514
532
|
return titles.get(trimmed.toLowerCase());
|
|
@@ -542,7 +560,7 @@ export class MemoryIndex {
|
|
|
542
560
|
const titles = this.links().titles;
|
|
543
561
|
const trimmed = ref.trim();
|
|
544
562
|
if (!trimmed) return undefined;
|
|
545
|
-
const byId = this.
|
|
563
|
+
const byId = this.idxForId(trimmed);
|
|
546
564
|
if (byId !== undefined && this.alive[byId]) return byId;
|
|
547
565
|
const byPath = this.relPathToIdx.get(normalizeRelPath(trimmed));
|
|
548
566
|
if (byPath !== undefined && this.alive[byPath]) return byPath;
|
|
@@ -827,6 +845,7 @@ export class MemoryIndex {
|
|
|
827
845
|
}
|
|
828
846
|
this.alive = new Array(docCount).fill(true);
|
|
829
847
|
this.idToIdx = new Map();
|
|
848
|
+
this.aliasToIdx = undefined;
|
|
830
849
|
this.relPathToIdx = new Map();
|
|
831
850
|
for (let i = 0; i < docCount; i += 1) {
|
|
832
851
|
this.idToIdx.set(this.docs[i].id, i);
|
|
@@ -935,6 +954,14 @@ export class MemoryIndex {
|
|
|
935
954
|
async search(query: string, options: SearchOptions = {}): Promise<SearchResult> {
|
|
936
955
|
const started = performance.now();
|
|
937
956
|
await this.refresh(false);
|
|
957
|
+
const result = this.searchWarm(query, options);
|
|
958
|
+
this.lastSearchMs = Math.round((performance.now() - started) * 100) / 100;
|
|
959
|
+
return result;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/** Score an index the caller has already refreshed. */
|
|
963
|
+
searchWarm(query: string, options: SearchOptions = {}): SearchResult {
|
|
964
|
+
const started = performance.now();
|
|
938
965
|
const result = searchIndex(this, query, options);
|
|
939
966
|
this.lastSearchMs = Math.round((performance.now() - started) * 100) / 100;
|
|
940
967
|
return result;
|
|
@@ -986,7 +1013,7 @@ export class MemoryIndex {
|
|
|
986
1013
|
if (recent.length < recentLimit) {
|
|
987
1014
|
recent.push({ id: meta.id, title: meta.title, relPath: meta.relPath, created: meta.created });
|
|
988
1015
|
recent.sort((a, b) => b.created - a.created);
|
|
989
|
-
} else if (meta.created > recent[recent.length - 1].created) {
|
|
1016
|
+
} else if (recentLimit > 0 && meta.created > recent[recent.length - 1].created) {
|
|
990
1017
|
recent[recent.length - 1] = { id: meta.id, title: meta.title, relPath: meta.relPath, created: meta.created };
|
|
991
1018
|
recent.sort((a, b) => b.created - a.created);
|
|
992
1019
|
}
|