@adhdev/daemon-core 0.9.82-rc.442 → 0.9.82-rc.444

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,7 @@
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';
34
60
 
35
61
  // ─── Types ─────────────────────────────────────────────────────────────────
36
62
 
@@ -382,6 +408,232 @@ function parsePbFile(
382
408
  ];
383
409
  }
384
410
 
411
+ // ─── SQLite (.db) conversation reader ────────────────────────────────────────
412
+ //
413
+ // Recent antigravity stores each conversation in a per-session SQLite db at
414
+ // conversations/<uuid>.db. See the file header for the schema. We decode the
415
+ // protobuf `step_payload` blobs with a minimal, dependency-free field walker —
416
+ // we only need two leaf strings (user prompt / assistant answer), so a full
417
+ // proto schema is unnecessary.
418
+
419
+ /** Antigravity step_type values that map to a chat message. */
420
+ const AGY_STEP_TYPE_USER = 14;
421
+ const AGY_STEP_TYPE_MODEL = 15;
422
+
423
+ interface ProtoField {
424
+ field: number;
425
+ wireType: number;
426
+ /** For wireType 2 (length-delimited): the raw bytes. */
427
+ bytes?: Buffer;
428
+ /** For wireType 0 (varint): the value. */
429
+ varint?: number;
430
+ }
431
+
432
+ /**
433
+ * Read a base-128 varint starting at `offset`. Returns [value, nextOffset].
434
+ * Values are read as JS numbers (safe: the fields we consume are small).
435
+ */
436
+ function readVarint(buf: Buffer, offset: number): [number, number] {
437
+ let result = 0;
438
+ let shift = 0;
439
+ let i = offset;
440
+ while (i < buf.length) {
441
+ const byte = buf[i];
442
+ i += 1;
443
+ result += (byte & 0x7f) * Math.pow(2, shift);
444
+ if ((byte & 0x80) === 0) return [result, i];
445
+ shift += 7;
446
+ if (shift > 63) break; // malformed / oversized
447
+ }
448
+ return [result, i];
449
+ }
450
+
451
+ /**
452
+ * Decode the top-level fields of a protobuf message. Best-effort: stops on the
453
+ * first malformed byte rather than throwing, so a partially-corrupt blob still
454
+ * yields the fields decoded so far.
455
+ */
456
+ function decodeProtoFields(buf: Buffer): ProtoField[] {
457
+ const fields: ProtoField[] = [];
458
+ let i = 0;
459
+ while (i < buf.length) {
460
+ const [key, afterKey] = readVarint(buf, i);
461
+ if (afterKey === i) break;
462
+ i = afterKey;
463
+ const field = Math.floor(key / 8);
464
+ const wireType = key & 7;
465
+ if (field <= 0) break;
466
+ if (wireType === 0) {
467
+ const [value, next] = readVarint(buf, i);
468
+ if (next === i) break;
469
+ i = next;
470
+ fields.push({ field, wireType, varint: value });
471
+ } else if (wireType === 2) {
472
+ const [len, afterLen] = readVarint(buf, i);
473
+ i = afterLen;
474
+ if (len < 0 || i + len > buf.length) break;
475
+ fields.push({ field, wireType, bytes: buf.subarray(i, i + len) });
476
+ i += len;
477
+ } else if (wireType === 5) {
478
+ i += 4;
479
+ } else if (wireType === 1) {
480
+ i += 8;
481
+ } else {
482
+ break; // wireType 3/4 (groups) — unused by antigravity payloads
483
+ }
484
+ }
485
+ return fields;
486
+ }
487
+
488
+ /** Return the bytes of the first length-delimited field with number `field`. */
489
+ function firstLenField(buf: Buffer, field: number): Buffer | null {
490
+ for (const f of decodeProtoFields(buf)) {
491
+ if (f.field === field && f.wireType === 2 && f.bytes) return f.bytes;
492
+ }
493
+ return null;
494
+ }
495
+
496
+ /** Heuristic: is this buffer (mostly) printable UTF-8 text? */
497
+ function looksLikeText(buf: Buffer): boolean {
498
+ if (buf.length === 0) return false;
499
+ let printable = 0;
500
+ for (let i = 0; i < buf.length; i++) {
501
+ const b = buf[i];
502
+ // ASCII printable + common whitespace, or any high byte (UTF-8 lead/cont).
503
+ if ((b >= 32 && b <= 126) || b === 9 || b === 10 || b === 13 || b >= 0x80) printable += 1;
504
+ }
505
+ return printable / buf.length >= 0.9;
506
+ }
507
+
508
+ /**
509
+ * Antigravity prefixes some assistant answers with a literal `MARKER_V1`
510
+ * sentinel followed by a blank line. Strip it so the user-visible bubble starts
511
+ * at the real text.
512
+ */
513
+ function stripAnswerMarker(text: string): string {
514
+ return text.replace(/^\s*MARKER_V1\s*/, '');
515
+ }
516
+
517
+ /**
518
+ * Extract the assistant's final natural-language answer from a step_type 15
519
+ * payload: field 20 → field 1 (identical to field 8). Field 20 → field 3 is the
520
+ * private reasoning summary and is deliberately skipped. Returns '' if absent
521
+ * (e.g. a pure reasoning / tool-only model step, which carries no user-visible
522
+ * answer text).
523
+ */
524
+ function extractModelAnswer(payload: Buffer): string {
525
+ const inner = firstLenField(payload, 20);
526
+ if (!inner) return '';
527
+ const answer = firstLenField(inner, 1) ?? firstLenField(inner, 8);
528
+ if (!answer || !looksLikeText(answer)) return '';
529
+ return stripAnswerMarker(answer.toString('utf-8')).trim();
530
+ }
531
+
532
+ /**
533
+ * Extract the user prompt from a step_type 14 payload: field 19 → field 2 (the
534
+ * clean prompt text; field 19 → field 3 wraps the same string with a leading
535
+ * newline and is used only as a fallback). The USER_REQUEST XML wrapper, when
536
+ * present, is unwrapped to match the brain-transcript reader's output.
537
+ */
538
+ function extractUserPrompt(payload: Buffer): string {
539
+ const inner = firstLenField(payload, 19);
540
+ if (!inner) return '';
541
+ const raw = firstLenField(inner, 2) ?? firstLenField(inner, 3);
542
+ if (!raw || !looksLikeText(raw)) return '';
543
+ const text = raw.toString('utf-8').trim();
544
+ if (!text) return '';
545
+ return extractUserRequestContent(text);
546
+ }
547
+
548
+ interface AgyDbStepRow {
549
+ idx: number;
550
+ step_type: number;
551
+ step_payload: Buffer | null;
552
+ }
553
+
554
+ /**
555
+ * Parse a per-session conversations/<uuid>.db (SQLite) into NativeHistoryMessages.
556
+ * Returns null when the db is unreadable, empty, or yields no chat messages.
557
+ */
558
+ function parseConversationDb(
559
+ filePath: string,
560
+ sessionId: string,
561
+ workspace?: string,
562
+ ): NativeHistoryMessage[] | null {
563
+ let db: any;
564
+ try {
565
+ const Database = loadBetterSqlite3();
566
+ db = new Database(filePath, { readonly: true, fileMustExist: true });
567
+ } catch {
568
+ return null;
569
+ }
570
+
571
+ let rows: AgyDbStepRow[];
572
+ try {
573
+ rows = db
574
+ .prepare(
575
+ `SELECT idx, step_type, step_payload
576
+ FROM steps
577
+ WHERE step_type IN (${AGY_STEP_TYPE_USER}, ${AGY_STEP_TYPE_MODEL})
578
+ ORDER BY idx ASC`,
579
+ )
580
+ .all() as AgyDbStepRow[];
581
+ } catch {
582
+ // `steps` table absent / unexpected schema.
583
+ return null;
584
+ } finally {
585
+ try { db.close(); } catch { /* ignore */ }
586
+ }
587
+
588
+ if (!Array.isArray(rows) || rows.length === 0) return null;
589
+
590
+ const normalizedWorkspace = typeof workspace === 'string' ? workspace.trim() : '';
591
+ const baseTs = statMtimeMs(filePath) || Date.now();
592
+ const messages: NativeHistoryMessage[] = [];
593
+
594
+ for (const row of rows) {
595
+ const payload = row.step_payload;
596
+ if (!payload || !Buffer.isBuffer(payload) || payload.length === 0) continue;
597
+
598
+ // Steps are ordered by idx; the db carries no per-step timestamp we can
599
+ // trust as ms, so synthesize a monotonically increasing receivedAt that
600
+ // preserves order (idx-derived) around the file mtime.
601
+ const receivedAt = baseTs + messages.length;
602
+
603
+ if (row.step_type === AGY_STEP_TYPE_USER) {
604
+ const content = extractUserPrompt(payload);
605
+ if (!content) continue;
606
+ const msg: NativeHistoryMessage = {
607
+ ts: new Date(receivedAt).toISOString(),
608
+ receivedAt,
609
+ role: 'user',
610
+ content,
611
+ kind: 'standard',
612
+ agent: 'antigravity-cli',
613
+ historySessionId: sessionId,
614
+ };
615
+ if (normalizedWorkspace) msg.workspace = normalizedWorkspace;
616
+ messages.push(msg);
617
+ } else if (row.step_type === AGY_STEP_TYPE_MODEL) {
618
+ const content = extractModelAnswer(payload);
619
+ if (!content) continue; // reasoning-only / tool-only model step — no answer text
620
+ const msg: NativeHistoryMessage = {
621
+ ts: new Date(receivedAt).toISOString(),
622
+ receivedAt,
623
+ role: 'assistant',
624
+ content,
625
+ kind: 'standard',
626
+ agent: 'antigravity-cli',
627
+ historySessionId: sessionId,
628
+ };
629
+ if (normalizedWorkspace) msg.workspace = normalizedWorkspace;
630
+ messages.push(msg);
631
+ }
632
+ }
633
+
634
+ return messages.length > 0 ? messages : null;
635
+ }
636
+
385
637
  // ─── Public API ─────────────────────────────────────────────────────────────
386
638
 
387
639
  /**
@@ -390,6 +642,7 @@ function parsePbFile(
390
642
  * `sessionPath` is the absolute path to one of:
391
643
  * - A brain transcript JSONL: ~/.gemini/antigravity-cli/brain/<uuid>/.system_generated/logs/transcript*.jsonl
392
644
  * - The shared history.jsonl: ~/.gemini/antigravity-cli/history.jsonl
645
+ * - A conversation SQLite db: ~/.gemini/antigravity-cli/conversations/<uuid>.db
393
646
  * - A conversation protobuf: ~/.gemini/antigravity-cli/conversations/<uuid>.pb
394
647
  *
395
648
  * The session UUID is inferred from the directory name (brain), filename (pb), or
@@ -430,7 +683,26 @@ export function readSession(
430
683
  };
431
684
  }
432
685
 
433
- // ── Case 2: .pb conversation file ───────────────────────────────────────
686
+ // ── Case 2a: .db conversation file (current per-session SQLite format) ───
687
+ if (sessionPath.endsWith('.db')) {
688
+ const dbSessionId = sessionId || path.basename(sessionPath, '.db');
689
+ if (!isUuidLike(dbSessionId)) return null;
690
+
691
+ const messages = parseConversationDb(sessionPath, dbSessionId, workspace);
692
+ if (!messages || messages.length === 0) return null;
693
+
694
+ return {
695
+ messages,
696
+ providerSessionId: dbSessionId,
697
+ source: 'provider-native',
698
+ sourcePath: sessionPath,
699
+ sourceMtimeMs,
700
+ nativeHistoryCoverage: 'full',
701
+ workspace,
702
+ };
703
+ }
704
+
705
+ // ── Case 2b: .pb conversation file (legacy protobuf) ─────────────────────
434
706
  if (sessionPath.endsWith('.pb')) {
435
707
  const pbSessionId = sessionId || path.basename(sessionPath, '.pb');
436
708
  if (!isUuidLike(pbSessionId)) return null;
@@ -607,34 +879,85 @@ export async function listSessions(_watchPath: string): Promise<NativeHistorySes
607
879
  seen.add(sessionId);
608
880
  }
609
881
 
610
- // ── Step 3: .pb files without any other source ─────────────────────────
882
+ // ── Step 3: conversations/<uuid>.db and .pb without any other source ────
883
+ //
884
+ // Current antigravity writes per-session SQLite dbs; older sessions kept a
885
+ // .pb protobuf. Both can coexist in conversations/. Discover both, but when
886
+ // the same uuid has a .db, prefer it (full coverage) over the .pb (best
887
+ // effort). brain/history sources already in `seen` still win over either.
611
888
  const convRoot = conversationsRoot();
612
889
  if (fs.existsSync(convRoot)) {
613
890
  let entries: fs.Dirent[] = [];
614
891
  try { entries = fs.readdirSync(convRoot, { withFileTypes: true }); } catch { /* ignore */ }
615
892
 
893
+ // Group by uuid so a .db supersedes a sibling .pb of the same session.
894
+ const byUuid = new Map<string, { db?: string; pb?: string }>();
616
895
  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);
896
+ if (!entry.isFile()) continue;
897
+ const dbMatch = /^([0-9a-f-]+)\.db$/i.exec(entry.name);
898
+ const pbMatch = /^([0-9a-f-]+)\.pb$/i.exec(entry.name);
899
+ if (dbMatch && isUuidLike(dbMatch[1])) {
900
+ const g = byUuid.get(dbMatch[1]) ?? {};
901
+ g.db = path.join(convRoot, entry.name);
902
+ byUuid.set(dbMatch[1], g);
903
+ } else if (pbMatch && isUuidLike(pbMatch[1])) {
904
+ const g = byUuid.get(pbMatch[1]) ?? {};
905
+ g.pb = path.join(convRoot, entry.name);
906
+ byUuid.set(pbMatch[1], g);
907
+ }
908
+ }
909
+
910
+ for (const [uuid, files] of byUuid.entries()) {
911
+ if (seen.has(uuid)) continue;
912
+
913
+ if (files.db) {
914
+ // Parse the db so listSessions reports accurate counts/preview and the
915
+ // session surfaces with full coverage (assistant answers included).
916
+ const workspace = workspaceBySession.get(uuid);
917
+ const messages = parseConversationDb(files.db, uuid, workspace);
918
+ const dbMtime = statMtimeMs(files.db);
919
+ if (messages && messages.length > 0) {
920
+ const lastMsg = messages[messages.length - 1];
921
+ const firstMsg = messages[0];
922
+ results.push({
923
+ historySessionId: uuid,
924
+ sessionId: uuid,
925
+ sourcePath: files.db,
926
+ sourceMtimeMs: dbMtime,
927
+ messageCount: messages.length,
928
+ firstMessageAt: firstMsg.receivedAt || dbMtime,
929
+ lastMessageAt: lastMsg.receivedAt || dbMtime,
930
+ sessionTitle: lastMsg.content,
931
+ preview: lastMsg.content,
932
+ workspace,
933
+ agent: 'antigravity-cli',
934
+ source: 'provider-native',
935
+ nativeHistoryCoverage: 'full',
936
+ });
937
+ seen.add(uuid);
938
+ continue;
939
+ }
940
+ // db unreadable/empty (e.g. sqlite binding unavailable) → fall through
941
+ // to the .pb best-effort entry below if one exists.
942
+ }
943
+
944
+ if (files.pb) {
945
+ const pbMtime = statMtimeMs(files.pb);
946
+ results.push({
947
+ historySessionId: uuid,
948
+ sessionId: uuid,
949
+ sourcePath: files.pb,
950
+ sourceMtimeMs: pbMtime,
951
+ messageCount: 0,
952
+ firstMessageAt: pbMtime,
953
+ lastMessageAt: pbMtime,
954
+ agent: 'antigravity-cli',
955
+ source: 'provider-native',
956
+ nativeHistoryCoverage: 'best-effort',
957
+ partialReason: 'antigravity_cli_pb_raw_text_extraction',
958
+ });
959
+ seen.add(uuid);
960
+ }
638
961
  }
639
962
  }
640
963
 
@@ -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,40 @@ 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
+ const brainRoot = path.join(agyRoot, 'brain');
243
+ if (fs.existsSync(brainRoot)) {
244
+ const cutoff = Date.now() - RECENT_WINDOW_MS;
245
+ const entries = fs.readdirSync(brainRoot, { withFileTypes: true })
246
+ .filter(e => e.isDirectory())
247
+ .map(e => ({ p: path.join(brainRoot, e.name), mtime: safeMtime(path.join(brainRoot, e.name)) }))
248
+ .filter(e => e.mtime >= cutoff)
249
+ .sort((a, b) => b.mtime - a.mtime);
250
+ for (const e of entries) {
251
+ const t = path.join(e.p, '.system_generated', 'logs', 'transcript.jsonl');
252
+ if (fs.existsSync(t)) return t;
253
+ }
254
+ }
255
+
256
+ // (3) No brain transcript and no bound session id: fall back to the most
257
+ // recently touched conversations/<uuid>.db (within the recency window).
258
+ const convRoot = path.join(agyRoot, 'conversations');
259
+ const newestDb = newestRecentFile(convRoot, /^[0-9a-f-]+\.db$/i);
260
+ if (newestDb) return newestDb;
261
+
243
262
  return null;
244
263
  }
245
264