memonaut 0.1.0 → 0.3.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 (47) hide show
  1. package/dist/cli-main.d.ts +12 -1
  2. package/dist/cli-main.d.ts.map +1 -1
  3. package/dist/cli-main.js +185 -25
  4. package/dist/cli-main.js.map +1 -1
  5. package/dist/cli.js +1 -1
  6. package/dist/cli.js.map +1 -1
  7. package/dist/format.d.ts.map +1 -1
  8. package/dist/format.js +13 -0
  9. package/dist/format.js.map +1 -1
  10. package/dist/index.d.ts +3 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +5 -0
  13. package/dist/index.js.map +1 -1
  14. package/dist/model.d.ts +23 -1
  15. package/dist/model.d.ts.map +1 -1
  16. package/dist/regex.d.ts +75 -0
  17. package/dist/regex.d.ts.map +1 -0
  18. package/dist/regex.js +242 -0
  19. package/dist/regex.js.map +1 -0
  20. package/dist/resources.d.ts +2 -0
  21. package/dist/resources.d.ts.map +1 -0
  22. package/dist/resources.js +25 -0
  23. package/dist/resources.js.map +1 -0
  24. package/dist/ripgrep.d.ts +52 -0
  25. package/dist/ripgrep.d.ts.map +1 -0
  26. package/dist/ripgrep.js +217 -0
  27. package/dist/ripgrep.js.map +1 -0
  28. package/dist/search.d.ts +22 -1
  29. package/dist/search.d.ts.map +1 -1
  30. package/dist/search.js +47 -23
  31. package/dist/search.js.map +1 -1
  32. package/dist/skills.d.ts +34 -0
  33. package/dist/skills.d.ts.map +1 -0
  34. package/dist/skills.js +59 -0
  35. package/dist/skills.js.map +1 -0
  36. package/package.json +4 -3
  37. package/skills/memonaut/SKILL.md +120 -0
  38. package/src/cli-main.ts +230 -30
  39. package/src/cli.ts +1 -1
  40. package/src/format.ts +12 -0
  41. package/src/index.ts +10 -0
  42. package/src/model.ts +23 -1
  43. package/src/regex.ts +378 -0
  44. package/src/resources.ts +26 -0
  45. package/src/ripgrep.ts +263 -0
  46. package/src/search.ts +68 -26
  47. package/src/skills.ts +86 -0
package/src/cli-main.ts CHANGED
@@ -11,11 +11,24 @@ import {
11
11
  renderTable,
12
12
  relativeTime,
13
13
  tildify,
14
+ type Style,
14
15
  } from './format.js';
15
16
  import {index} from './indexer.js';
16
- import {TIERS, type ChunkKind, type Tier} from './model.js';
17
+ import {TIERS, type ChunkKind, type SearchHit, type Tier} from './model.js';
17
18
  import {readRawEntry} from './pi-source.js';
19
+ import {
20
+ DEFAULT_MAX_MATCHES,
21
+ DEFAULT_MAX_PER_FILE,
22
+ regexSearch,
23
+ } from './regex.js';
18
24
  import {indexStats, readThread, resolveThread, search} from './search.js';
25
+ import {
26
+ availableSkills,
27
+ destinationFor,
28
+ installSkills,
29
+ isInstalled,
30
+ type Scope,
31
+ } from './skills.js';
19
32
 
20
33
  const USAGE = `memonaut — search your agent conversation transcripts
21
34
 
@@ -25,10 +38,12 @@ USAGE
25
38
  COMMANDS
26
39
  index Bring the index up to date (incremental by default)
27
40
  search <query...> Search transcripts, grouped by fork lineage
41
+ search --regex <pat> Exact regex search over the original transcripts
28
42
  show <ref> Print a thread (file id, session uuid prefix, name or path)
29
43
  sql <query> Run a read-only SQL query against the index
30
44
  stats Summarise what is indexed
31
45
  config Show config paths, or write a starter config
46
+ skills [install] List or install the agent skill (into ~/.agents/skills)
32
47
 
33
48
  SEARCH OPTIONS
34
49
  --cwd <glob> Filter by working directory (repeatable, globs, ~ ok)
@@ -47,6 +62,22 @@ SEARCH OPTIONS
47
62
  --json Machine-readable output
48
63
  --no-sync Skip the incremental catch-up before querying
49
64
 
65
+ REGEX OPTIONS
66
+ --regex <pattern> Exact regex search, run by ripgrep over the ORIGINAL
67
+ transcripts instead of the index. It can therefore
68
+ match text the index does not hold at all, notably
69
+ tool output (42% of the bytes, excluded by default),
70
+ and it can match inside identifiers, which FTS5
71
+ cannot. There is no relevance ranking: results are
72
+ ordered by recency. Needs ripgrep installed.
73
+ Every filter above applies, and the candidate files
74
+ come from the index, so ignored and (unless --private)
75
+ private transcripts are still never read.
76
+ -i, --ignore-case Case-insensitive matching (put it AFTER the pattern)
77
+ -F, --fixed Treat the pattern as a literal string, not a regex
78
+ --max-per-file <n> Stop after n matches in one transcript (default ${DEFAULT_MAX_PER_FILE}, 0 = all)
79
+ --max-matches <n> Stop the scan after n matching entries (default ${DEFAULT_MAX_MATCHES})
80
+
50
81
  INDEX OPTIONS
51
82
  --full Rebuild from scratch
52
83
  --tier <t> slim | default | full (overrides config for this run)
@@ -57,10 +88,15 @@ QUERY SYNTAX
57
88
  match in order, OR / NOT / NEAR(a b, 5) work, and trailing * is a prefix.
58
89
  If that fails to parse, the query is retried as quoted literal tokens.
59
90
 
91
+ SKILLS OPTIONS
92
+ --project Install into ./.agents/skills instead of ~/.agents/skills
93
+
60
94
  EXAMPLES
61
95
  recall search steering queue --project wherever --since 30d
62
96
  recall search '"fork point" OR parentSession' --threads all
63
97
  recall search --tool bash --kind bash 'fuser -k'
98
+ recall search --regex 'ERR_[A-Z_]+' --since 30d
99
+ recall search --regex 'handleToolCall(' --fixed --project wherever
64
100
  recall sql "select project, count(*) n from file group by 1 order by n desc limit 10"
65
101
  `;
66
102
 
@@ -179,10 +215,89 @@ function cmdIndex(argv: string[]): void {
179
215
  );
180
216
  }
181
217
 
182
- function cmdSearch(argv: string[]): void {
218
+ /** Flags that only mean something on the regex path. */
219
+ const REGEX_ONLY_FLAGS = [
220
+ 'ignore-case',
221
+ 'fixed',
222
+ 'max-per-file',
223
+ 'max-matches',
224
+ ];
225
+ /** Flags that only mean something to the full-text ranker. */
226
+ const FTS_ONLY_FLAGS = ['raw', 'no-recency'];
227
+
228
+ /** Numeric flags and the smallest value that means anything for each. */
229
+ const NUMERIC_FLAGS: Array<[string, number]> = [
230
+ ['max-per-file', 0], // 0 is documented as "no per-file cap"
231
+ ['max-matches', 1],
232
+ ];
233
+
234
+ /**
235
+ * Everything about a search that can be judged from the arguments alone.
236
+ *
237
+ * Pure, and exported, so the rules are testable without a process: the whole
238
+ * point of them is to refuse silently-wrong input, and "silently" is exactly
239
+ * what a hand-check misses. `Number('nonsense')` is NaN, and NaN quietly fails
240
+ * every `> 0` guard downstream, so a mistyped cap would REMOVE the bound it was
241
+ * meant to tighten. Those caps are the only thing between a pathological
242
+ * pattern and an unbounded read.
243
+ */
244
+ export function searchFlagError(
245
+ given: Record<string, unknown>,
246
+ text: string,
247
+ pattern: string | undefined,
248
+ ): string | null {
249
+ if (pattern === undefined) {
250
+ if (!text) return 'nothing to search for. Try `recall search <words>`';
251
+ const stray = REGEX_ONLY_FLAGS.find((f) => given[f] !== undefined);
252
+ if (stray) return `--${stray} only applies to --regex`;
253
+ } else {
254
+ if (text)
255
+ return `--regex takes the pattern itself, so "${text}" has nowhere to go. Try \`recall search --regex '${pattern}'\``;
256
+ if (!pattern.trim()) return '--regex needs a pattern';
257
+ const stray = FTS_ONLY_FLAGS.find((f) => given[f] === true);
258
+ if (stray)
259
+ return `--${stray} applies to full-text ranking, and a regex search has no bm25 to rank with`;
260
+ }
261
+ for (const [flag, min] of NUMERIC_FLAGS) {
262
+ const raw = given[flag];
263
+ if (raw === undefined) continue;
264
+ const n = Number(raw);
265
+ if (!Number.isInteger(n) || n < min)
266
+ return `--${flag} needs a whole number ${min} or more, not "${String(raw)}"`;
267
+ }
268
+ return null;
269
+ }
270
+
271
+ /** One rendered block per hit, or a plain reason there are none. */
272
+ function writeHits(
273
+ hits: SearchHit[],
274
+ opts: {style: Style; showPath: boolean},
275
+ nothing: string,
276
+ ): void {
277
+ if (hits.length === 0) {
278
+ process.stdout.write(nothing + '\n');
279
+ return;
280
+ }
281
+ process.stdout.write(
282
+ hits
283
+ .map((hit) =>
284
+ renderHit(hit, {style: opts.style, showPath: opts.showPath}),
285
+ )
286
+ .join('\n\n') + '\n',
287
+ );
288
+ }
289
+
290
+ async function cmdSearch(argv: string[]): Promise<void> {
183
291
  const {values, positionals} = parseArgs({
184
292
  args: argv,
185
293
  options: {
294
+ // No defaults on the mode-specific flags: `undefined` is what makes
295
+ // "you passed this to the wrong search" detectable.
296
+ regex: {type: 'string'},
297
+ 'ignore-case': {type: 'boolean', short: 'i'},
298
+ fixed: {type: 'boolean', short: 'F'},
299
+ 'max-per-file': {type: 'string'},
300
+ 'max-matches': {type: 'string'},
186
301
  cwd: {type: 'string', multiple: true},
187
302
  project: {type: 'string', multiple: true},
188
303
  role: {type: 'string', multiple: true},
@@ -202,15 +317,28 @@ function cmdSearch(argv: string[]): void {
202
317
  allowPositionals: true,
203
318
  });
204
319
  const text = positionals.join(' ').trim();
205
- if (!text) fail('nothing to search for. Try `recall search <words>`');
320
+ const pattern = values.regex as string | undefined;
321
+
322
+ // Everything that can be judged from the arguments alone is judged here,
323
+ // before a config is loaded, an index is synced or a file is opened.
324
+ const given = values as Record<string, unknown>;
325
+ const problem = searchFlagError(given, text, pattern);
326
+ if (problem) fail(problem);
327
+ const maxPerFile =
328
+ given['max-per-file'] === undefined
329
+ ? DEFAULT_MAX_PER_FILE
330
+ : Number(given['max-per-file']);
331
+ const maxMatches =
332
+ given['max-matches'] === undefined
333
+ ? DEFAULT_MAX_MATCHES
334
+ : Number(given['max-matches']);
206
335
 
207
336
  const config = loadConfig();
208
337
  if (!values['no-sync']) sync(config, Boolean(values.json));
209
338
 
210
339
  const db = openDb(config.dbPath, {readOnly: true});
211
340
  const threadsRaw = String(values.threads);
212
- const outcome = search(db, {
213
- text,
341
+ const common = {
214
342
  cwd: values.cwd as string[] | undefined,
215
343
  project: values.project as string[] | undefined,
216
344
  role: values.role as string[] | undefined,
@@ -221,32 +349,58 @@ function cmdSearch(argv: string[]): void {
221
349
  includePrivate: Boolean(values.private),
222
350
  limit: Number(values.limit),
223
351
  threadLimit: threadsRaw === 'all' ? -1 : Number(threadsRaw),
224
- noRecency: Boolean(values['no-recency']),
225
- raw: Boolean(values.raw),
226
- });
352
+ };
353
+ const style = makeStyle(colorsEnabled());
354
+ const render = {style, showPath: Boolean(values.path)};
355
+ const json = (payload: unknown): void => {
356
+ process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
357
+ };
358
+ const note = (line: string): void => {
359
+ process.stderr.write(style.dim(line + '\n'));
360
+ };
227
361
 
228
- if (values.json) {
229
- process.stdout.write(JSON.stringify(outcome, null, 2) + '\n');
230
- db.close();
231
- return;
232
- }
362
+ try {
363
+ if (pattern !== undefined) {
364
+ const outcome = await regexSearch(db, {
365
+ ...common,
366
+ pattern,
367
+ ignoreCase: Boolean(values['ignore-case']),
368
+ fixed: Boolean(values.fixed),
369
+ maxPerFile,
370
+ maxMatches,
371
+ });
372
+ if (values.json) return json(outcome);
373
+ writeHits(
374
+ outcome.hits,
375
+ render,
376
+ `no matches in ${outcome.filesSearched} transcript(s)`,
377
+ );
378
+ if (outcome.truncated)
379
+ note(
380
+ `(stopped at ${maxMatches} matching entries; narrow the pattern or raise --max-matches)`,
381
+ );
382
+ // The index is caught up before the query, so this means the transcripts
383
+ // moved underneath it: worth saying, since those lines were skipped.
384
+ if (outcome.unresolved > 0)
385
+ note(
386
+ `(${outcome.unresolved} matching line(s) are not in the index yet; \`recall index\` will pick them up)`,
387
+ );
388
+ return;
389
+ }
233
390
 
234
- const style = makeStyle(colorsEnabled());
235
- if (outcome.hits.length === 0) {
236
- process.stdout.write('no matches\n');
391
+ const outcome = search(db, {
392
+ ...common,
393
+ text,
394
+ noRecency: Boolean(values['no-recency']),
395
+ raw: Boolean(values.raw),
396
+ });
397
+ if (values.json) return json(outcome);
398
+ if (outcome.quotedFallback)
399
+ note(`(query retried as literal tokens: ${outcome.usedQuery})`);
400
+ writeHits(outcome.hits, render, 'no matches');
401
+ } finally {
237
402
  db.close();
238
- return;
239
- }
240
- if (outcome.quotedFallback) {
241
- process.stderr.write(
242
- style.dim(`(query retried as literal tokens: ${outcome.usedQuery})\n`),
243
- );
244
403
  }
245
- const rendered = outcome.hits.map((hit) =>
246
- renderHit(hit, {style, showPath: Boolean(values.path)}),
247
- );
248
- process.stdout.write(rendered.join('\n\n') + '\n');
249
- db.close();
250
404
  }
251
405
 
252
406
  function cmdShow(argv: string[]): void {
@@ -432,7 +586,51 @@ function cmdConfig(argv: string[]): void {
432
586
  );
433
587
  }
434
588
 
435
- function main(): void {
589
+ /**
590
+ * `recall skills [list|install]`.
591
+ *
592
+ * Kept flag-light on purpose: the only real decision is user versus project scope.
593
+ */
594
+ function cmdSkills(argv: string[]): void {
595
+ const scope: Scope = argv.includes('--project') ? 'project' : 'user';
596
+ const verb = argv.find((argument) => !argument.startsWith('-')) ?? 'list';
597
+ if (verb !== 'list' && verb !== 'install')
598
+ fail(`unknown skills command "${verb}". Try \`list\` or \`install\``);
599
+
600
+ const available = availableSkills();
601
+ if (available.length === 0) {
602
+ fail(
603
+ 'no skills found beside this package. A published install carries them; a checkout keeps them at the repo root',
604
+ );
605
+ }
606
+
607
+ const style = makeStyle(colorsEnabled());
608
+ if (verb === 'list') {
609
+ process.stdout.write(tildify(destinationFor(scope)) + '\n\n');
610
+ for (const skill of available) {
611
+ const mark = isInstalled(skill, scope) ? 'installed' : 'not installed';
612
+ process.stdout.write(
613
+ ` ${style.bold(skill.name)} ${style.dim(`(${mark})`)}\n ${skill.description}\n\n`,
614
+ );
615
+ }
616
+ process.stdout.write('Run `recall skills install` to copy them in.\n');
617
+ return;
618
+ }
619
+
620
+ const installed = installSkills(scope);
621
+ for (const entry of installed) {
622
+ process.stdout.write(
623
+ ` ${entry.replaced ? 'replaced' : 'installed'} ${tildify(entry.to)}\n`,
624
+ );
625
+ }
626
+ process.stdout.write(
627
+ style.dim(
628
+ `\n${installed.length} skill${installed.length === 1 ? '' : 's'} copied. They are copies, so re-run this after upgrading memonaut.\n`,
629
+ ),
630
+ );
631
+ }
632
+
633
+ async function main(): Promise<void> {
436
634
  const argv = process.argv.slice(2);
437
635
  const command = argv[0];
438
636
  const rest = argv.slice(1);
@@ -450,6 +648,8 @@ function main(): void {
450
648
  return cmdStats(rest);
451
649
  case 'config':
452
650
  return cmdConfig(rest);
651
+ case 'skills':
652
+ return cmdSkills(rest);
453
653
  case '--version':
454
654
  case '-v':
455
655
  process.stdout.write(version() + '\n');
@@ -466,9 +666,9 @@ function main(): void {
466
666
  }
467
667
 
468
668
  /** Entry point. Loaded dynamically by `cli.ts`, see the note there. */
469
- export function run(): void {
669
+ export async function run(): Promise<void> {
470
670
  try {
471
- main();
671
+ await main();
472
672
  } catch (err) {
473
673
  fail((err as Error).message);
474
674
  }
package/src/cli.ts CHANGED
@@ -14,4 +14,4 @@ import {silenceSqliteWarning} from './quiet.js';
14
14
  silenceSqliteWarning();
15
15
 
16
16
  const {run} = await import('./cli-main.js');
17
- run();
17
+ await run();
package/src/format.ts CHANGED
@@ -117,6 +117,18 @@ export function renderHit(hit: SearchHit, opts: RenderOptions): string {
117
117
  meta.push(
118
118
  `${hit.otherHits} more match${hit.otherHits === 1 ? '' : 'es'} in this lineage`,
119
119
  );
120
+ // The regex path reads the original transcripts, so it can match text no
121
+ // full-text query could reach: a kind the tier excludes (tool output is 42%
122
+ // of the bytes), or text past where a chunk was truncated. Saying so is the
123
+ // difference between a surprising result and an informative one. A match on
124
+ // the JSON scaffolding is NOT that, and has to say something else: the
125
+ // content around it is very probably indexed.
126
+ if (hit.matchedIn === 'transcript') {
127
+ const what = hit.kind ? `this ${hit.kind} text` : 'this text';
128
+ meta.push(`read from the transcript · ${what} is not indexed`);
129
+ } else if (hit.matchedIn === 'structure') {
130
+ meta.push('matched the transcript JSON, not the message text');
131
+ }
120
132
  if (meta.length) lines.push(' ' + style.dim(meta.join(' · ')));
121
133
 
122
134
  for (const thread of hit.threads) {
package/src/index.ts CHANGED
@@ -6,5 +6,15 @@ export * from './pi-source.js';
6
6
  export * from './db.js';
7
7
  export * from './indexer.js';
8
8
  export * from './search.js';
9
+ export * from './regex.js';
10
+ // Only what a caller of `regexSearch` needs: the errors it can throw and the
11
+ // pre-flight check. Spawning rg is an implementation detail, not API.
12
+ export {
13
+ RipgrepError,
14
+ RipgrepMissingError,
15
+ ripgrepAvailable,
16
+ ripgrepBinary,
17
+ } from './ripgrep.js';
9
18
  export * from './format.js';
19
+ export * from './skills.js';
10
20
  export {silenceSqliteWarning} from './quiet.js';
package/src/model.ts CHANGED
@@ -107,7 +107,13 @@ export interface SearchHit {
107
107
  role: string;
108
108
  tool: string | null;
109
109
  ts: string | null;
110
- kind: ChunkKind;
110
+ /**
111
+ * Which extracted text this hit is about. Always set on the full-text path,
112
+ * where a hit IS a chunk. Absent on the regex path when the match landed
113
+ * outside any extracted text (see `matchedIn`), because inventing a kind
114
+ * there would be a guess presented as a fact.
115
+ */
116
+ kind?: ChunkKind;
111
117
  snippet: string;
112
118
  score: number;
113
119
  /** Threads carrying this entry, most recently active first (may be capped). */
@@ -116,6 +122,22 @@ export interface SearchHit {
116
122
  threadTotal: number;
117
123
  /** Further matches in the same lineage that were folded into this group. */
118
124
  otherHits: number;
125
+ /**
126
+ * Regex path only: where the matched bytes actually live.
127
+ *
128
+ * - `index`: in extracted text that is also indexed, so a full-text query
129
+ * could in principle have found it too.
130
+ * - `transcript`: in what someone actually said or ran, but NOT in the index.
131
+ * Either the tier excludes that kind (tool output, by default) or the text
132
+ * sits past where the chunk was truncated. This is the whole reason to
133
+ * reach for a regex search.
134
+ * - `structure`: in the transcript's JSON scaffolding, a key, an id or an
135
+ * escape sequence, rather than in anything anyone wrote. Distinct from
136
+ * `transcript` on purpose: the content around it may well be indexed, and
137
+ * claiming otherwise would be exactly the dishonesty the flag exists to
138
+ * prevent.
139
+ */
140
+ matchedIn?: 'index' | 'transcript' | 'structure';
119
141
  }
120
142
 
121
143
  export interface ThreadRef {