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/config.ts ADDED
@@ -0,0 +1,130 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import {expandTilde} from './glob.js';
5
+ import type {Tier} from './model.js';
6
+ import {TIERS} from './model.js';
7
+
8
+ export interface SourceConfig {
9
+ /** Short label stored on every file row, so multi-source indexes stay legible. */
10
+ id: string;
11
+ /** Only `pi` for now. The adapter seam is here so other agents can be added. */
12
+ kind: 'pi';
13
+ root: string;
14
+ }
15
+
16
+ export interface Config {
17
+ sources: SourceConfig[];
18
+ /** cwd globs that are never read, never stored. */
19
+ ignore: string[];
20
+ /** cwd globs that are indexed but hidden from agents unless asked for. */
21
+ private: string[];
22
+ tier: Tier;
23
+ /** Bytes kept per tool result (tier `full` only). */
24
+ toolResultHeadBytes: number;
25
+ /** Bytes kept per tool call argument blob. */
26
+ toolArgsHeadBytes: number;
27
+ configDir: string;
28
+ dataDir: string;
29
+ dbPath: string;
30
+ configPath: string;
31
+ /** True when a config file was actually found on disk. */
32
+ loaded: boolean;
33
+ }
34
+
35
+ export const DEFAULT_TIER: Tier = 'default';
36
+
37
+ function xdg(envVar: string, fallback: string): string {
38
+ const v = process.env[envVar];
39
+ if (v && v.trim()) return v;
40
+ return path.join(os.homedir(), fallback);
41
+ }
42
+
43
+ export function defaultConfigDir(env = process.env): string {
44
+ if (env.MEMONAUT_CONFIG_DIR) return env.MEMONAUT_CONFIG_DIR;
45
+ return path.join(xdg('XDG_CONFIG_HOME', '.config'), 'memonaut');
46
+ }
47
+
48
+ export function defaultDataDir(env = process.env): string {
49
+ if (env.MEMONAUT_DATA_DIR) return env.MEMONAUT_DATA_DIR;
50
+ return path.join(xdg('XDG_DATA_HOME', '.local/share'), 'memonaut');
51
+ }
52
+
53
+ export function defaultSources(): SourceConfig[] {
54
+ return [
55
+ {id: 'pi', kind: 'pi', root: path.join(os.homedir(), '.pi/agent/sessions')},
56
+ ];
57
+ }
58
+
59
+ /**
60
+ * Load config, filling in defaults. Every path is resolved here so nothing
61
+ * downstream ever has to think about `~` or XDG again.
62
+ */
63
+ export function loadConfig(env = process.env): Config {
64
+ const configDir = defaultConfigDir(env);
65
+ const dataDir = defaultDataDir(env);
66
+ const configPath = path.join(configDir, 'config.json');
67
+
68
+ let raw: Record<string, unknown> = {};
69
+ let loaded = false;
70
+ if (fs.existsSync(configPath)) {
71
+ try {
72
+ raw = JSON.parse(fs.readFileSync(configPath, 'utf8')) as Record<
73
+ string,
74
+ unknown
75
+ >;
76
+ loaded = true;
77
+ } catch (err) {
78
+ throw new Error(
79
+ `config at ${configPath} is not valid JSON: ${(err as Error).message}`,
80
+ );
81
+ }
82
+ }
83
+
84
+ const sources =
85
+ Array.isArray(raw.sources) && raw.sources.length
86
+ ? (raw.sources as SourceConfig[])
87
+ : defaultSources();
88
+
89
+ const tier =
90
+ typeof raw.tier === 'string' && TIERS.includes(raw.tier as Tier)
91
+ ? (raw.tier as Tier)
92
+ : DEFAULT_TIER;
93
+
94
+ return {
95
+ sources: sources.map((s) => ({
96
+ ...s,
97
+ root: path.resolve(expandTilde(s.root)),
98
+ })),
99
+ ignore: (raw.ignore as string[]) ?? [],
100
+ private: (raw.private as string[]) ?? [],
101
+ tier,
102
+ toolResultHeadBytes: (raw.toolResultHeadBytes as number) ?? 4096,
103
+ toolArgsHeadBytes: (raw.toolArgsHeadBytes as number) ?? 2048,
104
+ configDir,
105
+ dataDir,
106
+ dbPath: env.MEMONAUT_DB ?? path.join(dataDir, 'index.db'),
107
+ configPath,
108
+ loaded,
109
+ };
110
+ }
111
+
112
+ /** Write a starter config, never clobbering an existing one. */
113
+ export function writeStarterConfig(config: Config): string {
114
+ fs.mkdirSync(config.configDir, {recursive: true});
115
+ if (fs.existsSync(config.configPath)) return config.configPath;
116
+ const starter = {
117
+ sources: defaultSources(),
118
+ ignore: ['/tmp/**'],
119
+ private: [],
120
+ tier: DEFAULT_TIER,
121
+ toolResultHeadBytes: 4096,
122
+ toolArgsHeadBytes: 2048,
123
+ };
124
+ fs.writeFileSync(
125
+ config.configPath,
126
+ JSON.stringify(starter, null, '\t') + '\n',
127
+ {mode: 0o600},
128
+ );
129
+ return config.configPath;
130
+ }
package/src/db.ts ADDED
@@ -0,0 +1,213 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import {DatabaseSync} from 'node:sqlite';
4
+
5
+ /**
6
+ * Bump when the schema or the extraction rules change. The index is a derived
7
+ * cache over files that are still on disk, and a full rebuild costs well under
8
+ * a minute, so a version mismatch simply throws the DB away rather than
9
+ * carrying migration code forever.
10
+ */
11
+ export const SCHEMA_VERSION = 1;
12
+
13
+ export type DB = DatabaseSync;
14
+
15
+ const SCHEMA = `
16
+ CREATE TABLE IF NOT EXISTS meta(
17
+ key TEXT PRIMARY KEY,
18
+ value TEXT NOT NULL
19
+ );
20
+
21
+ -- One transcript file. Also the unit of incremental indexing: (size, mtime,
22
+ -- head_hash, offset) is the watermark, and it is written LAST so a crash can
23
+ -- only ever cause re-work, never a silent gap.
24
+ CREATE TABLE IF NOT EXISTS file(
25
+ id INTEGER PRIMARY KEY,
26
+ path TEXT NOT NULL UNIQUE,
27
+ source TEXT NOT NULL,
28
+ session_uuid TEXT,
29
+ cwd TEXT,
30
+ project TEXT,
31
+ name TEXT,
32
+ parent_path TEXT,
33
+ parent_id INTEGER,
34
+ lineage_id INTEGER,
35
+ lineage_depth INTEGER NOT NULL DEFAULT 0,
36
+ orphaned INTEGER NOT NULL DEFAULT 0,
37
+ private INTEGER NOT NULL DEFAULT 0,
38
+ started TEXT,
39
+ last_activity TEXT,
40
+ entry_count INTEGER NOT NULL DEFAULT 0,
41
+ size INTEGER,
42
+ mtime INTEGER,
43
+ head_hash TEXT,
44
+ offset INTEGER NOT NULL DEFAULT 0,
45
+ indexed_at INTEGER
46
+ );
47
+ CREATE INDEX IF NOT EXISTS file_lineage ON file(lineage_id);
48
+ CREATE INDEX IF NOT EXISTS file_activity ON file(last_activity DESC);
49
+ CREATE INDEX IF NOT EXISTS file_uuid ON file(session_uuid);
50
+
51
+ -- One entry, stored ONCE per lineage even when N forks copied it. Keyed by
52
+ -- (lineage_id, entry_key) because entry keys are 8 hex chars and collide across
53
+ -- unrelated transcripts.
54
+ CREATE TABLE IF NOT EXISTS entry(
55
+ id INTEGER PRIMARY KEY,
56
+ lineage_id INTEGER NOT NULL,
57
+ entry_key TEXT NOT NULL,
58
+ role TEXT,
59
+ tool TEXT,
60
+ ts TEXT,
61
+ first_file INTEGER,
62
+ byte_offset INTEGER,
63
+ byte_len INTEGER,
64
+ UNIQUE(lineage_id, entry_key)
65
+ );
66
+ CREATE INDEX IF NOT EXISTS entry_ts ON entry(ts);
67
+
68
+ -- The fan-out edge: which threads carry this entry. A match in shared history
69
+ -- resolves to every fork that inherited it.
70
+ CREATE TABLE IF NOT EXISTS membership(
71
+ entry_id INTEGER NOT NULL,
72
+ file_id INTEGER NOT NULL,
73
+ seq INTEGER NOT NULL,
74
+ PRIMARY KEY(entry_id, file_id)
75
+ ) WITHOUT ROWID;
76
+ CREATE INDEX IF NOT EXISTS membership_file ON membership(file_id);
77
+
78
+ CREATE TABLE IF NOT EXISTS chunk(
79
+ id INTEGER PRIMARY KEY,
80
+ entry_id INTEGER NOT NULL,
81
+ part INTEGER NOT NULL,
82
+ kind TEXT NOT NULL,
83
+ text TEXT NOT NULL
84
+ );
85
+ CREATE INDEX IF NOT EXISTS chunk_entry ON chunk(entry_id);
86
+
87
+ CREATE VIRTUAL TABLE IF NOT EXISTS chunk_fts USING fts5(
88
+ text,
89
+ content='chunk',
90
+ content_rowid='id',
91
+ tokenize="unicode61 remove_diacritics 2 tokenchars '_-.$'"
92
+ );
93
+
94
+ CREATE TRIGGER IF NOT EXISTS chunk_ai AFTER INSERT ON chunk BEGIN
95
+ INSERT INTO chunk_fts(rowid, text) VALUES (new.id, new.text);
96
+ END;
97
+ CREATE TRIGGER IF NOT EXISTS chunk_ad AFTER DELETE ON chunk BEGIN
98
+ INSERT INTO chunk_fts(chunk_fts, rowid, text) VALUES ('delete', old.id, old.text);
99
+ END;
100
+ CREATE TRIGGER IF NOT EXISTS chunk_au AFTER UPDATE ON chunk BEGIN
101
+ INSERT INTO chunk_fts(chunk_fts, rowid, text) VALUES ('delete', old.id, old.text);
102
+ INSERT INTO chunk_fts(rowid, text) VALUES (new.id, new.text);
103
+ END;
104
+ `;
105
+
106
+ export interface OpenOptions {
107
+ readOnly?: boolean;
108
+ /** Throw instead of rebuilding when the schema version differs. */
109
+ noRebuild?: boolean;
110
+ }
111
+
112
+ export function getMeta(db: DB, key: string): string | undefined {
113
+ const row = db.prepare('SELECT value FROM meta WHERE key = ?').get(key) as
114
+ {value?: string} | undefined;
115
+ return row?.value;
116
+ }
117
+
118
+ export function setMeta(db: DB, key: string, value: string): void {
119
+ db.prepare(
120
+ 'INSERT INTO meta(key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value',
121
+ ).run(key, value);
122
+ }
123
+
124
+ /**
125
+ * Open the index, creating it if needed.
126
+ *
127
+ * The DB file is created 0600: it concentrates every secret that ever passed
128
+ * through a transcript, so it must never be world-readable.
129
+ */
130
+ export function openDb(dbPath: string, opts: OpenOptions = {}): DB {
131
+ if (opts.readOnly) {
132
+ if (!fs.existsSync(dbPath)) {
133
+ throw new Error(`no index at ${dbPath}. Run \`recall index\` first.`);
134
+ }
135
+ const db = new DatabaseSync(dbPath, {readOnly: true});
136
+ db.exec('PRAGMA query_only = 1');
137
+ return db;
138
+ }
139
+
140
+ fs.mkdirSync(path.dirname(dbPath), {recursive: true});
141
+ const fresh = !fs.existsSync(dbPath);
142
+ const db = new DatabaseSync(dbPath);
143
+ db.exec('PRAGMA journal_mode = WAL');
144
+ db.exec('PRAGMA synchronous = NORMAL');
145
+ db.exec('PRAGMA foreign_keys = OFF');
146
+ db.exec(SCHEMA);
147
+ if (fresh) {
148
+ try {
149
+ fs.chmodSync(dbPath, 0o600);
150
+ } catch {
151
+ /* best effort */
152
+ }
153
+ }
154
+
155
+ const version = getMeta(db, 'schema_version');
156
+ if (version !== undefined && Number(version) !== SCHEMA_VERSION) {
157
+ if (opts.noRebuild) {
158
+ throw new Error(
159
+ `index schema is v${version}, this build expects v${SCHEMA_VERSION}. Run \`recall index --full\`.`,
160
+ );
161
+ }
162
+ dropAll(db);
163
+ db.exec(SCHEMA);
164
+ }
165
+ setMeta(db, 'schema_version', String(SCHEMA_VERSION));
166
+ return db;
167
+ }
168
+
169
+ export function dropAll(db: DB): void {
170
+ db.exec(`
171
+ DROP TRIGGER IF EXISTS chunk_ai;
172
+ DROP TRIGGER IF EXISTS chunk_ad;
173
+ DROP TRIGGER IF EXISTS chunk_au;
174
+ DROP TABLE IF EXISTS chunk_fts;
175
+ DROP TABLE IF EXISTS chunk;
176
+ DROP TABLE IF EXISTS membership;
177
+ DROP TABLE IF EXISTS entry;
178
+ DROP TABLE IF EXISTS file;
179
+ DROP TABLE IF EXISTS meta;
180
+ `);
181
+ }
182
+
183
+ /**
184
+ * Drop all content for a whole lineage.
185
+ *
186
+ * Re-ingestion is a LINEAGE-level operation, never a file-level one: a forked
187
+ * file's membership rows point at entries owned by its parent, so re-reading
188
+ * one file in isolation would leave dangling references. Lineages are tiny
189
+ * (the vast majority are a single file; the largest measured is twelve), so
190
+ * this costs nothing and removes a whole class of bug.
191
+ */
192
+ export function clearLineage(db: DB, lineageId: number): void {
193
+ db.prepare(
194
+ 'DELETE FROM chunk WHERE entry_id IN (SELECT id FROM entry WHERE lineage_id = ?)',
195
+ ).run(lineageId);
196
+ db.prepare('DELETE FROM entry WHERE lineage_id = ?').run(lineageId);
197
+ db.prepare(
198
+ 'DELETE FROM membership WHERE file_id IN (SELECT id FROM file WHERE lineage_id = ?)',
199
+ ).run(lineageId);
200
+ db.prepare(
201
+ 'UPDATE file SET offset = 0, entry_count = 0, size = NULL, mtime = NULL, head_hash = NULL WHERE lineage_id = ?',
202
+ ).run(lineageId);
203
+ }
204
+
205
+ /** Remove a file that is no longer on disk, and anything only it owned. */
206
+ export function deleteFile(db: DB, fileId: number): void {
207
+ db.prepare(
208
+ 'DELETE FROM chunk WHERE entry_id IN (SELECT id FROM entry WHERE first_file = ?)',
209
+ ).run(fileId);
210
+ db.prepare('DELETE FROM entry WHERE first_file = ?').run(fileId);
211
+ db.prepare('DELETE FROM membership WHERE file_id = ?').run(fileId);
212
+ db.prepare('DELETE FROM file WHERE id = ?').run(fileId);
213
+ }
package/src/format.ts ADDED
@@ -0,0 +1,185 @@
1
+ import os from 'node:os';
2
+ import type {SearchHit, ThreadRef} from './model.js';
3
+
4
+ const HOME = os.homedir();
5
+
6
+ export interface Style {
7
+ bold(s: string): string;
8
+ dim(s: string): string;
9
+ hit(s: string): string;
10
+ label(s: string): string;
11
+ }
12
+
13
+ export function makeStyle(enabled: boolean): Style {
14
+ if (!enabled) {
15
+ const plain = (s: string) => s;
16
+ return {bold: plain, dim: plain, hit: plain, label: plain};
17
+ }
18
+ return {
19
+ bold: (s) => `\u001b[1m${s}\u001b[0m`,
20
+ dim: (s) => `\u001b[2m${s}\u001b[0m`,
21
+ hit: (s) => `\u001b[43m\u001b[30m${s}\u001b[0m`,
22
+ label: (s) => `\u001b[36m${s}\u001b[0m`,
23
+ };
24
+ }
25
+
26
+ export function colorsEnabled(
27
+ stream: NodeJS.WriteStream = process.stdout,
28
+ ): boolean {
29
+ if (process.env.NO_COLOR !== undefined) return false;
30
+ if (process.env.FORCE_COLOR !== undefined) return true;
31
+ return Boolean(stream.isTTY);
32
+ }
33
+
34
+ export function tildify(p: string | null | undefined): string {
35
+ if (!p) return '';
36
+ return p.startsWith(HOME) ? '~' + p.slice(HOME.length) : p;
37
+ }
38
+
39
+ export function shortTime(ts: string | null | undefined): string {
40
+ if (!ts) return '?';
41
+ return ts.slice(0, 16).replace('T', ' ');
42
+ }
43
+
44
+ export function relativeTime(
45
+ ts: string | null | undefined,
46
+ now = Date.now(),
47
+ ): string {
48
+ if (!ts) return '?';
49
+ const t = Date.parse(ts);
50
+ if (Number.isNaN(t)) return '?';
51
+ const s = Math.max(0, (now - t) / 1000);
52
+ if (s < 90) return 'just now';
53
+ const m = s / 60;
54
+ if (m < 90) return `${Math.round(m)}m ago`;
55
+ const h = m / 60;
56
+ if (h < 36) return `${Math.round(h)}h ago`;
57
+ const d = h / 24;
58
+ if (d < 8) return `${Math.round(d)}d ago`;
59
+ // Past a week, "2mo ago" stops distinguishing anything. Sibling forks are
60
+ // usually minutes apart, and the whole point of listing them is to tell them
61
+ // apart, so show the actual moment instead.
62
+ return shortTime(ts);
63
+ }
64
+
65
+ /** Collapse a snippet to one line and paint the FTS5 highlight markers. */
66
+ export function renderSnippet(snippet: string, style: Style): string {
67
+ const flat = snippet.replace(/\s+/g, ' ').trim();
68
+ return flat.replace(/\u0001([^\u0002]*)\u0002/g, (_m, inner: string) =>
69
+ style.hit(inner),
70
+ );
71
+ }
72
+
73
+ /**
74
+ * Label a thread with something you can act on.
75
+ *
76
+ * Sibling forks are created within the same second, so a truncated session UUID
77
+ * does NOT distinguish them (five of the twelve threads in one measured family
78
+ * share the first eight hex chars). The file id always does, and it is exactly
79
+ * what `recall show <id>` takes.
80
+ */
81
+ function threadLabel(thread: ThreadRef): string {
82
+ if (thread.name) return `#${thread.fileId} ${thread.name}`;
83
+ const base = thread.path.split('/').pop() ?? thread.path;
84
+ const uuid = /([0-9a-f]{8}-[0-9a-f]{4})/.exec(base);
85
+ return `#${thread.fileId} ${uuid ? uuid[1] : base.replace(/\.jsonl$/, '')}`;
86
+ }
87
+
88
+ export interface RenderOptions {
89
+ style: Style;
90
+ now?: number;
91
+ showPath?: boolean;
92
+ }
93
+
94
+ export function renderHit(hit: SearchHit, opts: RenderOptions): string {
95
+ const {style} = opts;
96
+ const now = opts.now ?? Date.now();
97
+ const primary = hit.threads[0];
98
+ const project = primary?.project ?? '?';
99
+ const lines: string[] = [];
100
+
101
+ const head = [
102
+ style.bold(project),
103
+ style.dim('·'),
104
+ style.label(hit.role + (hit.tool ? `:${hit.tool}` : '')),
105
+ style.dim(shortTime(hit.ts)),
106
+ ].join(' ');
107
+ lines.push(head);
108
+ lines.push(' ' + renderSnippet(hit.snippet, style));
109
+
110
+ const shared = hit.threadTotal > 1;
111
+ const meta: string[] = [];
112
+ if (shared)
113
+ meta.push(
114
+ `matched in shared history · carried by ${hit.threadTotal} threads`,
115
+ );
116
+ if (hit.otherHits > 0)
117
+ meta.push(
118
+ `${hit.otherHits} more match${hit.otherHits === 1 ? '' : 'es'} in this lineage`,
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
+ }
132
+ if (meta.length) lines.push(' ' + style.dim(meta.join(' · ')));
133
+
134
+ for (const thread of hit.threads) {
135
+ const bits = [
136
+ style.dim(shared ? '↳' : ' '),
137
+ threadLabel(thread),
138
+ style.dim(`last ${relativeTime(thread.lastActivity, now)}`),
139
+ style.dim(`+${thread.after} after`),
140
+ ];
141
+ if (opts.showPath) bits.push(style.dim(tildify(thread.path)));
142
+ lines.push(' ' + bits.join(' '));
143
+ }
144
+ const hidden = hit.threadTotal - hit.threads.length;
145
+ if (hidden > 0)
146
+ lines.push(
147
+ ' ' +
148
+ style.dim(
149
+ `+ ${hidden} more thread${hidden === 1 ? '' : 's'} (--threads all)`,
150
+ ),
151
+ );
152
+
153
+ return lines.join('\n');
154
+ }
155
+
156
+ export function renderTable(rows: Array<Record<string, unknown>>): string {
157
+ if (rows.length === 0) return '(no rows)';
158
+ const columns = Object.keys(rows[0]);
159
+ const widths = columns.map((c) =>
160
+ Math.max(
161
+ c.length,
162
+ ...rows.map(
163
+ (r) =>
164
+ String(r[c] ?? '')
165
+ .replace(/\s+/g, ' ')
166
+ .slice(0, 60).length,
167
+ ),
168
+ ),
169
+ );
170
+ const line = (cells: string[]) =>
171
+ cells.map((c, i) => c.padEnd(widths[i])).join(' ');
172
+ const out = [line(columns), line(widths.map((w) => '-'.repeat(w)))];
173
+ for (const row of rows) {
174
+ out.push(
175
+ line(
176
+ columns.map((c) =>
177
+ String(row[c] ?? '')
178
+ .replace(/\s+/g, ' ')
179
+ .slice(0, 60),
180
+ ),
181
+ ),
182
+ );
183
+ }
184
+ return out.join('\n');
185
+ }
package/src/glob.ts ADDED
@@ -0,0 +1,79 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+
4
+ /**
5
+ * Tiny glob matcher for path filters. No dependency, no surprises.
6
+ *
7
+ * - `~` expands to the home directory.
8
+ * - `**` crosses path separators, `*` and `?` do not.
9
+ * - A pattern with NO wildcard matches the path itself or anything under it,
10
+ * so `~/.agent-runner` behaves the way people expect without a trailing `/**`.
11
+ */
12
+ export function expandTilde(pattern: string): string {
13
+ if (pattern === '~') return os.homedir();
14
+ if (pattern.startsWith('~/'))
15
+ return path.join(os.homedir(), pattern.slice(2));
16
+ return pattern;
17
+ }
18
+
19
+ const SPECIAL = /[.+^${}()|[\]\\]/g;
20
+
21
+ export function globToRegExp(pattern: string): RegExp {
22
+ const expanded = expandTilde(pattern);
23
+ let out = '';
24
+ for (let i = 0; i < expanded.length; i++) {
25
+ const c = expanded[i];
26
+ if (c === '*') {
27
+ if (expanded[i + 1] === '*') {
28
+ // `**/` should also match zero directories, so `/a/**/b` matches `/a/b`.
29
+ if (expanded[i + 2] === '/') {
30
+ out += '(?:.*/)?';
31
+ i += 2;
32
+ } else {
33
+ out += '.*';
34
+ i += 1;
35
+ }
36
+ } else {
37
+ out += '[^/]*';
38
+ }
39
+ } else if (c === '?') {
40
+ out += '[^/]';
41
+ } else {
42
+ out += c.replace(SPECIAL, '\\$&');
43
+ }
44
+ }
45
+ return new RegExp(`^${out}$`);
46
+ }
47
+
48
+ export interface Matcher {
49
+ (value: string | null | undefined): boolean;
50
+ readonly empty: boolean;
51
+ }
52
+
53
+ /** Build a matcher for a list of patterns. An empty list matches nothing. */
54
+ export function matcher(patterns: string[]): Matcher {
55
+ const compiled = patterns.map((p) => {
56
+ const expanded = expandTilde(p);
57
+ const hasWildcard = /[*?]/.test(expanded);
58
+ return hasWildcard
59
+ ? {kind: 'glob' as const, re: globToRegExp(p)}
60
+ : {kind: 'prefix' as const, value: expanded.replace(/\/+$/, '')};
61
+ });
62
+ const fn = ((value: string | null | undefined) => {
63
+ if (!value) return false;
64
+ const normalized = value.length > 1 ? value.replace(/\/+$/, '') : value;
65
+ for (const c of compiled) {
66
+ if (c.kind === 'glob') {
67
+ if (c.re.test(value) || c.re.test(normalized)) return true;
68
+ } else if (
69
+ normalized === c.value ||
70
+ normalized.startsWith(c.value + '/')
71
+ ) {
72
+ return true;
73
+ }
74
+ }
75
+ return false;
76
+ }) as {(value: string | null | undefined): boolean; empty: boolean};
77
+ fn.empty = patterns.length === 0;
78
+ return fn as Matcher;
79
+ }
package/src/index.ts ADDED
@@ -0,0 +1,19 @@
1
+ export * from './model.js';
2
+ export * from './config.js';
3
+ export * from './glob.js';
4
+ export * from './lineage.js';
5
+ export * from './pi-source.js';
6
+ export * from './db.js';
7
+ export * from './indexer.js';
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';
18
+ export * from './format.js';
19
+ export {silenceSqliteWarning} from './quiet.js';