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 +19 -2
- package/index.ts +8 -1
- package/package.json +1 -1
- package/skills/memoria-sessions/SKILL.md +3 -0
- package/src/commands.ts +4 -2
- package/src/config.ts +2 -0
- package/src/recall.ts +16 -2
- package/src/runtime.ts +9 -3
- package/src/sessions.ts +252 -20
- package/src/tools.ts +10 -7
- package/src/types.ts +16 -0
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 |
|
|
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
|
|
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
|
@@ -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
|
-
|
|
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
|
|
443
|
-
|
|
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 {
|
|
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
|
-
/**
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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 {
|
|
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
|
-
|
|
384
|
-
|
|
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` : ""}
|
|
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
|
|
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 {
|