pi-memoria 0.1.1 → 0.1.3

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 CHANGED
@@ -1,4 +1,4 @@
1
- # memoria
1
+ # pi-memoria
2
2
 
3
3
  Agent-first long-term memory for [pi](https://github.com/badlogic/pi-mono).
4
4
 
@@ -17,8 +17,8 @@ a millisecond.
17
17
  - **Broad topics, never fact files.** `likes-apples.md` is a defect; facts
18
18
  accumulate in `food-preferences.md`.
19
19
  - **Names are handles.** Aliases make every nickname, full name or former name
20
- resolve to the same note, and notes can be re-filed later without breaking
21
- references.
20
+ resolve to the same note. Re-filing or consolidating notes also keeps retired
21
+ note ids usable as references.
22
22
  - **Zero runtime dependencies.** No database, no server, no embedding service, no
23
23
  build step. Retrieval is a BM25 index kept in memory.
24
24
 
@@ -83,11 +83,11 @@ Pi discovers `extensions/memoria/index.ts` on the next start (or after
83
83
  ### Install as a pi package
84
84
 
85
85
  ```bash
86
- pi install git:github.com/rcsaquino/pi-memoria@v0.1.1
86
+ pi install git:github.com/rcsaquino/pi-memoria@v0.1.3
87
87
  pi install npm:pi-memoria # if published to npm
88
88
  pi install ./path/to/pi-memoria # or from a local checkout
89
89
  pi list
90
- pi remove git:github.com/rcsaquino/pi-memoria@v0.1.1
90
+ pi remove git:github.com/rcsaquino/pi-memoria@v0.1.3
91
91
  ```
92
92
 
93
93
  `pi install` records the package in `~/.pi/agent/settings.json` (or
@@ -233,9 +233,10 @@ When the better name *is* known, `memoria_move` re-files the note:
233
233
 
234
234
  - Target free → the file is **renamed**; the note keeps its `id`, so ids read
235
235
  earlier in the session stay valid.
236
- - Target exists → facts are **merged** in (duplicates skipped, nothing
237
- overwritten) and the old file goes to `.trash/`. `merge: false` reports the
238
- conflict instead.
236
+ - Target exists → the move reports a conflict. Set `merge: true` to combine the
237
+ facts and links (duplicates skipped, nothing overwritten) and move the old
238
+ file to `.trash/`. A merge refuses to discard aliases or links when their
239
+ limits are full.
239
240
  - The previous topic, title and file name are added to the target's aliases, so
240
241
  old references keep working.
241
242
 
@@ -308,7 +309,7 @@ folder). Unknown keys are preserved verbatim through updates.
308
309
  | `memoria_recall` | Search long-term memory. Filters by category, tags, priority, recency, scope; options for `include_body`, `min_score`, `explain`, `rerank`, `drop_superseded`, prefix/fuzzy control. |
309
310
  | `memoria_write` | Add a fact to a broad **topic note** (`topic`, `content`, optional `label`, category, tags, aliases, related, supersedes, summary, priority). Creates the note on first use and appends afterwards. |
310
311
  | `memoria_read` | Read a full note by id, path or alias, with its aliases, related notes and superseding notes. |
311
- | `memoria_move` | Re-file a note under a better broad topic (`from`, `topic`, optional `category`, `merge`, `keep_alias`). Renames when the target is free, merges when it is not. |
312
+ | `memoria_move` | Re-file a note under a better broad topic (`from`, `topic`, optional `category`, `merge`, `keep_alias`). Renames when the target is free; set `merge: true` to combine with an occupied target. |
312
313
  | `memoria_sessions` | Search the transcripts of earlier conversations (`action: "search"`, plus `since_days`, `project`, `include_tools`, `user_only`) and read the surrounding dialogue (`action: "read"`, `path`, `line`, `window`) before quoting it. |
313
314
  | `memoria_hot` | Read/add/remove/replace/compact `MEMORY.md`, the always-in-context briefing. |
314
315
  | `memoria_list` | Browse categories, titles, ids, tags, aliases and summaries. |
@@ -326,7 +327,7 @@ have to ask.
326
327
  /memoria search <query> Ranked results with snippets (--primary/--project/--all)
327
328
  /memoria store <text> Quick capture into library/inbox
328
329
  /memoria read <id|path> Print a full note
329
- /memoria move <ref> <topic> Re-file a note under a better broad topic
330
+ /memoria move <ref> <topic> [--merge] Re-file a note; opt into merging an occupied target
330
331
  /memoria hot Show MEMORY.md
331
332
  /memoria topics Review notes: facts, size, usage, merge candidates
332
333
  /memoria diff [days] Memories created or updated recently (default 7 days)
@@ -409,6 +410,7 @@ back to the default rather than failing.
409
410
  | `sessionExcerptChars` | `400` | Context characters per transcript hit. |
410
411
  | `sessionCacheBytes` | `33554432` | In-memory budget for parsed transcripts. |
411
412
  | `sessionIncludeTools` | `false` | Include tool calls/results and compaction summaries by default. |
413
+ | `sessionRipgrep` | `true` | Use a ripgrep prefilter to skip match-free transcripts when `rg` is available; without it the slower built-in scanner runs. |
412
414
 
413
415
  ## Scopes: personal, project, read-only
414
416
 
@@ -495,14 +497,30 @@ What it does and does not touch:
495
497
  | Content | User and assistant **text** by default. Thinking blocks are never extracted. Tool calls/results, compaction summaries and extension payloads only with `include_tools: true`. |
496
498
  | Output | Bounded excerpts plus `file:line`; never a whole transcript dump. |
497
499
  | Writes | None. Transcripts are read-only and `read` refuses paths outside the session roots. |
498
- | Cost | Full cold scan of 110 sessions / 13 MB ≈ 120 ms; repeat searches ≈ 5 ms from the parse cache. |
499
- | Coverage | Exhaustive unless a budget stops it. `partial` (shown in the tool output, the recall block and `/memoria sessions`) means the scan stopped early: the hits are real, but older sessions were not read. Newest sessions are scanned first, already-parsed files stay cached (so each retry gets further), and an explicit `memoria_sessions` call uses the larger `sessionScanMs` budget. |
500
+ | Cost | With `rg`, only transcripts containing the query terms are parsed; without it, a full cold scan of 110 sessions / 13 MB ≈ 120 ms, repeats ≈ 5 ms from the parse cache. |
501
+ | Coverage | Exhaustive unless a budget stops it. `partial` (shown in the tool output, the recall block and `/memoria sessions`) means the scan stopped early: the hits are real, but older sessions were not read. Newest sessions are scanned first, already-parsed files stay cached while they fit `sessionCacheBytes` (so retries get further on corpora that fit the cache), and concurrent searches share one in-flight parse per transcript (so several `memoria_sessions` calls in one turn do not multiply the work). An explicit `memoria_sessions` call uses the larger `sessionScanMs` budget. |
500
502
  | Privacy | Nothing leaves the machine; the excerpts go to the model only when the feature runs. |
501
503
 
502
504
  `sessionRoots` exists for archives or a second agent directory. Set
503
505
  `sessionSearch: false` to disable the feature entirely, or
504
506
  `sessionFallback: false` to keep the tool while never searching automatically.
505
507
 
508
+ When `rg` is available (pi ships it and puts it on `PATH`), a selective search
509
+ asks ripgrep which transcripts contain the query terms and parses only those, so
510
+ even a gigabyte-scale history is answered within the normal budget. If `rg` is
511
+ missing or fails, memoria falls back to reading every transcript itself and says
512
+ so: the result carries a `ripgrep is not installed` note, and interactive mode
513
+ shows it once per session. Large histories may then be only partially covered.
514
+
515
+ Without `rg` (or for a deliberately disabled accelerator), the default
516
+ `sessionScanMs` budget and `sessionCacheBytes` cache cover only part of a large
517
+ corpus: searches report `partial`, and repeats are cold scans because the cache
518
+ cannot retain the parsed files. Raise `sessionScanMs` so an explicit
519
+ `memoria_sessions` call can cover the whole corpus in one pass, and
520
+ `sessionCacheBytes` only if you have the RAM for the parsed messages (at this
521
+ scale that means gigabytes). The automatic fallback remains capped at 400 ms,
522
+ so on such a corpus it is always a partial scan.
523
+
506
524
  ## Backup and migration
507
525
 
508
526
  `memoria_export` writes JSONL — one record per line, notes plus `MEMORY.md` — to
@@ -554,10 +572,13 @@ a burst of edits costs one refresh.
554
572
  - **Files, not a database.** Memoria never owns your data: notes are markdown
555
573
  with frontmatter, deletes are soft (moved to `.trash/`), and the index is
556
574
  derived and can be rebuilt with `/memoria reindex`.
557
- - **Crash-safe moves.** Category moves and renames are journaled, so an
558
- interrupted move is completed on the next start instead of leaving two copies.
575
+ - **Crash-safe moves.** Category moves, renames and merges are journaled. On
576
+ restart, a merge only removes its source after the target has all its facts
577
+ and the source's former id.
559
578
  - **Atomic writes.** Every write goes through a temp file and rename; there is no
560
579
  window where a note is half-written.
580
+ - **Scoped reads.** `memoria_read` only follows paths inside the memory library;
581
+ transcript window reads refuse paths or symlinks outside configured roots.
561
582
  - **Secrets:** memoria does not scan for credentials, and the learn prompt tells
562
583
  the model not to store them. If you keep secrets in the store anyway, remember
563
584
  that recall may surface them in model context — treat the store like your shell
@@ -583,7 +604,7 @@ a burst of edits costs one refresh.
583
604
 
584
605
  ```bash
585
606
  npm install # dev-only: pi host packages + typescript
586
- npm test # 144 tests, node:test, no build step
607
+ npm test # node:test, no build step
587
608
  npm run typecheck # tsc --noEmit
588
609
  npm run eval # recall@1/@5/MRR over the fixture corpus
589
610
  npm run bench # latency at 1k and 5k notes
@@ -630,8 +651,8 @@ src/commands.ts /memoria subcommands
630
651
  `"optional": true` in `peerDependenciesMeta`, keeping them in
631
652
  `devDependencies` for local tests only. Without that, npm installs a ~450 MB
632
653
  duplicate of pi into the package.
633
- 4. Tag releases (`git tag v0.1.1 && git push --tags`) and install by tag:
634
- `pi install git:github.com/<you>/pi-memoria@v0.1.1`.
654
+ 4. Tag releases (`git tag v<version> && git push --tags`) and install by tag:
655
+ `pi install git:github.com/<you>/pi-memoria@v<version>`.
635
656
  5. `npm pack --dry-run` should list only `index.ts`, `src/**`, `skills/**`,
636
657
  `README.md`, `LICENSE` and `package.json`.
637
658
 
package/index.ts CHANGED
@@ -25,47 +25,53 @@ 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 } from "./src/recall.ts";
28
+ import { RECALL_CUSTOM_TYPE, buildRecallParts, renderRecallBlock, renderStatusLine, renderSystemSection, ripgrepNotice } from "./src/recall.ts";
29
29
  import { buildTranscriptText, harvestSession } from "./src/learn.ts";
30
30
  import { createModelReranker } from "./src/rerank.ts";
31
31
  import { tokenizeRaw } from "./src/tokenize.ts";
32
- import { oneLine } from "./src/util.ts";
32
+ import { KeyedMutex, oneLine } from "./src/util.ts";
33
33
 
34
34
  const RECENT_PROMPT_LIMIT = 6;
35
35
 
36
36
  export default function memoria(pi: ExtensionAPI) {
37
37
  let runtime: MemoriaRuntime | undefined;
38
38
  let runtimeCwd: string | undefined;
39
- let initPromise: Promise<void> | undefined;
39
+ const runtimeLock = new KeyedMutex();
40
40
  let hotCache: { content: string; chars: number; limit: number; over: boolean; mtimeMs: number } | undefined;
41
41
  let recentPrompts: string[] = [];
42
+ /** The missing-ripgrep warning is shown once per session, not on every prompt. */
43
+ let ripgrepWarned = false;
42
44
  /** Auto-learn bookkeeping: user turns since the last extraction, and when. */
43
45
  let turnsSinceLearn = 0;
44
46
  let lastLearnAt = 0;
45
47
  let learnInFlight = false;
46
48
 
47
49
  /** Resolve (and lazily initialize) the runtime for this working directory. */
48
- const getRuntime = async (ctx: ExtensionContext): Promise<MemoriaRuntime> => {
50
+ const getRuntime = (ctx: ExtensionContext): Promise<MemoriaRuntime> => runtimeLock.run("runtime", async () => {
49
51
  if (!runtime || runtimeCwd !== ctx.cwd) {
50
- await runtime?.dispose().catch(() => {});
52
+ const previous = runtime;
53
+ runtime = undefined;
54
+ runtimeCwd = undefined;
55
+ await previous?.dispose().catch(() => {});
51
56
  // Follow pi's own agent directory (honours PI_CODING_AGENT_DIR) so the
52
57
  // store lives at `<agent dir>/memoria`.
53
- runtime = new MemoriaRuntime(ctx.cwd, { agentDir: getAgentDir() });
58
+ const next = new MemoriaRuntime(ctx.cwd, { agentDir: getAgentDir() });
59
+ try {
60
+ await next.init(true);
61
+ } catch (error) {
62
+ await next.dispose().catch(() => {});
63
+ throw error;
64
+ }
65
+ runtime = next;
54
66
  runtimeCwd = ctx.cwd;
55
67
  hotCache = undefined;
56
68
  recentPrompts = [];
57
69
  turnsSinceLearn = 0;
58
70
  lastLearnAt = 0;
59
71
  learnInFlight = false;
60
- initPromise = runtime.init(true).catch((error) => {
61
- // Allow a later attempt to retry instead of caching the rejection forever.
62
- initPromise = undefined;
63
- throw error;
64
- });
65
72
  }
66
- await initPromise;
67
73
  return runtime;
68
- };
74
+ });
69
75
 
70
76
  /** Read MEMORY.md, reusing the cached copy until its mtime changes. */
71
77
  const readHotCached = async (memoria: MemoriaRuntime): Promise<{ content: string; chars: number; limit: number; over: boolean }> => {
@@ -137,6 +143,11 @@ export default function memoria(pi: ExtensionAPI) {
137
143
  if (tokens.length < 2 && !/\d/.test(prompt)) return undefined;
138
144
  const parts = buildRecallParts(prompt, recentPrompts, memoria_.config.autoRecallLastTurns, memoria_.config.autoRecallPriorWeight);
139
145
  const result = await memoria_.recall(oneLine(prompt, 400), { limit: memoria_.config.autoRecallLimit, scope: "all", parts });
146
+ const rgNotice = ripgrepNotice(result.sessionStats?.ripgrep);
147
+ if (rgNotice && !ripgrepWarned) {
148
+ ripgrepWarned = true;
149
+ ctx.ui.notify(`memoria: ${rgNotice}.`, "warning");
150
+ }
140
151
  recentPrompts = [...recentPrompts, prompt].slice(-RECENT_PROMPT_LIMIT);
141
152
  const hits = result.hits.filter((hit) => hit.score >= memoria_.config.autoRecallMinScore);
142
153
  // Nothing in the library? Show what earlier conversations said instead
@@ -230,13 +241,14 @@ export default function memoria(pi: ExtensionAPI) {
230
241
  } catch {
231
242
  // Shutdown must never throw.
232
243
  }
233
- await runtime?.flushUsageNow().catch(() => {});
234
- await runtime?.dispose().catch(() => {});
235
- runtime = undefined;
236
- runtimeCwd = undefined;
237
- initPromise = undefined;
238
- hotCache = undefined;
239
- recentPrompts = [];
244
+ await runtimeLock.run("runtime", async () => {
245
+ await runtime?.flushUsageNow().catch(() => {});
246
+ await runtime?.dispose().catch(() => {});
247
+ runtime = undefined;
248
+ runtimeCwd = undefined;
249
+ hotCache = undefined;
250
+ recentPrompts = [];
251
+ });
240
252
  });
241
253
 
242
254
  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.1",
3
+ "version": "0.1.3",
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",
@@ -30,6 +30,9 @@ memoria_sessions { action: "search", query: "niri scale", include_tools: true, s
30
30
  - Every hit comes with a `file:` and `line:`, its role, project and score. If
31
31
  nothing matches, the result says how many sessions and messages were scanned —
32
32
  quote that when reporting, and try different wording before concluding.
33
+ - If the result says ripgrep is not installed (or that a scan was `partial`),
34
+ the search used the slower built-in scanner and may not have reached the
35
+ oldest sessions. Say so when coverage matters, and prefer narrower queries.
33
36
 
34
37
  The extension also searches transcripts **automatically** when the memory
35
38
  library returns nothing and `sessionFallback` is on. That injection is a hint,
package/src/commands.ts CHANGED
@@ -13,6 +13,7 @@ import { Markdown, Text } from "@earendil-works/pi-tui";
13
13
  import type { MemoriaRuntime } from "./runtime.ts";
14
14
  import type { RuntimeGetter } from "./tools.ts";
15
15
  import { harvestSession } from "./learn.ts";
16
+ import { ripgrepNotice } from "./recall.ts";
16
17
  import { formatSessionTime } from "./sessions.ts";
17
18
  import { describeAge } from "./usage.ts";
18
19
  import { atomicWriteFile, oneLine } from "./util.ts";
@@ -25,7 +26,7 @@ const SUBCOMMANDS: Array<{ value: string; label: string; description: string }>
25
26
  { value: "search", label: "search <query>", description: "Search long-term memory" },
26
27
  { value: "store", label: "store <text>", description: "Quickly save a memory to the inbox" },
27
28
  { 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: "move", label: "move <ref> <topic> [--merge]", description: "Re-file a note under a better broad topic" },
29
30
  { value: "hot", label: "hot", description: "Show MEMORY.md and its budget" },
30
31
  { value: "reindex", label: "reindex", description: "Rebuild the search index from disk" },
31
32
  { value: "index", label: "index", description: "Regenerate library/INDEX.md table of contents" },
@@ -182,12 +183,13 @@ export function registerMemoriaCommand(pi: ExtensionAPI, getRuntime: RuntimeGett
182
183
  }
183
184
  case "move": {
184
185
  const [ref, ...rest] = remainder.split(/\s+/).filter(Boolean);
185
- const topic = rest.join(" ").trim();
186
+ const merge = rest.includes("--merge");
187
+ const topic = rest.filter((word) => word !== "--merge").join(" ").trim();
186
188
  if (!ref || !topic) {
187
- ctx.ui.notify("Usage: /memoria move <id|path|alias> <new broad topic>", "warning");
189
+ ctx.ui.notify("Usage: /memoria move <id|path|alias> <new broad topic> [--merge]", "warning");
188
190
  return;
189
191
  }
190
- const moved = await runtime.move(ref, { topic }, "all");
192
+ const moved = await runtime.move(ref, { topic, merge }, "all");
191
193
  if (!moved) {
192
194
  ctx.ui.notify(`Memory not found: ${ref}`, "error");
193
195
  return;
@@ -271,7 +273,7 @@ export function registerMemoriaCommand(pi: ExtensionAPI, getRuntime: RuntimeGett
271
273
  lines.push("**Merge candidates** (same subject, two files):");
272
274
  for (const merge of merges) {
273
275
  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}\``,
276
+ `- \`${merge.a.relPath}\` ↔ \`${merge.b.relPath}\` (${Math.round(merge.score * 100)}% overlap: ${merge.shared.join(", ")}) → \`/memoria move ${merge.b.id} ${merge.a.title} --merge\``,
275
277
  );
276
278
  }
277
279
  }
@@ -318,11 +320,12 @@ export function registerMemoriaCommand(pi: ExtensionAPI, getRuntime: RuntimeGett
318
320
  });
319
321
  const stats = result.stats;
320
322
  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` : ""}).`;
323
+ const notice = ripgrepNotice(stats.ripgrep);
321
324
  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.`);
325
+ emit(pi, `memoria sessions: ${oneLine(query, 60)}`, `${header}${notice ? `\n\n_Note: ${notice}._` : ""}\n\nNo saved conversation matched. Try other wording, a name or a date.`);
323
326
  return;
324
327
  }
325
- const lines = [header, "", "_Evidence from raw transcripts, not curated memory._", ""];
328
+ const lines = [header, ...(notice ? [`_Note: ${notice}._`] : []), "", "_Evidence from raw transcripts, not curated memory._", ""];
326
329
  for (const hit of result.hits) {
327
330
  lines.push(`- **[${hit.role}] ${formatSessionTime(hit.timestamp)}** · ${hit.projectName} · score ${hit.score}${hit.exact ? " · exact" : ""}`);
328
331
  lines.push(` ${oneLine(hit.excerpt, 400)}`);
package/src/config.ts CHANGED
@@ -74,6 +74,7 @@ export const DEFAULT_CONFIG: MemoriaConfig = {
74
74
  sessionExcerptChars: 400,
75
75
  sessionCacheBytes: 32 * 1024 * 1024,
76
76
  sessionIncludeTools: false,
77
+ sessionRipgrep: true,
77
78
  };
78
79
 
79
80
  /** Filesystem context used to resolve store paths. */
@@ -168,6 +169,7 @@ function coerceConfig(raw: unknown): Partial<MemoriaConfig> {
168
169
  if (number(input.sessionScanMs) !== undefined) out.sessionScanMs = Math.max(50, Math.min(60_000, number(input.sessionScanMs)!));
169
170
  if (number(input.sessionExcerptChars) !== undefined) out.sessionExcerptChars = Math.max(80, Math.min(4000, number(input.sessionExcerptChars)!));
170
171
  if (number(input.sessionCacheBytes) !== undefined) out.sessionCacheBytes = Math.max(0, number(input.sessionCacheBytes)!);
172
+ if (bool(input.sessionRipgrep) !== undefined) out.sessionRipgrep = bool(input.sessionRipgrep);
171
173
  if (Array.isArray(input.sessionRoots)) out.sessionRoots = input.sessionRoots.filter((item): item is string => typeof item === "string");
172
174
  if (input.synonyms && typeof input.synonyms === "object" && !Array.isArray(input.synonyms)) {
173
175
  const table: Record<string, string[]> = {};
@@ -150,6 +150,8 @@ export class MemoryIndex {
150
150
 
151
151
  private termIds = new Map<string, number>();
152
152
  private idToIdx = new Map<string, number>();
153
+ /** Built only for lookups that miss canonical ids; aliases are not the hot path. */
154
+ private aliasToIdx: Map<string, number> | undefined;
153
155
  private relPathToIdx = new Map<string, number>();
154
156
  private bodyCache: BodyCache;
155
157
  private trigramIndex: Map<string, number[]> | undefined;
@@ -237,7 +239,20 @@ export class MemoryIndex {
237
239
  }
238
240
 
239
241
  idxForId(id: string): number | undefined {
240
- return this.idToIdx.get(id);
242
+ const direct = this.idToIdx.get(id);
243
+ if (direct !== undefined) return direct;
244
+ // Imports may have ids that do not use the mem_ prefix. A merge records the
245
+ // retired id as an alias, so every former id must remain usable.
246
+ if (!this.aliasToIdx) {
247
+ this.aliasToIdx = new Map();
248
+ for (let idx = 0; idx < this.docs.length; idx += 1) {
249
+ if (!this.alive[idx]) continue;
250
+ for (const alias of this.docs[idx].aliases) {
251
+ if (!this.aliasToIdx.has(alias)) this.aliasToIdx.set(alias, idx);
252
+ }
253
+ }
254
+ }
255
+ return this.aliasToIdx.get(id);
241
256
  }
242
257
 
243
258
  idxForRelPath(relPath: string): number {
@@ -323,6 +338,7 @@ export class MemoryIndex {
323
338
  this.docTfs.push(tfs);
324
339
  this.alive.push(true);
325
340
  this.idToIdx.set(doc.id, idx);
341
+ this.aliasToIdx = undefined;
326
342
  this.relPathToIdx.set(meta.relPath, idx);
327
343
  this.bodyCache.set(doc.path, doc.body);
328
344
  this.linkCache = undefined;
@@ -335,6 +351,7 @@ export class MemoryIndex {
335
351
  const doc = this.docs[idx];
336
352
  this.alive[idx] = false;
337
353
  this.idToIdx.delete(doc.id);
354
+ this.aliasToIdx = undefined;
338
355
  this.relPathToIdx.delete(doc.relPath);
339
356
  this.bodyCache.delete(doc.path);
340
357
  const termIds = this.docTerms[idx] ?? [];
@@ -414,6 +431,7 @@ export class MemoryIndex {
414
431
  this.docTfs = newDocTfs;
415
432
  this.alive = new Array(newDocs.length).fill(true);
416
433
  this.idToIdx = new Map();
434
+ this.aliasToIdx = undefined;
417
435
  this.relPathToIdx = new Map();
418
436
  for (let i = 0; i < newDocs.length; i += 1) {
419
437
  this.idToIdx.set(newDocs[i].id, i);
@@ -437,6 +455,7 @@ export class MemoryIndex {
437
455
  this.alive = [];
438
456
  this.termIds = new Map();
439
457
  this.idToIdx = new Map();
458
+ this.aliasToIdx = undefined;
440
459
  this.relPathToIdx = new Map();
441
460
  this.trigramIndex = undefined;
442
461
  this.fuzzyCache.clear();
@@ -505,10 +524,8 @@ export class MemoryIndex {
505
524
  const resolve = (ref: string): number | undefined => {
506
525
  const trimmed = ref.trim();
507
526
  if (!trimmed) return undefined;
508
- if (trimmed.startsWith("mem_")) {
509
- const idx = this.idToIdx.get(trimmed);
510
- if (idx !== undefined) return idx;
511
- }
527
+ const byId = this.idxForId(trimmed);
528
+ if (byId !== undefined) return byId;
512
529
  const byPath = this.relPathToIdx.get(normalizeRelPath(trimmed));
513
530
  if (byPath !== undefined) return byPath;
514
531
  return titles.get(trimmed.toLowerCase());
@@ -542,7 +559,7 @@ export class MemoryIndex {
542
559
  const titles = this.links().titles;
543
560
  const trimmed = ref.trim();
544
561
  if (!trimmed) return undefined;
545
- const byId = this.idToIdx.get(trimmed);
562
+ const byId = this.idxForId(trimmed);
546
563
  if (byId !== undefined && this.alive[byId]) return byId;
547
564
  const byPath = this.relPathToIdx.get(normalizeRelPath(trimmed));
548
565
  if (byPath !== undefined && this.alive[byPath]) return byPath;
@@ -827,6 +844,7 @@ export class MemoryIndex {
827
844
  }
828
845
  this.alive = new Array(docCount).fill(true);
829
846
  this.idToIdx = new Map();
847
+ this.aliasToIdx = undefined;
830
848
  this.relPathToIdx = new Map();
831
849
  for (let i = 0; i < docCount; i += 1) {
832
850
  this.idToIdx.set(this.docs[i].id, i);
@@ -935,6 +953,14 @@ export class MemoryIndex {
935
953
  async search(query: string, options: SearchOptions = {}): Promise<SearchResult> {
936
954
  const started = performance.now();
937
955
  await this.refresh(false);
956
+ const result = this.searchWarm(query, options);
957
+ this.lastSearchMs = Math.round((performance.now() - started) * 100) / 100;
958
+ return result;
959
+ }
960
+
961
+ /** Score an index the caller has already refreshed. */
962
+ searchWarm(query: string, options: SearchOptions = {}): SearchResult {
963
+ const started = performance.now();
938
964
  const result = searchIndex(this, query, options);
939
965
  this.lastSearchMs = Math.round((performance.now() - started) * 100) / 100;
940
966
  return result;
package/src/recall.ts CHANGED
@@ -87,11 +87,25 @@ export function renderSystemSection(input: SystemSectionInput): string {
87
87
  * Sessions are raw evidence rather than curated memory, so the block says so
88
88
  * explicitly and points at the verification step.
89
89
  */
90
- export function renderSessionFallback(hits: SessionHit[], stats?: Pick<SessionScanStats, "files" | "messages" | "partial">): string {
90
+ /**
91
+ * Warn that transcript search fell back to the slower built-in scanner.
92
+ *
93
+ * `undefined` for every status where the fallback was intentional (`disabled`)
94
+ * or the accelerator was simply not applicable (`unsupported`, `skipped`).
95
+ */
96
+ export function ripgrepNotice(status: SessionScanStats["ripgrep"]): string | undefined {
97
+ if (status === "missing") return "ripgrep is not installed, so transcripts are searched with the slower built-in scanner (large histories may be only partially covered)";
98
+ if (status === "error") return "ripgrep failed, so transcripts are searched with the slower built-in scanner";
99
+ return undefined;
100
+ }
101
+
102
+ export function renderSessionFallback(hits: SessionHit[], stats?: Pick<SessionScanStats, "files" | "messages" | "partial" | "ripgrep">): string {
91
103
  const lines: string[] = [];
92
104
  lines.push(
93
105
  `No memory note matched. Found ${hits.length} possibly relevant message${hits.length === 1 ? "" : "s"} in saved earlier conversations${stats ? ` (scanned ${stats.files} sessions, ${stats.messages} messages${stats.partial ? ", partial scan" : ""})` : ""}:`,
94
106
  );
107
+ const notice = stats ? ripgrepNotice(stats.ripgrep) : undefined;
108
+ if (notice) lines.push(`Note: ${notice}.`);
95
109
  for (const hit of hits) {
96
110
  lines.push(`- [${hit.role}, ${formatSessionTime(hit.timestamp)}] ${hit.projectName}: ${oneLine(hit.excerpt, 320)}`);
97
111
  lines.push(` file: ${hit.path}:${hit.line} (score ${hit.score}, matched: ${hit.matched.join(", ") || "-"})`);
@@ -113,7 +127,7 @@ export interface RecallRenderInput {
113
127
  timeWindow?: TimeWindow;
114
128
  /** Transcript excerpts returned when the library had no match. */
115
129
  sessionHits?: SessionHit[];
116
- sessionStats?: Pick<SessionScanStats, "files" | "messages" | "partial">;
130
+ sessionStats?: Pick<SessionScanStats, "files" | "messages" | "partial" | "ripgrep">;
117
131
  }
118
132
 
119
133
  /** Render the per-prompt recall block injected as a custom message. */