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
@@ -0,0 +1,618 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import {fileURLToPath} from 'node:url';
4
+ import {parseArgs} from 'node:util';
5
+ import {loadConfig, writeStarterConfig, type Config} from './config.js';
6
+ import {getMeta, openDb} from './db.js';
7
+ import {
8
+ colorsEnabled,
9
+ makeStyle,
10
+ renderHit,
11
+ renderTable,
12
+ relativeTime,
13
+ tildify,
14
+ type Style,
15
+ } from './format.js';
16
+ import {index} from './indexer.js';
17
+ import {TIERS, type ChunkKind, type SearchHit, type Tier} from './model.js';
18
+ import {readRawEntry} from './pi-source.js';
19
+ import {
20
+ DEFAULT_MAX_MATCHES,
21
+ DEFAULT_MAX_PER_FILE,
22
+ regexSearch,
23
+ } from './regex.js';
24
+ import {indexStats, readThread, resolveThread, search} from './search.js';
25
+
26
+ const USAGE = `memonaut — search your agent conversation transcripts
27
+
28
+ USAGE
29
+ recall <command> [options]
30
+
31
+ COMMANDS
32
+ index Bring the index up to date (incremental by default)
33
+ search <query...> Search transcripts, grouped by fork lineage
34
+ search --regex <pat> Exact regex search over the original transcripts
35
+ show <ref> Print a thread (file id, session uuid prefix, name or path)
36
+ sql <query> Run a read-only SQL query against the index
37
+ stats Summarise what is indexed
38
+ config Show config paths, or write a starter config
39
+
40
+ SEARCH OPTIONS
41
+ --cwd <glob> Filter by working directory (repeatable, globs, ~ ok)
42
+ --project <name> Filter by project (last segment of the cwd, repeatable)
43
+ --role <role> user | assistant | toolResult | bashExecution | ... (repeatable)
44
+ --tool <name> Filter by tool name (repeatable)
45
+ --kind <kind> user | assistant | thinking | toolCall | toolResult | bash | summary | name
46
+ --since <when> 30d, 6h, 2w, or an ISO date
47
+ --until <when> Same forms as --since
48
+ --limit <n> Result groups (default 20)
49
+ --threads <n|all> Threads shown per group (default 3)
50
+ --private Include transcripts matched by the config's private globs
51
+ --raw Pass the query to FTS5 verbatim (no quoting fallback)
52
+ --no-recency Rank on bm25 x kind weight only
53
+ --path Show full transcript paths
54
+ --json Machine-readable output
55
+ --no-sync Skip the incremental catch-up before querying
56
+
57
+ REGEX OPTIONS
58
+ --regex <pattern> Exact regex search, run by ripgrep over the ORIGINAL
59
+ transcripts instead of the index. It can therefore
60
+ match text the index does not hold at all, notably
61
+ tool output (42% of the bytes, excluded by default),
62
+ and it can match inside identifiers, which FTS5
63
+ cannot. There is no relevance ranking: results are
64
+ ordered by recency. Needs ripgrep installed.
65
+ Every filter above applies, and the candidate files
66
+ come from the index, so ignored and (unless --private)
67
+ private transcripts are still never read.
68
+ -i, --ignore-case Case-insensitive matching (put it AFTER the pattern)
69
+ -F, --fixed Treat the pattern as a literal string, not a regex
70
+ --max-per-file <n> Stop after n matches in one transcript (default ${DEFAULT_MAX_PER_FILE}, 0 = all)
71
+ --max-matches <n> Stop the scan after n matching entries (default ${DEFAULT_MAX_MATCHES})
72
+
73
+ INDEX OPTIONS
74
+ --full Rebuild from scratch
75
+ --tier <t> slim | default | full (overrides config for this run)
76
+ --quiet No progress output
77
+
78
+ QUERY SYNTAX
79
+ The query is an FTS5 MATCH expression: bare words are ANDed, "quoted phrases"
80
+ match in order, OR / NOT / NEAR(a b, 5) work, and trailing * is a prefix.
81
+ If that fails to parse, the query is retried as quoted literal tokens.
82
+
83
+ EXAMPLES
84
+ recall search steering queue --project wherever --since 30d
85
+ recall search '"fork point" OR parentSession' --threads all
86
+ recall search --tool bash --kind bash 'fuser -k'
87
+ recall search --regex 'ERR_[A-Z_]+' --since 30d
88
+ recall search --regex 'handleToolCall(' --fixed --project wherever
89
+ recall sql "select project, count(*) n from file group by 1 order by n desc limit 10"
90
+ `;
91
+
92
+ function fail(message: string): never {
93
+ process.stderr.write(`recall: ${message}\n`);
94
+ process.exit(1);
95
+ }
96
+
97
+ function version(): string {
98
+ try {
99
+ const here = path.dirname(fileURLToPath(import.meta.url));
100
+ for (const candidate of [
101
+ path.join(here, '../package.json'),
102
+ path.join(here, '../../package.json'),
103
+ ]) {
104
+ if (fs.existsSync(candidate)) {
105
+ return (
106
+ (JSON.parse(fs.readFileSync(candidate, 'utf8')) as {version?: string})
107
+ .version ?? '0.0.0'
108
+ );
109
+ }
110
+ }
111
+ } catch {
112
+ /* ignore */
113
+ }
114
+ return '0.0.0';
115
+ }
116
+
117
+ /** Accept `30d`, `6h`, `2w`, `3mo`, or anything Date can parse. */
118
+ function parseWhen(value: string | undefined): string | undefined {
119
+ if (!value) return undefined;
120
+ const m = /^(\d+)(mo|[smhdwy])$/.exec(value.trim());
121
+ if (m) {
122
+ const n = Number(m[1]);
123
+ const unit = m[2];
124
+ const ms =
125
+ unit === 's'
126
+ ? 1000
127
+ : unit === 'm'
128
+ ? 60_000
129
+ : unit === 'h'
130
+ ? 3_600_000
131
+ : unit === 'd'
132
+ ? 86_400_000
133
+ : unit === 'w'
134
+ ? 604_800_000
135
+ : unit === 'mo'
136
+ ? 2_592_000_000
137
+ : 31_536_000_000;
138
+ return new Date(Date.now() - n * ms).toISOString();
139
+ }
140
+ const parsed = Date.parse(value);
141
+ if (Number.isNaN(parsed)) fail(`cannot understand time "${value}"`);
142
+ return new Date(parsed).toISOString();
143
+ }
144
+
145
+ function withTier(config: Config, tier: string | undefined): Config {
146
+ if (!tier) return config;
147
+ if (!TIERS.includes(tier as Tier))
148
+ fail(`unknown tier "${tier}" (expected ${TIERS.join(', ')})`);
149
+ return {...config, tier: tier as Tier};
150
+ }
151
+
152
+ /** Cheap incremental catch-up so a query is never answered from a stale index. */
153
+ function sync(config: Config, quiet: boolean): void {
154
+ if (!fs.existsSync(config.dbPath)) {
155
+ fail(`no index at ${tildify(config.dbPath)}. Run \`recall index\` first.`);
156
+ }
157
+ const stats = index({config, onProgress: () => {}});
158
+ if (!quiet && stats.filesIndexed > 0) {
159
+ process.stderr.write(
160
+ `recall: synced ${stats.filesIndexed} transcript(s) in ${stats.durationMs}ms\n`,
161
+ );
162
+ }
163
+ }
164
+
165
+ function cmdIndex(argv: string[]): void {
166
+ const {values} = parseArgs({
167
+ args: argv,
168
+ options: {
169
+ full: {type: 'boolean', default: false},
170
+ tier: {type: 'string'},
171
+ quiet: {type: 'boolean', default: false},
172
+ json: {type: 'boolean', default: false},
173
+ },
174
+ allowPositionals: false,
175
+ });
176
+ const config = withTier(loadConfig(), values.tier as string | undefined);
177
+ const quiet = Boolean(values.quiet) || Boolean(values.json);
178
+ let lastLine = 0;
179
+ const stats = index({
180
+ config,
181
+ full: Boolean(values.full),
182
+ onProgress: (p) => {
183
+ if (quiet || !process.stderr.isTTY) return;
184
+ const now = Date.now();
185
+ if (now - lastLine < 100) return;
186
+ lastLine = now;
187
+ process.stderr.write(`\r${p.phase} ${p.done}/${p.total}\u001b[K`);
188
+ },
189
+ });
190
+ if (!quiet && process.stderr.isTTY) process.stderr.write('\r\u001b[K');
191
+ if (values.json) {
192
+ process.stdout.write(JSON.stringify(stats, null, 2) + '\n');
193
+ return;
194
+ }
195
+ const seconds = (stats.durationMs / 1000).toFixed(1);
196
+ process.stdout.write(
197
+ [
198
+ `indexed ${stats.filesIndexed} transcript(s) in ${seconds}s${stats.fullRebuild ? ' (full rebuild)' : ''}`,
199
+ ` seen ${stats.filesSeen} · skipped ${stats.filesSkipped} · appended ${stats.filesAppended} · removed ${stats.filesRemoved}`,
200
+ ` ignored ${stats.filesIgnored} · private ${stats.filesPrivate} · lineages rebuilt ${stats.lineagesRebuilt}`,
201
+ ` entries ${stats.entriesInserted} new, ${stats.entriesShared} inherited by forks · chunks ${stats.chunksInserted}`,
202
+ ` index: ${tildify(config.dbPath)}`,
203
+ ].join('\n') + '\n',
204
+ );
205
+ }
206
+
207
+ /** Flags that only mean something on the regex path. */
208
+ const REGEX_ONLY_FLAGS = [
209
+ 'ignore-case',
210
+ 'fixed',
211
+ 'max-per-file',
212
+ 'max-matches',
213
+ ];
214
+ /** Flags that only mean something to the full-text ranker. */
215
+ const FTS_ONLY_FLAGS = ['raw', 'no-recency'];
216
+
217
+ /** Numeric flags and the smallest value that means anything for each. */
218
+ const NUMERIC_FLAGS: Array<[string, number]> = [
219
+ ['max-per-file', 0], // 0 is documented as "no per-file cap"
220
+ ['max-matches', 1],
221
+ ];
222
+
223
+ /**
224
+ * Everything about a search that can be judged from the arguments alone.
225
+ *
226
+ * Pure, and exported, so the rules are testable without a process: the whole
227
+ * point of them is to refuse silently-wrong input, and "silently" is exactly
228
+ * what a hand-check misses. `Number('nonsense')` is NaN, and NaN quietly fails
229
+ * every `> 0` guard downstream, so a mistyped cap would REMOVE the bound it was
230
+ * meant to tighten. Those caps are the only thing between a pathological
231
+ * pattern and an unbounded read.
232
+ */
233
+ export function searchFlagError(
234
+ given: Record<string, unknown>,
235
+ text: string,
236
+ pattern: string | undefined,
237
+ ): string | null {
238
+ if (pattern === undefined) {
239
+ if (!text) return 'nothing to search for. Try `recall search <words>`';
240
+ const stray = REGEX_ONLY_FLAGS.find((f) => given[f] !== undefined);
241
+ if (stray) return `--${stray} only applies to --regex`;
242
+ } else {
243
+ if (text)
244
+ return `--regex takes the pattern itself, so "${text}" has nowhere to go. Try \`recall search --regex '${pattern}'\``;
245
+ if (!pattern.trim()) return '--regex needs a pattern';
246
+ const stray = FTS_ONLY_FLAGS.find((f) => given[f] === true);
247
+ if (stray)
248
+ return `--${stray} applies to full-text ranking, and a regex search has no bm25 to rank with`;
249
+ }
250
+ for (const [flag, min] of NUMERIC_FLAGS) {
251
+ const raw = given[flag];
252
+ if (raw === undefined) continue;
253
+ const n = Number(raw);
254
+ if (!Number.isInteger(n) || n < min)
255
+ return `--${flag} needs a whole number ${min} or more, not "${String(raw)}"`;
256
+ }
257
+ return null;
258
+ }
259
+
260
+ /** One rendered block per hit, or a plain reason there are none. */
261
+ function writeHits(
262
+ hits: SearchHit[],
263
+ opts: {style: Style; showPath: boolean},
264
+ nothing: string,
265
+ ): void {
266
+ if (hits.length === 0) {
267
+ process.stdout.write(nothing + '\n');
268
+ return;
269
+ }
270
+ process.stdout.write(
271
+ hits
272
+ .map((hit) =>
273
+ renderHit(hit, {style: opts.style, showPath: opts.showPath}),
274
+ )
275
+ .join('\n\n') + '\n',
276
+ );
277
+ }
278
+
279
+ async function cmdSearch(argv: string[]): Promise<void> {
280
+ const {values, positionals} = parseArgs({
281
+ args: argv,
282
+ options: {
283
+ // No defaults on the mode-specific flags: `undefined` is what makes
284
+ // "you passed this to the wrong search" detectable.
285
+ regex: {type: 'string'},
286
+ 'ignore-case': {type: 'boolean', short: 'i'},
287
+ fixed: {type: 'boolean', short: 'F'},
288
+ 'max-per-file': {type: 'string'},
289
+ 'max-matches': {type: 'string'},
290
+ cwd: {type: 'string', multiple: true},
291
+ project: {type: 'string', multiple: true},
292
+ role: {type: 'string', multiple: true},
293
+ tool: {type: 'string', multiple: true},
294
+ kind: {type: 'string', multiple: true},
295
+ since: {type: 'string'},
296
+ until: {type: 'string'},
297
+ limit: {type: 'string', default: '20'},
298
+ threads: {type: 'string', default: '3'},
299
+ private: {type: 'boolean', default: false},
300
+ raw: {type: 'boolean', default: false},
301
+ 'no-recency': {type: 'boolean', default: false},
302
+ path: {type: 'boolean', default: false},
303
+ json: {type: 'boolean', default: false},
304
+ 'no-sync': {type: 'boolean', default: false},
305
+ },
306
+ allowPositionals: true,
307
+ });
308
+ const text = positionals.join(' ').trim();
309
+ const pattern = values.regex as string | undefined;
310
+
311
+ // Everything that can be judged from the arguments alone is judged here,
312
+ // before a config is loaded, an index is synced or a file is opened.
313
+ const given = values as Record<string, unknown>;
314
+ const problem = searchFlagError(given, text, pattern);
315
+ if (problem) fail(problem);
316
+ const maxPerFile =
317
+ given['max-per-file'] === undefined
318
+ ? DEFAULT_MAX_PER_FILE
319
+ : Number(given['max-per-file']);
320
+ const maxMatches =
321
+ given['max-matches'] === undefined
322
+ ? DEFAULT_MAX_MATCHES
323
+ : Number(given['max-matches']);
324
+
325
+ const config = loadConfig();
326
+ if (!values['no-sync']) sync(config, Boolean(values.json));
327
+
328
+ const db = openDb(config.dbPath, {readOnly: true});
329
+ const threadsRaw = String(values.threads);
330
+ const common = {
331
+ cwd: values.cwd as string[] | undefined,
332
+ project: values.project as string[] | undefined,
333
+ role: values.role as string[] | undefined,
334
+ tool: values.tool as string[] | undefined,
335
+ kind: values.kind as ChunkKind[] | undefined,
336
+ since: parseWhen(values.since as string | undefined),
337
+ until: parseWhen(values.until as string | undefined),
338
+ includePrivate: Boolean(values.private),
339
+ limit: Number(values.limit),
340
+ threadLimit: threadsRaw === 'all' ? -1 : Number(threadsRaw),
341
+ };
342
+ const style = makeStyle(colorsEnabled());
343
+ const render = {style, showPath: Boolean(values.path)};
344
+ const json = (payload: unknown): void => {
345
+ process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
346
+ };
347
+ const note = (line: string): void => {
348
+ process.stderr.write(style.dim(line + '\n'));
349
+ };
350
+
351
+ try {
352
+ if (pattern !== undefined) {
353
+ const outcome = await regexSearch(db, {
354
+ ...common,
355
+ pattern,
356
+ ignoreCase: Boolean(values['ignore-case']),
357
+ fixed: Boolean(values.fixed),
358
+ maxPerFile,
359
+ maxMatches,
360
+ });
361
+ if (values.json) return json(outcome);
362
+ writeHits(
363
+ outcome.hits,
364
+ render,
365
+ `no matches in ${outcome.filesSearched} transcript(s)`,
366
+ );
367
+ if (outcome.truncated)
368
+ note(
369
+ `(stopped at ${maxMatches} matching entries; narrow the pattern or raise --max-matches)`,
370
+ );
371
+ // The index is caught up before the query, so this means the transcripts
372
+ // moved underneath it: worth saying, since those lines were skipped.
373
+ if (outcome.unresolved > 0)
374
+ note(
375
+ `(${outcome.unresolved} matching line(s) are not in the index yet; \`recall index\` will pick them up)`,
376
+ );
377
+ return;
378
+ }
379
+
380
+ const outcome = search(db, {
381
+ ...common,
382
+ text,
383
+ noRecency: Boolean(values['no-recency']),
384
+ raw: Boolean(values.raw),
385
+ });
386
+ if (values.json) return json(outcome);
387
+ if (outcome.quotedFallback)
388
+ note(`(query retried as literal tokens: ${outcome.usedQuery})`);
389
+ writeHits(outcome.hits, render, 'no matches');
390
+ } finally {
391
+ db.close();
392
+ }
393
+ }
394
+
395
+ function cmdShow(argv: string[]): void {
396
+ const {values, positionals} = parseArgs({
397
+ args: argv,
398
+ options: {
399
+ from: {type: 'string', default: '0'},
400
+ limit: {type: 'string', default: '40'},
401
+ full: {type: 'boolean', default: false},
402
+ json: {type: 'boolean', default: false},
403
+ },
404
+ allowPositionals: true,
405
+ });
406
+ const ref = positionals[0];
407
+ if (!ref)
408
+ fail(
409
+ 'which thread? Pass a file id, a session uuid prefix, a name, or a path',
410
+ );
411
+
412
+ const config = loadConfig();
413
+ const db = openDb(config.dbPath, {readOnly: true});
414
+ const thread = resolveThread(db, ref);
415
+ if (!thread) fail(`no thread matching "${ref}"`);
416
+
417
+ const entries = readThread(
418
+ db,
419
+ Number(thread.id),
420
+ Number(values.from),
421
+ Number(values.limit),
422
+ );
423
+ if (values.json) {
424
+ process.stdout.write(JSON.stringify({thread, entries}, null, 2) + '\n');
425
+ db.close();
426
+ return;
427
+ }
428
+
429
+ const style = makeStyle(colorsEnabled());
430
+ const header = [
431
+ style.bold(thread.name ?? thread.project ?? String(thread.id)),
432
+ style.dim(tildify(thread.cwd ?? '')),
433
+ style.dim(`${thread.entry_count} entries`),
434
+ style.dim(`last ${relativeTime(thread.last_activity)}`),
435
+ ].join(' · ');
436
+ process.stdout.write(
437
+ header + '\n' + style.dim(tildify(thread.path)) + '\n\n',
438
+ );
439
+
440
+ const indent = (text: string) =>
441
+ text
442
+ .split('\n')
443
+ .map((l) => ' ' + l)
444
+ .join('\n');
445
+
446
+ for (const entry of entries) {
447
+ const marks = [
448
+ style.label(entry.role + (entry.tool ? `:${entry.tool}` : '')),
449
+ ];
450
+ if (entry.shared) marks.push(style.dim('(inherited)'));
451
+ process.stdout.write(
452
+ `${style.dim(String(entry.seq).padStart(4))} ${marks.join(' ')}\n`,
453
+ );
454
+ if (values.full && entry.ownerPath) {
455
+ // Full fidelity means going back to the transcript: the index keeps tool
456
+ // output truncated (or absent), by design, and the byte range is the pointer.
457
+ const raw = readRawEntry(
458
+ entry.ownerPath,
459
+ entry.byteOffset,
460
+ entry.byteLength,
461
+ );
462
+ if (raw !== null) {
463
+ process.stdout.write(indent(JSON.stringify(raw, null, 2)) + '\n');
464
+ continue;
465
+ }
466
+ }
467
+ for (const t of entry.texts) {
468
+ const body = t.text.length > 2000 ? t.text.slice(0, 2000) + ' …' : t.text;
469
+ process.stdout.write(indent(body) + '\n');
470
+ }
471
+ }
472
+ db.close();
473
+ }
474
+
475
+ function cmdSql(argv: string[]): void {
476
+ const {values, positionals} = parseArgs({
477
+ args: argv,
478
+ options: {
479
+ json: {type: 'boolean', default: false},
480
+ limit: {type: 'string', default: '200'},
481
+ },
482
+ allowPositionals: true,
483
+ });
484
+ const sql = positionals.join(' ').trim();
485
+ if (!sql) fail('no SQL given');
486
+ const config = loadConfig();
487
+ const db = openDb(config.dbPath, {readOnly: true});
488
+ let rows: Array<Record<string, unknown>>;
489
+ try {
490
+ rows = db.prepare(sql).all() as unknown as Array<Record<string, unknown>>;
491
+ } catch (err) {
492
+ db.close();
493
+ fail((err as Error).message);
494
+ }
495
+ const capped = rows.slice(0, Number(values.limit));
496
+ if (values.json) process.stdout.write(JSON.stringify(capped, null, 2) + '\n');
497
+ else {
498
+ process.stdout.write(renderTable(capped) + '\n');
499
+ if (rows.length > capped.length) {
500
+ process.stdout.write(
501
+ `(${rows.length - capped.length} more rows, raise --limit)\n`,
502
+ );
503
+ }
504
+ }
505
+ db.close();
506
+ }
507
+
508
+ function cmdStats(argv: string[]): void {
509
+ const {values} = parseArgs({
510
+ args: argv,
511
+ options: {json: {type: 'boolean', default: false}},
512
+ allowPositionals: false,
513
+ });
514
+ const config = loadConfig();
515
+ const db = openDb(config.dbPath, {readOnly: true});
516
+ const stats = indexStats(db);
517
+ const indexedAt = getMeta(db, 'indexed_at');
518
+ const tier = getMeta(db, 'tier');
519
+ const size = fs.existsSync(config.dbPath)
520
+ ? fs.statSync(config.dbPath).size
521
+ : 0;
522
+ const payload = {
523
+ ...stats,
524
+ tier,
525
+ indexedAt: indexedAt ? new Date(Number(indexedAt)).toISOString() : null,
526
+ dbBytes: size,
527
+ };
528
+ if (values.json) {
529
+ process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
530
+ db.close();
531
+ return;
532
+ }
533
+ const style = makeStyle(colorsEnabled());
534
+ process.stdout.write(
535
+ [
536
+ `${style.bold(String(stats.files))} transcripts · ${stats.lineages} lineages · ${stats.forks} forks`,
537
+ `${stats.entries} entries · ${stats.memberships} memberships · ${stats.chunks} chunks`,
538
+ `${stats.projects} projects · ${stats.privateFiles} private · ${stats.orphans} orphaned`,
539
+ `span ${String(stats.oldest).slice(0, 10)} → ${String(stats.newest).slice(0, 10)}`,
540
+ `tier ${tier} · db ${(size / 1e6).toFixed(1)} MB · updated ${relativeTime(payload.indexedAt)}`,
541
+ style.dim(tildify(config.dbPath)),
542
+ ].join('\n') + '\n',
543
+ );
544
+ db.close();
545
+ }
546
+
547
+ function cmdConfig(argv: string[]): void {
548
+ const {values} = parseArgs({
549
+ args: argv,
550
+ options: {
551
+ init: {type: 'boolean', default: false},
552
+ json: {type: 'boolean', default: false},
553
+ },
554
+ allowPositionals: false,
555
+ });
556
+ const config = loadConfig();
557
+ if (values.init) {
558
+ const written = writeStarterConfig(config);
559
+ process.stdout.write(`config: ${tildify(written)}\n`);
560
+ return;
561
+ }
562
+ if (values.json) {
563
+ process.stdout.write(JSON.stringify(config, null, 2) + '\n');
564
+ return;
565
+ }
566
+ process.stdout.write(
567
+ [
568
+ `config ${tildify(config.configPath)}${config.loaded ? '' : ' (not created yet, using defaults)'}`,
569
+ `index ${tildify(config.dbPath)}`,
570
+ `tier ${config.tier}`,
571
+ `sources ${config.sources.map((s) => `${s.id}:${tildify(s.root)}`).join(', ')}`,
572
+ `ignore ${config.ignore.join(', ') || '(none)'}`,
573
+ `private ${config.private.join(', ') || '(none)'}`,
574
+ ].join('\n') + '\n',
575
+ );
576
+ }
577
+
578
+ async function main(): Promise<void> {
579
+ const argv = process.argv.slice(2);
580
+ const command = argv[0];
581
+ const rest = argv.slice(1);
582
+ switch (command) {
583
+ case 'index':
584
+ return cmdIndex(rest);
585
+ case 'search':
586
+ case 's':
587
+ return cmdSearch(rest);
588
+ case 'show':
589
+ return cmdShow(rest);
590
+ case 'sql':
591
+ return cmdSql(rest);
592
+ case 'stats':
593
+ return cmdStats(rest);
594
+ case 'config':
595
+ return cmdConfig(rest);
596
+ case '--version':
597
+ case '-v':
598
+ process.stdout.write(version() + '\n');
599
+ return;
600
+ case undefined:
601
+ case 'help':
602
+ case '--help':
603
+ case '-h':
604
+ process.stdout.write(USAGE);
605
+ return;
606
+ default:
607
+ fail(`unknown command "${command}". Try \`recall help\``);
608
+ }
609
+ }
610
+
611
+ /** Entry point. Loaded dynamically by `cli.ts`, see the note there. */
612
+ export async function run(): Promise<void> {
613
+ try {
614
+ await main();
615
+ } catch (err) {
616
+ fail((err as Error).message);
617
+ }
618
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+ import {silenceSqliteWarning} from './quiet.js';
3
+
4
+ /**
5
+ * Launcher, deliberately thin.
6
+ *
7
+ * `node:sqlite` prints an ExperimentalWarning the moment it is LOADED, and ES
8
+ * modules load the whole graph before any module body runs. So a static import
9
+ * of anything that touches sqlite would emit the warning before the filter
10
+ * could be installed, no matter where the call sits. Installing the filter here
11
+ * and pulling the real CLI in dynamically is what keeps two lines of noise off
12
+ * every single invocation.
13
+ */
14
+ silenceSqliteWarning();
15
+
16
+ const {run} = await import('./cli-main.js');
17
+ await run();