@estebanforge/pi-antigravity-bridge 1.3.1 → 1.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/README.md +13 -15
- package/docs/ARCHITECTURE.md +9 -40
- package/docs/DEVELOPMENT.md +5 -20
- package/docs/PI-BRIDGE-GAPS.md +7 -1
- package/extensions/index.ts +19 -7
- package/package.json +1 -3
- package/src/config.ts +17 -13
- package/src/provider.ts +51 -182
- package/src/poller.ts +0 -202
- package/src/protobuf.ts +0 -184
- package/src/runner.ts +0 -390
package/src/provider.ts
CHANGED
|
@@ -29,7 +29,6 @@ import {
|
|
|
29
29
|
type Usage,
|
|
30
30
|
} from "@earendil-works/pi-ai";
|
|
31
31
|
import type { Api } from "@earendil-works/pi-ai";
|
|
32
|
-
import { runAgyTurn, type AgyEvent, type AgyRunOptions } from "./runner.js";
|
|
33
32
|
import { AgyDriver, type DriverActivity, type TurnHandle } from "./driver.js";
|
|
34
33
|
import { toPiUsage } from "./stream-events.js";
|
|
35
34
|
import { mapAgyToolToNative } from "./native-tools.js";
|
|
@@ -85,6 +84,43 @@ const COMPACTION_MARKER = "compacted into the following summary";
|
|
|
85
84
|
const DIGEST_PREAMBLE =
|
|
86
85
|
"[The following is context from the broader pi session that this Antigravity turn was not directly spawned for: compaction summaries and turns handled by other providers or pi's own tools. Your own prior turns are already in your conversation history. Use this for continuity only.]";
|
|
87
86
|
|
|
87
|
+
// --- pi system prompt (G10) ----------------------------------------------------
|
|
88
|
+
//
|
|
89
|
+
// pi composes context.systemPrompt every turn: its own operating instructions
|
|
90
|
+
// plus every AGENTS.md/CLAUDE.md it loaded (global agent dir first, then
|
|
91
|
+
// ancestors). The provider used to drop it, so agy models never saw the user's
|
|
92
|
+
// machine-level or project-level instructions. agy has no system-prompt flag
|
|
93
|
+
// (verified against `agy --help`), so the only delivery path is the prompt
|
|
94
|
+
// text. We prepend it as a delimited block on the FIRST prompt of a fresh
|
|
95
|
+
// conversation only: agy keeps its own history, the block stays byte-identical
|
|
96
|
+
// afterwards, and agy's server-side prompt cache keeps hitting.
|
|
97
|
+
|
|
98
|
+
export const SYSTEM_PROMPT_PREAMBLE =
|
|
99
|
+
"[The following is the system prompt of the pi session that spawned this conversation: operating instructions plus project context (AGENTS.md files). Apply it for this whole conversation. Tool guidance may reference pi-side tools; use your own tools or the pi tool bridge for those actions.]";
|
|
100
|
+
|
|
101
|
+
export const SYSTEM_PROMPT_END = "[END SYSTEM PROMPT]";
|
|
102
|
+
|
|
103
|
+
/** Assemble the full agy prompt: system prompt block, pi-side digest, user
|
|
104
|
+
* prompt. Empty parts are dropped. Pure; exported for unit testing.
|
|
105
|
+
* Pass systemPrompt only on a fresh conversation (see runTurnDriver). */
|
|
106
|
+
export function buildFullPrompt(
|
|
107
|
+
systemPrompt: string | undefined,
|
|
108
|
+
digest: string,
|
|
109
|
+
prompt: string,
|
|
110
|
+
): string {
|
|
111
|
+
const parts: string[] = [];
|
|
112
|
+
if (systemPrompt) {
|
|
113
|
+
parts.push(`${SYSTEM_PROMPT_PREAMBLE}\n\n${systemPrompt}\n\n${SYSTEM_PROMPT_END}`);
|
|
114
|
+
}
|
|
115
|
+
if (digest) {
|
|
116
|
+
parts.push(`${DIGEST_PREAMBLE}\n\n${digest}`);
|
|
117
|
+
}
|
|
118
|
+
if (prompt) {
|
|
119
|
+
parts.push(prompt);
|
|
120
|
+
}
|
|
121
|
+
return parts.join("\n\n---\n\n");
|
|
122
|
+
}
|
|
123
|
+
|
|
88
124
|
/** Flatten any message content shape (string or content-block array) to text.
|
|
89
125
|
* Drops images, thinking, and tool-call blocks. */
|
|
90
126
|
function blocksToText(content: unknown): string {
|
|
@@ -245,11 +281,8 @@ export interface BlockState {
|
|
|
245
281
|
export interface StreamSimpleDeps {
|
|
246
282
|
entries: AgyModelEntry[];
|
|
247
283
|
store: SessionStore;
|
|
248
|
-
/**
|
|
249
|
-
*
|
|
250
|
-
runAgyTurn?: typeof runAgyTurn;
|
|
251
|
-
/** Persistent stream-json engine. When set (and config.engine selects it),
|
|
252
|
-
* turns run on the driver and bridge calls park as toolUse round-trips. */
|
|
284
|
+
/** Persistent stream-json driver. Turns run on the driver and bridge
|
|
285
|
+
* calls park as toolUse round-trips. Required with roundTrips. */
|
|
253
286
|
driver?: AgyDriver;
|
|
254
287
|
roundTrips?: ToolRoundTrips;
|
|
255
288
|
/** Replay store for the display-only antigravity wrapper tool. Required
|
|
@@ -627,7 +660,11 @@ async function runTurnDriver(
|
|
|
627
660
|
const effort = entry?.efforts?.length ? toAgyEffort(options?.reasoning, entry.efforts) : undefined;
|
|
628
661
|
const watermark = existing?.lastMessageCount ?? 0;
|
|
629
662
|
const digest = config.digest ? buildContextDigest(context.messages, watermark) : "";
|
|
630
|
-
|
|
663
|
+
// Fresh conversation only: agy stores the block in its own history, so
|
|
664
|
+
// re-sending it every turn would bloat each prompt and bust the cache.
|
|
665
|
+
const sysPrompt =
|
|
666
|
+
config.systemPrompt && !existing?.conversationId ? context.systemPrompt : undefined;
|
|
667
|
+
const fullPrompt = buildFullPrompt(sysPrompt, digest, prompt);
|
|
631
668
|
try {
|
|
632
669
|
handle = await deps.driver.run({
|
|
633
670
|
cwd,
|
|
@@ -692,13 +729,12 @@ async function runTurnDriver(
|
|
|
692
729
|
export function createStreamSimple(
|
|
693
730
|
deps: StreamSimpleDeps,
|
|
694
731
|
): (model: Model<Api>, context: Context, options?: SimpleStreamOptions) => AssistantMessageEventStream {
|
|
695
|
-
const { entries, store,
|
|
732
|
+
const { entries, store, driver, roundTrips } = deps;
|
|
696
733
|
|
|
697
734
|
return function streamSimple(model, context, options) {
|
|
698
735
|
const stream = createAssistantMessageEventStream();
|
|
699
736
|
// Fire the async turn; return the stream synchronously per pi's contract.
|
|
700
|
-
|
|
701
|
-
if (driver && roundTrips && config.engine === "stream-json") {
|
|
737
|
+
if (driver && roundTrips) {
|
|
702
738
|
void runTurnDriver(stream, model, context, options, entries, store, {
|
|
703
739
|
driver,
|
|
704
740
|
roundTrips,
|
|
@@ -706,183 +742,16 @@ export function createStreamSimple(
|
|
|
706
742
|
nativeActive: deps.nativeActive,
|
|
707
743
|
});
|
|
708
744
|
} else {
|
|
709
|
-
|
|
745
|
+
// Miswired extension: no driver means no engine. Fail the turn visibly
|
|
746
|
+
// instead of silently producing an empty assistant message.
|
|
747
|
+
const partial = newAssistant(model);
|
|
748
|
+
const blocks: BlockState = { partial, textIdx: null, thinkingIdx: null, started: false };
|
|
749
|
+
finalize(stream, blocks, "error", "antigravity driver not configured");
|
|
710
750
|
}
|
|
711
751
|
return stream;
|
|
712
752
|
};
|
|
713
753
|
}
|
|
714
754
|
|
|
715
|
-
async function runTurn(
|
|
716
|
-
stream: AssistantMessageEventStream,
|
|
717
|
-
model: Model<Api>,
|
|
718
|
-
context: Context,
|
|
719
|
-
options: SimpleStreamOptions | undefined,
|
|
720
|
-
entries: AgyModelEntry[],
|
|
721
|
-
store: SessionStore,
|
|
722
|
-
runFn: typeof runAgyTurn,
|
|
723
|
-
): Promise<void> {
|
|
724
|
-
const partial = newAssistant(model);
|
|
725
|
-
const blocks: BlockState = { partial, textIdx: null, thinkingIdx: null, started: false };
|
|
726
|
-
|
|
727
|
-
// Direct emit helpers. agy streams deltas that may not align to line
|
|
728
|
-
// boundaries; pi's TUI renders partial lines fine, so we append and push
|
|
729
|
-
// each delta straight through (no filtering, no buffering).
|
|
730
|
-
const appendTextDelta = (delta: string): void => {
|
|
731
|
-
appendText(stream, blocks, delta);
|
|
732
|
-
};
|
|
733
|
-
const appendThinkingDelta = (delta: string): void => {
|
|
734
|
-
appendThinking(stream, blocks, delta);
|
|
735
|
-
};
|
|
736
|
-
|
|
737
|
-
// Signal the turn has begun IMMEDIATELY. pi's native Working indicator is
|
|
738
|
-
// driven by the stream's start event (isStreaming). Without this, agy's
|
|
739
|
-
// initial thinking seconds (before it emits any step) show nothing and the
|
|
740
|
-
// UI looks frozen. Lazy start (on first content) was the old behavior.
|
|
741
|
-
ensureStarted(stream, blocks);
|
|
742
|
-
|
|
743
|
-
const cwd = (options as { cwd?: string } | undefined)?.cwd ?? process.cwd();
|
|
744
|
-
const key = sessionKey(options, cwd);
|
|
745
|
-
const existing = store.get(key);
|
|
746
|
-
const messageCount = context.messages.length;
|
|
747
|
-
|
|
748
|
-
const prompt = extractUserPrompt(context);
|
|
749
|
-
if (!prompt) {
|
|
750
|
-
finalize(stream, blocks, "error", "No user message to send to agy.");
|
|
751
|
-
return;
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
// Runtime config (mode, permissions, digest). Loaded fresh each turn so
|
|
755
|
-
// /agy toggles take effect immediately without a reload.
|
|
756
|
-
const config = loadConfig();
|
|
757
|
-
|
|
758
|
-
// G1: inject a delta digest of pi-side context agy was not spawned for
|
|
759
|
-
// (compaction summaries, other-provider turns), gated on config.digest:
|
|
760
|
-
// the digest changes every turn and defeats agy's prompt cache. agy keeps
|
|
761
|
-
// its own history; see docs/PI-BRIDGE-GAPS.md (G1).
|
|
762
|
-
const watermark = existing?.lastMessageCount ?? 0;
|
|
763
|
-
const digest = config.digest ? buildContextDigest(context.messages, watermark) : "";
|
|
764
|
-
const fullPrompt = digest ? `${DIGEST_PREAMBLE}\n\n${digest}\n\n---\n\n${prompt}` : prompt;
|
|
765
|
-
|
|
766
|
-
// Resolve the pi model id to its catalog entry. On a miss, fall through to
|
|
767
|
-
// the id itself - agy will likely reject, but the error reaches the user
|
|
768
|
-
// instead of a silent no-op.
|
|
769
|
-
const entry = entries.find((e) => e.id === model.id) ?? null;
|
|
770
|
-
const agyModel = entry?.full ?? model.id;
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
// Effort-driven bases always need --effort (a base slug is invalid on its
|
|
774
|
-
// own); fixed models never get it (agy rejects --effort for them). For an
|
|
775
|
-
// effort-driven base we clamp pi's level to the tiers agy offers it.
|
|
776
|
-
const effort = entry?.efforts?.length ? toAgyEffort(options?.reasoning, entry.efforts) : undefined;
|
|
777
|
-
|
|
778
|
-
const runOpts: AgyRunOptions = {
|
|
779
|
-
cwd,
|
|
780
|
-
model: agyModel,
|
|
781
|
-
mode: config.mode,
|
|
782
|
-
skipPermissions: config.skipPermissions,
|
|
783
|
-
effort,
|
|
784
|
-
prompt: fullPrompt,
|
|
785
|
-
conversationId: existing?.conversationId ?? null,
|
|
786
|
-
baseStepIdx: existing?.lastStepIdx ?? -1,
|
|
787
|
-
timeoutMin: DEFAULT_TIMEOUT_MIN,
|
|
788
|
-
signal: options?.signal,
|
|
789
|
-
};
|
|
790
|
-
|
|
791
|
-
// G8: per-turn diff context for agy's file edits (write_to_file et al.).
|
|
792
|
-
// Turn-scoped so concurrent turns never share OLD-content caches.
|
|
793
|
-
const diffCtx = new TurnDiffContext(createExecGitOps());
|
|
794
|
-
|
|
795
|
-
const onEvent = (event: AgyEvent) => {
|
|
796
|
-
switch (event.kind) {
|
|
797
|
-
case "text":
|
|
798
|
-
appendTextDelta(event.text);
|
|
799
|
-
break;
|
|
800
|
-
case "thinking":
|
|
801
|
-
appendThinkingDelta(event.text);
|
|
802
|
-
break;
|
|
803
|
-
case "tool": {
|
|
804
|
-
// G8: if agy wrote a file, surface a git-sourced diff; else the plain
|
|
805
|
-
// tool label. Always shown (agy's own tool loop, surfaced for visibility).
|
|
806
|
-
const edit = parseEditToolInput(event.inputJson ?? "");
|
|
807
|
-
if (edit) {
|
|
808
|
-
const absFile = path.isAbsolute(edit.file) ? edit.file : path.resolve(cwd, edit.file);
|
|
809
|
-
const outcome = diffCtx.diffEdit(absFile, edit.content);
|
|
810
|
-
const label = edit.description ?? path.basename(absFile);
|
|
811
|
-
appendThinkingDelta(`[agy edit: ${label}]\n`);
|
|
812
|
-
if (outcome.text) appendThinkingDelta(`${outcome.text}\n`);
|
|
813
|
-
} else {
|
|
814
|
-
appendThinkingDelta(`[agy tool: ${event.name}]\n`);
|
|
815
|
-
}
|
|
816
|
-
break;
|
|
817
|
-
}
|
|
818
|
-
case "title":
|
|
819
|
-
// Conversation title metadata - not streamed to the user.
|
|
820
|
-
break;
|
|
821
|
-
}
|
|
822
|
-
};
|
|
823
|
-
|
|
824
|
-
let result;
|
|
825
|
-
try {
|
|
826
|
-
result = await runFn(runOpts, onEvent);
|
|
827
|
-
} catch (err) {
|
|
828
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
829
|
-
finalize(stream, blocks, "error", `agy failed to start: ${msg}`);
|
|
830
|
-
return;
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
// Persist for the next turn (resume). Only bind when we actually discovered
|
|
834
|
-
// an id - a discovery miss shouldn't clobber a prior good binding.
|
|
835
|
-
// Persist for the next turn (resume). Only bind when we actually discovered
|
|
836
|
-
// an id - a discovery miss shouldn't clobber a prior good binding. The
|
|
837
|
-
// lastMessageCount watermark advances on a successful bind even if the turn
|
|
838
|
-
// later aborted or timed out: the prompt (digest included) was handed to
|
|
839
|
-
// agy at spawn, so its DB has seen that context. Guarding this on
|
|
840
|
-
// exitCode===0 would re-inject stale deltas after retryable failures.
|
|
841
|
-
if (result.conversationId) {
|
|
842
|
-
store.set(key, {
|
|
843
|
-
conversationId: result.conversationId,
|
|
844
|
-
lastStepIdx: result.lastIdx,
|
|
845
|
-
lastMessageCount: messageCount,
|
|
846
|
-
});
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
if (result.aborted) {
|
|
850
|
-
finalize(stream, blocks, "aborted", "Operation aborted");
|
|
851
|
-
return;
|
|
852
|
-
}
|
|
853
|
-
if (result.timedOut) {
|
|
854
|
-
const note = `agy exceeded the ${runOpts.timeoutMin}m timeout`;
|
|
855
|
-
finalize(stream, blocks, "error", note);
|
|
856
|
-
return;
|
|
857
|
-
}
|
|
858
|
-
if (result.exitCode !== 0) {
|
|
859
|
-
const detail = result.stderr.trim() || `agy exited with status ${result.exitCode}`;
|
|
860
|
-
finalize(stream, blocks, "error", detail);
|
|
861
|
-
return;
|
|
862
|
-
}
|
|
863
|
-
|
|
864
|
-
// Discovery miss: agy exited cleanly but we never bound a conversation id
|
|
865
|
-
// this turn (ambiguous snapshot, DB not created in time, or a prior session
|
|
866
|
-
// whose id failed CONV_ID_RE and silently fell through to fresh discovery).
|
|
867
|
-
// Guard on whether we bound THIS turn, not on whether a prior session
|
|
868
|
-
// existed - otherwise a corrupt existing entry re-opens the silent-empty-
|
|
869
|
-
// success hole the first review closed.
|
|
870
|
-
if (!result.conversationId) {
|
|
871
|
-
const detail =
|
|
872
|
-
"agy exited cleanly but its conversation database could not be bound. " +
|
|
873
|
-
"The run may have partially applied edits with no visible output.";
|
|
874
|
-
finalize(stream, blocks, "error", detail);
|
|
875
|
-
return;
|
|
876
|
-
}
|
|
877
|
-
|
|
878
|
-
// Success. If no text ever streamed (agy did only tool work, or returned
|
|
879
|
-
// empty), emit an empty text block so pi has a well-formed assistant turn.
|
|
880
|
-
if (blocks.textIdx === null && blocks.thinkingIdx === null) {
|
|
881
|
-
ensureTextOpen(stream, blocks);
|
|
882
|
-
}
|
|
883
|
-
finalize(stream, blocks, "stop");
|
|
884
|
-
}
|
|
885
|
-
|
|
886
755
|
/** Signal the start of the assistant turn exactly once. `start` is
|
|
887
756
|
* turn-level (analogous to Anthropic's message_start), not per-block - the
|
|
888
757
|
* per-block signals are text_start / thinking_start. */
|
package/src/poller.ts
DELETED
|
@@ -1,202 +0,0 @@
|
|
|
1
|
-
// Read-only poller over an agy conversation SQLite DB.
|
|
2
|
-
//
|
|
3
|
-
// Opens ~/.gemini/antigravity-cli/conversations/<uuid>.db read-only and reads
|
|
4
|
-
// newly-appended rows from the `steps` table on each poll. Uses node:sqlite
|
|
5
|
-
// (built into Node >= 22.5; this machine is 26.5.0) so there is no native
|
|
6
|
-
// dependency to ship. The caller drives a 250ms poll loop.
|
|
7
|
-
//
|
|
8
|
-
// Coalescing: agy's writer commits through its own connection, so we use
|
|
9
|
-
// SQLite's `PRAGMA data_version` to skip the SELECT when nothing has changed
|
|
10
|
-
// since the last poll (agy-acp pattern). data_version bumps on every commit
|
|
11
|
-
// by another connection - cheap and exact.
|
|
12
|
-
|
|
13
|
-
import fs from "node:fs";
|
|
14
|
-
import { DatabaseSync } from "node:sqlite";
|
|
15
|
-
import { toUint8 } from "./protobuf.js";
|
|
16
|
-
|
|
17
|
-
/** A raw step row as read from the DB. payload is the undecoded step_payload
|
|
18
|
-
* BLOB; callers pass it to the protobuf extractor. */
|
|
19
|
-
export interface Step {
|
|
20
|
-
idx: number;
|
|
21
|
-
stepType: number;
|
|
22
|
-
status: number;
|
|
23
|
-
payload: Uint8Array;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const SELECT_STEPS =
|
|
27
|
-
"SELECT idx, step_type, status, step_payload FROM steps WHERE idx > ? ORDER BY idx";
|
|
28
|
-
const SELECT_STEP_AT =
|
|
29
|
-
"SELECT idx, step_type, status, step_payload FROM steps WHERE idx = ?";
|
|
30
|
-
|
|
31
|
-
const HAS_STEPS =
|
|
32
|
-
"SELECT COUNT(*) > 0 AS present FROM sqlite_master WHERE type='table' AND name='steps'";
|
|
33
|
-
|
|
34
|
-
/** Open the DB read-only. Returns null when the file doesn't exist yet or
|
|
35
|
-
* lacks a steps table (agy hasn't created/flushed it). Throws are swallowed
|
|
36
|
-
* so a transient lock or half-written file is retried on the next poll. */
|
|
37
|
-
function openReadOnly(dbPath: string): DatabaseSync | null {
|
|
38
|
-
if (!fs.existsSync(dbPath)) return null;
|
|
39
|
-
try {
|
|
40
|
-
const db = new DatabaseSync(dbPath, { readOnly: true });
|
|
41
|
-
const row = db.prepare(HAS_STEPS).get() as { present?: number } | undefined;
|
|
42
|
-
if (!row?.present) {
|
|
43
|
-
db.close();
|
|
44
|
-
return null;
|
|
45
|
-
}
|
|
46
|
-
return db;
|
|
47
|
-
} catch {
|
|
48
|
-
return null;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** A reusable read handle on one conversation's steps table.
|
|
53
|
-
*
|
|
54
|
-
* Keeps one DB connection + prepared statement open for the life of a turn,
|
|
55
|
-
* so the poll loop isn't re-opening the file each tick. `poll()` returns only
|
|
56
|
-
* rows newer than the last one seen, advancing an internal cursor.
|
|
57
|
-
*
|
|
58
|
-
* A row whose payload fails to materialize (torn read while agy is mid-write)
|
|
59
|
-
* is dropped, not thrown - its idx is NOT advanced past, so it's retried on
|
|
60
|
-
* the next poll once the write settles. (agy-acp database.ts pattern.) */
|
|
61
|
-
export class ConversationPoller {
|
|
62
|
-
|
|
63
|
-
private db: DatabaseSync | null = null;
|
|
64
|
-
private selectStmt: ReturnType<DatabaseSync["prepare"]> | null = null;
|
|
65
|
-
private selectAtStmt: ReturnType<DatabaseSync["prepare"]> | null = null;
|
|
66
|
-
private dataVersionStmt: ReturnType<DatabaseSync["prepare"]> | null = null;
|
|
67
|
-
private lastDataVersion: number | null = null;
|
|
68
|
-
private _lastIdx: number;
|
|
69
|
-
|
|
70
|
-
constructor(
|
|
71
|
-
private readonly dbPath: string,
|
|
72
|
-
baseStepIdx = -1,
|
|
73
|
-
) {
|
|
74
|
-
this._lastIdx = baseStepIdx;
|
|
75
|
-
this.db = openReadOnly(dbPath);
|
|
76
|
-
if (this.db) {
|
|
77
|
-
this.selectStmt = this.db.prepare(SELECT_STEPS);
|
|
78
|
-
this.selectAtStmt = this.db.prepare(SELECT_STEP_AT);
|
|
79
|
-
this.dataVersionStmt = this.db.prepare("PRAGMA data_version");
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** True if the DB was openable at construction. False means agy hasn't
|
|
84
|
-
* created/flushed it yet - call tryOpen() on later polls. */
|
|
85
|
-
get isOpen(): boolean {
|
|
86
|
-
return this.db !== null;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/** The highest idx seen (or the base passed at construction). Persist this
|
|
90
|
-
* across turns so a resumed conversation only streams new steps. */
|
|
91
|
-
get lastIdx(): number {
|
|
92
|
-
return this._lastIdx;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/** Retry opening the DB if it wasn't ready at construction. Returns the
|
|
96
|
-
* new open state. Idempotent. */
|
|
97
|
-
tryOpen(): boolean {
|
|
98
|
-
if (this.db) return true;
|
|
99
|
-
this.db = openReadOnly(this.dbPath);
|
|
100
|
-
if (this.db) {
|
|
101
|
-
this.selectStmt = this.db.prepare(SELECT_STEPS);
|
|
102
|
-
this.selectAtStmt = this.db.prepare(SELECT_STEP_AT);
|
|
103
|
-
this.dataVersionStmt = this.db.prepare("PRAGMA data_version");
|
|
104
|
-
return true;
|
|
105
|
-
}
|
|
106
|
-
return false;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/** Returns true when another connection has committed since the last poll.
|
|
110
|
-
* When false, readNewSteps() and poll() are guaranteed to return [] and can
|
|
111
|
-
* be skipped. Call ONCE per tick: it advances the data_version cursor, so a
|
|
112
|
-
* second call in the same tick sees no change. Exposed so the runner can
|
|
113
|
-
* gate its in-place step re-read (readStepAt) behind the same check and
|
|
114
|
-
* avoid a redundant SELECT every idle tick while agy is thinking. */
|
|
115
|
-
hasChanged(): boolean {
|
|
116
|
-
if (!this.db || !this.dataVersionStmt) return true; // force a read on first poll
|
|
117
|
-
const row = this.dataVersionStmt.get() as { data_version?: number } | undefined;
|
|
118
|
-
const v = row?.data_version ?? 0;
|
|
119
|
-
if (this.lastDataVersion === null) {
|
|
120
|
-
this.lastDataVersion = v;
|
|
121
|
-
return true;
|
|
122
|
-
}
|
|
123
|
-
if (v === this.lastDataVersion) return false;
|
|
124
|
-
this.lastDataVersion = v;
|
|
125
|
-
return true;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** Read new steps since the last call WITHOUT re-checking data_version.
|
|
129
|
-
* The caller gates this behind hasChanged() so the SELECT only fires when a
|
|
130
|
-
* commit actually landed. Returns [] when the DB isn't open or has no new
|
|
131
|
-
* rows. Advances the cursor past every successfully-read row. */
|
|
132
|
-
readNewSteps(): Step[] {
|
|
133
|
-
if (!this.db || !this.selectStmt) return [];
|
|
134
|
-
const rows = this.selectStmt.all(this._lastIdx) as Array<{
|
|
135
|
-
idx: number;
|
|
136
|
-
step_type: number;
|
|
137
|
-
status: number;
|
|
138
|
-
step_payload: unknown;
|
|
139
|
-
}>;
|
|
140
|
-
const out: Step[] = [];
|
|
141
|
-
let advanced = this._lastIdx;
|
|
142
|
-
for (const r of rows) {
|
|
143
|
-
try {
|
|
144
|
-
out.push({
|
|
145
|
-
idx: r.idx,
|
|
146
|
-
stepType: r.step_type,
|
|
147
|
-
status: r.status,
|
|
148
|
-
payload: toUint8(r.step_payload),
|
|
149
|
-
});
|
|
150
|
-
advanced = r.idx;
|
|
151
|
-
} catch {
|
|
152
|
-
// Torn read: drop this row, do not advance. Retried next poll.
|
|
153
|
-
break;
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
this._lastIdx = Math.max(this._lastIdx, advanced);
|
|
157
|
-
return out;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/** Convenience: hasChanged() + readNewSteps() in one call. Kept for the
|
|
161
|
-
* decode-db diagnostic and any caller that doesn't need the separate
|
|
162
|
-
* in-place re-read. */
|
|
163
|
-
poll(): Step[] {
|
|
164
|
-
return this.hasChanged() ? this.readNewSteps() : [];
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/** Read a single step by idx without advancing the cursor. Used to re-check
|
|
168
|
-
* the last text/thinking step: agy extends the step it is currently writing
|
|
169
|
-
* in place (same idx, growing text), and poll() only returns idx > lastIdx. */
|
|
170
|
-
readStepAt(idx: number): Step | null {
|
|
171
|
-
if (!this.db || !this.selectAtStmt) return null;
|
|
172
|
-
let row;
|
|
173
|
-
try {
|
|
174
|
-
row = this.selectAtStmt.get(idx) as
|
|
175
|
-
| { idx: number; step_type: number; status: number; step_payload: unknown }
|
|
176
|
-
| undefined;
|
|
177
|
-
} catch {
|
|
178
|
-
return null;
|
|
179
|
-
}
|
|
180
|
-
if (!row) return null;
|
|
181
|
-
try {
|
|
182
|
-
return {
|
|
183
|
-
idx: row.idx,
|
|
184
|
-
stepType: row.step_type,
|
|
185
|
-
status: row.status,
|
|
186
|
-
payload: toUint8(row.step_payload),
|
|
187
|
-
};
|
|
188
|
-
} catch {
|
|
189
|
-
return null;
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/** Release the DB handle. Safe to call multiple times. */
|
|
194
|
-
close(): void {
|
|
195
|
-
try {
|
|
196
|
-
this.db?.close();
|
|
197
|
-
} catch {
|
|
198
|
-
// already closed
|
|
199
|
-
}
|
|
200
|
-
this.db = null;
|
|
201
|
-
}
|
|
202
|
-
}
|
package/src/protobuf.ts
DELETED
|
@@ -1,184 +0,0 @@
|
|
|
1
|
-
// Hand-rolled protobuf decoder for agy's `step_payload` blobs.
|
|
2
|
-
//
|
|
3
|
-
// agy writes per-conversation SQLite DBs at ~/.gemini/antigravity-cli/
|
|
4
|
-
// conversations/<uuid>.db. The `steps.step_payload` column is a protobuf blob
|
|
5
|
-
// with NO published schema. Field numbers below are load-bearing
|
|
6
|
-
// reverse-engineered facts (cross-checked against the shindgew/agy-acp and
|
|
7
|
-
// shubzkothekar/antigravity-acp decoders, plus real DB inspection on this
|
|
8
|
-
// machine, agy v1.1.7).
|
|
9
|
-
//
|
|
10
|
-
// We hand-roll the varint walker instead of pulling @bufbuild/protobuf or
|
|
11
|
-
// generating from .proto. The openab/agy-acp Rust port proves ~94 lines is
|
|
12
|
-
// enough for text + tool name extraction. Unknown fields are skipped per
|
|
13
|
-
// protobuf wire-format rules, so a future agy that adds fields won't break us.
|
|
14
|
-
//
|
|
15
|
-
// Layout we care about:
|
|
16
|
-
// step_payload:
|
|
17
|
-
// field 20 (submessage) = agentText { 1: text }
|
|
18
|
-
// field 5 (submessage) = toolRun { 4: toolCall { 2|9: name, 3: inputJson } }
|
|
19
|
-
// field 30 (submessage) = titleUpdate { 4: title }
|
|
20
|
-
// (fuller map in decodeStepPayload - only the ones we stream to pi.)
|
|
21
|
-
|
|
22
|
-
export type ByteSource = Uint8Array | ArrayBufferLike;
|
|
23
|
-
|
|
24
|
-
/** Read a base-128 varint starting at offset `i`. Returns [value, nextOffset].
|
|
25
|
-
*
|
|
26
|
-
* NOTE on precision: accumulation uses bitwise OR and shift, which are
|
|
27
|
-
* 32-bit operations in JS. Values needing 5+ continuation bytes (>32 bits)
|
|
28
|
-
* are truncated, not decoded correctly. This is acceptable here because agy
|
|
29
|
-
* field numbers and payload lengths are always small (well under 2^32). The
|
|
30
|
-
* 10-byte cap is a DoS guard (stop a corrupt blob spinning forever), not a
|
|
31
|
-
* correctness guarantee for the full 64-bit varint range. */
|
|
32
|
-
export function readVarint(buf: Uint8Array, i: number): [number, number] {
|
|
33
|
-
let result = 0;
|
|
34
|
-
let shift = 0;
|
|
35
|
-
let offset = i;
|
|
36
|
-
// protobuf caps varints at 10 bytes (64-bit). Cap the loop so a corrupt
|
|
37
|
-
// blob can't spin forever.
|
|
38
|
-
for (let count = 0; count < 10; count++) {
|
|
39
|
-
if (offset >= buf.length) {
|
|
40
|
-
throw new RangeError(`varint at ${i} ran past end of buffer`);
|
|
41
|
-
}
|
|
42
|
-
const byte = buf[offset++];
|
|
43
|
-
result |= (byte & 0x7f) << shift;
|
|
44
|
-
if ((byte & 0x80) === 0) return [result >>> 0, offset];
|
|
45
|
-
shift += 7;
|
|
46
|
-
}
|
|
47
|
-
throw new RangeError(`varint at ${i} exceeded 10 bytes`);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** A (fieldNumber, wireType, valueSlice) triple produced by walking one field.
|
|
51
|
-
* For length-delimited fields (wire 2), `bytes` is the field payload.
|
|
52
|
-
* For varints (wire 0), `varint` holds the value. */
|
|
53
|
-
export interface Field {
|
|
54
|
-
field: number;
|
|
55
|
-
wire: number;
|
|
56
|
-
bytes: Uint8Array | null; // wire 2 payload (view into the source buffer)
|
|
57
|
-
varint: number | null; // wire 0 value
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** Walk every top-level field in a protobuf message. Returns the fields in
|
|
61
|
-
* order. Packed/repeated fields are not collapsed - callers see each
|
|
62
|
-
* occurrence. Unknown fields are included so the walker is reusable. */
|
|
63
|
-
export function walkFields(buf: Uint8Array): Field[] {
|
|
64
|
-
const out: Field[] = [];
|
|
65
|
-
let i = 0;
|
|
66
|
-
while (i < buf.length) {
|
|
67
|
-
const [tag, afterTag] = readVarint(buf, i);
|
|
68
|
-
i = afterTag;
|
|
69
|
-
const field = tag >>> 3;
|
|
70
|
-
const wire = tag & 0x07;
|
|
71
|
-
if (wire === 0) {
|
|
72
|
-
// varint
|
|
73
|
-
const [val, after] = readVarint(buf, i);
|
|
74
|
-
i = after;
|
|
75
|
-
out.push({ field, wire, bytes: null, varint: val });
|
|
76
|
-
} else if (wire === 2) {
|
|
77
|
-
// length-delimited
|
|
78
|
-
const [len, afterLen] = readVarint(buf, i);
|
|
79
|
-
i = afterLen;
|
|
80
|
-
if (i + len > buf.length) {
|
|
81
|
-
throw new RangeError(`field ${field}: length ${len} runs past buffer end`);
|
|
82
|
-
}
|
|
83
|
-
// subarray is a VIEW into the same backing buffer (no copy). Safe here
|
|
84
|
-
// because the view is decoded and discarded within this poll; do NOT
|
|
85
|
-
// retain `Field.bytes` past the current call - the source buffer may
|
|
86
|
-
// be reused or collected differently than a retained slice expects.
|
|
87
|
-
out.push({ field, wire, bytes: buf.subarray(i, i + len), varint: null });
|
|
88
|
-
i += len;
|
|
89
|
-
} else if (wire === 5) {
|
|
90
|
-
// fixed32
|
|
91
|
-
out.push({ field, wire, bytes: null, varint: null });
|
|
92
|
-
i += 4;
|
|
93
|
-
} else if (wire === 1) {
|
|
94
|
-
// fixed64
|
|
95
|
-
out.push({ field, wire, bytes: null, varint: null });
|
|
96
|
-
i += 8;
|
|
97
|
-
} else {
|
|
98
|
-
// wire 3/4 (start/end group) are deprecated and agy never emits them.
|
|
99
|
-
// Throw rather than silently drop every field after this point - a
|
|
100
|
-
// corrupt byte that looks like a group delimiter should fail loudly so
|
|
101
|
-
// the caller (pollOnce) can drop the step and retry on the next poll.
|
|
102
|
-
throw new RangeError(`unexpected wire type ${wire} at field ${field}`);
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
return out;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/** Find the first length-delimited field with the given number, or null.
|
|
109
|
-
* Equivalent to agy-acp's readSubmessage + readMessage dispatch for one field. */
|
|
110
|
-
export function getField(buf: Uint8Array, target: number): Uint8Array | null {
|
|
111
|
-
for (const f of walkFields(buf)) {
|
|
112
|
-
if (f.field === target && f.wire === 2 && f.bytes) return f.bytes;
|
|
113
|
-
}
|
|
114
|
-
return null;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/** Decode a UTF-8 slice to a string. Tolerant: invalid bytes become U+FFFD. */
|
|
118
|
-
const utf8 = new TextDecoder("utf-8", { fatal: false });
|
|
119
|
-
export function utf8String(bytes: Uint8Array): string {
|
|
120
|
-
return utf8.decode(bytes);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
export interface AgentText {
|
|
124
|
-
text: string;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
export interface ToolCallInfo {
|
|
128
|
-
/** Primary tool name (field 2 of toolCall). */
|
|
129
|
-
name: string;
|
|
130
|
-
/** Raw input JSON string (field 3 of toolCall), unparsed. */
|
|
131
|
-
inputJson: string;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** Extract agent text from a step_payload: field 20 -> field 1.
|
|
135
|
-
* Returns null if the payload has no agentText field. */
|
|
136
|
-
export function extractAgentText(payload: Uint8Array): AgentText | null {
|
|
137
|
-
const agentText = getField(payload, 20);
|
|
138
|
-
if (!agentText) return null;
|
|
139
|
-
const text = getField(agentText, 1);
|
|
140
|
-
if (!text) return null;
|
|
141
|
-
return { text: utf8String(text) };
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/** Extract tool-call info from a step_payload: field 5 (toolRun) -> field 4
|
|
145
|
-
* (toolCall) -> fields 2/9 (name) and 3 (inputJson). Returns null if the
|
|
146
|
-
* payload has no toolRun.toolCall. */
|
|
147
|
-
export function extractToolCall(payload: Uint8Array): ToolCallInfo | null {
|
|
148
|
-
const toolRun = getField(payload, 5);
|
|
149
|
-
if (!toolRun) return null;
|
|
150
|
-
const toolCall = getField(toolRun, 4);
|
|
151
|
-
if (!toolCall) return null;
|
|
152
|
-
// Name lives at field 2 (namePrimary) or field 9 (nameSecondary).
|
|
153
|
-
let name = "";
|
|
154
|
-
let inputJson = "";
|
|
155
|
-
for (const f of walkFields(toolCall)) {
|
|
156
|
-
if (f.field === 2 && f.bytes) name ||= utf8String(f.bytes);
|
|
157
|
-
else if (f.field === 9 && f.bytes && !name) name = utf8String(f.bytes);
|
|
158
|
-
else if (f.field === 3 && f.bytes) inputJson ||= utf8String(f.bytes);
|
|
159
|
-
}
|
|
160
|
-
if (!name && !inputJson) return null;
|
|
161
|
-
return { name, inputJson };
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/** Extract the title from a step_payload: field 30 (titleUpdate) -> field 4.
|
|
165
|
-
* Returns null when absent. */
|
|
166
|
-
export function extractTitle(payload: Uint8Array): string | null {
|
|
167
|
-
const titleUpdate = getField(payload, 30);
|
|
168
|
-
if (!titleUpdate) return null;
|
|
169
|
-
const title = getField(titleUpdate, 4);
|
|
170
|
-
return title ? utf8String(title) : null;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/** Decode a Buffer/Uint8Array-shaped column value to a clean Uint8Array.
|
|
174
|
-
* node:sqlite returns Uint8Array for BLOB; better-sqlite3 returns Buffer. */
|
|
175
|
-
export function toUint8(v: unknown): Uint8Array {
|
|
176
|
-
if (v instanceof Uint8Array) return v;
|
|
177
|
-
// Buffer is a Uint8Array subclass; instanceof covers it but be defensive.
|
|
178
|
-
if (ArrayBuffer.isView(v)) {
|
|
179
|
-
const view = v as Uint8Array;
|
|
180
|
-
return new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
|
|
181
|
-
}
|
|
182
|
-
if (v == null) return new Uint8Array(0);
|
|
183
|
-
return new Uint8Array(0);
|
|
184
|
-
}
|