memonaut 0.0.0 → 0.1.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 +3 -0
- package/dist/cli-main.d.ts.map +1 -0
- package/dist/cli-main.js +428 -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 +130 -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 +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -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 +99 -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/search.d.ts +93 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +285 -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 +475 -0
- package/src/cli.ts +17 -0
- package/src/config.ts +130 -0
- package/src/db.ts +213 -0
- package/src/format.ts +173 -0
- package/src/glob.ts +79 -0
- package/src/index.ts +10 -0
- package/src/indexer.ts +534 -0
- package/src/lineage.ts +111 -0
- package/src/model.ts +135 -0
- package/src/pi-source.ts +328 -0
- package/src/quiet.ts +22 -0
- package/src/search.ts +462 -0
package/src/cli-main.ts
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
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
|
+
} from './format.js';
|
|
15
|
+
import {index} from './indexer.js';
|
|
16
|
+
import {TIERS, type ChunkKind, type Tier} from './model.js';
|
|
17
|
+
import {readRawEntry} from './pi-source.js';
|
|
18
|
+
import {indexStats, readThread, resolveThread, search} from './search.js';
|
|
19
|
+
|
|
20
|
+
const USAGE = `memonaut — search your agent conversation transcripts
|
|
21
|
+
|
|
22
|
+
USAGE
|
|
23
|
+
recall <command> [options]
|
|
24
|
+
|
|
25
|
+
COMMANDS
|
|
26
|
+
index Bring the index up to date (incremental by default)
|
|
27
|
+
search <query...> Search transcripts, grouped by fork lineage
|
|
28
|
+
show <ref> Print a thread (file id, session uuid prefix, name or path)
|
|
29
|
+
sql <query> Run a read-only SQL query against the index
|
|
30
|
+
stats Summarise what is indexed
|
|
31
|
+
config Show config paths, or write a starter config
|
|
32
|
+
|
|
33
|
+
SEARCH OPTIONS
|
|
34
|
+
--cwd <glob> Filter by working directory (repeatable, globs, ~ ok)
|
|
35
|
+
--project <name> Filter by project (last segment of the cwd, repeatable)
|
|
36
|
+
--role <role> user | assistant | toolResult | bashExecution | ... (repeatable)
|
|
37
|
+
--tool <name> Filter by tool name (repeatable)
|
|
38
|
+
--kind <kind> user | assistant | thinking | toolCall | toolResult | bash | summary | name
|
|
39
|
+
--since <when> 30d, 6h, 2w, or an ISO date
|
|
40
|
+
--until <when> Same forms as --since
|
|
41
|
+
--limit <n> Result groups (default 20)
|
|
42
|
+
--threads <n|all> Threads shown per group (default 3)
|
|
43
|
+
--private Include transcripts matched by the config's private globs
|
|
44
|
+
--raw Pass the query to FTS5 verbatim (no quoting fallback)
|
|
45
|
+
--no-recency Rank on bm25 x kind weight only
|
|
46
|
+
--path Show full transcript paths
|
|
47
|
+
--json Machine-readable output
|
|
48
|
+
--no-sync Skip the incremental catch-up before querying
|
|
49
|
+
|
|
50
|
+
INDEX OPTIONS
|
|
51
|
+
--full Rebuild from scratch
|
|
52
|
+
--tier <t> slim | default | full (overrides config for this run)
|
|
53
|
+
--quiet No progress output
|
|
54
|
+
|
|
55
|
+
QUERY SYNTAX
|
|
56
|
+
The query is an FTS5 MATCH expression: bare words are ANDed, "quoted phrases"
|
|
57
|
+
match in order, OR / NOT / NEAR(a b, 5) work, and trailing * is a prefix.
|
|
58
|
+
If that fails to parse, the query is retried as quoted literal tokens.
|
|
59
|
+
|
|
60
|
+
EXAMPLES
|
|
61
|
+
recall search steering queue --project wherever --since 30d
|
|
62
|
+
recall search '"fork point" OR parentSession' --threads all
|
|
63
|
+
recall search --tool bash --kind bash 'fuser -k'
|
|
64
|
+
recall sql "select project, count(*) n from file group by 1 order by n desc limit 10"
|
|
65
|
+
`;
|
|
66
|
+
|
|
67
|
+
function fail(message: string): never {
|
|
68
|
+
process.stderr.write(`recall: ${message}\n`);
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function version(): string {
|
|
73
|
+
try {
|
|
74
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
75
|
+
for (const candidate of [
|
|
76
|
+
path.join(here, '../package.json'),
|
|
77
|
+
path.join(here, '../../package.json'),
|
|
78
|
+
]) {
|
|
79
|
+
if (fs.existsSync(candidate)) {
|
|
80
|
+
return (
|
|
81
|
+
(JSON.parse(fs.readFileSync(candidate, 'utf8')) as {version?: string})
|
|
82
|
+
.version ?? '0.0.0'
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
/* ignore */
|
|
88
|
+
}
|
|
89
|
+
return '0.0.0';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Accept `30d`, `6h`, `2w`, `3mo`, or anything Date can parse. */
|
|
93
|
+
function parseWhen(value: string | undefined): string | undefined {
|
|
94
|
+
if (!value) return undefined;
|
|
95
|
+
const m = /^(\d+)(mo|[smhdwy])$/.exec(value.trim());
|
|
96
|
+
if (m) {
|
|
97
|
+
const n = Number(m[1]);
|
|
98
|
+
const unit = m[2];
|
|
99
|
+
const ms =
|
|
100
|
+
unit === 's'
|
|
101
|
+
? 1000
|
|
102
|
+
: unit === 'm'
|
|
103
|
+
? 60_000
|
|
104
|
+
: unit === 'h'
|
|
105
|
+
? 3_600_000
|
|
106
|
+
: unit === 'd'
|
|
107
|
+
? 86_400_000
|
|
108
|
+
: unit === 'w'
|
|
109
|
+
? 604_800_000
|
|
110
|
+
: unit === 'mo'
|
|
111
|
+
? 2_592_000_000
|
|
112
|
+
: 31_536_000_000;
|
|
113
|
+
return new Date(Date.now() - n * ms).toISOString();
|
|
114
|
+
}
|
|
115
|
+
const parsed = Date.parse(value);
|
|
116
|
+
if (Number.isNaN(parsed)) fail(`cannot understand time "${value}"`);
|
|
117
|
+
return new Date(parsed).toISOString();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function withTier(config: Config, tier: string | undefined): Config {
|
|
121
|
+
if (!tier) return config;
|
|
122
|
+
if (!TIERS.includes(tier as Tier))
|
|
123
|
+
fail(`unknown tier "${tier}" (expected ${TIERS.join(', ')})`);
|
|
124
|
+
return {...config, tier: tier as Tier};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Cheap incremental catch-up so a query is never answered from a stale index. */
|
|
128
|
+
function sync(config: Config, quiet: boolean): void {
|
|
129
|
+
if (!fs.existsSync(config.dbPath)) {
|
|
130
|
+
fail(`no index at ${tildify(config.dbPath)}. Run \`recall index\` first.`);
|
|
131
|
+
}
|
|
132
|
+
const stats = index({config, onProgress: () => {}});
|
|
133
|
+
if (!quiet && stats.filesIndexed > 0) {
|
|
134
|
+
process.stderr.write(
|
|
135
|
+
`recall: synced ${stats.filesIndexed} transcript(s) in ${stats.durationMs}ms\n`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function cmdIndex(argv: string[]): void {
|
|
141
|
+
const {values} = parseArgs({
|
|
142
|
+
args: argv,
|
|
143
|
+
options: {
|
|
144
|
+
full: {type: 'boolean', default: false},
|
|
145
|
+
tier: {type: 'string'},
|
|
146
|
+
quiet: {type: 'boolean', default: false},
|
|
147
|
+
json: {type: 'boolean', default: false},
|
|
148
|
+
},
|
|
149
|
+
allowPositionals: false,
|
|
150
|
+
});
|
|
151
|
+
const config = withTier(loadConfig(), values.tier as string | undefined);
|
|
152
|
+
const quiet = Boolean(values.quiet) || Boolean(values.json);
|
|
153
|
+
let lastLine = 0;
|
|
154
|
+
const stats = index({
|
|
155
|
+
config,
|
|
156
|
+
full: Boolean(values.full),
|
|
157
|
+
onProgress: (p) => {
|
|
158
|
+
if (quiet || !process.stderr.isTTY) return;
|
|
159
|
+
const now = Date.now();
|
|
160
|
+
if (now - lastLine < 100) return;
|
|
161
|
+
lastLine = now;
|
|
162
|
+
process.stderr.write(`\r${p.phase} ${p.done}/${p.total}\u001b[K`);
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
if (!quiet && process.stderr.isTTY) process.stderr.write('\r\u001b[K');
|
|
166
|
+
if (values.json) {
|
|
167
|
+
process.stdout.write(JSON.stringify(stats, null, 2) + '\n');
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const seconds = (stats.durationMs / 1000).toFixed(1);
|
|
171
|
+
process.stdout.write(
|
|
172
|
+
[
|
|
173
|
+
`indexed ${stats.filesIndexed} transcript(s) in ${seconds}s${stats.fullRebuild ? ' (full rebuild)' : ''}`,
|
|
174
|
+
` seen ${stats.filesSeen} · skipped ${stats.filesSkipped} · appended ${stats.filesAppended} · removed ${stats.filesRemoved}`,
|
|
175
|
+
` ignored ${stats.filesIgnored} · private ${stats.filesPrivate} · lineages rebuilt ${stats.lineagesRebuilt}`,
|
|
176
|
+
` entries ${stats.entriesInserted} new, ${stats.entriesShared} inherited by forks · chunks ${stats.chunksInserted}`,
|
|
177
|
+
` index: ${tildify(config.dbPath)}`,
|
|
178
|
+
].join('\n') + '\n',
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function cmdSearch(argv: string[]): void {
|
|
183
|
+
const {values, positionals} = parseArgs({
|
|
184
|
+
args: argv,
|
|
185
|
+
options: {
|
|
186
|
+
cwd: {type: 'string', multiple: true},
|
|
187
|
+
project: {type: 'string', multiple: true},
|
|
188
|
+
role: {type: 'string', multiple: true},
|
|
189
|
+
tool: {type: 'string', multiple: true},
|
|
190
|
+
kind: {type: 'string', multiple: true},
|
|
191
|
+
since: {type: 'string'},
|
|
192
|
+
until: {type: 'string'},
|
|
193
|
+
limit: {type: 'string', default: '20'},
|
|
194
|
+
threads: {type: 'string', default: '3'},
|
|
195
|
+
private: {type: 'boolean', default: false},
|
|
196
|
+
raw: {type: 'boolean', default: false},
|
|
197
|
+
'no-recency': {type: 'boolean', default: false},
|
|
198
|
+
path: {type: 'boolean', default: false},
|
|
199
|
+
json: {type: 'boolean', default: false},
|
|
200
|
+
'no-sync': {type: 'boolean', default: false},
|
|
201
|
+
},
|
|
202
|
+
allowPositionals: true,
|
|
203
|
+
});
|
|
204
|
+
const text = positionals.join(' ').trim();
|
|
205
|
+
if (!text) fail('nothing to search for. Try `recall search <words>`');
|
|
206
|
+
|
|
207
|
+
const config = loadConfig();
|
|
208
|
+
if (!values['no-sync']) sync(config, Boolean(values.json));
|
|
209
|
+
|
|
210
|
+
const db = openDb(config.dbPath, {readOnly: true});
|
|
211
|
+
const threadsRaw = String(values.threads);
|
|
212
|
+
const outcome = search(db, {
|
|
213
|
+
text,
|
|
214
|
+
cwd: values.cwd as string[] | undefined,
|
|
215
|
+
project: values.project as string[] | undefined,
|
|
216
|
+
role: values.role as string[] | undefined,
|
|
217
|
+
tool: values.tool as string[] | undefined,
|
|
218
|
+
kind: values.kind as ChunkKind[] | undefined,
|
|
219
|
+
since: parseWhen(values.since as string | undefined),
|
|
220
|
+
until: parseWhen(values.until as string | undefined),
|
|
221
|
+
includePrivate: Boolean(values.private),
|
|
222
|
+
limit: Number(values.limit),
|
|
223
|
+
threadLimit: threadsRaw === 'all' ? -1 : Number(threadsRaw),
|
|
224
|
+
noRecency: Boolean(values['no-recency']),
|
|
225
|
+
raw: Boolean(values.raw),
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
if (values.json) {
|
|
229
|
+
process.stdout.write(JSON.stringify(outcome, null, 2) + '\n');
|
|
230
|
+
db.close();
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const style = makeStyle(colorsEnabled());
|
|
235
|
+
if (outcome.hits.length === 0) {
|
|
236
|
+
process.stdout.write('no matches\n');
|
|
237
|
+
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
|
+
}
|
|
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
|
+
}
|
|
251
|
+
|
|
252
|
+
function cmdShow(argv: string[]): void {
|
|
253
|
+
const {values, positionals} = parseArgs({
|
|
254
|
+
args: argv,
|
|
255
|
+
options: {
|
|
256
|
+
from: {type: 'string', default: '0'},
|
|
257
|
+
limit: {type: 'string', default: '40'},
|
|
258
|
+
full: {type: 'boolean', default: false},
|
|
259
|
+
json: {type: 'boolean', default: false},
|
|
260
|
+
},
|
|
261
|
+
allowPositionals: true,
|
|
262
|
+
});
|
|
263
|
+
const ref = positionals[0];
|
|
264
|
+
if (!ref)
|
|
265
|
+
fail(
|
|
266
|
+
'which thread? Pass a file id, a session uuid prefix, a name, or a path',
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
const config = loadConfig();
|
|
270
|
+
const db = openDb(config.dbPath, {readOnly: true});
|
|
271
|
+
const thread = resolveThread(db, ref);
|
|
272
|
+
if (!thread) fail(`no thread matching "${ref}"`);
|
|
273
|
+
|
|
274
|
+
const entries = readThread(
|
|
275
|
+
db,
|
|
276
|
+
Number(thread.id),
|
|
277
|
+
Number(values.from),
|
|
278
|
+
Number(values.limit),
|
|
279
|
+
);
|
|
280
|
+
if (values.json) {
|
|
281
|
+
process.stdout.write(JSON.stringify({thread, entries}, null, 2) + '\n');
|
|
282
|
+
db.close();
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const style = makeStyle(colorsEnabled());
|
|
287
|
+
const header = [
|
|
288
|
+
style.bold(thread.name ?? thread.project ?? String(thread.id)),
|
|
289
|
+
style.dim(tildify(thread.cwd ?? '')),
|
|
290
|
+
style.dim(`${thread.entry_count} entries`),
|
|
291
|
+
style.dim(`last ${relativeTime(thread.last_activity)}`),
|
|
292
|
+
].join(' · ');
|
|
293
|
+
process.stdout.write(
|
|
294
|
+
header + '\n' + style.dim(tildify(thread.path)) + '\n\n',
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
const indent = (text: string) =>
|
|
298
|
+
text
|
|
299
|
+
.split('\n')
|
|
300
|
+
.map((l) => ' ' + l)
|
|
301
|
+
.join('\n');
|
|
302
|
+
|
|
303
|
+
for (const entry of entries) {
|
|
304
|
+
const marks = [
|
|
305
|
+
style.label(entry.role + (entry.tool ? `:${entry.tool}` : '')),
|
|
306
|
+
];
|
|
307
|
+
if (entry.shared) marks.push(style.dim('(inherited)'));
|
|
308
|
+
process.stdout.write(
|
|
309
|
+
`${style.dim(String(entry.seq).padStart(4))} ${marks.join(' ')}\n`,
|
|
310
|
+
);
|
|
311
|
+
if (values.full && entry.ownerPath) {
|
|
312
|
+
// Full fidelity means going back to the transcript: the index keeps tool
|
|
313
|
+
// output truncated (or absent), by design, and the byte range is the pointer.
|
|
314
|
+
const raw = readRawEntry(
|
|
315
|
+
entry.ownerPath,
|
|
316
|
+
entry.byteOffset,
|
|
317
|
+
entry.byteLength,
|
|
318
|
+
);
|
|
319
|
+
if (raw !== null) {
|
|
320
|
+
process.stdout.write(indent(JSON.stringify(raw, null, 2)) + '\n');
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
for (const t of entry.texts) {
|
|
325
|
+
const body = t.text.length > 2000 ? t.text.slice(0, 2000) + ' …' : t.text;
|
|
326
|
+
process.stdout.write(indent(body) + '\n');
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
db.close();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function cmdSql(argv: string[]): void {
|
|
333
|
+
const {values, positionals} = parseArgs({
|
|
334
|
+
args: argv,
|
|
335
|
+
options: {
|
|
336
|
+
json: {type: 'boolean', default: false},
|
|
337
|
+
limit: {type: 'string', default: '200'},
|
|
338
|
+
},
|
|
339
|
+
allowPositionals: true,
|
|
340
|
+
});
|
|
341
|
+
const sql = positionals.join(' ').trim();
|
|
342
|
+
if (!sql) fail('no SQL given');
|
|
343
|
+
const config = loadConfig();
|
|
344
|
+
const db = openDb(config.dbPath, {readOnly: true});
|
|
345
|
+
let rows: Array<Record<string, unknown>>;
|
|
346
|
+
try {
|
|
347
|
+
rows = db.prepare(sql).all() as unknown as Array<Record<string, unknown>>;
|
|
348
|
+
} catch (err) {
|
|
349
|
+
db.close();
|
|
350
|
+
fail((err as Error).message);
|
|
351
|
+
}
|
|
352
|
+
const capped = rows.slice(0, Number(values.limit));
|
|
353
|
+
if (values.json) process.stdout.write(JSON.stringify(capped, null, 2) + '\n');
|
|
354
|
+
else {
|
|
355
|
+
process.stdout.write(renderTable(capped) + '\n');
|
|
356
|
+
if (rows.length > capped.length) {
|
|
357
|
+
process.stdout.write(
|
|
358
|
+
`(${rows.length - capped.length} more rows, raise --limit)\n`,
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
db.close();
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function cmdStats(argv: string[]): void {
|
|
366
|
+
const {values} = parseArgs({
|
|
367
|
+
args: argv,
|
|
368
|
+
options: {json: {type: 'boolean', default: false}},
|
|
369
|
+
allowPositionals: false,
|
|
370
|
+
});
|
|
371
|
+
const config = loadConfig();
|
|
372
|
+
const db = openDb(config.dbPath, {readOnly: true});
|
|
373
|
+
const stats = indexStats(db);
|
|
374
|
+
const indexedAt = getMeta(db, 'indexed_at');
|
|
375
|
+
const tier = getMeta(db, 'tier');
|
|
376
|
+
const size = fs.existsSync(config.dbPath)
|
|
377
|
+
? fs.statSync(config.dbPath).size
|
|
378
|
+
: 0;
|
|
379
|
+
const payload = {
|
|
380
|
+
...stats,
|
|
381
|
+
tier,
|
|
382
|
+
indexedAt: indexedAt ? new Date(Number(indexedAt)).toISOString() : null,
|
|
383
|
+
dbBytes: size,
|
|
384
|
+
};
|
|
385
|
+
if (values.json) {
|
|
386
|
+
process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
|
|
387
|
+
db.close();
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
const style = makeStyle(colorsEnabled());
|
|
391
|
+
process.stdout.write(
|
|
392
|
+
[
|
|
393
|
+
`${style.bold(String(stats.files))} transcripts · ${stats.lineages} lineages · ${stats.forks} forks`,
|
|
394
|
+
`${stats.entries} entries · ${stats.memberships} memberships · ${stats.chunks} chunks`,
|
|
395
|
+
`${stats.projects} projects · ${stats.privateFiles} private · ${stats.orphans} orphaned`,
|
|
396
|
+
`span ${String(stats.oldest).slice(0, 10)} → ${String(stats.newest).slice(0, 10)}`,
|
|
397
|
+
`tier ${tier} · db ${(size / 1e6).toFixed(1)} MB · updated ${relativeTime(payload.indexedAt)}`,
|
|
398
|
+
style.dim(tildify(config.dbPath)),
|
|
399
|
+
].join('\n') + '\n',
|
|
400
|
+
);
|
|
401
|
+
db.close();
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function cmdConfig(argv: string[]): void {
|
|
405
|
+
const {values} = parseArgs({
|
|
406
|
+
args: argv,
|
|
407
|
+
options: {
|
|
408
|
+
init: {type: 'boolean', default: false},
|
|
409
|
+
json: {type: 'boolean', default: false},
|
|
410
|
+
},
|
|
411
|
+
allowPositionals: false,
|
|
412
|
+
});
|
|
413
|
+
const config = loadConfig();
|
|
414
|
+
if (values.init) {
|
|
415
|
+
const written = writeStarterConfig(config);
|
|
416
|
+
process.stdout.write(`config: ${tildify(written)}\n`);
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
if (values.json) {
|
|
420
|
+
process.stdout.write(JSON.stringify(config, null, 2) + '\n');
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
process.stdout.write(
|
|
424
|
+
[
|
|
425
|
+
`config ${tildify(config.configPath)}${config.loaded ? '' : ' (not created yet, using defaults)'}`,
|
|
426
|
+
`index ${tildify(config.dbPath)}`,
|
|
427
|
+
`tier ${config.tier}`,
|
|
428
|
+
`sources ${config.sources.map((s) => `${s.id}:${tildify(s.root)}`).join(', ')}`,
|
|
429
|
+
`ignore ${config.ignore.join(', ') || '(none)'}`,
|
|
430
|
+
`private ${config.private.join(', ') || '(none)'}`,
|
|
431
|
+
].join('\n') + '\n',
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function main(): void {
|
|
436
|
+
const argv = process.argv.slice(2);
|
|
437
|
+
const command = argv[0];
|
|
438
|
+
const rest = argv.slice(1);
|
|
439
|
+
switch (command) {
|
|
440
|
+
case 'index':
|
|
441
|
+
return cmdIndex(rest);
|
|
442
|
+
case 'search':
|
|
443
|
+
case 's':
|
|
444
|
+
return cmdSearch(rest);
|
|
445
|
+
case 'show':
|
|
446
|
+
return cmdShow(rest);
|
|
447
|
+
case 'sql':
|
|
448
|
+
return cmdSql(rest);
|
|
449
|
+
case 'stats':
|
|
450
|
+
return cmdStats(rest);
|
|
451
|
+
case 'config':
|
|
452
|
+
return cmdConfig(rest);
|
|
453
|
+
case '--version':
|
|
454
|
+
case '-v':
|
|
455
|
+
process.stdout.write(version() + '\n');
|
|
456
|
+
return;
|
|
457
|
+
case undefined:
|
|
458
|
+
case 'help':
|
|
459
|
+
case '--help':
|
|
460
|
+
case '-h':
|
|
461
|
+
process.stdout.write(USAGE);
|
|
462
|
+
return;
|
|
463
|
+
default:
|
|
464
|
+
fail(`unknown command "${command}". Try \`recall help\``);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** Entry point. Loaded dynamically by `cli.ts`, see the note there. */
|
|
469
|
+
export function run(): void {
|
|
470
|
+
try {
|
|
471
|
+
main();
|
|
472
|
+
} catch (err) {
|
|
473
|
+
fail((err as Error).message);
|
|
474
|
+
}
|
|
475
|
+
}
|
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
|
+
run();
|
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
|
+
}
|