memonaut 0.0.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/LICENSE +661 -0
  2. package/dist/cli-main.d.ts +14 -0
  3. package/dist/cli-main.d.ts.map +1 -0
  4. package/dist/cli-main.js +551 -0
  5. package/dist/cli-main.js.map +1 -0
  6. package/dist/cli.d.ts +3 -0
  7. package/dist/cli.d.ts.map +1 -0
  8. package/dist/cli.js +16 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/config.d.ts +38 -0
  11. package/dist/config.d.ts.map +1 -0
  12. package/dist/config.js +86 -0
  13. package/dist/config.js.map +1 -0
  14. package/dist/db.d.ts +37 -0
  15. package/dist/db.d.ts.map +1 -0
  16. package/dist/db.js +184 -0
  17. package/dist/db.js.map +1 -0
  18. package/dist/format.d.ts +22 -0
  19. package/dist/format.d.ts.map +1 -0
  20. package/dist/format.js +143 -0
  21. package/dist/format.js.map +1 -0
  22. package/dist/glob.d.ts +17 -0
  23. package/dist/glob.d.ts.map +1 -0
  24. package/dist/glob.js +77 -0
  25. package/dist/glob.js.map +1 -0
  26. package/dist/index.d.ts +13 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +15 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/indexer.d.ts +50 -0
  31. package/dist/indexer.d.ts.map +1 -0
  32. package/dist/indexer.js +404 -0
  33. package/dist/indexer.js.map +1 -0
  34. package/dist/lineage.d.ts +31 -0
  35. package/dist/lineage.d.ts.map +1 -0
  36. package/dist/lineage.js +94 -0
  37. package/dist/lineage.js.map +1 -0
  38. package/dist/model.d.ts +121 -0
  39. package/dist/model.d.ts.map +1 -0
  40. package/dist/model.js +41 -0
  41. package/dist/model.js.map +1 -0
  42. package/dist/pi-source.d.ts +47 -0
  43. package/dist/pi-source.d.ts.map +1 -0
  44. package/dist/pi-source.js +309 -0
  45. package/dist/pi-source.js.map +1 -0
  46. package/dist/quiet.d.ts +7 -0
  47. package/dist/quiet.d.ts.map +1 -0
  48. package/dist/quiet.js +19 -0
  49. package/dist/quiet.js.map +1 -0
  50. package/dist/regex.d.ts +75 -0
  51. package/dist/regex.d.ts.map +1 -0
  52. package/dist/regex.js +242 -0
  53. package/dist/regex.js.map +1 -0
  54. package/dist/ripgrep.d.ts +52 -0
  55. package/dist/ripgrep.d.ts.map +1 -0
  56. package/dist/ripgrep.js +217 -0
  57. package/dist/ripgrep.js.map +1 -0
  58. package/dist/search.d.ts +114 -0
  59. package/dist/search.d.ts.map +1 -0
  60. package/dist/search.js +309 -0
  61. package/dist/search.js.map +1 -0
  62. package/dist/silence-sqlite-warning.d.ts +2 -0
  63. package/dist/silence-sqlite-warning.d.ts.map +1 -0
  64. package/dist/silence-sqlite-warning.js +9 -0
  65. package/dist/silence-sqlite-warning.js.map +1 -0
  66. package/package.json +57 -2
  67. package/src/cli-main.ts +618 -0
  68. package/src/cli.ts +17 -0
  69. package/src/config.ts +130 -0
  70. package/src/db.ts +213 -0
  71. package/src/format.ts +185 -0
  72. package/src/glob.ts +79 -0
  73. package/src/index.ts +19 -0
  74. package/src/indexer.ts +534 -0
  75. package/src/lineage.ts +111 -0
  76. package/src/model.ts +157 -0
  77. package/src/pi-source.ts +328 -0
  78. package/src/quiet.ts +22 -0
  79. package/src/regex.ts +378 -0
  80. package/src/ripgrep.ts +263 -0
  81. package/src/search.ts +504 -0
package/src/regex.ts ADDED
@@ -0,0 +1,378 @@
1
+ import type {DB} from './db.js';
2
+ import type {ChunkKind, SearchHit} from './model.js';
3
+ import {extractEntry} from './pi-source.js';
4
+ import {ripgrep, type RipgrepMatch} from './ripgrep.js';
5
+ import {collectThreads, loadFiles, visibleSets} from './search.js';
6
+
7
+ /**
8
+ * Exact regex search, delegated to ripgrep, resolved back through the index.
9
+ *
10
+ * The two search paths are complementary and neither pretends to be the other.
11
+ * FTS5 tokenises, so it cannot match inside an identifier and knows nothing
12
+ * about text the tier excluded; ripgrep reads the ORIGINAL transcripts, so it
13
+ * matches anything that was ever written down, notably tool output (42% of the
14
+ * bytes, excluded by default). What it does NOT have is ranking.
15
+ *
16
+ * Three things make this more than `rg ~/.pi/agent/sessions`:
17
+ *
18
+ * 1. The candidate file list comes from the INDEX, never from a directory walk,
19
+ * so transcripts the user told the tool to ignore are still never read, and
20
+ * `private` still holds.
21
+ * 2. A matched line is resolved to an entry by `(lineage_id, entry_key)`, read
22
+ * out of the line's own JSON. Never by byte offset: `entry.byte_offset` is
23
+ * the offset in the file that FIRST recorded the entry, and a fork's copy of
24
+ * that entry sits somewhere else entirely in the child file.
25
+ * 3. Forks are deduplicated. A fork copies the shared prefix, so rg reports a
26
+ * shared entry once per fork file; the result is ONE hit carrying N threads,
27
+ * exactly as the FTS path would report it.
28
+ */
29
+
30
+ export interface RegexQuery {
31
+ /** A ripgrep (Rust regex) pattern, or a literal string when `fixed`. */
32
+ pattern: string;
33
+ cwd?: string[];
34
+ project?: string[];
35
+ role?: string[];
36
+ tool?: string[];
37
+ kind?: ChunkKind[];
38
+ since?: string;
39
+ until?: string;
40
+ includePrivate?: boolean;
41
+ limit?: number;
42
+ /** Threads listed per group; the total is always reported. */
43
+ threadLimit?: number;
44
+ /** `rg -i`. */
45
+ ignoreCase?: boolean;
46
+ /** `rg -F`: the pattern is a literal string. */
47
+ fixed?: boolean;
48
+ /** `rg -m`: per-file cap. 0 disables it. */
49
+ maxPerFile?: number;
50
+ /** Stop the scan after this many distinct matching entries. */
51
+ maxMatches?: number;
52
+ /** Override the ripgrep binary (tests, unusual installs). */
53
+ binary?: string;
54
+ }
55
+
56
+ export interface RegexOutcome {
57
+ hits: SearchHit[];
58
+ /** Transcripts actually handed to rg, after index-side filtering. */
59
+ filesSearched: number;
60
+ /** Matching lines rg reported, before fork deduplication. */
61
+ rawMatches: number;
62
+ /** Distinct entries left after fork deduplication. */
63
+ entriesMatched: number;
64
+ /** Groups found before `limit` was applied. */
65
+ scanned: number;
66
+ /** True when the scan stopped at `maxMatches` rather than at the end. */
67
+ truncated: boolean;
68
+ /**
69
+ * Matched lines that no longer resolve to an indexed entry: a transcript
70
+ * that grew since the last sync, or a line the extractor skips.
71
+ */
72
+ unresolved: number;
73
+ }
74
+
75
+ export const DEFAULT_MAX_PER_FILE = 200;
76
+ export const DEFAULT_MAX_MATCHES = 2000;
77
+
78
+ /** Context kept either side of the match in a snippet, in characters. */
79
+ const SNIPPET_RADIUS = 70;
80
+
81
+ interface EntryRow {
82
+ id: number;
83
+ entry_key: string;
84
+ lineage_id: number;
85
+ role: string;
86
+ tool: string | null;
87
+ ts: string | null;
88
+ }
89
+
90
+ interface Candidate {
91
+ entry: EntryRow;
92
+ kind?: ChunkKind;
93
+ snippet: string;
94
+ matchedIn: NonNullable<SearchHit['matchedIn']>;
95
+ }
96
+
97
+ /** Everything the extractor can see, so the snippet is not limited by the tier. */
98
+ const FULL_EXTRACT = {
99
+ tier: 'full' as const,
100
+ toolResultHeadBytes: Number.MAX_SAFE_INTEGER,
101
+ toolArgsHeadBytes: Number.MAX_SAFE_INTEGER,
102
+ };
103
+
104
+ function windowAround(
105
+ text: string,
106
+ start: number,
107
+ length: number,
108
+ radius = SNIPPET_RADIUS,
109
+ ): string {
110
+ const from = Math.max(0, start - radius);
111
+ const to = Math.min(text.length, start + length + radius);
112
+ // \u0001/\u0002 are the same markers FTS5 `snippet()` emits, so the renderer
113
+ // paints a regex hit exactly like a normal one.
114
+ return (
115
+ (from > 0 ? '…' : '') +
116
+ text.slice(from, start) +
117
+ '\u0001' +
118
+ text.slice(start, start + length) +
119
+ '\u0002' +
120
+ text.slice(start + length, to) +
121
+ (to < text.length ? '…' : '')
122
+ );
123
+ }
124
+
125
+ function indexOfNeedle(
126
+ haystack: string,
127
+ needle: string,
128
+ ignoreCase: boolean,
129
+ ): number {
130
+ if (!needle) return -1;
131
+ if (!ignoreCase) return haystack.indexOf(needle);
132
+ return haystack.toLowerCase().indexOf(needle.toLowerCase());
133
+ }
134
+
135
+ /**
136
+ * Turn a matched raw line into something a human can read, and say where the
137
+ * matched bytes actually live.
138
+ *
139
+ * The needle is rg's own `submatches[0]`, which is a LITERAL whatever the
140
+ * pattern was, so nothing here has to re-implement Rust regex semantics in
141
+ * JavaScript.
142
+ *
143
+ * Three outcomes, and keeping them apart is the honesty of the whole feature:
144
+ * the needle is in extracted text that is also indexed (`index`); it is in
145
+ * extracted text that is NOT indexed, because of the tier or because the chunk
146
+ * was truncated before it (`transcript`); or it is in none of the extracted
147
+ * text at all, so it matched a JSON key, an id or an escape sequence
148
+ * (`structure`). Only the middle one means "the index could never have found
149
+ * this". Labelling a match on `"timestamp":` that way would be a lie about an
150
+ * entry whose text is indexed perfectly well.
151
+ */
152
+ function describeMatch(
153
+ raw: Record<string, unknown>,
154
+ match: RipgrepMatch,
155
+ ignoreCase: boolean,
156
+ indexedTexts: string[],
157
+ ): {
158
+ kind?: ChunkKind;
159
+ snippet: string;
160
+ matchedIn: NonNullable<SearchHit['matchedIn']>;
161
+ } {
162
+ const extracted = extractEntry(raw, 0, 0, FULL_EXTRACT)?.chunks ?? [];
163
+ for (const chunk of extracted) {
164
+ const at = indexOfNeedle(chunk.text, match.needle, ignoreCase);
165
+ if (at < 0) continue;
166
+ const indexed = indexedTexts.some(
167
+ (text) => indexOfNeedle(text, match.needle, ignoreCase) >= 0,
168
+ );
169
+ return {
170
+ kind: chunk.kind,
171
+ snippet: windowAround(chunk.text, at, match.needle.length),
172
+ matchedIn: indexed ? 'index' : 'transcript',
173
+ };
174
+ }
175
+ // Nothing anyone wrote: no kind fits, so none is invented.
176
+ const at = indexOfNeedle(match.line, match.needle, ignoreCase);
177
+ return {
178
+ snippet:
179
+ at >= 0
180
+ ? windowAround(match.line.trimEnd(), at, match.needle.length)
181
+ : match.line.slice(0, SNIPPET_RADIUS * 2),
182
+ matchedIn: 'structure',
183
+ };
184
+ }
185
+
186
+ /**
187
+ * Search the transcripts themselves, and report the result exactly as the
188
+ * index-backed search would.
189
+ */
190
+ export async function regexSearch(
191
+ db: DB,
192
+ query: RegexQuery,
193
+ ): Promise<RegexOutcome> {
194
+ const limit = query.limit ?? 20;
195
+ const threadLimit = query.threadLimit ?? 3;
196
+ // A cap that is NaN or zero would mean "collect every match in the corpus",
197
+ // which is exactly the unbounded read this is here to prevent.
198
+ const maxMatches =
199
+ query.maxMatches !== undefined &&
200
+ Number.isFinite(query.maxMatches) &&
201
+ query.maxMatches > 0
202
+ ? query.maxMatches
203
+ : DEFAULT_MAX_MATCHES;
204
+ const files = loadFiles(db);
205
+ const visible = visibleSets(files, query);
206
+
207
+ // The earliest moment a lineage could hold: a fork's own `started` is later
208
+ // than the entries it inherited, so an `until` bound must be judged against
209
+ // the whole family or a fork whose parent is gone from disk would be skipped.
210
+ const lineageStart = new Map<number, string>();
211
+ for (const file of files.values()) {
212
+ if (file.lineage_id === null || !file.started) continue;
213
+ const id = Number(file.lineage_id);
214
+ const known = lineageStart.get(id);
215
+ if (!known || file.started < known) lineageStart.set(id, file.started);
216
+ }
217
+
218
+ // Path to lineage: the lineage is all a matched line needs, because entry
219
+ // identity is (lineage_id, entry_key) and a fork copy resolves to the entry
220
+ // its ancestor owns.
221
+ const byPath = new Map<string, number>();
222
+ const paths: string[] = [];
223
+ for (const file of files.values()) {
224
+ const fileId = Number(file.id);
225
+ if (!visible.files.has(fileId)) continue;
226
+ if (file.lineage_id === null) continue;
227
+ const lineageId = Number(file.lineage_id);
228
+ // Time prefilters, kept conservative: `last_activity` is the newest entry
229
+ // in this file INCLUDING inherited ones, so it is a sound `since` bound.
230
+ if (query.since && file.last_activity && file.last_activity < query.since)
231
+ continue;
232
+ const start = lineageStart.get(lineageId);
233
+ if (query.until && start && start > query.until) continue;
234
+ byPath.set(file.path, lineageId);
235
+ paths.push(file.path);
236
+ }
237
+
238
+ const outcome: RegexOutcome = {
239
+ hits: [],
240
+ filesSearched: paths.length,
241
+ rawMatches: 0,
242
+ entriesMatched: 0,
243
+ scanned: 0,
244
+ truncated: false,
245
+ unresolved: 0,
246
+ };
247
+ if (paths.length === 0) return outcome;
248
+
249
+ const entryStmt = db.prepare(
250
+ `SELECT id, entry_key, lineage_id, role, tool, ts
251
+ FROM entry WHERE lineage_id = ? AND entry_key = ?`,
252
+ );
253
+ const chunkStmt = db.prepare('SELECT text FROM chunk WHERE entry_id = ?');
254
+ const roleFilter = query.role?.length ? new Set(query.role) : null;
255
+ const toolFilter = query.tool?.length ? new Set(query.tool) : null;
256
+ const kindFilter = query.kind?.length ? new Set<string>(query.kind) : null;
257
+
258
+ const seen = new Set<number>();
259
+ const candidates: Candidate[] = [];
260
+
261
+ await ripgrep(
262
+ paths,
263
+ {
264
+ pattern: query.pattern,
265
+ ignoreCase: query.ignoreCase,
266
+ fixed: query.fixed,
267
+ maxPerFile: query.maxPerFile ?? DEFAULT_MAX_PER_FILE,
268
+ binary: query.binary,
269
+ },
270
+ (match) => {
271
+ outcome.rawMatches++;
272
+ const lineage = byPath.get(match.path);
273
+ if (lineage === undefined) return;
274
+ let raw: Record<string, unknown>;
275
+ try {
276
+ raw = JSON.parse(match.line) as Record<string, unknown>;
277
+ } catch {
278
+ outcome.unresolved++;
279
+ return;
280
+ }
281
+ const entryKey = typeof raw.id === 'string' ? raw.id : null;
282
+ if (!entryKey) return; // the session header line carries no identity
283
+ const entry = entryStmt.get(lineage, entryKey) as unknown as
284
+ EntryRow | undefined;
285
+ if (!entry) {
286
+ outcome.unresolved++;
287
+ return;
288
+ }
289
+ // THE fork rule: a shared entry is reported by rg once per fork file
290
+ // that copied it, and must collapse to one hit here. `(lineage, key)`
291
+ // resolved to one entry row, so its id is the deduplication key.
292
+ const entryId = Number(entry.id);
293
+ if (seen.has(entryId)) return;
294
+ seen.add(entryId);
295
+
296
+ if (roleFilter && !roleFilter.has(entry.role)) return;
297
+ if (toolFilter && !(entry.tool && toolFilter.has(entry.tool))) return;
298
+ if (query.since && (!entry.ts || entry.ts < query.since)) return;
299
+ if (query.until && (!entry.ts || entry.ts > query.until)) return;
300
+
301
+ const indexedTexts = (
302
+ chunkStmt.all(entryId) as unknown as Array<{text: string}>
303
+ ).map((r) => r.text);
304
+ const described = describeMatch(
305
+ raw,
306
+ match,
307
+ Boolean(query.ignoreCase),
308
+ indexedTexts,
309
+ );
310
+ // A structural match has no kind, so a --kind filter excludes it: it is
311
+ // not text of that kind, and pretending otherwise would be a guess.
312
+ if (kindFilter && !(described.kind && kindFilter.has(described.kind)))
313
+ return;
314
+
315
+ candidates.push({entry, ...described});
316
+ if (candidates.length >= maxMatches) {
317
+ outcome.truncated = true;
318
+ return false;
319
+ }
320
+ },
321
+ );
322
+
323
+ outcome.entriesMatched = candidates.length;
324
+
325
+ // No bm25 here, and inventing a score would be a lie. Recency of the matched
326
+ // entry is the ordering, newest first.
327
+ candidates.sort((a, b) => (b.entry.ts ?? '').localeCompare(a.entry.ts ?? ''));
328
+
329
+ const memberStmt = db.prepare(
330
+ 'SELECT file_id, seq FROM membership WHERE entry_id = ?',
331
+ );
332
+ const groups = new Map<number, SearchHit>();
333
+ const order: number[] = [];
334
+ for (const candidate of candidates) {
335
+ const lineageId = Number(candidate.entry.lineage_id);
336
+ const existing = groups.get(lineageId);
337
+ if (existing) {
338
+ // Same grouping as the FTS path: one chatty lineage cannot own the page,
339
+ // and the matches it displaced are counted rather than dropped silently.
340
+ existing.otherHits++;
341
+ continue;
342
+ }
343
+ const threads = collectThreads(
344
+ Number(candidate.entry.id),
345
+ memberStmt,
346
+ files,
347
+ visible.files,
348
+ );
349
+ if (threads.length === 0) continue;
350
+ const hit: SearchHit = {
351
+ entryId: Number(candidate.entry.id),
352
+ entryKey: candidate.entry.entry_key,
353
+ lineageId,
354
+ role: candidate.entry.role,
355
+ tool: candidate.entry.tool,
356
+ ts: candidate.entry.ts,
357
+ kind: candidate.kind,
358
+ snippet: candidate.snippet,
359
+ // No bm25 to rank with, so no score is claimed; the array order is the
360
+ // answer, newest match first.
361
+ score: 0,
362
+ threads:
363
+ threadLimit < 0 ? threads : threads.slice(0, Math.max(threadLimit, 1)),
364
+ threadTotal: threads.length,
365
+ otherHits: 0,
366
+ matchedIn: candidate.matchedIn,
367
+ };
368
+ groups.set(lineageId, hit);
369
+ order.push(lineageId);
370
+ }
371
+
372
+ outcome.scanned = order.length;
373
+ outcome.hits = order
374
+ .map((id) => groups.get(id))
375
+ .filter((h): h is SearchHit => Boolean(h))
376
+ .slice(0, limit);
377
+ return outcome;
378
+ }
package/src/ripgrep.ts ADDED
@@ -0,0 +1,263 @@
1
+ import {spawn} from 'node:child_process';
2
+
3
+ /**
4
+ * Thin wrapper around `rg --json`.
5
+ *
6
+ * Regex over the raw transcripts is a genuinely different capability from the
7
+ * FTS index (see `docs/DESIGN.md`): FTS5 tokenises, so it can never do
8
+ * substring-inside-identifier or true regex, and the raw files hold text the
9
+ * index deliberately does not (tool output is 42% of the bytes). Rather than
10
+ * reimplement a scanner over 2 GB of JSONL in JavaScript, this shells out to
11
+ * the tool that already does it well, and refuses to pretend when it is absent.
12
+ */
13
+
14
+ export const DEFAULT_RG = 'rg';
15
+
16
+ export function ripgrepBinary(env = process.env): string {
17
+ return env.MEMONAUT_RG?.trim() || DEFAULT_RG;
18
+ }
19
+
20
+ export class RipgrepMissingError extends Error {
21
+ readonly binary: string;
22
+ constructor(binary: string) {
23
+ super(
24
+ [
25
+ `regex search needs ripgrep, and \`${binary}\` was not found.`,
26
+ ' install it: apt install ripgrep · brew install ripgrep · cargo install ripgrep',
27
+ ' or point MEMONAUT_RG at the binary.',
28
+ ].join('\n'),
29
+ );
30
+ this.name = 'RipgrepMissingError';
31
+ this.binary = binary;
32
+ }
33
+ }
34
+
35
+ export class RipgrepError extends Error {
36
+ constructor(message: string) {
37
+ super(`ripgrep: ${message}`);
38
+ this.name = 'RipgrepError';
39
+ }
40
+ }
41
+
42
+ /** One matching line, as rg reported it. */
43
+ export interface RipgrepMatch {
44
+ /** Path exactly as it was handed to rg, so it can be looked up in the index. */
45
+ path: string;
46
+ /** The whole matching line, which for a transcript is one complete JSON entry. */
47
+ line: string;
48
+ /** Byte offset of the line within the file. */
49
+ offset: number;
50
+ /** The literal text the pattern matched. A literal, whatever the pattern was. */
51
+ needle: string;
52
+ }
53
+
54
+ export interface RipgrepOptions {
55
+ pattern: string;
56
+ ignoreCase?: boolean;
57
+ /** `-F`: the pattern is a literal string, not a regex. */
58
+ fixed?: boolean;
59
+ /** `-m`: per-file cap. 0 disables it. */
60
+ maxPerFile?: number;
61
+ binary?: string;
62
+ }
63
+
64
+ /**
65
+ * argv budget per invocation.
66
+ *
67
+ * `ARG_MAX` is ~2 MB on Linux and 256 KB on macOS, and a transcript path is
68
+ * ~110 bytes, so batching by both count and byte length keeps every platform
69
+ * far away from E2BIG.
70
+ */
71
+ const BATCH_FILES = 400;
72
+ const BATCH_BYTES = 96_000;
73
+
74
+ function decode(field: {text?: string; bytes?: string} | undefined): string {
75
+ if (!field) return '';
76
+ if (typeof field.text === 'string') return field.text;
77
+ if (typeof field.bytes === 'string')
78
+ return Buffer.from(field.bytes, 'base64').toString('utf8');
79
+ return '';
80
+ }
81
+
82
+ function batches(paths: string[]): string[][] {
83
+ const out: string[][] = [];
84
+ let current: string[] = [];
85
+ let bytes = 0;
86
+ for (const p of paths) {
87
+ if (
88
+ current.length > 0 &&
89
+ (current.length >= BATCH_FILES || bytes + p.length + 1 > BATCH_BYTES)
90
+ ) {
91
+ out.push(current);
92
+ current = [];
93
+ bytes = 0;
94
+ }
95
+ current.push(p);
96
+ bytes += p.length + 1;
97
+ }
98
+ if (current.length) out.push(current);
99
+ return out;
100
+ }
101
+
102
+ function baseArgs(opts: RipgrepOptions): string[] {
103
+ const args = [
104
+ '--json',
105
+ // Errors about files that vanished between the index scan and now are not
106
+ // the user's problem; a real pattern error still reaches stderr.
107
+ '--no-messages',
108
+ // The user's ripgrep config must not change what a search means here.
109
+ '--no-config',
110
+ // Explicit paths already come from the index, which is the only ignore
111
+ // list that matters. --text stops a transcript being silently skipped as
112
+ // binary; they are JSON, so there is nothing to blow up on.
113
+ '--no-ignore',
114
+ '--text',
115
+ ];
116
+ if (opts.ignoreCase) args.push('-i');
117
+ if (opts.fixed) args.push('-F');
118
+ // Explicitly finite: a NaN cap must not take the same branch as the
119
+ // documented "0 means all", or a mistyped flag silently removes the bound.
120
+ if (Number.isFinite(opts.maxPerFile) && (opts.maxPerFile as number) > 0)
121
+ args.push('-m', String(opts.maxPerFile));
122
+ // -e, so a pattern starting with `-` is a pattern and not a flag.
123
+ args.push('-e', opts.pattern);
124
+ return args;
125
+ }
126
+
127
+ /**
128
+ * Run rg over `paths`, calling `onMatch` for each matching line.
129
+ *
130
+ * Output is consumed as it arrives, so at most ONE record is ever held: a
131
+ * single transcript line can be megabytes (a base64 image), and `--json` echoes
132
+ * the whole line back, so accumulating rg's output is not an option. What
133
+ * `onMatch` then does with that one line is its own business, and runs inline
134
+ * on this handler. Returning `false` stops the scan and kills rg.
135
+ */
136
+ export async function ripgrep(
137
+ paths: string[],
138
+ opts: RipgrepOptions,
139
+ onMatch: (match: RipgrepMatch) => boolean | void,
140
+ ): Promise<void> {
141
+ if (paths.length === 0) return;
142
+ const binary = opts.binary ?? ripgrepBinary();
143
+ const args = baseArgs(opts);
144
+ let stop = false;
145
+ for (const batch of batches(paths)) {
146
+ if (stop) return;
147
+ await runOne(binary, [...args, '--', ...batch], (match) => {
148
+ const keepGoing = onMatch(match);
149
+ if (keepGoing === false) {
150
+ stop = true;
151
+ return false;
152
+ }
153
+ return true;
154
+ });
155
+ }
156
+ }
157
+
158
+ function runOne(
159
+ binary: string,
160
+ args: string[],
161
+ onMatch: (match: RipgrepMatch) => boolean,
162
+ ): Promise<void> {
163
+ return new Promise((resolve, reject) => {
164
+ const child = spawn(binary, args, {stdio: ['ignore', 'pipe', 'pipe']});
165
+ let stderr = '';
166
+ let buffer = '';
167
+ let killed = false;
168
+ let settled = false;
169
+
170
+ const finish = (err?: Error) => {
171
+ if (settled) return;
172
+ settled = true;
173
+ if (err) reject(err);
174
+ else resolve();
175
+ };
176
+
177
+ child.on('error', (err) => {
178
+ const code = (err as NodeJS.ErrnoException).code;
179
+ if (code === 'ENOENT') finish(new RipgrepMissingError(binary));
180
+ else finish(err);
181
+ });
182
+ child.stderr.setEncoding('utf8');
183
+ child.stderr.on('data', (chunk: string) => {
184
+ if (stderr.length < 4096) stderr += chunk;
185
+ });
186
+ child.stdout.setEncoding('utf8');
187
+ child.stdout.on('error', () => {
188
+ /* EPIPE after an early kill */
189
+ });
190
+ child.stdout.on('data', (chunk: string) => {
191
+ if (killed) return;
192
+ buffer += chunk;
193
+ let nl: number;
194
+ while ((nl = buffer.indexOf('\n')) >= 0) {
195
+ const line = buffer.slice(0, nl);
196
+ buffer = buffer.slice(nl + 1);
197
+ if (!line) continue;
198
+ let record: RgRecord;
199
+ try {
200
+ record = JSON.parse(line) as RgRecord;
201
+ } catch {
202
+ continue;
203
+ }
204
+ if (record.type !== 'match') continue;
205
+ const data = record.data;
206
+ const sub = data.submatches?.[0];
207
+ const match: RipgrepMatch = {
208
+ path: decode(data.path),
209
+ line: decode(data.lines),
210
+ offset: Number(data.absolute_offset ?? 0),
211
+ needle: decode(sub?.match),
212
+ };
213
+ if (!onMatch(match)) {
214
+ killed = true;
215
+ buffer = '';
216
+ child.kill();
217
+ return;
218
+ }
219
+ }
220
+ });
221
+ child.on('close', (code) => {
222
+ if (killed) return finish();
223
+ // 0 = matches, 1 = no matches. 2 is "something went wrong", which with
224
+ // --no-messages is usually just a transcript that was deleted since the
225
+ // index last saw it; a real complaint (a bad pattern) reaches stderr.
226
+ if (code !== null && code >= 2 && stderr.trim())
227
+ return finish(new RipgrepError(stderr.trim()));
228
+ finish();
229
+ });
230
+ });
231
+ }
232
+
233
+ interface RgRecord {
234
+ type: string;
235
+ data: {
236
+ path?: {text?: string; bytes?: string};
237
+ lines?: {text?: string; bytes?: string};
238
+ absolute_offset?: number;
239
+ submatches?: Array<{
240
+ match?: {text?: string; bytes?: string};
241
+ start: number;
242
+ end: number;
243
+ }>;
244
+ };
245
+ }
246
+
247
+ /** True when a usable ripgrep is on PATH. For tests and for `--help` honesty. */
248
+ export async function ripgrepAvailable(
249
+ binary = ripgrepBinary(),
250
+ ): Promise<boolean> {
251
+ try {
252
+ await new Promise<void>((resolve, reject) => {
253
+ const child = spawn(binary, ['--version'], {stdio: 'ignore'});
254
+ child.on('error', reject);
255
+ child.on('close', (code) =>
256
+ code === 0 ? resolve() : reject(new Error(String(code))),
257
+ );
258
+ });
259
+ return true;
260
+ } catch {
261
+ return false;
262
+ }
263
+ }