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.
Files changed (71) hide show
  1. package/LICENSE +661 -0
  2. package/dist/cli-main.d.ts +3 -0
  3. package/dist/cli-main.d.ts.map +1 -0
  4. package/dist/cli-main.js +428 -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 +130 -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 +11 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +11 -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 +99 -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/search.d.ts +93 -0
  51. package/dist/search.d.ts.map +1 -0
  52. package/dist/search.js +285 -0
  53. package/dist/search.js.map +1 -0
  54. package/dist/silence-sqlite-warning.d.ts +2 -0
  55. package/dist/silence-sqlite-warning.d.ts.map +1 -0
  56. package/dist/silence-sqlite-warning.js +9 -0
  57. package/dist/silence-sqlite-warning.js.map +1 -0
  58. package/package.json +57 -2
  59. package/src/cli-main.ts +475 -0
  60. package/src/cli.ts +17 -0
  61. package/src/config.ts +130 -0
  62. package/src/db.ts +213 -0
  63. package/src/format.ts +173 -0
  64. package/src/glob.ts +79 -0
  65. package/src/index.ts +10 -0
  66. package/src/indexer.ts +534 -0
  67. package/src/lineage.ts +111 -0
  68. package/src/model.ts +135 -0
  69. package/src/pi-source.ts +328 -0
  70. package/src/quiet.ts +22 -0
  71. package/src/search.ts +462 -0
@@ -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
+ }