pi-memoria 0.1.1 → 0.1.2

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
@@ -409,6 +409,7 @@ back to the default rather than failing.
409
409
  | `sessionExcerptChars` | `400` | Context characters per transcript hit. |
410
410
  | `sessionCacheBytes` | `33554432` | In-memory budget for parsed transcripts. |
411
411
  | `sessionIncludeTools` | `false` | Include tool calls/results and compaction summaries by default. |
412
+ | `sessionRipgrep` | `true` | Use a ripgrep prefilter to skip match-free transcripts when `rg` is available; without it the slower built-in scanner runs. |
412
413
 
413
414
  ## Scopes: personal, project, read-only
414
415
 
@@ -495,14 +496,30 @@ What it does and does not touch:
495
496
  | 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
497
  | Output | Bounded excerpts plus `file:line`; never a whole transcript dump. |
497
498
  | 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. |
499
+ | 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. |
500
+ | 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
501
  | Privacy | Nothing leaves the machine; the excerpts go to the model only when the feature runs. |
501
502
 
502
503
  `sessionRoots` exists for archives or a second agent directory. Set
503
504
  `sessionSearch: false` to disable the feature entirely, or
504
505
  `sessionFallback: false` to keep the tool while never searching automatically.
505
506
 
507
+ When `rg` is available (pi ships it and puts it on `PATH`), a selective search
508
+ asks ripgrep which transcripts contain the query terms and parses only those, so
509
+ even a gigabyte-scale history is answered within the normal budget. If `rg` is
510
+ missing or fails, memoria falls back to reading every transcript itself and says
511
+ so: the result carries a `ripgrep is not installed` note, and interactive mode
512
+ shows it once per session. Large histories may then be only partially covered.
513
+
514
+ Without `rg` (or for a deliberately disabled accelerator), the default
515
+ `sessionScanMs` budget and `sessionCacheBytes` cache cover only part of a large
516
+ corpus: searches report `partial`, and repeats are cold scans because the cache
517
+ cannot retain the parsed files. Raise `sessionScanMs` so an explicit
518
+ `memoria_sessions` call can cover the whole corpus in one pass, and
519
+ `sessionCacheBytes` only if you have the RAM for the parsed messages (at this
520
+ scale that means gigabytes). The automatic fallback remains capped at 400 ms,
521
+ so on such a corpus it is always a partial scan.
522
+
506
523
  ## Backup and migration
507
524
 
508
525
  `memoria_export` writes JSONL — one record per line, notes plus `MEMORY.md` — to
package/index.ts CHANGED
@@ -25,7 +25,7 @@ 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";
@@ -39,6 +39,8 @@ export default function memoria(pi: ExtensionAPI) {
39
39
  let initPromise: Promise<void> | undefined;
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;
@@ -137,6 +139,11 @@ export default function memoria(pi: ExtensionAPI) {
137
139
  if (tokens.length < 2 && !/\d/.test(prompt)) return undefined;
138
140
  const parts = buildRecallParts(prompt, recentPrompts, memoria_.config.autoRecallLastTurns, memoria_.config.autoRecallPriorWeight);
139
141
  const result = await memoria_.recall(oneLine(prompt, 400), { limit: memoria_.config.autoRecallLimit, scope: "all", parts });
142
+ const rgNotice = ripgrepNotice(result.sessionStats?.ripgrep);
143
+ if (rgNotice && !ripgrepWarned) {
144
+ ripgrepWarned = true;
145
+ ctx.ui.notify(`memoria: ${rgNotice}.`, "warning");
146
+ }
140
147
  recentPrompts = [...recentPrompts, prompt].slice(-RECENT_PROMPT_LIMIT);
141
148
  const hits = result.hits.filter((hit) => hit.score >= memoria_.config.autoRecallMinScore);
142
149
  // Nothing in the library? Show what earlier conversations said instead
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-memoria",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
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";
@@ -318,11 +319,12 @@ export function registerMemoriaCommand(pi: ExtensionAPI, getRuntime: RuntimeGett
318
319
  });
319
320
  const stats = result.stats;
320
321
  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` : ""}).`;
322
+ const notice = ripgrepNotice(stats.ripgrep);
321
323
  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.`);
324
+ 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
325
  return;
324
326
  }
325
- const lines = [header, "", "_Evidence from raw transcripts, not curated memory._", ""];
327
+ const lines = [header, ...(notice ? [`_Note: ${notice}._`] : []), "", "_Evidence from raw transcripts, not curated memory._", ""];
326
328
  for (const hit of result.hits) {
327
329
  lines.push(`- **[${hit.role}] ${formatSessionTime(hit.timestamp)}** · ${hit.projectName} · score ${hit.score}${hit.exact ? " · exact" : ""}`);
328
330
  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[]> = {};
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. */
package/src/runtime.ts CHANGED
@@ -439,9 +439,12 @@ export class MemoriaRuntime {
439
439
  // search budget; the explicit tool call can take longer.
440
440
  budgetMs: Math.min(this.config.sessionScanMs, 400),
441
441
  }).catch(() => undefined);
442
- if (fallback && fallback.hits.length > 0) {
443
- result.sessionHits = fallback.hits;
442
+ if (fallback) {
443
+ // Stats are kept even with no hits: they carry the ripgrep status the
444
+ // caller warns about, and the renderers only show the excerpts when
445
+ // `sessionHits` is non-empty.
444
446
  result.sessionStats = fallback.stats;
447
+ if (fallback.hits.length > 0) result.sessionHits = fallback.hits;
445
448
  }
446
449
  }
447
450
  const wanted = options.rerank ?? this.config.rerank;
@@ -583,12 +586,15 @@ export class MemoriaRuntime {
583
586
  /** Lazily create the transcript store (keys on config + agent dir). */
584
587
  private sessions(): SessionStore {
585
588
  const roots = this.sessionRoots();
586
- const key = `${roots.join("|")}::${this.config.sessionCacheBytes}::${this.config.sessionExcerptChars}`;
589
+ const key = `${roots.join("|")}::${this.config.sessionCacheBytes}::${this.config.sessionExcerptChars}::${this.config.sessionRipgrep}`;
587
590
  if (!this.sessionStore || this.sessionStoreKey !== key) {
588
591
  this.sessionStore = new SessionStore({
589
592
  roots,
590
593
  cacheBytes: this.config.sessionCacheBytes,
591
594
  excerptChars: this.config.sessionExcerptChars,
595
+ rgPath: this.config.sessionRipgrep ? undefined : null,
596
+ // pi ships ripgrep under <agent dir>/bin and puts it on PATH.
597
+ rgCandidates: [join(this.pathContext.agentDir, "bin", process.platform === "win32" ? "rg.exe" : "rg")],
592
598
  });
593
599
  this.sessionStoreKey = key;
594
600
  }
package/src/sessions.ts CHANGED
@@ -23,15 +23,20 @@
23
23
  * paths outside the configured roots.
24
24
  */
25
25
 
26
- import { existsSync } from "node:fs";
26
+ import { spawn } from "node:child_process";
27
+ import { accessSync, constants, existsSync } from "node:fs";
27
28
  import { readdir, readFile, stat } from "node:fs/promises";
28
- import { basename, join, relative, resolve, sep } from "node:path";
29
+ import { basename, delimiter, join, relative, resolve, sep } from "node:path";
29
30
  import { isCjkChar, stem, tokenize } from "./tokenize.ts";
30
31
  import { oneLine, truncateChars } from "./util.ts";
31
32
  import type { SessionFile, SessionHit, SessionMessage, SessionReadResult, SessionScanStats, SessionSearchOptions, SessionSearchResult, SessionWindowMessage } from "./types.ts";
32
33
 
33
34
  /** Hard cap on a single extracted message, to keep the cache useful. */
34
35
  export const MAX_SESSION_MESSAGE_CHARS = 4000;
36
+ /** Upper bound on ripgrep's file list, so a pathological match set cannot exhaust memory. */
37
+ const MAX_RG_OUTPUT_CHARS = 16 * 1024 * 1024;
38
+ /** ripgrep is killed after this long, even when the search itself has no budget. */
39
+ const MAX_RG_TIMEOUT_MS = 10_000;
35
40
  /** Directories deeper than this under a root are ignored. */
36
41
  const MAX_WALK_DEPTH = 4;
37
42
  /** Files larger than this are skipped rather than read into memory. */
@@ -182,6 +187,53 @@ function cacheKey(path: string, includeTools: boolean): string {
182
187
  return `${path}::${includeTools ? "all" : "core"}`;
183
188
  }
184
189
 
190
+ /** Characters a ripgrep needle may contain: ASCII alphanumerics, `_` and CJK. */
191
+ function needleSafe(word: string): boolean {
192
+ if (!word) return false;
193
+ for (const ch of word) {
194
+ if (/[a-z0-9_]/.test(ch) || isCjkChar(ch)) continue;
195
+ return false;
196
+ }
197
+ return true;
198
+ }
199
+
200
+ /**
201
+ * Fixed-string needles for the ripgrep prefilter, or `undefined` when the query
202
+ * cannot be proven to be covered by them.
203
+ *
204
+ * A file is skipped only when ripgrep finds none of these strings, so they must
205
+ * cover everything the scorer can match: every scoring term (`tokenize` already
206
+ * lowercases) and — because a phrase match implies all of its words — the
207
+ * longest safe word of each exact phrase. Terms ending in `y` also probe the
208
+ * `…ies` surface form: that is the one stemmer rule which is not
209
+ * prefix-preserving, so query "party" must also match "parties". Non-ASCII,
210
+ * non-CJK words disable the prefilter rather than risk a normalization
211
+ * mismatch.
212
+ */
213
+ export function ripgrepNeedles(phrases: string[], terms: string[]): string[] | undefined {
214
+ const needles = new Set<string>();
215
+ const add = (word: string): void => {
216
+ const normalized = word.toLowerCase();
217
+ if (!needleSafe(normalized)) return;
218
+ needles.add(normalized);
219
+ if (normalized.length >= 4 && normalized.endsWith("y")) needles.add(`${normalized.slice(0, -1)}ies`);
220
+ };
221
+ for (const term of terms) {
222
+ if (!needleSafe(term.toLowerCase())) return undefined;
223
+ add(term);
224
+ }
225
+ for (const phrase of phrases) {
226
+ // One word of a phrase is enough to witness it: a phrase match implies
227
+ // every word is present. The longest safe word is the most selective, and
228
+ // a short but common witness ("a", "out") would match nearly everything.
229
+ const words = phrase.split(" ").filter((word) => needleSafe(word.toLowerCase()));
230
+ if (words.length === 0) return undefined;
231
+ add(words.reduce((best, word) => (word.length > best.length ? word : best)));
232
+ }
233
+ if (needles.size === 0 || needles.size > 64) return undefined;
234
+ return [...needles];
235
+ }
236
+
185
237
  export interface SessionStoreOptions {
186
238
  /** Transcript roots to scan, in priority order. */
187
239
  roots: string[];
@@ -189,6 +241,14 @@ export interface SessionStoreOptions {
189
241
  cacheBytes?: number;
190
242
  /** Default excerpt size. */
191
243
  excerptChars?: number;
244
+ /**
245
+ * Ripgrep binary used to find candidate transcripts before parsing. `null`
246
+ * disables the accelerator, a path uses exactly that binary, and `undefined`
247
+ * auto-detects from `rgCandidates` and then `PATH`.
248
+ */
249
+ rgPath?: string | null;
250
+ /** Locations checked before `PATH` when auto-detecting (e.g. pi's bin dir). */
251
+ rgCandidates?: string[];
192
252
  }
193
253
 
194
254
  interface CachedFile {
@@ -206,14 +266,114 @@ interface CachedFile {
206
266
  export class SessionStore {
207
267
  readonly roots: string[];
208
268
  private cache = new Map<string, CachedFile>();
269
+ /** In-flight parses, so concurrent searches share one read/parse per file. */
270
+ private inflight = new Map<string, Promise<{ entry: CachedFile | undefined; skipped: number; rawBytes: number }>>();
209
271
  private cacheBytes = 0;
210
272
  private cacheBudget: number;
211
273
  private excerptChars: number;
274
+ private rgPathOption: string | null | undefined;
275
+ private rgCandidates: string[];
276
+ /** Resolved once per store: a path, `null` (unavailable/disabled), or unknown. */
277
+ private rgLocated: string | null | undefined;
212
278
 
213
279
  constructor(options: SessionStoreOptions) {
214
280
  this.roots = options.roots;
215
281
  this.cacheBudget = Math.max(0, options.cacheBytes ?? 32 * 1024 * 1024);
216
282
  this.excerptChars = Math.max(80, options.excerptChars ?? 400);
283
+ this.rgPathOption = options.rgPath;
284
+ this.rgCandidates = options.rgCandidates ?? [];
285
+ }
286
+
287
+ /** Resolve the ripgrep binary once; `null` means the accelerator is off. */
288
+ private locateRipgrep(): string | null {
289
+ if (this.rgLocated !== undefined) return this.rgLocated;
290
+ const candidates: string[] = [];
291
+ if (typeof this.rgPathOption === "string") {
292
+ candidates.push(this.rgPathOption);
293
+ } else if (this.rgPathOption === undefined) {
294
+ candidates.push(...this.rgCandidates);
295
+ for (const dir of (process.env.PATH ?? "").split(delimiter)) {
296
+ if (!dir) continue;
297
+ candidates.push(join(dir, process.platform === "win32" ? "rg.exe" : "rg"));
298
+ }
299
+ }
300
+ for (const candidate of candidates) {
301
+ try {
302
+ accessSync(candidate, constants.X_OK);
303
+ this.rgLocated = candidate;
304
+ return candidate;
305
+ } catch {
306
+ // Try the next location.
307
+ }
308
+ }
309
+ this.rgLocated = null;
310
+ return null;
311
+ }
312
+
313
+ /**
314
+ * List the transcripts containing at least one needle with ripgrep.
315
+ *
316
+ * Returns matching paths, `"missing"` when no usable binary exists, or
317
+ * `"error"` when ripgrep failed, timed out, or produced more output than the
318
+ * caller can use. Both non-result outcomes make the caller fall back to the
319
+ * full parse, so correctness never depends on the accelerator.
320
+ */
321
+ private async ripgrepFiles(needles: string[], timeoutMs: number): Promise<Set<string> | "missing" | "error"> {
322
+ const binary = this.locateRipgrep();
323
+ if (!binary) return "missing";
324
+ const roots = this.roots.filter((root) => existsSync(root));
325
+ if (roots.length === 0) return new Set<string>();
326
+ const args = ["--files-with-matches", "--ignore-case", "--fixed-strings", "--no-messages", "--no-ignore", "--text", "--iglob", "*.jsonl"];
327
+ for (const needle of needles) args.push("-e", needle);
328
+ args.push("--", ...roots);
329
+ return await new Promise<Set<string> | "error">((settle) => {
330
+ let child;
331
+ try {
332
+ child = spawn(binary, args, { stdio: ["ignore", "pipe", "ignore"], windowsHide: true });
333
+ } catch {
334
+ settle("error");
335
+ return;
336
+ }
337
+ let out = "";
338
+ let settled = false;
339
+ let timer: ReturnType<typeof setTimeout> | undefined;
340
+ const finish = (value: Set<string> | "error"): void => {
341
+ if (settled) return;
342
+ settled = true;
343
+ if (timer) clearTimeout(timer);
344
+ settle(value);
345
+ };
346
+ const kill = (): void => {
347
+ try {
348
+ child.kill("SIGKILL");
349
+ } catch {
350
+ // Already gone.
351
+ }
352
+ };
353
+ timer = setTimeout(() => {
354
+ kill();
355
+ finish("error");
356
+ }, timeoutMs);
357
+ child.stdout?.setEncoding("utf8");
358
+ child.stdout?.on("data", (chunk: string) => {
359
+ out += chunk;
360
+ if (out.length > MAX_RG_OUTPUT_CHARS) {
361
+ kill();
362
+ finish("error");
363
+ }
364
+ });
365
+ child.on("error", () => finish("error"));
366
+ child.on("close", (code) => {
367
+ if (code === 1) return finish(new Set<string>());
368
+ if (code !== 0) return finish("error");
369
+ const paths = new Set<string>();
370
+ for (const line of out.split("\n")) {
371
+ const path = line.trim();
372
+ if (path) paths.add(resolve(path));
373
+ }
374
+ finish(paths);
375
+ });
376
+ });
217
377
  }
218
378
 
219
379
  /** True when at least one root exists on disk. */
@@ -296,26 +456,18 @@ export class SessionStore {
296
456
  }
297
457
  }
298
458
 
299
- /** Parse a transcript, reusing the cache when mtime and size are unchanged. */
300
- async messagesFor(file: SessionFile, includeTools: boolean): Promise<{ messages: SessionMessage[]; cached: boolean; skipped: number; rawBytes: number; cwd?: string }> {
301
- const key = cacheKey(file.path, includeTools);
302
- // A full parse can serve the default view, but the default view cannot
303
- // serve an includeTools search (it never extracted the tool text).
304
- const cached = includeTools
305
- ? this.cache.get(cacheKey(file.path, true))
306
- : (this.cache.get(cacheKey(file.path, false)) ?? this.cache.get(cacheKey(file.path, true)));
307
- if (cached && cached.mtimeMs === file.mtimeMs && cached.size === file.size) {
308
- // Touch for LRU ordering.
309
- this.cache.delete(key);
310
- this.cache.set(key, cached);
311
- const visible = includeTools ? cached.messages : cached.messages.filter((message) => message.role !== "tool" && message.role !== "summary");
312
- return { messages: visible, cached: true, skipped: 0, rawBytes: cached.rawBytes, cwd: cached.header.cwd };
313
- }
459
+ /** Default-view projection of a parsed file (tool plumbing hidden unless asked). */
460
+ private view(entry: CachedFile, includeTools: boolean): SessionMessage[] {
461
+ return includeTools ? entry.messages : entry.messages.filter((message) => message.role !== "tool" && message.role !== "summary");
462
+ }
463
+
464
+ /** Read and parse one transcript, populating the cache (no in-flight handling). */
465
+ private async parseFile(file: SessionFile, includeTools: boolean): Promise<{ entry: CachedFile | undefined; skipped: number; rawBytes: number }> {
314
466
  let raw: string;
315
467
  try {
316
468
  raw = await readFile(file.path, "utf8");
317
469
  } catch {
318
- return { messages: [], cached: false, skipped: 1, rawBytes: 0 };
470
+ return { entry: undefined, skipped: 1, rawBytes: 0 };
319
471
  }
320
472
  const messages: SessionMessage[] = [];
321
473
  let skipped = 0;
@@ -380,8 +532,51 @@ export class SessionStore {
380
532
  header: { cwd, startedAt: headerStarted || file.startedAt },
381
533
  };
382
534
  this.put(parsed, includeTools);
383
- const visible = includeTools ? messages : messages.filter((message) => message.role !== "tool" && message.role !== "summary");
384
- return { messages: visible, cached: false, skipped, rawBytes: raw.length, cwd };
535
+ return { entry: parsed, skipped, rawBytes: raw.length };
536
+ }
537
+
538
+ /**
539
+ * Parse a transcript, reusing the cache when mtime and size are unchanged.
540
+ *
541
+ * Concurrent searches (the common case, since one assistant turn can issue
542
+ * several `memoria_sessions` calls at once) share a single in-flight parse
543
+ * per file, so parallel searches never multiply read/parse work on one event
544
+ * loop and never lose older files to another search's wall-clock budget.
545
+ */
546
+ async messagesFor(file: SessionFile, includeTools: boolean): Promise<{ messages: SessionMessage[]; cached: boolean; skipped: number; rawBytes: number; cwd?: string }> {
547
+ const key = cacheKey(file.path, includeTools);
548
+ const allKey = cacheKey(file.path, true);
549
+ // A full parse can serve the default view, but the default view cannot
550
+ // serve an includeTools search (it never extracted the tool text).
551
+ const cached = includeTools ? this.cache.get(allKey) : (this.cache.get(cacheKey(file.path, false)) ?? this.cache.get(allKey));
552
+ if (cached && cached.mtimeMs === file.mtimeMs && cached.size === file.size) {
553
+ // Touch for LRU ordering, under the key the entry actually lives on.
554
+ for (const candidate of [key, allKey]) {
555
+ if (this.cache.get(candidate) === cached) {
556
+ this.cache.delete(candidate);
557
+ this.cache.set(candidate, cached);
558
+ break;
559
+ }
560
+ }
561
+ return { messages: this.view(cached, includeTools), cached: true, skipped: 0, rawBytes: cached.rawBytes, cwd: cached.header.cwd };
562
+ }
563
+ // Join an in-flight parse when it can answer this request: the same
564
+ // variant always, or a full parse for a default-view request.
565
+ const pending = this.inflight.get(key) ?? (includeTools ? undefined : this.inflight.get(allKey));
566
+ if (pending) {
567
+ const { entry, skipped, rawBytes } = await pending;
568
+ if (!entry) return { messages: [], cached: false, skipped, rawBytes };
569
+ return { messages: this.view(entry, includeTools), cached: false, skipped, rawBytes: entry.rawBytes, cwd: entry.header.cwd };
570
+ }
571
+ const promise = this.parseFile(file, includeTools);
572
+ this.inflight.set(key, promise);
573
+ try {
574
+ const { entry, skipped, rawBytes } = await promise;
575
+ if (!entry) return { messages: [], cached: false, skipped, rawBytes };
576
+ return { messages: this.view(entry, includeTools), cached: false, skipped, rawBytes: entry.rawBytes, cwd: entry.header.cwd };
577
+ } finally {
578
+ this.inflight.delete(key);
579
+ }
385
580
  }
386
581
 
387
582
  private put(entry: CachedFile, includeTools: boolean): void {
@@ -448,6 +643,38 @@ export class SessionStore {
448
643
  // could not be completed is reported as partial.
449
644
  const listing = await this.listFiles({ deadline: budgetMs > 0 ? started + budgetMs : undefined });
450
645
  if (listing.truncated) stats.partial = true;
646
+
647
+ // Ripgrep narrows the parse set to files that contain at least one query
648
+ // needle. Files outside that set are provably match-free, so they count as
649
+ // scanned without being read. Without a usable binary (or a query we cannot
650
+ // prefilter safely) every listed file is parsed exactly as before.
651
+ let parseFilter: Set<string> | undefined;
652
+ if (this.rgPathOption === null) {
653
+ stats.ripgrep = "disabled";
654
+ } else {
655
+ const needles = ripgrepNeedles(phrases, scoringTerms);
656
+ if (!needles) {
657
+ stats.ripgrep = "unsupported";
658
+ } else {
659
+ const remaining = budgetMs > 0 ? started + budgetMs - performance.now() : Number.POSITIVE_INFINITY;
660
+ if (listing.files.length === 0 || remaining <= 0) {
661
+ stats.ripgrep = "skipped";
662
+ } else {
663
+ // The accelerator is fast, but give it a hard cap so a hung binary
664
+ // or a pathological mount cannot stall a prompt. Overrunning the
665
+ // remaining budget only makes the parse below report `partial`.
666
+ const timeout = Number.isFinite(remaining) ? Math.min(MAX_RG_TIMEOUT_MS, Math.max(500, Math.round(remaining * 4))) : MAX_RG_TIMEOUT_MS;
667
+ const found = await this.ripgrepFiles(needles, timeout);
668
+ if (found === "missing") stats.ripgrep = "missing";
669
+ else if (found === "error") stats.ripgrep = "error";
670
+ else {
671
+ parseFilter = found;
672
+ stats.ripgrep = "used";
673
+ }
674
+ }
675
+ }
676
+ }
677
+
451
678
  const candidates: Candidate[] = [];
452
679
  for (const file of listing.files) {
453
680
  if (since !== undefined && file.startedAt < since) continue;
@@ -458,6 +685,11 @@ export class SessionStore {
458
685
  stats.partial = true;
459
686
  break;
460
687
  }
688
+ if (parseFilter && !parseFilter.has(resolve(file.path))) {
689
+ stats.files += 1;
690
+ stats.bytes += file.size;
691
+ continue;
692
+ }
461
693
  const parsed = await this.messagesFor(file, includeTools);
462
694
  stats.files += 1;
463
695
  stats.bytes += parsed.rawBytes;
package/src/tools.ts CHANGED
@@ -13,10 +13,10 @@ import type { AgentToolResult, ExtensionAPI, ExtensionContext } from "@earendil-
13
13
  import { Text } from "@earendil-works/pi-tui";
14
14
  import type { MemoriaRuntime } from "./runtime.ts";
15
15
  import { readStoreFile } from "./store.ts";
16
- import { renderSessionFallback } from "./recall.ts";
16
+ import { renderSessionFallback, ripgrepNotice } from "./recall.ts";
17
17
  import { formatSessionTime } from "./sessions.ts";
18
18
  import { atomicWriteFile, oneLine, truncateChars } from "./util.ts";
19
- import type { Priority, Scope, SearchHit } from "./types.ts";
19
+ import type { Priority, Scope, SearchHit, SessionScanStats } from "./types.ts";
20
20
 
21
21
  /** Inline export/import payload cap; larger dumps must use a file path. */
22
22
  const MAX_INLINE_TRANSFER_CHARS = 60_000;
@@ -762,17 +762,19 @@ export function registerMemoriaTools(pi: ExtensionAPI, getRuntime: RuntimeGetter
762
762
  userOnly: params.user_only,
763
763
  });
764
764
  const stats = result.stats;
765
+ const notice = ripgrepNotice(stats.ripgrep);
765
766
  if (result.hits.length === 0) {
766
767
  const where = stats.roots.length > 0 ? stats.roots.join(", ") : "(no session directory found)";
767
768
  return {
768
769
  content: text(
769
- `No saved conversation matched "${oneLine(params.query, 120)}".\nSearched ${where}: ${stats.files} sessions, ${stats.messages} messages (${(stats.bytes / 1_048_576).toFixed(1)} MB) in ${result.tookMs}ms${stats.partial ? " (partial scan: budget reached, results may be incomplete)" : ""}${stats.skipped > 0 ? `, skipped ${stats.skipped} unreadable records` : ""}.\nTry different wording, a name or date, or include_tools: true for tool output and compaction summaries.`,
770
+ `No saved conversation matched "${oneLine(params.query, 120)}".\nSearched ${where}: ${stats.files} sessions, ${stats.messages} messages (${(stats.bytes / 1_048_576).toFixed(1)} MB) in ${result.tookMs}ms${stats.partial ? " (partial scan: budget reached, results may be incomplete)" : ""}${stats.skipped > 0 ? `, skipped ${stats.skipped} unreadable records` : ""}.${notice ? `\nNote: ${notice}.` : ""}\nTry different wording, a name or date, or include_tools: true for tool output and compaction summaries.`,
770
771
  ),
771
- details: { kind: "session", count: 0, partial: stats.partial },
772
+ details: { kind: "session", count: 0, partial: stats.partial, ripgrep: stats.ripgrep },
772
773
  };
773
774
  }
774
775
  const lines = [
775
776
  `${result.hits.length} match${result.hits.length === 1 ? "" : "es"} for "${oneLine(params.query, 120)}" in saved conversations (${result.tookMs}ms, ${stats.files} sessions / ${stats.messages} messages scanned${stats.cachedFiles > 0 ? `, ${stats.cachedFiles} cached` : ""}${stats.partial ? ", PARTIAL: budget reached" : ""}).`,
777
+ ...(notice ? [`Note: ${notice}.`] : []),
776
778
  "Evidence from raw transcripts, not curated memory.",
777
779
  "",
778
780
  ];
@@ -788,7 +790,7 @@ export function registerMemoriaTools(pi: ExtensionAPI, getRuntime: RuntimeGetter
788
790
  );
789
791
  return {
790
792
  content: text(lines.join("\n")),
791
- details: { kind: "session", count: result.hits.length, partial: stats.partial },
793
+ details: { kind: "session", count: result.hits.length, partial: stats.partial, ripgrep: stats.ripgrep },
792
794
  };
793
795
  },
794
796
  renderCall(args, theme) {
@@ -796,10 +798,11 @@ export function registerMemoriaTools(pi: ExtensionAPI, getRuntime: RuntimeGetter
796
798
  return new Text(theme.fg("toolTitle", theme.bold("memoria_sessions ")) + theme.fg("accent", label), 0, 0);
797
799
  },
798
800
  renderResult(result, options, theme) {
799
- const details = result.details as { kind?: string; count?: number; line?: number; partial?: boolean } | undefined;
801
+ const details = result.details as { kind?: string; count?: number; line?: number; partial?: boolean; ripgrep?: SessionScanStats["ripgrep"] } | undefined;
800
802
  if (details?.kind === "session_read") return new Text(theme.fg("success", `✓ window at line ${details.line ?? "?"}`), 0, 0);
801
803
  if (!details?.count) return new Text(theme.fg("warning", details?.partial ? "no matches (partial scan)" : "no matches"), 0, 0);
802
- const text = theme.fg("success", `✓ ${details.count} past-session matches`) + theme.fg("dim", details.partial ? " (partial)" : "");
804
+ const slow = details?.ripgrep === "missing" || details?.ripgrep === "error";
805
+ const text = theme.fg("success", `✓ ${details.count} past-session matches`) + theme.fg("dim", `${details.partial ? " (partial)" : ""}${slow ? " (slower fallback: no ripgrep)" : ""}`);
803
806
  return new Text(text + (options.expanded ? `\n${theme.fg("dim", "see output for excerpts and file:line")}` : ""), 0, 0);
804
807
  },
805
808
  });
package/src/types.ts CHANGED
@@ -430,6 +430,8 @@ export interface MemoriaConfig {
430
430
  sessionCacheBytes: number;
431
431
  /** Include tool calls, tool results and compaction summaries by default. */
432
432
  sessionIncludeTools: boolean;
433
+ /** Use a ripgrep prefilter to skip match-free transcripts when `rg` exists. */
434
+ sessionRipgrep: boolean;
433
435
  }
434
436
 
435
437
  /* ------------------------------------------------------------------ */
@@ -497,6 +499,18 @@ export interface SessionHit {
497
499
  excerpt: string;
498
500
  }
499
501
 
502
+ /**
503
+ * How the ripgrep candidate prefilter contributed to a transcript scan.
504
+ *
505
+ * `used` means ripgrep listed the files containing at least one query needle
506
+ * and the rest were skipped without parsing. The others are fallbacks so the
507
+ * search still works without a usable `rg`:
508
+ * `missing` (no binary), `error` (spawn failure/timeout/bad exit),
509
+ * `disabled` (config), `unsupported` (query cannot be prefiltered safely),
510
+ * `skipped` (no budget left to try).
511
+ */
512
+ export type SessionRipgrepStatus = "used" | "missing" | "error" | "disabled" | "unsupported" | "skipped";
513
+
500
514
  export interface SessionScanStats {
501
515
  roots: string[];
502
516
  files: number;
@@ -508,6 +522,8 @@ export interface SessionScanStats {
508
522
  skipped: number;
509
523
  /** True when the budget stopped the scan before every file was read. */
510
524
  partial: boolean;
525
+ /** Ripgrep prefilter outcome, when a scan ran. */
526
+ ripgrep?: SessionRipgrepStatus;
511
527
  }
512
528
 
513
529
  export interface SessionSearchResult {