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/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
- let initPromise: Promise<void> | undefined;
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 = async (ctx: ExtensionContext): Promise<MemoriaRuntime> => {
44
+ const getRuntime = (ctx: ExtensionContext): Promise<MemoriaRuntime> => runtimeLock.run("runtime", async () => {
51
45
  if (!runtime || runtimeCwd !== ctx.cwd) {
52
- await runtime?.dispose().catch(() => {});
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
- runtime = new MemoriaRuntime(ctx.cwd, { agentDir: getAgentDir() });
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(4);
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 hits = result.hits.filter((hit) => hit.score >= memoria_.config.autoRecallMinScore);
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 = hits.length === 0 ? result.sessionHits : undefined;
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: hits.map((hit) => hit.doc.id), tookMs: result.tookMs, sessionHits: sessionHits?.length ?? 0 },
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
- * Automatic session learning.
180
- *
181
- * Runs only when configured, only after enough conversation has accumulated,
182
- * and at most once per cooldown window. It never blocks the turn: extraction
183
- * happens after the agent loop ends.
184
- */
185
- const maybeAutoLearn = async (ctx: ExtensionContext, trigger: "settle" | "shutdown"): Promise<void> => {
186
- if (!runtime || learnInFlight) return;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-memoria",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Agent-first long-term memory for pi: a categorized markdown library with automatic, sub-millisecond recall.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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** — `memoria_export` to JSONL, `memoria_import` to restore
56
- (`dry_run: true` first, `mode: "merge"` unless you mean to replace).
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 { atomicWriteFile, oneLine } from "./util.ts";
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"} | **auto-learn:** ${runtime.config.autoLearn}`);
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 topic = rest.join(" ").trim();
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: 6,
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);
@@ -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
- return this.idToIdx.get(id);
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
- if (trimmed.startsWith("mem_")) {
509
- const idx = this.idToIdx.get(trimmed);
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.idToIdx.get(trimmed);
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
  }