@estebanforge/pi-antigravity-bridge 1.6.0 → 1.6.2

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 CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [1.6.2] - 2026-09-19
6
+
7
+ ### Fixed
8
+
9
+ - **Compatibility with host 0.86.0's normalized provider stream input.** Stream entry points now receive a branded `TranscriptContext` (`{ messages }` only): the `context.systemPrompt` / `context.tools` fields are gone, and only `normalizeContext()` produces the new type. G10 fresh-conversation delivery read `context.systemPrompt`, so every fresh agy conversation silently lost the system prompt block (operating instructions, AGENTS.md project context, tool-priority note); the prompt now comes from the transcript via `getCurrentSystemPrompt(context.messages)`. `emitToolUse` arguments are restricted to `Record<string, JsonValue>` per the tightened `ToolCall.arguments` contract (driver args arrive JSON-decoded off the wire, so the two call-site casts are sound). Test fixtures in `provider-{sysprompt,streaming,late-result,escalation}.test.ts` and the `scripts/test-provider.ts` smoke context wrap raw objects with `normalizeContext()`. Dev pins `@earendil-works/*` raised `^0.85.0` to `^0.86.0`; `tsc` clean, 360 tests green.
10
+
11
+ ## [1.6.1] - 2026-09-18
12
+
13
+ ### Added
14
+
15
+ - **`thinking` / `effort` params on AskAntigravity (synonyms).** Delegation calls had no direct tier knob: the only lever was tier sugar inside `model` ("flash high"), so a caller told "peer review on high thinking" fell through to the configured default (medium) with no signal anything was dropped. The tool now takes `thinking` (pi vocabulary: minimal|low|medium|high|xhigh|max, clamped to agy's low|medium|high; unknown values fall back to low) plus `effort` as the same knob under agy's own name, so calling models reach for either word. An explicit level beats a tier embedded in `model` and the config default; passing both with different values errors; fixed-thinking families (Claude, GPT-OSS) ignore it because agy rejects `--effort` for them. `toAgyEffort` moved from `src/provider.ts` to `src/models.ts` so provider turns and the delegation tool share one clamp.
16
+
5
17
  ## [1.6.0] - 2026-09-14
6
18
 
7
19
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estebanforge/pi-antigravity-bridge",
3
- "version": "1.6.0",
3
+ "version": "1.6.2",
4
4
  "description": "Gemini provider for Pi on the Antigravity ACP server (official Google ACP) or the stream-json agy CLI. antigravity/* models in Pi's /model picker, no-patch MCP bridge: agy runs Pi's tools. ToS safe to use.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -59,10 +59,10 @@
59
59
  }
60
60
  },
61
61
  "devDependencies": {
62
- "@earendil-works/pi-ai": "^0.85.0",
63
- "@earendil-works/pi-coding-agent": "^0.85.0",
64
- "@earendil-works/pi-server": "^0.85.0",
65
- "@earendil-works/pi-tui": "^0.85.0",
62
+ "@earendil-works/pi-ai": "^0.86.0",
63
+ "@earendil-works/pi-coding-agent": "^0.86.0",
64
+ "@earendil-works/pi-server": "^0.86.0",
65
+ "@earendil-works/pi-tui": "^0.86.0",
66
66
  "@types/node": "^22.0.0",
67
67
  "tsx": "^4.19.0",
68
68
  "typebox": "^1.1.38",
package/src/ask-tool.ts CHANGED
@@ -17,7 +17,7 @@ import * as path from "node:path";
17
17
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
18
18
  import { buildSessionContext, getAgentDir, keyHint } from "@earendil-works/pi-coding-agent";
19
19
  import { Text } from "@earendil-works/pi-tui";
20
- import { contentText } from "@earendil-works/pi-ai";
20
+ import { contentText, type ThinkingLevel } from "@earendil-works/pi-ai";
21
21
  import { Type } from "typebox";
22
22
  import {
23
23
  CONVERSATIONS_DIR,
@@ -26,7 +26,7 @@ import {
26
26
  } from "./discovery.js";
27
27
  import { loadConfig, type AgyMode, type ThinkingTier } from "./config.js";
28
28
  import { acquireBridgeSuppression } from "./mcp-registration.js";
29
- import { spawnAgyModelsRaw } from "./models.js";
29
+ import { AGY_EFFORT_ORDER, spawnAgyModelsRaw, toAgyEffort } from "./models.js";
30
30
 
31
31
  // --- Constants -------------------------------------------------------------
32
32
 
@@ -80,7 +80,12 @@ EXECUTION MODES (param: mode):
80
80
  - **plan**: agy reviews and plans without writing. Use for cross-review and read-only tasks.
81
81
  - **accept-edits** (default): agy applies edits directly inside the workspace.
82
82
 
83
- COMPACT OUTPUT (param: digest): when true, the prompt is prefixed to request compact digests instead of full file contents. Defaults on for plan, off for accept-edits.`;
83
+ COMPACT OUTPUT (param: digest): when true, the prompt is prefixed to request compact digests instead of full file contents. Defaults on for plan, off for accept-edits.
84
+
85
+ THINKING LEVEL (params: thinking, effort - SYNONYMS for one knob):
86
+ - pi calls it thinking, agy calls it effort. Same thing. Pass ONE of the two.
87
+ - Values (pi vocabulary): minimal|low|medium|high|xhigh|max. Clamped to agy's low|medium|high; unknown values fall back to low. "peer review on high thinking" -> thinking: "high".
88
+ - An explicit level beats a tier embedded in model ("flash high") and the configured default. Omit both for the configured default.`;
84
89
 
85
90
  // --- Types -----------------------------------------------------------------
86
91
 
@@ -176,6 +181,10 @@ export function resolveModel(
176
181
  input: string,
177
182
  entries: ModelEntry[],
178
183
  defaultThinking: ThinkingTier,
184
+ /** Explicit thinking param (thinking/effort). Beats a tier embedded in
185
+ * the alias ("flash high") and the configured default; clamped to the
186
+ * family's real tiers, ignored for fixed-thinking families. */
187
+ preferredTier?: ThinkingTier,
179
188
  ): ResolvedModel | null {
180
189
  const lower = input.toLowerCase().trim();
181
190
 
@@ -232,6 +241,7 @@ export function resolveModel(
232
241
  if (familyTiers.size === 0) return toResolved(candidates[0].full, null);
233
242
 
234
243
  const preferred =
244
+ preferredTier ??
235
245
  tier ??
236
246
  (familyTiers.has(defaultThinking) ? defaultThinking : FAMILY_DEFAULT_TIER[family]);
237
247
  const chosenTier = nearestTier([...familyTiers], preferred);
@@ -293,6 +303,18 @@ export async function registerAskAntigravityTool(
293
303
  "Model alias or exact id. Friendly: 'flash', 'pro', 'gemini'. Add a tier: 'flash high'. Pin a version: '3.5 flash'. Exact: 'gemini-3.6-flash-medium'. Omit for the configured default.",
294
304
  }),
295
305
  ),
306
+ thinking: Type.Optional(
307
+ Type.String({
308
+ description:
309
+ "Thinking level (= agy effort tier). pi vocabulary: minimal|low|medium|high|xhigh|max, clamped to agy's low|medium|high (unknown values fall back to low). Overrides a tier embedded in `model`. Omit for the configured default.",
310
+ }),
311
+ ),
312
+ effort: Type.Optional(
313
+ Type.String({
314
+ description:
315
+ "Alias for `thinking` (agy's own name for the same knob). Pass ONE of the two; different values on both is an error.",
316
+ }),
317
+ ),
296
318
  mode: Type.Optional(
297
319
  Type.Union([Type.Literal("plan"), Type.Literal("accept-edits")], {
298
320
  description:
@@ -327,8 +349,14 @@ export async function registerAskAntigravityTool(
327
349
  // row identifies what will actually run, not just explicit args.
328
350
  const cfg = loadConfig();
329
351
  const requestedModel = (args.model as string | undefined)?.trim() || cfg.defaultModel;
352
+ const thinkingArg = (args.thinking as string | undefined) ?? (args.effort as string | undefined);
330
353
  const resolved =
331
- resolveModel(requestedModel, entries, cfg.defaultThinking) ?? { model: requestedModel };
354
+ resolveModel(
355
+ requestedModel,
356
+ entries,
357
+ cfg.defaultThinking,
358
+ thinkingArg ? toAgyEffort(thinkingArg as ThinkingLevel, AGY_EFFORT_ORDER) : undefined,
359
+ ) ?? { model: requestedModel };
332
360
  const thinking: ThinkingTier = resolved.effort ?? cfg.defaultThinking;
333
361
  const mode: AgyMode = (args.mode as AgyMode | undefined) ?? "accept-edits";
334
362
  const useDigest = typeof args.digest === "boolean" ? args.digest : mode === "plan";
@@ -415,8 +443,27 @@ export async function registerAskAntigravityTool(
415
443
  details: emptyDetails(requestedModel),
416
444
  };
417
445
  }
446
+ if (
447
+ typeof params.thinking === "string" &&
448
+ typeof params.effort === "string" &&
449
+ params.thinking !== params.effort
450
+ ) {
451
+ return {
452
+ content: [
453
+ {
454
+ type: "text",
455
+ text: "thinking and effort are synonyms for the same knob - pass one, not both with different values.",
456
+ },
457
+ ],
458
+ details: emptyDetails(requestedModel),
459
+ };
460
+ }
461
+ const thinkingArg = (params.thinking as string | undefined) ?? (params.effort as string | undefined);
462
+ const preferredTier = thinkingArg
463
+ ? toAgyEffort(thinkingArg as ThinkingLevel, AGY_EFFORT_ORDER)
464
+ : undefined;
418
465
  const resolved =
419
- resolveModel(requestedModel, entries, config.defaultThinking) ?? {
466
+ resolveModel(requestedModel, entries, config.defaultThinking, preferredTier) ?? {
420
467
  model: requestedModel,
421
468
  };
422
469
 
package/src/models.ts CHANGED
@@ -21,7 +21,7 @@ import { spawn } from "node:child_process";
21
21
  import fs from "node:fs";
22
22
  import os from "node:os";
23
23
  import path from "node:path";
24
- import type { Api, Model, ThinkingLevelMap } from "@earendil-works/pi-ai";
24
+ import type { Api, Model, ThinkingLevel, ThinkingLevelMap } from "@earendil-works/pi-ai";
25
25
 
26
26
  const DISCOVERY_TIMEOUT_MS = 8_000;
27
27
 
@@ -31,6 +31,47 @@ export type AgyEffort = "low" | "medium" | "high";
31
31
  /** low < medium < high, for sorting/clamping. */
32
32
  const EFFORT_RANK: Record<AgyEffort, number> = { low: 0, medium: 1, high: 2 };
33
33
 
34
+ /** pi thinking-effort order mirrors agy's, for clamping. */
35
+ export const AGY_EFFORT_ORDER: readonly AgyEffort[] = ["low", "medium", "high"];
36
+
37
+ /** Map pi's thinking level onto one of the tiers `efforts` the base actually
38
+ * supports, clamping to the nearest available. agy rejects an effort tier a
39
+ * base doesn't list (e.g. medium on Pro), so we never emit one. Shared by the
40
+ * provider turns AND the AskAntigravity delegation tool (thinking/effort
41
+ * params): one vocabulary, one clamp. When pi sends no level we fall to the
42
+ * first available tier. */
43
+ export function toAgyEffort(
44
+ reasoning: ThinkingLevel | undefined,
45
+ efforts: readonly AgyEffort[],
46
+ ): AgyEffort {
47
+ let candidate: AgyEffort;
48
+ switch (reasoning) {
49
+ case "minimal":
50
+ case "low":
51
+ candidate = "low";
52
+ break;
53
+ case "medium":
54
+ candidate = "medium";
55
+ break;
56
+ case "high":
57
+ case "xhigh":
58
+ case "max":
59
+ candidate = "high";
60
+ break;
61
+ default:
62
+ candidate = efforts[0] ?? "low";
63
+ }
64
+ if (efforts.includes(candidate)) return candidate;
65
+ const i = AGY_EFFORT_ORDER.indexOf(candidate);
66
+ for (let j = i; j < AGY_EFFORT_ORDER.length; j++) {
67
+ if (efforts.includes(AGY_EFFORT_ORDER[j])) return AGY_EFFORT_ORDER[j];
68
+ }
69
+ for (let j = i - 1; j >= 0; j--) {
70
+ if (efforts.includes(AGY_EFFORT_ORDER[j])) return AGY_EFFORT_ORDER[j];
71
+ }
72
+ return efforts[0] ?? "low";
73
+ }
74
+
34
75
  /** Split an agy slug into (base, tier). tier is null when the slug has no
35
76
  * -high/-medium/-low suffix (claude-sonnet-4-6, gpt-oss-120b-medium's "medium"
36
77
  * IS its suffix here, claude-opus-4-6-thinking is not a tier). */
package/src/provider.ts CHANGED
@@ -19,13 +19,15 @@
19
19
 
20
20
  import {
21
21
  createAssistantMessageEventStream,
22
+ getCurrentSystemPrompt,
22
23
  type AssistantMessage,
23
24
  type AssistantMessageEventStream,
24
- type Context,
25
+ type JsonValue,
25
26
  type Message,
26
27
  type Model,
27
28
  type SimpleStreamOptions,
28
29
  type ThinkingLevel,
30
+ type TranscriptContext,
29
31
  type Usage,
30
32
  } from "@earendil-works/pi-ai";
31
33
  import fs from "node:fs";
@@ -33,7 +35,7 @@ import type { Api } from "@earendil-works/pi-ai";
33
35
  import type { DriverActivity, TurnDriver, TurnHandle } from "./driver-types.js";
34
36
  import { toPiUsage } from "./stream-events.js";
35
37
  import { mapAgyToolToNative } from "./native-tools.js";
36
- import { type AgyEffort, type AgyModelEntry } from "./models.js";
38
+ import { toAgyEffort, type AgyModelEntry } from "./models.js";
37
39
  import { SessionStore } from "./sessions.js";
38
40
  import { loadConfig } from "./config.js";
39
41
  import { GATE_MARKER, mapNativeToShadow, stripMarkerFields } from "./approval-gate.js";
@@ -59,7 +61,7 @@ function zeroUsage(): Usage {
59
61
  /** Extract the latest user message as a flat prompt string. agy maintains its
60
62
  * own conversation history via --conversation, so we collapse pi's structured
61
63
  * message to text. Returns null if the last message isn't a user message. */
62
- function extractUserPrompt(context: Context): string | null {
64
+ function extractUserPrompt(context: TranscriptContext): string | null {
63
65
  const last = context.messages[context.messages.length - 1];
64
66
  if (!last || last.role !== "user") return null;
65
67
  const content = last.content;
@@ -75,7 +77,7 @@ function extractUserPrompt(context: Context): string | null {
75
77
  /** Image blocks of the latest user message (pi-ai ImageContent: base64 data
76
78
  * + mimeType). The ACP engine forwards them as typed content blocks; the
77
79
  * stream-json CLI prompt is text-only, so its driver simply ignores these. */
78
- function extractImages(context: Context): Array<{ data: string; mimeType: string }> {
80
+ function extractImages(context: TranscriptContext): Array<{ data: string; mimeType: string }> {
79
81
  const last = context.messages[context.messages.length - 1];
80
82
  if (!last || last.role !== "user" || typeof last.content === "string") return [];
81
83
  return last.content
@@ -100,7 +102,8 @@ const DIGEST_PREAMBLE =
100
102
 
101
103
  // --- pi system prompt (G10) ----------------------------------------------------
102
104
  //
103
- // pi composes context.systemPrompt every turn: its own operating instructions
105
+ // pi normalizes the system prompt into the transcript every turn (read via
106
+ // getCurrentSystemPrompt): its own operating instructions
104
107
  // plus every AGENTS.md/CLAUDE.md it loaded (global agent dir first, then
105
108
  // ancestors). The provider used to drop it, so agy models never saw the user's
106
109
  // machine-level or project-level instructions. agy has no system-prompt flag
@@ -338,46 +341,6 @@ export interface StreamSimpleDeps {
338
341
  log?: (event: string, data?: unknown, level?: "debug" | "info" | "warn" | "error") => void;
339
342
  }
340
343
 
341
- /** pi thinking-effort order mirrors agy's, for clamping. */
342
- const AGY_EFFORT_ORDER: readonly AgyEffort[] = ["low", "medium", "high"];
343
-
344
- /** Map pi's thinking level onto one of the tiers `efforts` the base actually
345
- * supports, clamping to the nearest available. agy rejects an effort tier a
346
- * base doesn't list (e.g. medium on Pro), so we never emit one. A base slug is
347
- * invalid without --effort, so when pi sends no level we default to the
348
- * middle tier (or the highest available). */
349
- export function toAgyEffort(
350
- reasoning: ThinkingLevel | undefined,
351
- efforts: readonly AgyEffort[],
352
- ): AgyEffort {
353
- let candidate: AgyEffort;
354
- switch (reasoning) {
355
- case "minimal":
356
- case "low":
357
- candidate = "low";
358
- break;
359
- case "medium":
360
- candidate = "medium";
361
- break;
362
- case "high":
363
- case "xhigh":
364
- case "max":
365
- candidate = "high";
366
- break;
367
- default:
368
- candidate = efforts[0] ?? "low";
369
- }
370
- if (efforts.includes(candidate)) return candidate;
371
- const i = AGY_EFFORT_ORDER.indexOf(candidate);
372
- for (let j = i; j < AGY_EFFORT_ORDER.length; j++) {
373
- if (efforts.includes(AGY_EFFORT_ORDER[j])) return AGY_EFFORT_ORDER[j];
374
- }
375
- for (let j = i - 1; j >= 0; j--) {
376
- if (efforts.includes(AGY_EFFORT_ORDER[j])) return AGY_EFFORT_ORDER[j];
377
- }
378
- return efforts[0] ?? "low";
379
- }
380
-
381
344
  // --- G9: no-patch pi-tool round-trips -----------------------------------------
382
345
  //
383
346
  // The MCP bridge's onToolCall parks the call here instead of executing it:
@@ -961,13 +924,14 @@ function nextRtId(kind: "nat" | "wrap"): string {
961
924
  return `${kind}-${++RT_SEQ}`;
962
925
  }
963
926
 
964
- /** Emit a complete toolCall block and end the pi call with toolUse. */
927
+ /** Emit a complete toolCall block and end the pi call with toolUse.
928
+ * pi 0.86 restricts ToolCall.arguments to JSON-compatible values. */
965
929
  function emitToolUse(
966
930
  stream: AssistantMessageEventStream,
967
931
  blocks: BlockState,
968
932
  id: string,
969
933
  name: string,
970
- args: Record<string, unknown>,
934
+ args: Record<string, JsonValue>,
971
935
  ): void {
972
936
  const partial = blocks.partial;
973
937
  closeThinking(stream, blocks);
@@ -1090,7 +1054,7 @@ function acpEditFileArg(args: Record<string, unknown>): string | undefined {
1090
1054
  emitToolUse(stream, blocks, id, mapped.tool, {
1091
1055
  reasoning: `re-exec of agy ${activity.name} for display`,
1092
1056
  ...mapped.args,
1093
- });
1057
+ } as Record<string, JsonValue>);
1094
1058
  return "parked";
1095
1059
  }
1096
1060
  {
@@ -1107,7 +1071,9 @@ function acpEditFileArg(args: Record<string, unknown>): string | undefined {
1107
1071
  case "bridge_call": {
1108
1072
  // Park the pi call: real tool name + args, toolUse stopReason. pi
1109
1073
  // executes; the toolResult returns on the next stream call.
1110
- emitToolUse(stream, blocks, activity.callId, activity.name, activity.args);
1074
+ // Driver args are JSON-decoded off the agy wire, so the JsonValue
1075
+ // cast is sound.
1076
+ emitToolUse(stream, blocks, activity.callId, activity.name, activity.args as Record<string, JsonValue>);
1111
1077
  return "parked";
1112
1078
  }
1113
1079
  }
@@ -1117,7 +1083,7 @@ function acpEditFileArg(args: Record<string, unknown>): string | undefined {
1117
1083
  async function runTurnDriver(
1118
1084
  stream: AssistantMessageEventStream,
1119
1085
  model: Model<Api>,
1120
- context: Context,
1086
+ context: TranscriptContext,
1121
1087
  options: SimpleStreamOptions | undefined,
1122
1088
  entries: AgyModelEntry[],
1123
1089
  store: SessionStore,
@@ -1219,7 +1185,9 @@ async function runTurnDriver(
1219
1185
  // Fresh conversation only: agy stores the block in its own history, so
1220
1186
  // re-sending it every turn would bloat each prompt and bust the cache.
1221
1187
  const sysPrompt =
1222
- config.systemPrompt && !existing?.conversationId ? context.systemPrompt : undefined;
1188
+ config.systemPrompt && !existing?.conversationId
1189
+ ? getCurrentSystemPrompt(context.messages) || undefined
1190
+ : undefined;
1223
1191
  const fullPrompt =
1224
1192
  late.length > 0
1225
1193
  ? buildLateResultPrompt(late, prompt || undefined)
@@ -1298,7 +1266,7 @@ async function runTurnDriver(
1298
1266
  * fallback). */
1299
1267
  export function createStreamSimple(
1300
1268
  deps: StreamSimpleDeps,
1301
- ): (model: Model<Api>, context: Context, options?: SimpleStreamOptions) => AssistantMessageEventStream {
1269
+ ): (model: Model<Api>, context: TranscriptContext, options?: SimpleStreamOptions) => AssistantMessageEventStream {
1302
1270
  const { entries, store, roundTrips } = deps;
1303
1271
 
1304
1272
  return function streamSimple(model, context, options) {