@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/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
- /** Override the agy turn runner (tests inject a scripted event source).
249
- * Defaults to the real runAgyTurn. Legacy engine only. */
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
- const fullPrompt = digest ? `${DIGEST_PREAMBLE}\n\n${digest}\n\n---\n\n${prompt}` : prompt;
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, runAgyTurn: runFn = runAgyTurn, driver, roundTrips } = deps;
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
- const config = loadConfig();
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
- void runTurn(stream, model, context, options, entries, store, runFn);
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
- }