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.
- package/LICENSE +661 -0
- package/dist/cli-main.d.ts +14 -0
- package/dist/cli-main.d.ts.map +1 -0
- package/dist/cli-main.js +551 -0
- package/dist/cli-main.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +16 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +38 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +86 -0
- package/dist/config.js.map +1 -0
- package/dist/db.d.ts +37 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +184 -0
- package/dist/db.js.map +1 -0
- package/dist/format.d.ts +22 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/format.js +143 -0
- package/dist/format.js.map +1 -0
- package/dist/glob.d.ts +17 -0
- package/dist/glob.d.ts.map +1 -0
- package/dist/glob.js +77 -0
- package/dist/glob.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/indexer.d.ts +50 -0
- package/dist/indexer.d.ts.map +1 -0
- package/dist/indexer.js +404 -0
- package/dist/indexer.js.map +1 -0
- package/dist/lineage.d.ts +31 -0
- package/dist/lineage.d.ts.map +1 -0
- package/dist/lineage.js +94 -0
- package/dist/lineage.js.map +1 -0
- package/dist/model.d.ts +121 -0
- package/dist/model.d.ts.map +1 -0
- package/dist/model.js +41 -0
- package/dist/model.js.map +1 -0
- package/dist/pi-source.d.ts +47 -0
- package/dist/pi-source.d.ts.map +1 -0
- package/dist/pi-source.js +309 -0
- package/dist/pi-source.js.map +1 -0
- package/dist/quiet.d.ts +7 -0
- package/dist/quiet.d.ts.map +1 -0
- package/dist/quiet.js +19 -0
- package/dist/quiet.js.map +1 -0
- package/dist/regex.d.ts +75 -0
- package/dist/regex.d.ts.map +1 -0
- package/dist/regex.js +242 -0
- package/dist/regex.js.map +1 -0
- package/dist/ripgrep.d.ts +52 -0
- package/dist/ripgrep.d.ts.map +1 -0
- package/dist/ripgrep.js +217 -0
- package/dist/ripgrep.js.map +1 -0
- package/dist/search.d.ts +114 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +309 -0
- package/dist/search.js.map +1 -0
- package/dist/silence-sqlite-warning.d.ts +2 -0
- package/dist/silence-sqlite-warning.d.ts.map +1 -0
- package/dist/silence-sqlite-warning.js +9 -0
- package/dist/silence-sqlite-warning.js.map +1 -0
- package/package.json +57 -2
- package/src/cli-main.ts +618 -0
- package/src/cli.ts +17 -0
- package/src/config.ts +130 -0
- package/src/db.ts +213 -0
- package/src/format.ts +185 -0
- package/src/glob.ts +79 -0
- package/src/index.ts +19 -0
- package/src/indexer.ts +534 -0
- package/src/lineage.ts +111 -0
- package/src/model.ts +157 -0
- package/src/pi-source.ts +328 -0
- package/src/quiet.ts +22 -0
- package/src/regex.ts +378 -0
- package/src/ripgrep.ts +263 -0
- package/src/search.ts +504 -0
package/src/cli-main.ts
ADDED
|
@@ -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();
|