@estebanforge/pi-ask-antigravity 1.2.3 → 1.2.5

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.
Files changed (2) hide show
  1. package/extensions/index.ts +260 -4
  2. package/package.json +5 -4
@@ -34,11 +34,15 @@ import * as os from "node:os";
34
34
  import * as path from "node:path";
35
35
  import {
36
36
  type AgentToolResult,
37
+ buildSessionContext,
38
+ getAgentDir,
37
39
  getSettingsListTheme,
40
+ keyHint,
38
41
  type ExtensionAPI,
39
42
  } from "@earendil-works/pi-coding-agent";
40
43
 
41
44
  import { Container, SettingsList, Text, type SettingItem } from "@earendil-works/pi-tui";
45
+ import { contentText } from "@earendil-works/pi-ai";
42
46
  import { Type } from "typebox";
43
47
 
44
48
  // --- Constants -------------------------------------------------------------
@@ -52,6 +56,10 @@ const DISCOVERY_POLL_ATTEMPTS = 5;
52
56
  const DISCOVERY_POLL_MS = 100;
53
57
  const GLOBAL_CONFIG_PATH = path.join(os.homedir(), ".pi", "agent", "ask-antigravity.json");
54
58
 
59
+ // renderCall / renderResult preview limits (match pi-claude-bridge).
60
+ const PREVIEW_MAX_CHARS = 1000;
61
+ const PREVIEW_MAX_LINES = 6;
62
+
55
63
  const DEFAULT_MODEL = "flash";
56
64
  const DEFAULT_THINKING = "medium";
57
65
  // Default on: without --dangerously-skip-permissions, any run_command hangs in
@@ -69,6 +77,24 @@ const FAMILY_DEFAULT_TIER: Record<Family, ThinkingTier> = {
69
77
 
70
78
  const TIER_RANK: Record<ThinkingTier, number> = { low: 0, medium: 1, high: 2 };
71
79
 
80
+ /** pi thinking-level vocabulary -> agy tier, for the thinking/effort tool
81
+ * params. Unknown values fall to low (agy always thinks something). */
82
+ function levelToTier(level: string): ThinkingTier {
83
+ switch (level) {
84
+ case "minimal":
85
+ case "low":
86
+ return "low";
87
+ case "medium":
88
+ return "medium";
89
+ case "high":
90
+ case "xhigh":
91
+ case "max":
92
+ return "high";
93
+ default:
94
+ return "low";
95
+ }
96
+ }
97
+
72
98
  // Mode = which agy tool-loop policy to apply. Distinct from the alias layer.
73
99
  // "plan" → --mode plan (no edits; review-only)
74
100
  // "accept-edits" → --mode accept-edits (agy applies edits)
@@ -132,7 +158,12 @@ EXECUTION MODES (param: mode):
132
158
  - **accept-edits** (default): agy applies edits directly inside the workspace.
133
159
  - For agy's orthogonal \`--sandbox\` shell-containment flag, set the \`AGY_EXTRA_ARGS=--sandbox\` env var.
134
160
 
135
- 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. Use true whenever you do not need full file payloads (review, exploration, planning).`;
161
+ 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. Use true whenever you do not need full file payloads (review, exploration, planning).
162
+
163
+ THINKING LEVEL (params: thinking, effort - SYNONYMS for one knob):
164
+ - pi calls it thinking, agy calls it effort. Same thing. Pass ONE of the two.
165
+ - 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".
166
+ - An explicit level beats a tier embedded in model ("flash high") and the configured default. Omit both for the configured default.`;
136
167
 
137
168
  // --- Types -----------------------------------------------------------------
138
169
 
@@ -310,6 +341,10 @@ export function resolveModel(
310
341
  input: string,
311
342
  entries: ModelEntry[],
312
343
  defaultThinking: ThinkingTier,
344
+ /** Explicit thinking param (thinking/effort). Beats a tier embedded in
345
+ * the alias ("flash high") and the configured default; clamped to the
346
+ * family's real tiers, ignored for fixed-thinking families. */
347
+ preferredTier?: ThinkingTier,
313
348
  ): ResolvedModel | null {
314
349
  const lower = input.toLowerCase().trim();
315
350
 
@@ -392,6 +427,7 @@ export function resolveModel(
392
427
  if (familyTiers.size === 0) return toResolved(candidates[0].full, null); // no tiers on any entry
393
428
 
394
429
  const preferred =
430
+ preferredTier ??
395
431
  tier ??
396
432
  (familyTiers.has(defaultThinking) ? defaultThinking : FAMILY_DEFAULT_TIER[family]);
397
433
  const chosenTier = nearestTier([...familyTiers], preferred);
@@ -645,14 +681,82 @@ function newConversationId(
645
681
  return null;
646
682
  }
647
683
 
684
+ // --- Full-context export (opt-in includeContext) --------------------------
685
+ // NOTE: duplicated per pi-ask-* package (each is self-contained). Duck-typed
686
+ // over role/content to tolerate AgentMessage's union + custom message types.
687
+
688
+ // Tool-call inputs and tool-result bodies are clamped so the exported
689
+ // transcript stays reviewable; the agent can re-read any source file by path.
690
+ // User/assistant prose is kept in full (that IS the conversation).
691
+ const CONTEXT_BLOCK_MAX_CHARS = 2000;
692
+
693
+ function clampBlock(text: unknown, limit = CONTEXT_BLOCK_MAX_CHARS): string {
694
+ const t = String(text ?? "");
695
+ return t.length > limit ? `${t.slice(0, limit)}\n…[truncated, ${t.length - limit} more chars]` : t;
696
+ }
697
+
698
+ /** Render resolved pi AgentMessages to a readable markdown transcript.
699
+ * Pure: no IO. Caller writes the returned string to a temp file. */
700
+ export function renderAgentMessagesMarkdown(messages: readonly unknown[]): string {
701
+ const lines: string[] = [
702
+ "# Pi conversation context",
703
+ "",
704
+ `_Exported for full-context delegation. ${messages.length} message(s)._`,
705
+ "",
706
+ ];
707
+ for (const raw of messages) {
708
+ const m = raw as { role?: string; content?: unknown };
709
+ const role = m.role ?? "message";
710
+ const content = m.content;
711
+ if (role === "assistant") {
712
+ const blocks = (Array.isArray(content) ? content : []) as ReadonlyArray<{
713
+ type: string;
714
+ text?: string;
715
+ name?: string;
716
+ input?: unknown;
717
+ }>;
718
+ const text = blocks
719
+ .filter((b) => b.type === "text")
720
+ .map((b) => b.text ?? "")
721
+ .join("\n");
722
+ if (text.trim()) lines.push("## Assistant", "", text, "");
723
+ for (const b of blocks) {
724
+ if (b.type === "toolCall" || b.type === "tool_use") {
725
+ const input = clampBlock(
726
+ typeof b.input === "string" ? b.input : JSON.stringify(b.input ?? ""),
727
+ 500,
728
+ );
729
+ lines.push(`> tool call: ${b.name ?? "(unknown)"}(${input})`, "");
730
+ }
731
+ }
732
+ } else if (role === "toolResult" || role === "tool_result" || role === "tool") {
733
+ const text = contentText(content as any);
734
+ if (text.trim()) lines.push("## Tool result", "", clampBlock(text), "");
735
+ } else {
736
+ const text = contentText(content as any);
737
+ if (text.trim()) lines.push(`## ${role}`, "", clampBlock(text), "");
738
+ }
739
+ }
740
+ return lines.join("\n");
741
+ }
742
+
743
+ /** Centralized scratch dir for full-context exports, following the
744
+ * ~/.pi/extensions-data/<author>/<extension>/ convention (see pi-token-cost-ledger).
745
+ * Derived from getAgentDir() so rebranded distros resolve correctly. */
746
+ function askContextDir(): string {
747
+ return path.join(path.dirname(getAgentDir()), "extensions-data", "estebanforge", "pi-ask-antigravity");
748
+ }
749
+
648
750
  // --- Extension -------------------------------------------------------------
649
751
 
650
752
  interface AgyDetails {
651
753
  model: string | null;
652
754
  resolvedModel: string | null;
755
+ thinking: ThinkingTier | null;
653
756
  mode: Mode;
654
757
  digest: boolean;
655
758
  conversationId: string | null;
759
+ includeContext: boolean;
656
760
  exitCode: number;
657
761
  aborted: boolean;
658
762
  timedOut: boolean;
@@ -825,6 +929,18 @@ export default async function (pi: ExtensionAPI) {
825
929
  }),
826
930
  ),
827
931
  model: modelParam,
932
+ thinking: Type.Optional(
933
+ Type.String({
934
+ description:
935
+ "Thinking level (= agy effort tier). pi vocabulary: minimal|low|medium|high|xhigh|max, clamped to agy's low|medium|high. Overrides a tier embedded in `model`. Omit for the configured default.",
936
+ }),
937
+ ),
938
+ effort: Type.Optional(
939
+ Type.String({
940
+ description:
941
+ "Alias for `thinking` (agy's own name for the same knob). Pass ONE of the two; different values on both is an error.",
942
+ }),
943
+ ),
828
944
  mode: Type.Optional(
829
945
  Type.Union(
830
946
  [
@@ -855,7 +971,86 @@ export default async function (pi: ExtensionAPI) {
855
971
  description: `Hard cap on the agy run in minutes. Default ${DEFAULT_TIMEOUT_MIN}.`,
856
972
  }),
857
973
  ),
974
+ includeContext: Type.Optional(
975
+ Type.Boolean({
976
+ description:
977
+ "When true, export the current pi conversation (resolved, as markdown) to a temp file inside the workspace and tell agy to read it first. Default false (isolated one-shot). Opt in only when the user explicitly wants agy to see the full conversation; it costs agy tokens to read.",
978
+ }),
979
+ ),
858
980
  }),
981
+ renderCall(args, theme, _context) {
982
+ // Show RESOLVED model/thinking/mode (config defaults applied) so the
983
+ // row identifies what will actually run, not just explicit args.
984
+ // agy folds the thinking tier into the model alias or --effort; we
985
+ // surface the resolved tier separately for identification.
986
+ const cfg = loadConfig();
987
+ const requestedModel = (args.model as string | undefined)?.trim() || cfg.defaultModel;
988
+ const thinkingArg = (args.thinking as string | undefined) ?? (args.effort as string | undefined);
989
+ const resolved =
990
+ resolveModel(
991
+ requestedModel,
992
+ discovered,
993
+ cfg.defaultThinking,
994
+ thinkingArg ? levelToTier(thinkingArg) : undefined,
995
+ ) ?? { model: requestedModel };
996
+ const thinking: ThinkingTier = resolved.effort ?? cfg.defaultThinking;
997
+ const mode: Mode = (args.mode as Mode | undefined) ?? "accept-edits";
998
+ const useDigest = typeof args.digest === "boolean" ? args.digest : mode === "plan";
999
+ const isContinue =
1000
+ typeof args.conversationId === "string" && CONV_ID_RE.test(args.conversationId);
1001
+
1002
+ const tags: string[] = [`model=${resolved.model}`, `thinking=${thinking}`];
1003
+ if (mode !== "accept-edits") tags.push(`mode=${mode}`);
1004
+ if (useDigest) tags.push("digest");
1005
+ if (isContinue) tags.push("continue");
1006
+ if (args.includeContext) tags.push("context=full");
1007
+
1008
+ let text = theme.fg("mdLink", theme.bold("AskAntigravity "));
1009
+ text += `${theme.fg("accent", `[${tags.join(", ")}]`)} `;
1010
+
1011
+ const prompt = String(args.prompt ?? "");
1012
+ const truncated = prompt.length > PREVIEW_MAX_CHARS ? prompt.slice(0, PREVIEW_MAX_CHARS) : prompt;
1013
+ const lines = truncated.split("\n").slice(0, PREVIEW_MAX_LINES);
1014
+ text += theme.fg("muted", `"${lines.join("\n")}"`);
1015
+ if (prompt.length > PREVIEW_MAX_CHARS || prompt.split("\n").length > PREVIEW_MAX_LINES) {
1016
+ text += theme.fg("dim", " …");
1017
+ }
1018
+ return new Text(text, 0, 0);
1019
+ },
1020
+ renderResult(result, { expanded, isPartial }, theme) {
1021
+ const d = result.details as AgyDetails | undefined;
1022
+ if (isPartial) {
1023
+ const status = result.content[0]?.type === "text" ? result.content[0].text : "working...";
1024
+ return new Text(theme.fg("mdLink", "◉ AskAntigravity ") + theme.fg("muted", status), 0, 0);
1025
+ }
1026
+
1027
+ const body = result.content[0]?.type === "text" ? result.content[0].text : "";
1028
+ const errored = d?.exitCode !== 0 || !!d?.aborted || !!d?.timedOut;
1029
+
1030
+ let text = errored
1031
+ ? theme.fg("error", "✗ AskAntigravity error")
1032
+ : theme.fg("mdLink", "✓ AskAntigravity");
1033
+
1034
+ const rTags: string[] = [];
1035
+ if (d?.resolvedModel || d?.model) rTags.push(`model=${d?.resolvedModel ?? d?.model}`);
1036
+ if (d?.thinking) rTags.push(`thinking=${d.thinking}`);
1037
+ if (d?.mode && d.mode !== "accept-edits") rTags.push(`mode=${d.mode}`);
1038
+ if (d?.includeContext) rTags.push("context=full");
1039
+ if (rTags.length) text += ` ${theme.fg("accent", `[${rTags.join(", ")}]`)}`;
1040
+ if (d?.durationMs) text += ` ${theme.fg("dim", `${(d.durationMs / 1000).toFixed(1)}s`)}`;
1041
+
1042
+ if (expanded) {
1043
+ if (body) text += `\n${theme.fg("toolOutput", body)}`;
1044
+ } else {
1045
+ const truncated = body.length > PREVIEW_MAX_CHARS ? body.slice(0, PREVIEW_MAX_CHARS) : body;
1046
+ const lines = truncated.split("\n").slice(0, PREVIEW_MAX_LINES);
1047
+ if (lines.length) text += `\n${theme.fg("toolOutput", lines.join("\n"))}`;
1048
+ if (body.length > PREVIEW_MAX_CHARS || body.split("\n").length > PREVIEW_MAX_LINES) {
1049
+ text += `\n${theme.fg("dim", `… (${keyHint("app.tools.expand", "to expand")})`)}`;
1050
+ }
1051
+ }
1052
+ return new Text(text, 0, 0);
1053
+ },
859
1054
  async execute(toolCallId, params, signal, onUpdate, ctx) {
860
1055
  // Circular-delegation guard (best-effort). This extension registers
861
1056
  // NO provider, so the check only fires if a future agy-as-provider
@@ -901,8 +1096,29 @@ export default async function (pi: ExtensionAPI) {
901
1096
  details: emptyDetails(requestedModel, null),
902
1097
  };
903
1098
  }
1099
+ if (
1100
+ typeof params.thinking === "string" &&
1101
+ typeof params.effort === "string" &&
1102
+ params.thinking !== params.effort
1103
+ ) {
1104
+ return {
1105
+ content: [
1106
+ {
1107
+ type: "text",
1108
+ text: "thinking and effort are synonyms for the same knob - pass one, not both with different values.",
1109
+ },
1110
+ ],
1111
+ details: emptyDetails(requestedModel, null),
1112
+ };
1113
+ }
1114
+ const thinkingArg = (params.thinking as string | undefined) ?? (params.effort as string | undefined);
904
1115
  const resolved =
905
- resolveModel(requestedModel, discovered, config.defaultThinking) ?? {
1116
+ resolveModel(
1117
+ requestedModel,
1118
+ discovered,
1119
+ config.defaultThinking,
1120
+ thinkingArg ? levelToTier(thinkingArg) : undefined,
1121
+ ) ?? {
906
1122
  model: requestedModel,
907
1123
  };
908
1124
 
@@ -951,6 +1167,29 @@ export default async function (pi: ExtensionAPI) {
951
1167
  ? `(Use compact digests, not full file contents.)\n${params.prompt}`
952
1168
  : params.prompt;
953
1169
 
1170
+ // Opt-in full-context export (isolated stays the default).
1171
+ let contextFile: string | null = null;
1172
+ if (params.includeContext) {
1173
+ try {
1174
+ const { messages } = buildSessionContext(ctx.sessionManager.getBranch());
1175
+ if (messages.length) {
1176
+ const md = renderAgentMessagesMarkdown(messages);
1177
+ const ctxDir = askContextDir();
1178
+ fs.mkdirSync(ctxDir, { recursive: true });
1179
+ contextFile = path.join(
1180
+ ctxDir,
1181
+ `.ask-context-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.md`,
1182
+ );
1183
+ fs.writeFileSync(contextFile, md, { mode: 0o600 });
1184
+ }
1185
+ } catch {
1186
+ contextFile = null;
1187
+ }
1188
+ }
1189
+ const effectivePrompt = contextFile
1190
+ ? `The full pi conversation context (as markdown) is at: ${contextFile}\nRead that file first for context, then do the task below.\n\n---\n\n${finalPrompt}`
1191
+ : finalPrompt;
1192
+
954
1193
  const args: string[] = ["--add-dir", cwd];
955
1194
  const extra = extraArgs();
956
1195
  if (extra.length) args.push(...extra);
@@ -963,14 +1202,17 @@ export default async function (pi: ExtensionAPI) {
963
1202
  if (config.skipPermissions) args.push("--dangerously-skip-permissions");
964
1203
  if (isContinuation) args.push("--conversation", rawConvId as string);
965
1204
  args.push("--print-timeout", `${timeoutMin}m`);
966
- args.push("-p", finalPrompt);
1205
+ if (contextFile) args.push("--add-dir", askContextDir());
1206
+ args.push("-p", effectivePrompt);
967
1207
 
968
1208
  const details: AgyDetails = {
969
1209
  model: requestedModel,
970
1210
  resolvedModel: resolved.model,
1211
+ thinking: resolved.effort ?? config.defaultThinking,
971
1212
  mode,
972
1213
  digest: useDigest,
973
1214
  conversationId: isContinuation ? (rawConvId as string) : null,
1215
+ includeContext: contextFile !== null,
974
1216
  exitCode: 0,
975
1217
  aborted: false,
976
1218
  timedOut: false,
@@ -1211,16 +1453,30 @@ export default async function (pi: ExtensionAPI) {
1211
1453
  details,
1212
1454
  };
1213
1455
  }
1456
+ finally {
1457
+ if (contextFile) {
1458
+ try {
1459
+ fs.unlinkSync(contextFile);
1460
+ } catch {}
1461
+ }
1462
+ }
1214
1463
  },
1215
1464
  });
1216
1465
  }
1217
1466
 
1218
- function emptyDetails(model: string | null, resolvedModel: string | null): AgyDetails {
1467
+ function emptyDetails(
1468
+ model: string | null,
1469
+ resolvedModel: string | null,
1470
+ thinking: ThinkingTier | null = null,
1471
+ includeContext: boolean = false,
1472
+ ): AgyDetails {
1219
1473
  return {
1220
1474
  model,
1221
1475
  resolvedModel,
1476
+ thinking,
1222
1477
  mode: "accept-edits",
1223
1478
  digest: false,
1479
+ includeContext,
1224
1480
  conversationId: null,
1225
1481
  exitCode: 0,
1226
1482
  aborted: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estebanforge/pi-ask-antigravity",
3
- "version": "1.2.3",
3
+ "version": "1.2.5",
4
4
  "description": "Pi tool that delegates a self-contained sub-task to Google Antigravity's agy CLI (the CLI for Gemini). Friendly model aliases (flash/pro/gemini), configurable default model + thinking, and the AskAntigravity delegation tool.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -49,9 +49,10 @@
49
49
  }
50
50
  },
51
51
  "devDependencies": {
52
- "@earendil-works/pi-coding-agent": "^0.84.0",
53
- "@earendil-works/pi-ai": "^0.84.0",
54
- "@earendil-works/pi-tui": "^0.84.0",
52
+ "@earendil-works/pi-ai": "^0.85.0",
53
+ "@earendil-works/pi-coding-agent": "^0.85.0",
54
+ "@earendil-works/pi-server": "^0.85.0",
55
+ "@earendil-works/pi-tui": "^0.85.0",
55
56
  "@types/node": "^22.0.0",
56
57
  "typebox": "^1.1.38",
57
58
  "typescript": "^5.8.0",