@estebanforge/pi-antigravity-bridge 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +125 -0
- package/LICENSE +21 -0
- package/README.md +153 -0
- package/docs/ARCHITECTURE.md +48 -0
- package/docs/DEVELOPMENT.md +64 -0
- package/docs/PI-BRIDGE-GAPS.md +186 -0
- package/docs/PI-INVOKETOOL-PATCH.md +227 -0
- package/extensions/index.ts +474 -0
- package/package.json +69 -0
- package/src/ask-tool.ts +579 -0
- package/src/config.ts +119 -0
- package/src/diff-render.ts +190 -0
- package/src/discovery.ts +199 -0
- package/src/mcp-server.ts +443 -0
- package/src/models.ts +261 -0
- package/src/patcher.ts +571 -0
- package/src/poller.ts +202 -0
- package/src/protobuf.ts +184 -0
- package/src/provider.ts +502 -0
- package/src/runner.ts +386 -0
- package/src/sessions.ts +159 -0
package/src/poller.ts
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
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
|
+
}
|