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/indexer.ts ADDED
@@ -0,0 +1,534 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import type {Config} from './config.js';
4
+ import type {DB} from './db.js';
5
+ import {clearLineage, deleteFile, getMeta, openDb, setMeta} from './db.js';
6
+ import {matcher} from './glob.js';
7
+ import {buildLineage, projectOf} from './lineage.js';
8
+ import type {FileMeta, TranscriptHeader} from './model.js';
9
+ import {
10
+ headFingerprint,
11
+ listTranscripts,
12
+ matchesStoredHead,
13
+ parseTranscript,
14
+ readHeader,
15
+ } from './pi-source.js';
16
+
17
+ export interface IndexOptions {
18
+ config: Config;
19
+ /** Ignore watermarks and rebuild everything. */
20
+ full?: boolean;
21
+ db?: DB;
22
+ onProgress?: (progress: IndexProgress) => void;
23
+ }
24
+
25
+ export interface IndexProgress {
26
+ phase: 'scan' | 'ingest';
27
+ done: number;
28
+ total: number;
29
+ current?: string;
30
+ }
31
+
32
+ export interface IndexStats {
33
+ filesSeen: number;
34
+ filesIgnored: number;
35
+ filesPrivate: number;
36
+ filesIndexed: number;
37
+ filesSkipped: number;
38
+ filesAppended: number;
39
+ filesRemoved: number;
40
+ lineagesRebuilt: number;
41
+ entriesInserted: number;
42
+ entriesShared: number;
43
+ chunksInserted: number;
44
+ malformed: number;
45
+ bytesRead: number;
46
+ durationMs: number;
47
+ fullRebuild: boolean;
48
+ }
49
+
50
+ interface FileRow {
51
+ id: number;
52
+ path: string;
53
+ size: number | null;
54
+ mtime: number | null;
55
+ head_hash: string | null;
56
+ offset: number;
57
+ entry_count: number;
58
+ lineage_id: number | null;
59
+ parent_id: number | null;
60
+ lineage_depth: number;
61
+ orphaned: number;
62
+ private: number;
63
+ session_uuid: string | null;
64
+ cwd: string | null;
65
+ parent_path: string | null;
66
+ }
67
+
68
+ function num(value: unknown): number {
69
+ return typeof value === 'bigint' ? Number(value) : (value as number);
70
+ }
71
+
72
+ /** One open per file: the header and the fingerprint come from the same read. */
73
+ function probe(file: string): {
74
+ header: TranscriptHeader | null;
75
+ headHash: string;
76
+ } {
77
+ return {header: readHeader(file), headHash: headFingerprint(file)};
78
+ }
79
+
80
+ export function index(opts: IndexOptions): IndexStats {
81
+ const started = Date.now();
82
+ const {config} = opts;
83
+ const db = opts.db ?? openDb(config.dbPath);
84
+ const report = opts.onProgress ?? (() => {});
85
+
86
+ const stats: IndexStats = {
87
+ filesSeen: 0,
88
+ filesIgnored: 0,
89
+ filesPrivate: 0,
90
+ filesIndexed: 0,
91
+ filesSkipped: 0,
92
+ filesAppended: 0,
93
+ filesRemoved: 0,
94
+ lineagesRebuilt: 0,
95
+ entriesInserted: 0,
96
+ entriesShared: 0,
97
+ chunksInserted: 0,
98
+ malformed: 0,
99
+ bytesRead: 0,
100
+ durationMs: 0,
101
+ fullRebuild: false,
102
+ };
103
+
104
+ // A tier change alters what text exists at all, so it forces a rebuild.
105
+ const storedTier = getMeta(db, 'tier');
106
+ let full = opts.full === true;
107
+ if (storedTier !== undefined && storedTier !== config.tier) full = true;
108
+ stats.fullRebuild = full;
109
+
110
+ const isIgnored = matcher(config.ignore);
111
+ const isPrivate = matcher(config.private);
112
+
113
+ // Everything already known, loaded up front. This is what lets a warm run
114
+ // avoid opening a single transcript: a file whose (size, mtime) still match
115
+ // its watermark cannot have a different header than the one already stored.
116
+ const existing = new Map<string, FileRow>();
117
+ for (const row of db
118
+ .prepare(
119
+ `SELECT id, path, size, mtime, head_hash, offset, entry_count, lineage_id,
120
+ parent_id, lineage_depth, orphaned, private, session_uuid, cwd, parent_path
121
+ FROM file`,
122
+ )
123
+ .all() as unknown as FileRow[]) {
124
+ existing.set(row.path, row);
125
+ }
126
+
127
+ // ---- scan -------------------------------------------------------------
128
+ const headers = new Map<string, TranscriptHeader>();
129
+ const metas = new Map<string, FileMeta>();
130
+ const discovered: Array<{file: string; source: string}> = [];
131
+ for (const source of config.sources) {
132
+ if (!fs.existsSync(source.root)) continue;
133
+ for (const file of listTranscripts(source.root))
134
+ discovered.push({file, source: source.id});
135
+ }
136
+ let scanned = 0;
137
+ for (const {file, source} of discovered) {
138
+ stats.filesSeen++;
139
+ if (++scanned % 250 === 0)
140
+ report({
141
+ phase: 'scan',
142
+ done: scanned,
143
+ total: discovered.length,
144
+ current: file,
145
+ });
146
+ let st: fs.Stats;
147
+ try {
148
+ st = fs.statSync(file);
149
+ } catch {
150
+ continue;
151
+ }
152
+ const mtime = Math.floor(st.mtimeMs);
153
+ const known = existing.get(file);
154
+ const unchanged =
155
+ !full &&
156
+ known &&
157
+ known.size === st.size &&
158
+ known.mtime === mtime &&
159
+ known.head_hash !== null;
160
+
161
+ let header: TranscriptHeader | null;
162
+ let headHash: string;
163
+ if (unchanged && known) {
164
+ // Reuse what the DB already knows instead of re-opening the file. On a
165
+ // 3,800-file corpus this is the difference between a ~20 ms stat pass and
166
+ // a ~70 ms open-and-read pass, paid on every single query.
167
+ header = {
168
+ sessionUuid: known.session_uuid ?? undefined,
169
+ cwd: known.cwd ?? undefined,
170
+ parentPath: known.parent_path ?? undefined,
171
+ };
172
+ headHash = known.head_hash as string;
173
+ } else {
174
+ const probed = probe(file);
175
+ header = probed.header;
176
+ headHash = probed.headHash;
177
+ }
178
+ if (!header) continue;
179
+
180
+ // Ignore rules match the REAL cwd from the header. The directory name is
181
+ // a lossy mangling of it and must never be used for a filtering decision.
182
+ const target = header.cwd ?? file;
183
+ if (isIgnored(target)) {
184
+ stats.filesIgnored++;
185
+ continue;
186
+ }
187
+ const priv = isPrivate(target);
188
+ if (priv) stats.filesPrivate++;
189
+ headers.set(file, header);
190
+ metas.set(file, {
191
+ path: file,
192
+ source,
193
+ header,
194
+ size: st.size,
195
+ mtime,
196
+ headHash,
197
+ private: priv,
198
+ });
199
+ }
200
+ report({phase: 'scan', done: scanned, total: discovered.length});
201
+
202
+ if (full) {
203
+ db.exec('BEGIN');
204
+ db.prepare('DELETE FROM chunk').run();
205
+ db.prepare('DELETE FROM entry').run();
206
+ db.prepare('DELETE FROM membership').run();
207
+ db.prepare(
208
+ 'UPDATE file SET offset = 0, entry_count = 0, size = NULL, mtime = NULL, head_hash = NULL',
209
+ ).run();
210
+ db.exec('COMMIT');
211
+ }
212
+
213
+ // ---- identity rows ----------------------------------------------------
214
+ const lineage = buildLineage(headers);
215
+
216
+ const upsertFile = db.prepare(`
217
+ INSERT INTO file(path, source, session_uuid, cwd, project, parent_path, private, started)
218
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
219
+ ON CONFLICT(path) DO UPDATE SET
220
+ source = excluded.source,
221
+ session_uuid = excluded.session_uuid,
222
+ cwd = excluded.cwd,
223
+ project = excluded.project,
224
+ parent_path = excluded.parent_path,
225
+ private = excluded.private,
226
+ started = excluded.started
227
+ `);
228
+ const selectFileId = db.prepare('SELECT id FROM file WHERE path = ?');
229
+ const updateLineage = db.prepare(
230
+ 'UPDATE file SET parent_id = ?, lineage_id = ?, lineage_depth = ?, orphaned = ? WHERE id = ?',
231
+ );
232
+
233
+ db.exec('BEGIN');
234
+ const idOf = new Map<string, number>();
235
+ for (const meta of metas.values()) {
236
+ const known = existing.get(meta.path);
237
+ // Identity comes from the header, so for a file we did not re-read it can
238
+ // only change when the CONFIG changed (the private flag). Skipping the
239
+ // no-op upsert keeps a warm run from rewriting 3,000 identical rows.
240
+ if (known && known.private === (meta.private ? 1 : 0)) {
241
+ idOf.set(meta.path, num(known.id));
242
+ continue;
243
+ }
244
+ upsertFile.run(
245
+ meta.path,
246
+ meta.source,
247
+ meta.header.sessionUuid ?? null,
248
+ meta.header.cwd ?? null,
249
+ projectOf(meta.header.cwd),
250
+ meta.header.parentPath ?? null,
251
+ meta.private ? 1 : 0,
252
+ meta.header.started ?? null,
253
+ );
254
+ const row = selectFileId.get(meta.path) as {id: number} | undefined;
255
+ if (row) idOf.set(meta.path, num(row.id));
256
+ }
257
+ for (const [file, info] of lineage) {
258
+ const id = idOf.get(file);
259
+ if (id === undefined) continue;
260
+ const rootId = idOf.get(info.root) ?? id;
261
+ const parentId = info.parent ? (idOf.get(info.parent) ?? null) : null;
262
+ const orphaned = info.orphaned ? 1 : 0;
263
+ const known = existing.get(file);
264
+ // Lineage links depend on OTHER files existing, so they are recomputed on
265
+ // every run, but only written when they actually moved.
266
+ if (
267
+ known &&
268
+ num(known.parent_id ?? 0) === num(parentId ?? 0) &&
269
+ num(known.lineage_id ?? 0) === rootId &&
270
+ num(known.lineage_depth) === info.depth &&
271
+ num(known.orphaned) === orphaned
272
+ ) {
273
+ continue;
274
+ }
275
+ updateLineage.run(parentId, rootId, info.depth, orphaned, id);
276
+ }
277
+ db.exec('COMMIT');
278
+
279
+ // Files that vanished or became ignored.
280
+ if (existing.size !== metas.size) {
281
+ db.exec('BEGIN');
282
+ for (const [knownPath, row] of [...existing]) {
283
+ if (metas.has(knownPath)) continue;
284
+ deleteFile(db, num(row.id));
285
+ existing.delete(knownPath);
286
+ stats.filesRemoved++;
287
+ }
288
+ db.exec('COMMIT');
289
+ }
290
+
291
+ // ---- decide what to do per file ---------------------------------------
292
+ for (const [newPath, meta] of metas) {
293
+ if (existing.has(newPath)) continue;
294
+ const id = idOf.get(newPath);
295
+ if (id === undefined) continue;
296
+ existing.set(newPath, {
297
+ id,
298
+ path: newPath,
299
+ size: null,
300
+ mtime: null,
301
+ head_hash: null,
302
+ offset: 0,
303
+ entry_count: 0,
304
+ lineage_id: null,
305
+ parent_id: null,
306
+ lineage_depth: 0,
307
+ orphaned: 0,
308
+ private: meta.private ? 1 : 0,
309
+ session_uuid: meta.header.sessionUuid ?? null,
310
+ cwd: meta.header.cwd ?? null,
311
+ parent_path: meta.header.parentPath ?? null,
312
+ });
313
+ }
314
+
315
+ type Action = 'clean' | 'append' | 'dirty';
316
+ const action = new Map<string, Action>();
317
+ for (const meta of metas.values()) {
318
+ const row = existing.get(meta.path);
319
+ if (!row || row.size === null || row.mtime === null) {
320
+ action.set(meta.path, 'dirty');
321
+ continue;
322
+ }
323
+ if (
324
+ row.head_hash === meta.headHash &&
325
+ row.size === meta.size &&
326
+ row.mtime === meta.mtime
327
+ ) {
328
+ action.set(meta.path, 'clean');
329
+ } else if (
330
+ meta.size > (row.size ?? 0) &&
331
+ matchesStoredHead(meta.path, row.head_hash)
332
+ ) {
333
+ // Grew, and everything we already read is byte-identical: append-only, so
334
+ // resume from the stored offset instead of re-reading the whole file.
335
+ action.set(meta.path, 'append');
336
+ } else {
337
+ action.set(meta.path, 'dirty');
338
+ }
339
+ }
340
+
341
+ // Re-ingestion happens per LINEAGE: a fork's membership rows point at entries
342
+ // owned by an ancestor, so one file can never be rebuilt alone.
343
+ const byLineage = new Map<string, string[]>();
344
+ for (const [file, info] of lineage) {
345
+ const list = byLineage.get(info.root);
346
+ if (list) list.push(file);
347
+ else byLineage.set(info.root, [file]);
348
+ }
349
+
350
+ const work: Array<{lineageRoot: string; files: string[]; rebuild: boolean}> =
351
+ [];
352
+ for (const [root, group] of byLineage) {
353
+ const rebuild = group.some((f) => action.get(f) === 'dirty');
354
+ const touched = rebuild || group.some((f) => action.get(f) === 'append');
355
+ if (!touched) {
356
+ stats.filesSkipped += group.length;
357
+ continue;
358
+ }
359
+ if (rebuild) stats.lineagesRebuilt++;
360
+ const ordered = group.slice().sort((a, b) => {
361
+ const da = lineage.get(a)?.depth ?? 0;
362
+ const db_ = lineage.get(b)?.depth ?? 0;
363
+ if (da !== db_) return da - db_;
364
+ return (metas.get(a)?.header.started ?? '').localeCompare(
365
+ metas.get(b)?.header.started ?? '',
366
+ );
367
+ });
368
+ work.push({lineageRoot: root, files: ordered, rebuild});
369
+ }
370
+
371
+ // ---- ingest -----------------------------------------------------------
372
+ const insertEntry = db.prepare(`
373
+ INSERT OR IGNORE INTO entry(lineage_id, entry_key, role, tool, ts, first_file, byte_offset, byte_len)
374
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
375
+ `);
376
+ const findEntry = db.prepare(
377
+ 'SELECT id FROM entry WHERE lineage_id = ? AND entry_key = ?',
378
+ );
379
+ const insertChunk = db.prepare(
380
+ 'INSERT INTO chunk(entry_id, part, kind, text) VALUES (?, ?, ?, ?)',
381
+ );
382
+ const insertMembership = db.prepare(
383
+ 'INSERT OR IGNORE INTO membership(entry_id, file_id, seq) VALUES (?, ?, ?)',
384
+ );
385
+ const updateFileSummary = db.prepare(
386
+ 'UPDATE file SET name = COALESCE(?, name), last_activity = ?, entry_count = ? WHERE id = ?',
387
+ );
388
+ const updateWatermark = db.prepare(
389
+ 'UPDATE file SET size = ?, mtime = ?, head_hash = ?, offset = ?, indexed_at = ? WHERE id = ?',
390
+ );
391
+
392
+ const extractOpts = {
393
+ tier: config.tier,
394
+ toolResultHeadBytes: config.toolResultHeadBytes,
395
+ toolArgsHeadBytes: config.toolArgsHeadBytes,
396
+ };
397
+
398
+ let done = 0;
399
+ for (const unit of work) {
400
+ db.exec('BEGIN');
401
+ try {
402
+ const lineageId = idOf.get(unit.lineageRoot);
403
+ if (unit.rebuild && lineageId !== undefined) clearLineage(db, lineageId);
404
+
405
+ for (const file of unit.files) {
406
+ const meta = metas.get(file);
407
+ const fileId = idOf.get(file);
408
+ if (!meta || fileId === undefined) continue;
409
+ const act = unit.rebuild ? 'dirty' : action.get(file);
410
+ if (act === 'clean') continue;
411
+
412
+ const row = existing.get(file);
413
+ const fromOffset =
414
+ unit.rebuild || act === 'dirty' ? 0 : (row?.offset ?? 0);
415
+ let seq = unit.rebuild || act === 'dirty' ? 0 : (row?.entry_count ?? 0);
416
+
417
+ const parsed = parseTranscript(file, fromOffset, extractOpts);
418
+ stats.malformed += parsed.malformed;
419
+ stats.bytesRead += parsed.endOffset - fromOffset;
420
+
421
+ const lid = lineageId ?? fileId;
422
+ let lastActivity: string | null = null;
423
+ let sessionName: string | null = null;
424
+
425
+ for (const entry of parsed.entries) {
426
+ const result = insertEntry.run(
427
+ lid,
428
+ entry.entryKey,
429
+ entry.role,
430
+ entry.tool,
431
+ entry.ts,
432
+ fileId,
433
+ entry.byteOffset,
434
+ entry.byteLength,
435
+ );
436
+ let entryId: number;
437
+ if (num(result.changes) > 0) {
438
+ entryId = num(result.lastInsertRowid);
439
+ stats.entriesInserted++;
440
+ let part = 0;
441
+ for (const chunk of entry.chunks) {
442
+ const text = chunk.text.trim();
443
+ if (!text) continue;
444
+ insertChunk.run(entryId, part++, chunk.kind, text);
445
+ stats.chunksInserted++;
446
+ }
447
+ } else {
448
+ // Already present: this entry was copied here by a fork. Store the
449
+ // membership edge only, never a second copy of the text.
450
+ const found = findEntry.get(lid, entry.entryKey) as
451
+ {id: number} | undefined;
452
+ if (!found) continue;
453
+ entryId = num(found.id);
454
+ stats.entriesShared++;
455
+ }
456
+ insertMembership.run(entryId, fileId, seq++);
457
+ if (entry.ts && (!lastActivity || entry.ts > lastActivity))
458
+ lastActivity = entry.ts;
459
+ if (entry.sessionName) sessionName = entry.sessionName;
460
+ }
461
+
462
+ updateFileSummary.run(
463
+ sessionName,
464
+ lastActivity ?? meta.header.started ?? null,
465
+ seq,
466
+ fileId,
467
+ );
468
+ // Watermark LAST: if anything above fails, the next run redoes this file
469
+ // rather than skipping over a gap.
470
+ updateWatermark.run(
471
+ meta.size,
472
+ meta.mtime,
473
+ meta.headHash,
474
+ parsed.endOffset,
475
+ Date.now(),
476
+ fileId,
477
+ );
478
+ if (act === 'append') stats.filesAppended++;
479
+ stats.filesIndexed++;
480
+ }
481
+ db.exec('COMMIT');
482
+ } catch (err) {
483
+ db.exec('ROLLBACK');
484
+ throw err;
485
+ }
486
+ done += unit.files.length;
487
+ report({
488
+ phase: 'ingest',
489
+ done,
490
+ total: work.reduce((n, u) => n + u.files.length, 0),
491
+ current: unit.lineageRoot,
492
+ });
493
+ }
494
+
495
+ setMeta(db, 'tier', config.tier);
496
+ setMeta(db, 'indexed_at', String(Date.now()));
497
+ setMeta(db, 'roots', JSON.stringify(config.sources.map((s) => s.root)));
498
+
499
+ stats.durationMs = Date.now() - started;
500
+ if (!opts.db) db.close();
501
+ return stats;
502
+ }
503
+
504
+ /** Absolute path of the index for a config, for messages and tooling. */
505
+ export function describeIndex(config: Config): string {
506
+ return path.resolve(config.dbPath);
507
+ }
508
+
509
+ export interface SyncResult {
510
+ /** False when a recent enough sync meant there was nothing to do. */
511
+ ran: boolean;
512
+ stats?: IndexStats;
513
+ }
514
+
515
+ /**
516
+ * Catch the index up, unless it was already synced within `ttlMs`.
517
+ *
518
+ * A warm catch-up over a 3,800-file corpus costs ~120 ms, which is cheap enough
519
+ * to pay before a query but NOT cheap enough to pay five times in one agent
520
+ * turn. The TTL is what makes "always fresh" affordable for callers that query
521
+ * in a burst.
522
+ */
523
+ export function syncIfStale(config: Config, ttlMs = 15_000): SyncResult {
524
+ if (!fs.existsSync(config.dbPath))
525
+ throw new Error(`no index at ${config.dbPath}`);
526
+ const db = openDb(config.dbPath);
527
+ try {
528
+ const last = Number(getMeta(db, 'indexed_at') ?? 0);
529
+ if (Number.isFinite(last) && Date.now() - last < ttlMs) return {ran: false};
530
+ return {ran: true, stats: index({config, db})};
531
+ } finally {
532
+ db.close();
533
+ }
534
+ }
package/src/lineage.ts ADDED
@@ -0,0 +1,111 @@
1
+ import path from 'node:path';
2
+ import type {TranscriptHeader} from './model.js';
3
+
4
+ export interface LineageInfo {
5
+ /** Resolved parent transcript path, or null when this file is a root. */
6
+ parent: string | null;
7
+ /** Path of the lineage root (may be the file itself). */
8
+ root: string;
9
+ depth: number;
10
+ /**
11
+ * True when the header named a parent we could not find. The file is then
12
+ * treated as its own root, but the flag is kept so search can say so instead
13
+ * of silently pretending the history never existed.
14
+ */
15
+ orphaned: boolean;
16
+ }
17
+
18
+ /**
19
+ * Resolve fork edges into a forest.
20
+ *
21
+ * `parentSession` is an ABSOLUTE path, so it breaks the moment a sessions
22
+ * directory is moved or a home directory is renamed. Measured on a real corpus:
23
+ * 1 of 133 parents was already missing. So resolution is tried three ways
24
+ * before giving up: exact path, path relocated under the configured roots, and
25
+ * finally the session UUID embedded in the filename.
26
+ */
27
+ export function buildLineage(
28
+ headers: Map<string, TranscriptHeader>,
29
+ ): Map<string, LineageInfo> {
30
+ const byBasename = new Map<string, string[]>();
31
+ const byUuid = new Map<string, string>();
32
+ for (const [file, header] of headers) {
33
+ const base = path.basename(file);
34
+ const list = byBasename.get(base);
35
+ if (list) list.push(file);
36
+ else byBasename.set(base, [file]);
37
+ if (header.sessionUuid && !byUuid.has(header.sessionUuid))
38
+ byUuid.set(header.sessionUuid, file);
39
+ }
40
+
41
+ const resolveParent = (declared: string): string | null => {
42
+ const abs = path.resolve(declared);
43
+ if (headers.has(abs)) return abs;
44
+ const candidates = byBasename.get(path.basename(abs));
45
+ if (candidates && candidates.length === 1) return candidates[0];
46
+ const uuid = uuidFromFilename(abs);
47
+ if (uuid) {
48
+ const byId = byUuid.get(uuid);
49
+ if (byId) return byId;
50
+ }
51
+ return null;
52
+ };
53
+
54
+ const parents = new Map<string, {parent: string | null; declared: boolean}>();
55
+ for (const [file, header] of headers) {
56
+ if (!header.parentPath) {
57
+ parents.set(file, {parent: null, declared: false});
58
+ continue;
59
+ }
60
+ parents.set(file, {
61
+ parent: resolveParent(header.parentPath),
62
+ declared: true,
63
+ });
64
+ }
65
+
66
+ const out = new Map<string, LineageInfo>();
67
+ const resolve = (file: string, seen: Set<string>): LineageInfo => {
68
+ const cached = out.get(file);
69
+ if (cached) return cached;
70
+ const info = parents.get(file) ?? {parent: null, declared: false};
71
+ const orphaned = info.declared && info.parent === null;
72
+ if (!info.parent || seen.has(info.parent)) {
73
+ // No parent, an unresolvable parent, or a cycle: this file is a root.
74
+ const self: LineageInfo = {parent: null, root: file, depth: 0, orphaned};
75
+ out.set(file, self);
76
+ return self;
77
+ }
78
+ seen.add(file);
79
+ const parentInfo = resolve(info.parent, seen);
80
+ const self: LineageInfo = {
81
+ parent: info.parent,
82
+ root: parentInfo.root,
83
+ depth: parentInfo.depth + 1,
84
+ orphaned,
85
+ };
86
+ out.set(file, self);
87
+ return self;
88
+ };
89
+
90
+ for (const file of headers.keys()) resolve(file, new Set());
91
+ return out;
92
+ }
93
+
94
+ const UUID_RE =
95
+ /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i;
96
+
97
+ export function uuidFromFilename(file: string): string | null {
98
+ const m = UUID_RE.exec(path.basename(file));
99
+ return m ? m[1].toLowerCase() : null;
100
+ }
101
+
102
+ /**
103
+ * Human label for a working directory: the last path segment, which is the
104
+ * project name in every layout anyone actually uses.
105
+ */
106
+ export function projectOf(cwd: string | undefined): string | null {
107
+ if (!cwd) return null;
108
+ const trimmed = cwd.replace(/\/+$/, '');
109
+ const base = path.basename(trimmed);
110
+ return base || null;
111
+ }