@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.
@@ -0,0 +1,502 @@
1
+ // The pi provider: streamSimple(model, context, options) -> AssistantMessageEventStream.
2
+ //
3
+ // For each turn pi calls streamSimple. We:
4
+ // 1. extract the latest user message (agy keeps its own history, so we send
5
+ // only the new prompt, not pi's full transcript)
6
+ // 2. resolve the pi model id to the exact agy model string
7
+ // 3. look up the stored agy conversation id + last streamed step for this
8
+ // pi session (resume) or start fresh
9
+ // 4. spawn agy via runAgyTurn, mapping decoded AgyEvents to pi stream events
10
+ // 5. persist the conversation id + final step idx for the next turn
11
+ //
12
+ // Event mapping (close-on-switch: at most one content block open at a time,
13
+ // matching pi-claude-bridge's lifecycle):
14
+ // agy text -> pi text block (text_start / text_delta / text_end)
15
+ // agy thinking -> pi thinking block
16
+ // agy tool -> pi thinking block, labelled "[agy tool: <name>]"
17
+ // We do NOT emit toolCall blocks: agy runs its OWN closed tool loop, so there
18
+ // is no toolUse stopReason and no tool-result delivery path back to pi.
19
+
20
+ import {
21
+ createAssistantMessageEventStream,
22
+ type AssistantMessage,
23
+ type AssistantMessageEventStream,
24
+ type Context,
25
+ type Message,
26
+ type Model,
27
+ type SimpleStreamOptions,
28
+ type Usage,
29
+ } from "@earendil-works/pi-ai";
30
+ import type { Api } from "@earendil-works/pi-ai";
31
+ import { runAgyTurn, type AgyEvent, type AgyRunOptions } from "./runner.js";
32
+ import { resolveAgyString, type AgyModelEntry } from "./models.js";
33
+ import { SessionStore } from "./sessions.js";
34
+ import { loadConfig } from "./config.js";
35
+ import path from "node:path";
36
+ import { TurnDiffContext, createExecGitOps, parseEditToolInput } from "./diff-render.js";
37
+
38
+ const DEFAULT_TIMEOUT_MIN = 10;
39
+
40
+ /** Zero-usage helper. agy doesn't expose token counts; pi's cost math gets
41
+ * zeros (we're not billing through this provider). */
42
+ function zeroUsage(): Usage {
43
+ return {
44
+ input: 0,
45
+ output: 0,
46
+ cacheRead: 0,
47
+ cacheWrite: 0,
48
+ totalTokens: 0,
49
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
50
+ };
51
+ }
52
+
53
+ /** Extract the latest user message as a flat prompt string. agy maintains its
54
+ * own conversation history via --conversation, so we collapse pi's structured
55
+ * message to text. Returns null if the last message isn't a user message. */
56
+ function extractUserPrompt(context: Context): string | null {
57
+ const last = context.messages[context.messages.length - 1];
58
+ if (!last || last.role !== "user") return null;
59
+ const content = last.content;
60
+ if (typeof content === "string") return content;
61
+ // Flatten text blocks; drop images (agy CLI prompt is text-only via -p).
62
+ return content
63
+ .filter((b): b is { type: "text"; text: string } => b.type === "text")
64
+ .map((b) => b.text)
65
+ .join("\n")
66
+ .trim() || null;
67
+ }
68
+
69
+ // --- G1: pi-side context digest --------------------------------------------------
70
+ //
71
+ // agy keeps its OWN conversation history (resumed via --conversation), so it
72
+ // already holds every turn it produced. What it lacks is pi-side context it was
73
+ // never spawned for: pi's compaction summaries and turns handled by OTHER
74
+ // providers (or pi's own tools). pi materializes all of that into
75
+ // context.messages every turn (verified: session-manager.js -> convertToLlm),
76
+ // so we build a DELTA digest from those messages and prepend it to the prompt.
77
+ // No pi patch, no new MCP tool. See docs/PI-BRIDGE-GAPS.md (G1).
78
+
79
+ const COMPACTION_MARKER = "compacted into the following summary";
80
+
81
+ const DIGEST_PREAMBLE =
82
+ "[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.]";
83
+
84
+ /** Flatten any message content shape (string or content-block array) to text.
85
+ * Drops images, thinking, and tool-call blocks. */
86
+ function blocksToText(content: unknown): string {
87
+ if (typeof content === "string") return content;
88
+ if (!Array.isArray(content)) return "";
89
+ return content
90
+ .filter(
91
+ (b): b is { type: "text"; text: string } =>
92
+ typeof b === "object" && b !== null && (b as { type?: string }).type === "text",
93
+ )
94
+ .map((b) => b.text)
95
+ .join("\n");
96
+ }
97
+
98
+ /** A compaction summary arrives wrapped in pi's boilerplate prefix/suffix.
99
+ * Return just the summary body. */
100
+ function stripCompactionWrapping(t: string): string {
101
+ const open = t.indexOf("<summary>");
102
+ const close = t.lastIndexOf("</summary>");
103
+ if (open >= 0 && close > open) return t.slice(open + "<summary>".length, close).trim();
104
+ return t.trim();
105
+ }
106
+
107
+ export interface DigestOptions {
108
+ /** Provider id whose assistant turns are already in agy's own DB and so
109
+ * must be skipped to avoid double-counting. Default "antigravity". */
110
+ ownProvider?: string;
111
+ /** Soft cap on the digest body (0 = unbounded). Default 8000. */
112
+ maxChars?: number;
113
+ }
114
+
115
+ /** Build a delta digest of pi-side context agy was not spawned for: the most
116
+ * recent compaction summary plus turns since the watermark that were not
117
+ * produced by this provider. Pure: no I/O. Exported for unit testing.
118
+ *
119
+ * Delta, not replay: skip our own assistant turns (provider === ownProvider)
120
+ * and clamp the window to after any compaction (pre-compaction detail is
121
+ * either already in agy's DB or summarized by the injected summary).
122
+ *
123
+ * Fidelity note: other-provider assistant turns contribute only their text
124
+ * blocks; tool-call and thinking blocks are dropped. The intent (which tool)
125
+ * is lost, but their results still surface separately as toolResult messages. */
126
+ export function buildContextDigest(
127
+ messages: Message[],
128
+ watermark: number,
129
+ opts: DigestOptions = {},
130
+ ): string {
131
+ const own = opts.ownProvider ?? "antigravity";
132
+ const maxChars = opts.maxChars ?? 8000;
133
+ if (messages.length === 0) return "";
134
+
135
+ let summaryPart: string | null = null;
136
+ const deltaParts: string[] = [];
137
+
138
+ // 1. Most-recent compaction summary (scan the whole list; it is never in
139
+ // agy's DB, so it is always safe and high-value to inject).
140
+ let lastCompactionIdx = -1;
141
+ for (let i = messages.length - 1; i >= 0; i--) {
142
+ const m = messages[i];
143
+ if (m.role !== "user") continue;
144
+ const t = blocksToText(m.content);
145
+ if (t.includes(COMPACTION_MARKER)) {
146
+ lastCompactionIdx = i;
147
+ summaryPart = `[pi compaction summary]\n${stripCompactionWrapping(t)}`;
148
+ break;
149
+ }
150
+ }
151
+
152
+ // 2. Delta since the watermark, excluding the trailing current prompt.
153
+ // Clamp start to just after the compaction summary when one is present.
154
+ let start = Math.max(0, Math.floor(watermark));
155
+ if (lastCompactionIdx >= 0) start = Math.max(start, lastCompactionIdx + 1);
156
+ const end = Math.max(0, messages.length - 1);
157
+ for (let i = start; i < end; i++) {
158
+ const m = messages[i];
159
+ if (m.role === "assistant") {
160
+ if (m.provider === own) continue; // our own turn: already in agy's DB
161
+ const t = blocksToText(m.content).trim();
162
+ if (!t) continue;
163
+ deltaParts.push(`[assistant turn from ${m.provider}]\n${t}`);
164
+ } else if (m.role === "user") {
165
+ const t = blocksToText(m.content);
166
+ if (t.includes(COMPACTION_MARKER)) continue; // injected as summaryPart
167
+ if (!t.trim()) continue;
168
+ deltaParts.push(`[earlier user message]\n${t}`);
169
+ } else if (m.role === "toolResult") {
170
+ const t = blocksToText(m.content).trim();
171
+ deltaParts.push(
172
+ `[tool result: ${m.toolName}${m.isError ? " (error)" : ""}]\n${t || "(no text output)"}`,
173
+ );
174
+ }
175
+ }
176
+
177
+ // Assemble. The compaction summary is always kept intact (it is the
178
+ // canonical compressed history). The DELTA is truncated from the newest end
179
+ // backward when over budget: recent context matters more for continuity
180
+ // than older detail, so drop the oldest delta first. If even the newest
181
+ // single item exceeds the budget, keep its tail slice.
182
+ const SEP = "\n\n";
183
+ const MARKER = "[truncated]";
184
+ let delta = deltaParts.join(SEP);
185
+ if (maxChars > 0) {
186
+ const budget = Math.max(0, maxChars - (summaryPart ? summaryPart.length + SEP.length : 0));
187
+ if (delta.length > budget) {
188
+ const kept: string[] = [];
189
+ let used = 0;
190
+ for (let i = deltaParts.length - 1; i >= 0; i--) {
191
+ const cost = deltaParts[i].length + (kept.length > 0 ? SEP.length : 0);
192
+ if (used + cost > budget) break;
193
+ kept.unshift(deltaParts[i]);
194
+ used += cost;
195
+ }
196
+ if (kept.length > 0) {
197
+ delta = `${MARKER}\n${kept.join(SEP)}`;
198
+ } else {
199
+ const room = Math.max(0, budget - MARKER.length - 1);
200
+ delta = room > 0 ? `${MARKER}\n${deltaParts[deltaParts.length - 1].slice(-room)}` : "";
201
+ }
202
+ }
203
+ }
204
+
205
+ return [summaryPart, delta]
206
+ .filter((s): s is string => typeof s === "string" && s.length > 0)
207
+ .join(SEP);
208
+ }
209
+
210
+ /** Build a fresh AssistantMessage shell for this turn. Mutated as blocks
211
+ * stream; passed as `partial` with every event. */
212
+ function newAssistant(model: Model<Api>): AssistantMessage {
213
+ return {
214
+ role: "assistant",
215
+ content: [],
216
+ api: model.api,
217
+ provider: model.provider,
218
+ model: model.id,
219
+ usage: zeroUsage(),
220
+ stopReason: "stop",
221
+ timestamp: Date.now(),
222
+ };
223
+ }
224
+
225
+ /** Session key: prefer pi's sessionId (stable per conversation), fall back to
226
+ * cwd so a single pi process still resumes correctly when sessionId is absent. */
227
+ function sessionKey(options: SimpleStreamOptions | undefined, cwd: string): string {
228
+ const sid = (options as { sessionId?: string } | undefined)?.sessionId;
229
+ return sid && sid.length > 0 ? `sid:${sid}` : `cwd:${cwd}`;
230
+ }
231
+
232
+ /** Track which content block is currently open so we close-on-switch.
233
+ * At most one of textIdx / thinkingIdx is non-null at a time. */
234
+ interface BlockState {
235
+ partial: AssistantMessage;
236
+ textIdx: number | null;
237
+ thinkingIdx: number | null;
238
+ started: boolean;
239
+ }
240
+
241
+ export interface StreamSimpleDeps {
242
+ entries: AgyModelEntry[];
243
+ store: SessionStore;
244
+ /** Override the agy turn runner (tests inject a scripted event source).
245
+ * Defaults to the real runAgyTurn. */
246
+ runAgyTurn?: typeof runAgyTurn;
247
+ }
248
+
249
+ /** Build the streamSimple closure. Captures the model catalog + session store
250
+ * resolved at extension load. */
251
+ export function createStreamSimple(
252
+ deps: StreamSimpleDeps,
253
+ ): (model: Model<Api>, context: Context, options?: SimpleStreamOptions) => AssistantMessageEventStream {
254
+ const { entries, store, runAgyTurn: runFn = runAgyTurn } = deps;
255
+
256
+ return function streamSimple(model, context, options) {
257
+ const stream = createAssistantMessageEventStream();
258
+ // Fire the async turn; return the stream synchronously per pi's contract.
259
+ void runTurn(stream, model, context, options, entries, store, runFn);
260
+ return stream;
261
+ };
262
+ }
263
+
264
+ async function runTurn(
265
+ stream: AssistantMessageEventStream,
266
+ model: Model<Api>,
267
+ context: Context,
268
+ options: SimpleStreamOptions | undefined,
269
+ entries: AgyModelEntry[],
270
+ store: SessionStore,
271
+ runFn: typeof runAgyTurn,
272
+ ): Promise<void> {
273
+ const partial = newAssistant(model);
274
+ const blocks: BlockState = { partial, textIdx: null, thinkingIdx: null, started: false };
275
+
276
+ // Direct emit helpers. agy streams deltas that may not align to line
277
+ // boundaries; pi's TUI renders partial lines fine, so we append and push
278
+ // each delta straight through (no filtering, no buffering).
279
+ const appendText = (delta: string): void => {
280
+ ensureTextOpen(stream, blocks);
281
+ textAt(partial, blocks.textIdx!).text += delta;
282
+ stream.push({ type: "text_delta", contentIndex: blocks.textIdx!, delta, partial });
283
+ };
284
+ const appendThinking = (delta: string): void => {
285
+ ensureThinkingOpen(stream, blocks);
286
+ thinkingAt(partial, blocks.thinkingIdx!).thinking += delta;
287
+ stream.push({ type: "thinking_delta", contentIndex: blocks.thinkingIdx!, delta, partial });
288
+ };
289
+
290
+ // Signal the turn has begun IMMEDIATELY. pi's native Working indicator is
291
+ // driven by the stream's start event (isStreaming). Without this, agy's
292
+ // initial thinking seconds (before it emits any step) show nothing and the
293
+ // UI looks frozen. Lazy start (on first content) was the old behavior.
294
+ ensureStarted(stream, blocks);
295
+
296
+ const cwd = (options as { cwd?: string } | undefined)?.cwd ?? process.cwd();
297
+ const key = sessionKey(options, cwd);
298
+ const existing = store.get(key);
299
+ const messageCount = context.messages.length;
300
+
301
+ const prompt = extractUserPrompt(context);
302
+ if (!prompt) {
303
+ finalize(stream, blocks, "error", "No user message to send to agy.");
304
+ return;
305
+ }
306
+
307
+ // G1: inject a delta digest of pi-side context agy was not spawned for
308
+ // (compaction summaries, other-provider turns). agy keeps its own history,
309
+ // so this is a delta, not a replay. See docs/PI-BRIDGE-GAPS.md (G1).
310
+ const watermark = existing?.lastMessageCount ?? 0;
311
+ const digest = buildContextDigest(context.messages, watermark);
312
+ const fullPrompt = digest ? `${DIGEST_PREAMBLE}\n\n${digest}\n\n---\n\n${prompt}` : prompt;
313
+
314
+ // Resolve the pi model id to the exact agy string. Fall through to the id
315
+ // itself on miss - agy will likely reject, but the error reaches the user
316
+ // instead of a silent no-op.
317
+ const agyModel = resolveAgyString(model.id, entries) ?? model.id;
318
+
319
+ // Runtime config (mode, permissions). Loaded fresh each turn so /agy
320
+ // toggles take effect immediately without a reload.
321
+ const config = loadConfig();
322
+
323
+ const runOpts: AgyRunOptions = {
324
+ cwd,
325
+ model: agyModel,
326
+ mode: config.mode,
327
+ skipPermissions: config.skipPermissions,
328
+ prompt: fullPrompt,
329
+ conversationId: existing?.conversationId ?? null,
330
+ baseStepIdx: existing?.lastStepIdx ?? -1,
331
+ timeoutMin: DEFAULT_TIMEOUT_MIN,
332
+ signal: options?.signal,
333
+ };
334
+
335
+ // G8: per-turn diff context for agy's file edits (write_to_file et al.).
336
+ // Turn-scoped so concurrent turns never share OLD-content caches.
337
+ const diffCtx = new TurnDiffContext(createExecGitOps());
338
+
339
+ const onEvent = (event: AgyEvent) => {
340
+ switch (event.kind) {
341
+ case "text":
342
+ appendText(event.text);
343
+ break;
344
+ case "thinking":
345
+ appendThinking(event.text);
346
+ break;
347
+ case "tool": {
348
+ // G8: if agy wrote a file, surface a git-sourced diff; else the plain
349
+ // tool label. Always shown (agy's own tool loop, surfaced for visibility).
350
+ const edit = parseEditToolInput(event.inputJson ?? "");
351
+ if (edit) {
352
+ const absFile = path.isAbsolute(edit.file) ? edit.file : path.resolve(cwd, edit.file);
353
+ const outcome = diffCtx.diffEdit(absFile, edit.content);
354
+ const label = edit.description ?? path.basename(absFile);
355
+ appendThinking(`[agy edit: ${label}]\n`);
356
+ if (outcome.text) appendThinking(`${outcome.text}\n`);
357
+ } else {
358
+ appendThinking(`[agy tool: ${event.name}]\n`);
359
+ }
360
+ break;
361
+ }
362
+ case "title":
363
+ // Conversation title metadata - not streamed to the user.
364
+ break;
365
+ }
366
+ };
367
+
368
+ let result;
369
+ try {
370
+ result = await runFn(runOpts, onEvent);
371
+ } catch (err) {
372
+ const msg = err instanceof Error ? err.message : String(err);
373
+ finalize(stream, blocks, "error", `agy failed to start: ${msg}`);
374
+ return;
375
+ }
376
+
377
+ // Persist for the next turn (resume). Only bind when we actually discovered
378
+ // an id - a discovery miss shouldn't clobber a prior good binding.
379
+ // Persist for the next turn (resume). Only bind when we actually discovered
380
+ // an id - a discovery miss shouldn't clobber a prior good binding. The
381
+ // lastMessageCount watermark advances on a successful bind even if the turn
382
+ // later aborted or timed out: the prompt (digest included) was handed to
383
+ // agy at spawn, so its DB has seen that context. Guarding this on
384
+ // exitCode===0 would re-inject stale deltas after retryable failures.
385
+ if (result.conversationId) {
386
+ store.set(key, {
387
+ conversationId: result.conversationId,
388
+ lastStepIdx: result.lastIdx,
389
+ lastMessageCount: messageCount,
390
+ });
391
+ }
392
+
393
+ if (result.aborted) {
394
+ finalize(stream, blocks, "aborted", "Operation aborted");
395
+ return;
396
+ }
397
+ if (result.timedOut) {
398
+ const note = `agy exceeded the ${runOpts.timeoutMin}m timeout`;
399
+ finalize(stream, blocks, "error", note);
400
+ return;
401
+ }
402
+ if (result.exitCode !== 0) {
403
+ const detail = result.stderr.trim() || `agy exited with status ${result.exitCode}`;
404
+ finalize(stream, blocks, "error", detail);
405
+ return;
406
+ }
407
+
408
+ // Discovery miss: agy exited cleanly but we never bound a conversation id
409
+ // this turn (ambiguous snapshot, DB not created in time, or a prior session
410
+ // whose id failed CONV_ID_RE and silently fell through to fresh discovery).
411
+ // Guard on whether we bound THIS turn, not on whether a prior session
412
+ // existed - otherwise a corrupt existing entry re-opens the silent-empty-
413
+ // success hole the first review closed.
414
+ if (!result.conversationId) {
415
+ const detail =
416
+ "agy exited cleanly but its conversation database could not be bound. " +
417
+ "The run may have partially applied edits with no visible output.";
418
+ finalize(stream, blocks, "error", detail);
419
+ return;
420
+ }
421
+
422
+ // Success. If no text ever streamed (agy did only tool work, or returned
423
+ // empty), emit an empty text block so pi has a well-formed assistant turn.
424
+ if (blocks.textIdx === null && blocks.thinkingIdx === null) {
425
+ ensureTextOpen(stream, blocks);
426
+ }
427
+ finalize(stream, blocks, "stop");
428
+ }
429
+
430
+ /** Signal the start of the assistant turn exactly once. `start` is
431
+ * turn-level (analogous to Anthropic's message_start), not per-block - the
432
+ * per-block signals are text_start / thinking_start. */
433
+ function ensureStarted(stream: AssistantMessageEventStream, b: BlockState): void {
434
+ if (b.started) return;
435
+ b.started = true;
436
+ stream.push({ type: "start", partial: b.partial });
437
+ }
438
+
439
+ /** Open the text block, closing the thinking block first if it's open. */
440
+ function ensureTextOpen(stream: AssistantMessageEventStream, b: BlockState): void {
441
+ if (b.textIdx !== null) return;
442
+ closeThinking(stream, b);
443
+ ensureStarted(stream, b);
444
+ b.partial.content.push({ type: "text", text: "" });
445
+ b.textIdx = b.partial.content.length - 1;
446
+ stream.push({ type: "text_start", contentIndex: b.textIdx, partial: b.partial });
447
+ }
448
+
449
+ /** Open the thinking block, closing the text block first if it's open. */
450
+ function ensureThinkingOpen(stream: AssistantMessageEventStream, b: BlockState): void {
451
+ if (b.thinkingIdx !== null) return;
452
+ closeText(stream, b);
453
+ ensureStarted(stream, b);
454
+ b.partial.content.push({ type: "thinking", thinking: "" });
455
+ b.thinkingIdx = b.partial.content.length - 1;
456
+ stream.push({ type: "thinking_start", contentIndex: b.thinkingIdx, partial: b.partial });
457
+ }
458
+
459
+ function closeText(stream: AssistantMessageEventStream, b: BlockState): void {
460
+ if (b.textIdx === null) return;
461
+ const idx = b.textIdx;
462
+ b.textIdx = null;
463
+ stream.push({ type: "text_end", contentIndex: idx, content: textAt(b.partial, idx).text, partial: b.partial });
464
+ }
465
+
466
+ function closeThinking(stream: AssistantMessageEventStream, b: BlockState): void {
467
+ if (b.thinkingIdx === null) return;
468
+ const idx = b.thinkingIdx;
469
+ b.thinkingIdx = null;
470
+ stream.push({ type: "thinking_end", contentIndex: idx, content: thinkingAt(b.partial, idx).thinking, partial: b.partial });
471
+ }
472
+
473
+ // Typed accessors: AssistantMessage.content is a discriminated union, but we
474
+ // always know which slot holds which block (we just pushed it). The cast is
475
+ // sound and keeps every mutation site free of scattered `as` expressions.
476
+ function textAt(p: AssistantMessage, idx: number): { type: "text"; text: string } {
477
+ return p.content[idx] as { type: "text"; text: string };
478
+ }
479
+
480
+ function thinkingAt(p: AssistantMessage, idx: number): { type: "thinking"; thinking: string } {
481
+ return p.content[idx] as { type: "thinking"; thinking: string };
482
+ }
483
+
484
+ /** Close any open block and push the terminal event. */
485
+ function finalize(
486
+ stream: AssistantMessageEventStream,
487
+ b: BlockState,
488
+ reason: "stop" | "error" | "aborted",
489
+ message?: string,
490
+ ): void {
491
+ closeText(stream, b);
492
+ closeThinking(stream, b);
493
+ if (reason === "stop") {
494
+ b.partial.stopReason = "stop";
495
+ stream.push({ type: "done", reason: "stop", message: b.partial });
496
+ } else {
497
+ b.partial.stopReason = reason;
498
+ if (message) b.partial.errorMessage = message;
499
+ stream.push({ type: "error", reason, error: b.partial });
500
+ }
501
+ stream.end();
502
+ }