@adhdev/daemon-core 0.9.82-rc.443 → 0.9.82-rc.445

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.
@@ -12,11 +12,35 @@
12
12
  * { source: 'USER_EXPLICIT'|'MODEL', type: string, content: string, status: 'DONE'|..., created_at: number }
13
13
  *
14
14
  * 3. ~/.gemini/antigravity-cli/conversations/<uuid>.pb
15
- * Protobuf binary — schema not publicly documented. Adapter extracts
15
+ * Legacy protobuf binary — schema not publicly documented. Adapter extracts
16
16
  * printable UTF-8 text runs as best-effort content (no proto library needed).
17
17
  *
18
+ * 4. ~/.gemini/antigravity-cli/conversations/<uuid>.db ← current format
19
+ * Per-session SQLite database. Recent antigravity migrated conversation
20
+ * storage from .pb (+ brain/*.jsonl) to a per-session SQLite db. The
21
+ * schema is a trajectory of `steps`, NOT a simple messages(role,content)
22
+ * table:
23
+ * steps(idx INTEGER PK, step_type INTEGER, status INTEGER,
24
+ * step_payload BLOB [protobuf], ...)
25
+ * Each `step_payload` is a protobuf message. Empirically (introspected
26
+ * from real stores):
27
+ * - step_type 14 → a USER turn. The prompt text is the largest
28
+ * contiguous UTF-8 run inside the payload (field 19 subtree).
29
+ * - step_type 15 → a MODEL/assistant turn. The assistant's final
30
+ * natural-language answer lives at payload field 20 → field 1
31
+ * (identical to field 8). Field 20 → field 3 is the internal
32
+ * reasoning summary and is intentionally NOT surfaced.
33
+ * - other step types are tool calls / ephemeral system context.
34
+ * We read the blobs with a tiny dependency-free protobuf field walker
35
+ * (no proto schema / codegen needed) and map the two message step types.
36
+ * Because the daemon does NOT read this db, native history previously
37
+ * returned 0 rows for these sessions and read_chat fell back to the
38
+ * pty parser (which only echoes the user's own input) — assistant
39
+ * answers appeared lost even though they were on disk.
40
+ *
18
41
  * This adapter provides:
19
- * - Full coverage when a brain transcript exists (authoritative source).
42
+ * - Full coverage from a per-session .db (current format) — preferred.
43
+ * - Full coverage when a brain transcript exists (legacy authoritative source).
20
44
  * - Partial coverage (user prompts only) from history.jsonl as fallback.
21
45
  * - Best-effort raw-string extraction from .pb files when no other source exists.
22
46
  *
@@ -24,6 +48,7 @@
24
48
  * ~/.gemini/antigravity-cli/history.jsonl
25
49
  * ~/.gemini/antigravity-cli/brain/{uuid}/.system_generated/logs/transcript*.jsonl
26
50
  * ~/.gemini/antigravity-cli/conversations/{uuid}.pb
51
+ * ~/.gemini/antigravity-cli/conversations/{uuid}.db
27
52
  *
28
53
  * OSS code (AGPL-3.0). Must not import from packages/ (proprietary).
29
54
  */
@@ -31,6 +56,8 @@
31
56
  import * as fs from 'fs';
32
57
  import * as path from 'path';
33
58
  import * as os from 'os';
59
+ import { loadBetterSqlite3 } from '../../system/load-better-sqlite3.js';
60
+ import { LOG } from '../../logging/logger.js';
34
61
 
35
62
  // ─── Types ─────────────────────────────────────────────────────────────────
36
63
 
@@ -382,6 +409,250 @@ function parsePbFile(
382
409
  ];
383
410
  }
384
411
 
412
+ // ─── SQLite (.db) conversation reader ────────────────────────────────────────
413
+ //
414
+ // Recent antigravity stores each conversation in a per-session SQLite db at
415
+ // conversations/<uuid>.db. See the file header for the schema. We decode the
416
+ // protobuf `step_payload` blobs with a minimal, dependency-free field walker —
417
+ // we only need two leaf strings (user prompt / assistant answer), so a full
418
+ // proto schema is unnecessary.
419
+
420
+ /** Antigravity step_type values that map to a chat message. */
421
+ const AGY_STEP_TYPE_USER = 14;
422
+ const AGY_STEP_TYPE_MODEL = 15;
423
+
424
+ interface ProtoField {
425
+ field: number;
426
+ wireType: number;
427
+ /** For wireType 2 (length-delimited): the raw bytes. */
428
+ bytes?: Buffer;
429
+ /** For wireType 0 (varint): the value. */
430
+ varint?: number;
431
+ }
432
+
433
+ /**
434
+ * Read a base-128 varint starting at `offset`. Returns [value, nextOffset].
435
+ * Values are read as JS numbers (safe: the fields we consume are small).
436
+ */
437
+ function readVarint(buf: Buffer, offset: number): [number, number] {
438
+ let result = 0;
439
+ let shift = 0;
440
+ let i = offset;
441
+ while (i < buf.length) {
442
+ const byte = buf[i];
443
+ i += 1;
444
+ result += (byte & 0x7f) * Math.pow(2, shift);
445
+ if ((byte & 0x80) === 0) return [result, i];
446
+ shift += 7;
447
+ if (shift > 63) break; // malformed / oversized
448
+ }
449
+ return [result, i];
450
+ }
451
+
452
+ /**
453
+ * Decode the top-level fields of a protobuf message. Best-effort: stops on the
454
+ * first malformed byte rather than throwing, so a partially-corrupt blob still
455
+ * yields the fields decoded so far.
456
+ */
457
+ function decodeProtoFields(buf: Buffer): ProtoField[] {
458
+ const fields: ProtoField[] = [];
459
+ let i = 0;
460
+ while (i < buf.length) {
461
+ const [key, afterKey] = readVarint(buf, i);
462
+ if (afterKey === i) break;
463
+ i = afterKey;
464
+ const field = Math.floor(key / 8);
465
+ const wireType = key & 7;
466
+ if (field <= 0) break;
467
+ if (wireType === 0) {
468
+ const [value, next] = readVarint(buf, i);
469
+ if (next === i) break;
470
+ i = next;
471
+ fields.push({ field, wireType, varint: value });
472
+ } else if (wireType === 2) {
473
+ const [len, afterLen] = readVarint(buf, i);
474
+ i = afterLen;
475
+ if (len < 0 || i + len > buf.length) break;
476
+ fields.push({ field, wireType, bytes: buf.subarray(i, i + len) });
477
+ i += len;
478
+ } else if (wireType === 5) {
479
+ i += 4;
480
+ } else if (wireType === 1) {
481
+ i += 8;
482
+ } else {
483
+ break; // wireType 3/4 (groups) — unused by antigravity payloads
484
+ }
485
+ }
486
+ return fields;
487
+ }
488
+
489
+ /** Return the bytes of the first length-delimited field with number `field`. */
490
+ function firstLenField(buf: Buffer, field: number): Buffer | null {
491
+ for (const f of decodeProtoFields(buf)) {
492
+ if (f.field === field && f.wireType === 2 && f.bytes) return f.bytes;
493
+ }
494
+ return null;
495
+ }
496
+
497
+ /** Heuristic: is this buffer (mostly) printable UTF-8 text? */
498
+ function looksLikeText(buf: Buffer): boolean {
499
+ if (buf.length === 0) return false;
500
+ let printable = 0;
501
+ for (let i = 0; i < buf.length; i++) {
502
+ const b = buf[i];
503
+ // ASCII printable + common whitespace, or any high byte (UTF-8 lead/cont).
504
+ if ((b >= 32 && b <= 126) || b === 9 || b === 10 || b === 13 || b >= 0x80) printable += 1;
505
+ }
506
+ return printable / buf.length >= 0.9;
507
+ }
508
+
509
+ /**
510
+ * Antigravity prefixes some assistant answers with a literal `MARKER_V1`
511
+ * sentinel followed by a blank line. Strip it so the user-visible bubble starts
512
+ * at the real text.
513
+ */
514
+ function stripAnswerMarker(text: string): string {
515
+ return text.replace(/^\s*MARKER_V1\s*/, '');
516
+ }
517
+
518
+ /**
519
+ * Extract the assistant's final natural-language answer from a step_type 15
520
+ * payload: field 20 → field 1 (identical to field 8). Field 20 → field 3 is the
521
+ * private reasoning summary and is deliberately skipped. Returns '' if absent
522
+ * (e.g. a pure reasoning / tool-only model step, which carries no user-visible
523
+ * answer text).
524
+ */
525
+ function extractModelAnswer(payload: Buffer): string {
526
+ const inner = firstLenField(payload, 20);
527
+ if (!inner) return '';
528
+ const answer = firstLenField(inner, 1) ?? firstLenField(inner, 8);
529
+ if (!answer || !looksLikeText(answer)) return '';
530
+ return stripAnswerMarker(answer.toString('utf-8')).trim();
531
+ }
532
+
533
+ /**
534
+ * Extract the user prompt from a step_type 14 payload: field 19 → field 2 (the
535
+ * clean prompt text; field 19 → field 3 wraps the same string with a leading
536
+ * newline and is used only as a fallback). The USER_REQUEST XML wrapper, when
537
+ * present, is unwrapped to match the brain-transcript reader's output.
538
+ */
539
+ function extractUserPrompt(payload: Buffer): string {
540
+ const inner = firstLenField(payload, 19);
541
+ if (!inner) return '';
542
+ const raw = firstLenField(inner, 2) ?? firstLenField(inner, 3);
543
+ if (!raw || !looksLikeText(raw)) return '';
544
+ const text = raw.toString('utf-8').trim();
545
+ if (!text) return '';
546
+ return extractUserRequestContent(text);
547
+ }
548
+
549
+ interface AgyDbStepRow {
550
+ idx: number;
551
+ step_type: number;
552
+ step_payload: Buffer | null;
553
+ }
554
+
555
+ /**
556
+ * Parse a per-session conversations/<uuid>.db (SQLite) into NativeHistoryMessages.
557
+ * Returns null when the db is unreadable, empty, or yields no chat messages.
558
+ */
559
+ function parseConversationDb(
560
+ filePath: string,
561
+ sessionId: string,
562
+ workspace?: string,
563
+ ): NativeHistoryMessage[] | null {
564
+ let db: any;
565
+ try {
566
+ const Database = loadBetterSqlite3();
567
+ db = new Database(filePath, { readonly: true, fileMustExist: true });
568
+ } catch (err) {
569
+ // better-sqlite3 unavailable (ABI mismatch / not installed in this bundle)
570
+ // or the db handle failed to open. This is the silent-degrade that made a
571
+ // live read_chat return 0 assistant messages with no trace — the answers
572
+ // are on disk but unreadable. Log it (once-ish, at WARN) so the failure is
573
+ // greppable in daemon logs and distinguishable from "no db file". The
574
+ // reader still degrades gracefully (returns null → dispatcher falls back to
575
+ // brain/.pb), but the operator now knows WHY the .db path produced nothing.
576
+ LOG.warn(
577
+ 'NativeHistory',
578
+ `antigravity .db reader could not open ${path.basename(filePath)}: ${err instanceof Error ? err.message : String(err)} (better-sqlite3 load/open failed — assistant answers in this .db will not surface)`,
579
+ );
580
+ return null;
581
+ }
582
+
583
+ let rows: AgyDbStepRow[];
584
+ try {
585
+ rows = db
586
+ .prepare(
587
+ `SELECT idx, step_type, step_payload
588
+ FROM steps
589
+ WHERE step_type IN (${AGY_STEP_TYPE_USER}, ${AGY_STEP_TYPE_MODEL})
590
+ ORDER BY idx ASC`,
591
+ )
592
+ .all() as AgyDbStepRow[];
593
+ } catch (err) {
594
+ // `steps` table absent / unexpected schema — a real (but recoverable)
595
+ // shape mismatch, not the binding-missing case above. Log at debug so a
596
+ // schema drift in a future antigravity release is diagnosable without
597
+ // spamming logs for every legacy db.
598
+ LOG.debug(
599
+ 'NativeHistory',
600
+ `antigravity .db ${path.basename(filePath)} has no readable steps table: ${err instanceof Error ? err.message : String(err)}`,
601
+ );
602
+ return null;
603
+ } finally {
604
+ try { db.close(); } catch { /* ignore */ }
605
+ }
606
+
607
+ if (!Array.isArray(rows) || rows.length === 0) return null;
608
+
609
+ const normalizedWorkspace = typeof workspace === 'string' ? workspace.trim() : '';
610
+ const baseTs = statMtimeMs(filePath) || Date.now();
611
+ const messages: NativeHistoryMessage[] = [];
612
+
613
+ for (const row of rows) {
614
+ const payload = row.step_payload;
615
+ if (!payload || !Buffer.isBuffer(payload) || payload.length === 0) continue;
616
+
617
+ // Steps are ordered by idx; the db carries no per-step timestamp we can
618
+ // trust as ms, so synthesize a monotonically increasing receivedAt that
619
+ // preserves order (idx-derived) around the file mtime.
620
+ const receivedAt = baseTs + messages.length;
621
+
622
+ if (row.step_type === AGY_STEP_TYPE_USER) {
623
+ const content = extractUserPrompt(payload);
624
+ if (!content) continue;
625
+ const msg: NativeHistoryMessage = {
626
+ ts: new Date(receivedAt).toISOString(),
627
+ receivedAt,
628
+ role: 'user',
629
+ content,
630
+ kind: 'standard',
631
+ agent: 'antigravity-cli',
632
+ historySessionId: sessionId,
633
+ };
634
+ if (normalizedWorkspace) msg.workspace = normalizedWorkspace;
635
+ messages.push(msg);
636
+ } else if (row.step_type === AGY_STEP_TYPE_MODEL) {
637
+ const content = extractModelAnswer(payload);
638
+ if (!content) continue; // reasoning-only / tool-only model step — no answer text
639
+ const msg: NativeHistoryMessage = {
640
+ ts: new Date(receivedAt).toISOString(),
641
+ receivedAt,
642
+ role: 'assistant',
643
+ content,
644
+ kind: 'standard',
645
+ agent: 'antigravity-cli',
646
+ historySessionId: sessionId,
647
+ };
648
+ if (normalizedWorkspace) msg.workspace = normalizedWorkspace;
649
+ messages.push(msg);
650
+ }
651
+ }
652
+
653
+ return messages.length > 0 ? messages : null;
654
+ }
655
+
385
656
  // ─── Public API ─────────────────────────────────────────────────────────────
386
657
 
387
658
  /**
@@ -390,6 +661,7 @@ function parsePbFile(
390
661
  * `sessionPath` is the absolute path to one of:
391
662
  * - A brain transcript JSONL: ~/.gemini/antigravity-cli/brain/<uuid>/.system_generated/logs/transcript*.jsonl
392
663
  * - The shared history.jsonl: ~/.gemini/antigravity-cli/history.jsonl
664
+ * - A conversation SQLite db: ~/.gemini/antigravity-cli/conversations/<uuid>.db
393
665
  * - A conversation protobuf: ~/.gemini/antigravity-cli/conversations/<uuid>.pb
394
666
  *
395
667
  * The session UUID is inferred from the directory name (brain), filename (pb), or
@@ -430,7 +702,26 @@ export function readSession(
430
702
  };
431
703
  }
432
704
 
433
- // ── Case 2: .pb conversation file ───────────────────────────────────────
705
+ // ── Case 2a: .db conversation file (current per-session SQLite format) ───
706
+ if (sessionPath.endsWith('.db')) {
707
+ const dbSessionId = sessionId || path.basename(sessionPath, '.db');
708
+ if (!isUuidLike(dbSessionId)) return null;
709
+
710
+ const messages = parseConversationDb(sessionPath, dbSessionId, workspace);
711
+ if (!messages || messages.length === 0) return null;
712
+
713
+ return {
714
+ messages,
715
+ providerSessionId: dbSessionId,
716
+ source: 'provider-native',
717
+ sourcePath: sessionPath,
718
+ sourceMtimeMs,
719
+ nativeHistoryCoverage: 'full',
720
+ workspace,
721
+ };
722
+ }
723
+
724
+ // ── Case 2b: .pb conversation file (legacy protobuf) ─────────────────────
434
725
  if (sessionPath.endsWith('.pb')) {
435
726
  const pbSessionId = sessionId || path.basename(sessionPath, '.pb');
436
727
  if (!isUuidLike(pbSessionId)) return null;
@@ -607,34 +898,85 @@ export async function listSessions(_watchPath: string): Promise<NativeHistorySes
607
898
  seen.add(sessionId);
608
899
  }
609
900
 
610
- // ── Step 3: .pb files without any other source ─────────────────────────
901
+ // ── Step 3: conversations/<uuid>.db and .pb without any other source ────
902
+ //
903
+ // Current antigravity writes per-session SQLite dbs; older sessions kept a
904
+ // .pb protobuf. Both can coexist in conversations/. Discover both, but when
905
+ // the same uuid has a .db, prefer it (full coverage) over the .pb (best
906
+ // effort). brain/history sources already in `seen` still win over either.
611
907
  const convRoot = conversationsRoot();
612
908
  if (fs.existsSync(convRoot)) {
613
909
  let entries: fs.Dirent[] = [];
614
910
  try { entries = fs.readdirSync(convRoot, { withFileTypes: true }); } catch { /* ignore */ }
615
911
 
912
+ // Group by uuid so a .db supersedes a sibling .pb of the same session.
913
+ const byUuid = new Map<string, { db?: string; pb?: string }>();
616
914
  for (const entry of entries) {
617
- if (!entry.isFile() || !/^[0-9a-f-]+\.pb$/i.test(entry.name)) continue;
618
- const pbSessionId = entry.name.replace(/\.pb$/, '');
619
- if (!isUuidLike(pbSessionId) || seen.has(pbSessionId)) continue;
620
-
621
- const pbPath = path.join(convRoot, entry.name);
622
- const pbMtime = statMtimeMs(pbPath);
623
-
624
- results.push({
625
- historySessionId: pbSessionId,
626
- sessionId: pbSessionId,
627
- sourcePath: pbPath,
628
- sourceMtimeMs: pbMtime,
629
- messageCount: 0,
630
- firstMessageAt: pbMtime,
631
- lastMessageAt: pbMtime,
632
- agent: 'antigravity-cli',
633
- source: 'provider-native',
634
- nativeHistoryCoverage: 'best-effort',
635
- partialReason: 'antigravity_cli_pb_raw_text_extraction',
636
- });
637
- seen.add(pbSessionId);
915
+ if (!entry.isFile()) continue;
916
+ const dbMatch = /^([0-9a-f-]+)\.db$/i.exec(entry.name);
917
+ const pbMatch = /^([0-9a-f-]+)\.pb$/i.exec(entry.name);
918
+ if (dbMatch && isUuidLike(dbMatch[1])) {
919
+ const g = byUuid.get(dbMatch[1]) ?? {};
920
+ g.db = path.join(convRoot, entry.name);
921
+ byUuid.set(dbMatch[1], g);
922
+ } else if (pbMatch && isUuidLike(pbMatch[1])) {
923
+ const g = byUuid.get(pbMatch[1]) ?? {};
924
+ g.pb = path.join(convRoot, entry.name);
925
+ byUuid.set(pbMatch[1], g);
926
+ }
927
+ }
928
+
929
+ for (const [uuid, files] of byUuid.entries()) {
930
+ if (seen.has(uuid)) continue;
931
+
932
+ if (files.db) {
933
+ // Parse the db so listSessions reports accurate counts/preview and the
934
+ // session surfaces with full coverage (assistant answers included).
935
+ const workspace = workspaceBySession.get(uuid);
936
+ const messages = parseConversationDb(files.db, uuid, workspace);
937
+ const dbMtime = statMtimeMs(files.db);
938
+ if (messages && messages.length > 0) {
939
+ const lastMsg = messages[messages.length - 1];
940
+ const firstMsg = messages[0];
941
+ results.push({
942
+ historySessionId: uuid,
943
+ sessionId: uuid,
944
+ sourcePath: files.db,
945
+ sourceMtimeMs: dbMtime,
946
+ messageCount: messages.length,
947
+ firstMessageAt: firstMsg.receivedAt || dbMtime,
948
+ lastMessageAt: lastMsg.receivedAt || dbMtime,
949
+ sessionTitle: lastMsg.content,
950
+ preview: lastMsg.content,
951
+ workspace,
952
+ agent: 'antigravity-cli',
953
+ source: 'provider-native',
954
+ nativeHistoryCoverage: 'full',
955
+ });
956
+ seen.add(uuid);
957
+ continue;
958
+ }
959
+ // db unreadable/empty (e.g. sqlite binding unavailable) → fall through
960
+ // to the .pb best-effort entry below if one exists.
961
+ }
962
+
963
+ if (files.pb) {
964
+ const pbMtime = statMtimeMs(files.pb);
965
+ results.push({
966
+ historySessionId: uuid,
967
+ sessionId: uuid,
968
+ sourcePath: files.pb,
969
+ sourceMtimeMs: pbMtime,
970
+ messageCount: 0,
971
+ firstMessageAt: pbMtime,
972
+ lastMessageAt: pbMtime,
973
+ agent: 'antigravity-cli',
974
+ source: 'provider-native',
975
+ nativeHistoryCoverage: 'best-effort',
976
+ partialReason: 'antigravity_cli_pb_raw_text_extraction',
977
+ });
978
+ seen.add(uuid);
979
+ }
638
980
  }
639
981
  }
640
982
 
@@ -97,7 +97,7 @@ function resolveSourcePath(reader: ReaderId, workspace: string, sessionId: strin
97
97
  switch (reader) {
98
98
  case 'claude-cli': return resolveClaudePath(workspace, sessionId);
99
99
  case 'codex-cli': return resolveCodexPath(workspace, sessionId, sessionStartedAtMs);
100
- case 'antigravity-cli': return resolveAntigravityPath(workspace);
100
+ case 'antigravity-cli': return resolveAntigravityPath(workspace, sessionId);
101
101
  case 'hermes-cli': return resolveHermesPath(workspace, sessionId);
102
102
  }
103
103
  }
@@ -225,21 +225,45 @@ function resolveRealPath(value: string): string {
225
225
  try { return fs.realpathSync(value); } catch { return value; }
226
226
  }
227
227
 
228
- function resolveAntigravityPath(workspace: string): string | null {
228
+ function resolveAntigravityPath(workspace: string, sessionId: string): string | null {
229
229
  void workspace;
230
- // agy brain/<uuid>/.system_generated/logs/transcript.jsonl
231
- const brainRoot = path.join(os.homedir(), '.gemini', 'antigravity-cli', 'brain');
232
- if (!fs.existsSync(brainRoot)) return null;
233
- const cutoff = Date.now() - RECENT_WINDOW_MS;
234
- const entries = fs.readdirSync(brainRoot, { withFileTypes: true })
235
- .filter(e => e.isDirectory())
236
- .map(e => ({ p: path.join(brainRoot, e.name), mtime: safeMtime(path.join(brainRoot, e.name)) }))
237
- .filter(e => e.mtime >= cutoff)
238
- .sort((a, b) => b.mtime - a.mtime);
239
- for (const e of entries) {
240
- const t = path.join(e.p, '.system_generated', 'logs', 'transcript.jsonl');
241
- if (fs.existsSync(t)) return t;
230
+ const agyRoot = path.join(os.homedir(), '.gemini', 'antigravity-cli');
231
+
232
+ // (1) Exact session bind: current antigravity writes a per-session SQLite db
233
+ // at conversations/<uuid>.db. If the caller knows the session id and the
234
+ // db exists, bind straight to it this is the authoritative source and
235
+ // carries the assistant answers the brain/pty path was losing.
236
+ if (sessionId && isUuidLikeSessionId(sessionId)) {
237
+ const dbPath = path.join(agyRoot, 'conversations', `${sessionId}.db`);
238
+ if (fs.existsSync(dbPath)) return dbPath;
242
239
  }
240
+
241
+ // (2) brain/<uuid>/.system_generated/logs/transcript.jsonl (legacy full source).
242
+ // Only bind to a brain transcript that is NON-EMPTY: current antigravity
243
+ // writes this file but leaves it 0 bytes (all real conversation data now
244
+ // lives in the per-session .db), so an empty transcript here would
245
+ // otherwise shadow the .db fallback below and return no messages. Skip
246
+ // empty transcripts so an unbound read still reaches the .db.
247
+ const brainRoot = path.join(agyRoot, 'brain');
248
+ if (fs.existsSync(brainRoot)) {
249
+ const cutoff = Date.now() - RECENT_WINDOW_MS;
250
+ const entries = fs.readdirSync(brainRoot, { withFileTypes: true })
251
+ .filter(e => e.isDirectory())
252
+ .map(e => ({ p: path.join(brainRoot, e.name), mtime: safeMtime(path.join(brainRoot, e.name)) }))
253
+ .filter(e => e.mtime >= cutoff)
254
+ .sort((a, b) => b.mtime - a.mtime);
255
+ for (const e of entries) {
256
+ const t = path.join(e.p, '.system_generated', 'logs', 'transcript.jsonl');
257
+ if (fs.existsSync(t) && safeSize(t) > 0) return t;
258
+ }
259
+ }
260
+
261
+ // (3) No brain transcript and no bound session id: fall back to the most
262
+ // recently touched conversations/<uuid>.db (within the recency window).
263
+ const convRoot = path.join(agyRoot, 'conversations');
264
+ const newestDb = newestRecentFile(convRoot, /^[0-9a-f-]+\.db$/i);
265
+ if (newestDb) return newestDb;
266
+
243
267
  return null;
244
268
  }
245
269
 
@@ -335,6 +359,10 @@ function safeMtime(p: string): number {
335
359
  try { return Math.floor(fs.statSync(p).mtimeMs); } catch { return 0; }
336
360
  }
337
361
 
362
+ function safeSize(p: string): number {
363
+ try { return fs.statSync(p).size; } catch { return 0; }
364
+ }
365
+
338
366
  function normalizeRole(r: any): 'user' | 'assistant' | 'system' {
339
367
  const s = String(r ?? '').toLowerCase();
340
368
  if (s === 'user' || s === 'human') return 'user';