@estebanforge/pi-antigravity-bridge 1.2.6 → 1.3.1

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
@@ -30,6 +30,9 @@ import {
30
30
  } from "@earendil-works/pi-ai";
31
31
  import type { Api } from "@earendil-works/pi-ai";
32
32
  import { runAgyTurn, type AgyEvent, type AgyRunOptions } from "./runner.js";
33
+ import { AgyDriver, type DriverActivity, type TurnHandle } from "./driver.js";
34
+ import { toPiUsage } from "./stream-events.js";
35
+ import { mapAgyToolToNative } from "./native-tools.js";
33
36
  import { type AgyEffort, type AgyModelEntry } from "./models.js";
34
37
  import { SessionStore } from "./sessions.js";
35
38
  import { loadConfig } from "./config.js";
@@ -232,7 +235,7 @@ function sessionKey(options: SimpleStreamOptions | undefined, cwd: string): stri
232
235
 
233
236
  /** Track which content block is currently open so we close-on-switch.
234
237
  * At most one of textIdx / thinkingIdx is non-null at a time. */
235
- interface BlockState {
238
+ export interface BlockState {
236
239
  partial: AssistantMessage;
237
240
  textIdx: number | null;
238
241
  thinkingIdx: number | null;
@@ -243,8 +246,19 @@ export interface StreamSimpleDeps {
243
246
  entries: AgyModelEntry[];
244
247
  store: SessionStore;
245
248
  /** Override the agy turn runner (tests inject a scripted event source).
246
- * Defaults to the real runAgyTurn. */
249
+ * Defaults to the real runAgyTurn. Legacy engine only. */
247
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. */
253
+ driver?: AgyDriver;
254
+ roundTrips?: ToolRoundTrips;
255
+ /** Replay store for the display-only antigravity wrapper tool. Required
256
+ * for native re-exec and wrapper cards; without it tool steps render as
257
+ * thinking labels only. */
258
+ replay?: WrapperReplay;
259
+ /** Whether a pi tool is active in the session; native re-exec toolCalls
260
+ * are only emitted for active builtins (else the wrapper). */
261
+ nativeActive?: (name: string) => boolean;
248
262
  }
249
263
 
250
264
  /** pi thinking-effort order mirrors agy's, for clamping. */
@@ -287,17 +301,413 @@ export function toAgyEffort(
287
301
  return efforts[0] ?? "low";
288
302
  }
289
303
 
304
+ // --- G9: no-patch pi-tool round-trips -----------------------------------------
305
+ //
306
+ // The MCP bridge's onToolCall parks the call here instead of executing it:
307
+ // the pending call is injected into the live driver turn as a bridge_call
308
+ // activity, the provider ends the pi assistant message with stopReason
309
+ // "toolUse" for the REAL pi tool, and pi's own loop executes it (native
310
+ // cards, permissions, hooks). The toolResult arrives in the NEXT stream
311
+ // call's context; resolve() then completes the parked MCP HTTP response and
312
+ // agy continues its still-running turn. No pi patch, no privileged API.
313
+
314
+ const BRIDGE_TIMEOUT_MS = 480_000;
315
+
316
+ export interface BridgeCallResultShape {
317
+ content: Array<{ type: string; text?: string }>;
318
+ isError: boolean;
319
+ }
320
+
321
+ interface PendingRoundTrip {
322
+ /** "bridge": parked MCP HTTP call; resolve() completes it.
323
+ * "rt": native re-exec / wrapper round-trip; pi already executed, the
324
+ * toolResult only confirms continuation, nothing remote to settle. */
325
+ kind: "bridge" | "rt";
326
+ name: string;
327
+ resolve?: (r: BridgeCallResultShape) => void;
328
+ reject?: (e: Error) => void;
329
+ timer?: NodeJS.Timeout;
330
+ onAbort?: () => void;
331
+ signal?: AbortSignal;
332
+ }
333
+
334
+ /** Replay store for the display-only `antigravity` wrapper tool. The
335
+ * provider records each mutating agy step's output before emitting the
336
+ * toolUse; the wrapper tool's execute() returns it, so pi renders a real
337
+ * toolCall/toolResult pair without re-running anything. */
338
+ export class WrapperReplay {
339
+ #map = new Map<string, string>();
340
+ set(key: string, output: string): void {
341
+ this.#map.set(key, output);
342
+ }
343
+ get(key: string): string | undefined {
344
+ return this.#map.get(key);
345
+ }
346
+ /** Single-use consume: wrapper execute() takes the entry so stale outputs
347
+ * cannot be enumerated by later callers and the map cannot grow unbounded. */
348
+ take(key: string): string | undefined {
349
+ const v = this.#map.get(key);
350
+ this.#map.delete(key);
351
+ return v;
352
+ }
353
+ get size(): number {
354
+ return this.#map.size;
355
+ }
356
+ }
357
+
358
+ export class ToolRoundTrips {
359
+ #pending = new Map<string, PendingRoundTrip>();
360
+ #driver: AgyDriver;
361
+ #log: (s: string, d?: unknown) => void;
362
+
363
+ constructor(driver: AgyDriver, log?: (s: string, d?: unknown) => void) {
364
+ this.#driver = driver;
365
+ this.#log = log ?? (() => {});
366
+ }
367
+
368
+ get pendingIds(): string[] {
369
+ return [...this.#pending.keys()];
370
+ }
371
+
372
+ /** Fail all pending calls (driver recycle/shutdown path). */
373
+ failAll(reason: string): void {
374
+ for (const id of [...this.#pending.keys()]) this.#fail(id, reason);
375
+ }
376
+
377
+ #fail(callId: string, reason: string): void {
378
+ const entry = this.#pending.get(callId);
379
+ if (!entry) return;
380
+ if (entry.kind === "rt") {
381
+ // Nothing to reject, but the entry must not leak past turn death.
382
+ this.#pending.delete(callId);
383
+ return;
384
+ }
385
+ this.#pending.delete(callId);
386
+ clearTimeout(entry.timer);
387
+ if (entry.onAbort && entry.signal) entry.signal.removeEventListener("abort", entry.onAbort);
388
+ entry.reject!(new Error(reason));
389
+ this.#driver.kickIdle();
390
+ this.#log("round-trip-fail", { callId, name: entry.name, reason });
391
+ }
392
+
393
+ /** Park the MCP call: inject into the live agy turn; the promise settles
394
+ * when pi's toolResult lands (resolve) or fail-closed (timeout/abort). */
395
+ onToolCall = (
396
+ callId: string,
397
+ name: string,
398
+ args: Record<string, unknown>,
399
+ signal: AbortSignal,
400
+ ): Promise<BridgeCallResultShape> => {
401
+ const handle = this.#driver.activeHandle;
402
+ if (!handle) {
403
+ return Promise.reject(
404
+ new Error(
405
+ "no active antigravity turn; the pi tool bridge only works while an antigravity model is streaming",
406
+ ),
407
+ );
408
+ }
409
+ return new Promise<BridgeCallResultShape>((resolve, reject) => {
410
+ const timer = setTimeout(() => {
411
+ this.#fail(callId, `pi tool round-trip timed out after ${BRIDGE_TIMEOUT_MS / 1000}s`);
412
+ }, BRIDGE_TIMEOUT_MS);
413
+ const onAbort = () => this.#fail(callId, "agy disconnected before the tool result arrived");
414
+ signal.addEventListener("abort", onAbort, { once: true });
415
+ this.#pending.set(callId, { kind: "bridge", name, resolve, reject, timer, onAbort, signal });
416
+ handle.pushExternal({ type: "bridge_call", callId, name, args });
417
+ });
418
+ };
419
+
420
+ /** Track a native re-exec or wrapper round-trip: pi executes the tool in
421
+ * its own loop; the arriving toolResult only confirms continuation. */
422
+ track(id: string, name: string): void {
423
+ this.#pending.set(id, { kind: "rt", name });
424
+ }
425
+
426
+ /** Complete a parked call from a pi toolResult message. Returns false when
427
+ * the id matches nothing pending. */
428
+ resolve(toolCallId: string, text: string, isError: boolean): boolean {
429
+ const entry = this.#pending.get(toolCallId);
430
+ if (!entry) return false;
431
+ this.#pending.delete(toolCallId);
432
+ clearTimeout(entry.timer);
433
+ if (entry.onAbort && entry.signal) entry.signal.removeEventListener("abort", entry.onAbort);
434
+ if (entry.kind === "rt") {
435
+ this.#log("round-trip-rt-done", { callId: toolCallId, name: entry.name, isError });
436
+ return true;
437
+ }
438
+ entry.resolve!({ content: [{ type: "text", text }], isError });
439
+ this.#driver.kickIdle();
440
+ this.#log("round-trip-resolved", { callId: toolCallId, name: entry.name, isError });
441
+ return true;
442
+ }
443
+ }
444
+
445
+ /** Extract toolResult messages whose toolCallId is still parked, as text. */
446
+ export function collectToolResults(
447
+ messages: Message[],
448
+ pendingIds: readonly string[],
449
+ ): Array<{ toolCallId: string; text: string; isError: boolean }> {
450
+ if (pendingIds.length === 0) return [];
451
+ const pending = new Set(pendingIds);
452
+ const out: Array<{ toolCallId: string; text: string; isError: boolean }> = [];
453
+ for (const m of messages) {
454
+ if (m.role !== "toolResult") continue;
455
+ const id = (m as { toolCallId?: string }).toolCallId;
456
+ if (!id || !pending.has(id)) continue;
457
+ out.push({ toolCallId: id, text: blocksToText(m.content).trim(), isError: m.isError === true });
458
+ }
459
+ return out;
460
+ }
461
+
462
+ // --- stream-json engine -------------------------------------------------------
463
+
464
+ export interface DriverDeps {
465
+ driver: AgyDriver;
466
+ roundTrips: ToolRoundTrips;
467
+ replay?: WrapperReplay;
468
+ nativeActive?: (name: string) => boolean;
469
+ }
470
+
471
+ /** Map one DriverActivity onto the open pi stream. Returns "parked" when the
472
+ * activity ended the pi call with a toolUse round-trip. */
473
+ export interface ActivityFeatures {
474
+ replay?: WrapperReplay;
475
+ nativeActive?: (name: string) => boolean;
476
+ roundTrips?: ToolRoundTrips;
477
+ }
478
+
479
+ /** Process-wide counter: round-trip ids must never repeat across turns in
480
+ * one session transcript. */
481
+ let RT_SEQ = 0;
482
+ function nextRtId(kind: "nat" | "wrap"): string {
483
+ return `${kind}-${++RT_SEQ}`;
484
+ }
485
+
486
+ /** Emit a complete toolCall block and end the pi call with toolUse. */
487
+ function emitToolUse(
488
+ stream: AssistantMessageEventStream,
489
+ blocks: BlockState,
490
+ id: string,
491
+ name: string,
492
+ args: Record<string, unknown>,
493
+ ): void {
494
+ const partial = blocks.partial;
495
+ closeThinking(stream, blocks);
496
+ closeText(stream, blocks);
497
+ const toolCall = { type: "toolCall" as const, id, name, arguments: args };
498
+ partial.content.push(toolCall);
499
+ const contentIndex = partial.content.length - 1;
500
+ stream.push({ type: "toolcall_start", contentIndex, partial });
501
+ stream.push({ type: "toolcall_end", contentIndex, toolCall, partial });
502
+ partial.stopReason = "toolUse";
503
+ stream.push({ type: "done", reason: "toolUse", message: partial });
504
+ stream.end();
505
+ }
506
+
507
+ export function consumeActivity(
508
+ stream: AssistantMessageEventStream,
509
+ blocks: BlockState,
510
+ activity: DriverActivity,
511
+ diffCtx: TurnDiffContext,
512
+ cwd: string,
513
+ feats: ActivityFeatures,
514
+ ): "parked" | "continue" {
515
+ const partial = blocks.partial;
516
+ switch (activity.type) {
517
+ case "text":
518
+ appendText(stream, blocks, activity.delta);
519
+ return "continue";
520
+ case "thought":
521
+ // agy reports a token count only; no text body to render.
522
+ return "continue";
523
+ case "usage":
524
+ toPiUsage(activity.usage, partial.usage);
525
+ return "continue";
526
+ case "tool_start":
527
+ // Rendering happens on completion (output/diff available).
528
+ return "continue";
529
+ case "tool_done": {
530
+ // G8: agy file edits surface a git-sourced diff in a thinking block.
531
+ let inputJson: string | undefined;
532
+ try {
533
+ inputJson = JSON.stringify(activity.args);
534
+ } catch {
535
+ inputJson = undefined;
536
+ }
537
+ const edit = inputJson ? parseEditToolInput(inputJson) : null;
538
+ if (edit) {
539
+ const absFile = path.isAbsolute(edit.file) ? edit.file : path.resolve(cwd, edit.file);
540
+ const outcome = diffCtx.diffEdit(absFile, edit.content);
541
+ const label = edit.description ?? path.basename(absFile);
542
+ appendThinking(stream, blocks, `[agy edit: ${label}]\n`);
543
+ if (outcome.text) appendThinking(stream, blocks, `${outcome.text}\n`);
544
+ } else {
545
+ appendThinking(stream, blocks, `[agy tool: ${activity.name}]\n`);
546
+ }
547
+ // Native re-exec: read-only agy tools re-run as REAL pi builtins so
548
+ // their cards render natively. Everything else replays through the
549
+ // display-only wrapper tool. Both end the pi call with toolUse and
550
+ // resume on the toolResult continuation. Without a replay store
551
+ // (feature off) keep the label-only behavior.
552
+ if (!feats.replay || !feats.roundTrips) return "continue";
553
+ const mapped = mapAgyToolToNative(activity.name, activity.args);
554
+ if (mapped && (!feats.nativeActive || feats.nativeActive(mapped.tool))) {
555
+ const id = nextRtId("nat");
556
+ feats.roundTrips.track(id, mapped.tool);
557
+ // pi requires a reasoning argument on read/edit-class builtin calls
558
+ // (validated against the wrapped schema); harmless where absent.
559
+ emitToolUse(stream, blocks, id, mapped.tool, {
560
+ reasoning: `re-exec of agy ${activity.name} for display`,
561
+ ...mapped.args,
562
+ });
563
+ return "parked";
564
+ }
565
+ {
566
+ const id = nextRtId("wrap");
567
+ feats.replay.set(id, activity.output ?? "(agy recorded no output)");
568
+ feats.roundTrips.track(id, activity.name);
569
+ emitToolUse(stream, blocks, id, "antigravity", { tool: activity.name, key: id });
570
+ return "parked";
571
+ }
572
+ }
573
+ case "tool_error":
574
+ appendThinking(stream, blocks, `[agy tool: ${activity.name} failed: ${activity.message}]\n`);
575
+ return "continue";
576
+ case "bridge_call": {
577
+ // Park the pi call: real tool name + args, toolUse stopReason. pi
578
+ // executes; the toolResult returns on the next stream call.
579
+ emitToolUse(stream, blocks, activity.callId, activity.name, activity.args);
580
+ return "parked";
581
+ }
582
+ }
583
+ }
584
+
585
+ /** The stream-json engine: persistent driver + toolUse round-trips. */
586
+ async function runTurnDriver(
587
+ stream: AssistantMessageEventStream,
588
+ model: Model<Api>,
589
+ context: Context,
590
+ options: SimpleStreamOptions | undefined,
591
+ entries: AgyModelEntry[],
592
+ store: SessionStore,
593
+ deps: DriverDeps,
594
+ ): Promise<void> {
595
+ const partial = newAssistant(model);
596
+ const blocks: BlockState = { partial, textIdx: null, thinkingIdx: null, started: false };
597
+
598
+ const cwd = (options as { cwd?: string } | undefined)?.cwd ?? process.cwd();
599
+ const key = sessionKey(options, cwd);
600
+ const existing = store.get(key);
601
+ const messageCount = context.messages.length;
602
+ const config = loadConfig();
603
+
604
+ // Continuation: resolve parked round-trips from pi's toolResult messages,
605
+ // then re-attach to the still-running agy turn. No new user event is sent:
606
+ // agy receives the result via the bridge's MCP HTTP response.
607
+ const results = collectToolResults(context.messages, deps.roundTrips.pendingIds);
608
+ const isContinuation = results.length > 0;
609
+ for (const r of results) deps.roundTrips.resolve(r.toolCallId, r.text, r.isError);
610
+
611
+ let handle: TurnHandle;
612
+ if (isContinuation) {
613
+ const active = deps.driver.reentry();
614
+ if (!active) {
615
+ finalize(stream, blocks, "error", "tool result arrived but no antigravity turn is running");
616
+ return;
617
+ }
618
+ handle = active;
619
+ } else {
620
+ const prompt = extractUserPrompt(context);
621
+ if (!prompt) {
622
+ finalize(stream, blocks, "error", "No user message to send to agy.");
623
+ return;
624
+ }
625
+ const entry = entries.find((e) => e.id === model.id) ?? null;
626
+ const agyModel = entry?.full ?? model.id;
627
+ const effort = entry?.efforts?.length ? toAgyEffort(options?.reasoning, entry.efforts) : undefined;
628
+ const watermark = existing?.lastMessageCount ?? 0;
629
+ const digest = config.digest ? buildContextDigest(context.messages, watermark) : "";
630
+ const fullPrompt = digest ? `${DIGEST_PREAMBLE}\n\n${digest}\n\n---\n\n${prompt}` : prompt;
631
+ try {
632
+ handle = await deps.driver.run({
633
+ cwd,
634
+ model: agyModel,
635
+ effort,
636
+ mode: config.mode,
637
+ skipPermissions: config.skipPermissions,
638
+ conversationId: existing?.conversationId ?? null,
639
+ prompt: fullPrompt,
640
+ signal: options?.signal,
641
+ });
642
+ } catch (err) {
643
+ const msg = err instanceof Error ? err.message : String(err);
644
+ finalize(stream, blocks, "error", `agy failed to start: ${msg}`);
645
+ return;
646
+ }
647
+ }
648
+
649
+ ensureStarted(stream, blocks);
650
+ const diffCtx = new TurnDiffContext(createExecGitOps());
651
+ const feats: ActivityFeatures = {
652
+ replay: deps.replay,
653
+ nativeActive: deps.nativeActive,
654
+ roundTrips: deps.roundTrips,
655
+ };
656
+
657
+ for (;;) {
658
+ const activity = await handle.next();
659
+ if (!activity) break;
660
+ if (consumeActivity(stream, blocks, activity, diffCtx, cwd, feats) === "parked") return;
661
+ }
662
+
663
+ const outcome = await handle.outcome;
664
+ if (outcome.conversationId) {
665
+ store.set(key, {
666
+ conversationId: outcome.conversationId,
667
+ lastStepIdx: -1,
668
+ lastMessageCount: messageCount,
669
+ });
670
+ }
671
+ if (outcome.aborted) {
672
+ finalize(stream, blocks, "aborted", "Operation aborted");
673
+ return;
674
+ }
675
+ if (outcome.status === "ERROR") {
676
+ finalize(stream, blocks, "error", outcome.error ?? "agy turn failed");
677
+ return;
678
+ }
679
+ if (blocks.textIdx === null && blocks.thinkingIdx === null && outcome.response) {
680
+ appendText(stream, blocks, outcome.response);
681
+ }
682
+ if (blocks.textIdx === null && blocks.thinkingIdx === null) {
683
+ ensureTextOpen(stream, blocks);
684
+ }
685
+ finalize(stream, blocks, "stop");
686
+ }
687
+
290
688
  /** Build the streamSimple closure. Captures the model catalog + session store
291
- * resolved at extension load. */
689
+ * resolved at extension load. When a driver is provided, turns run on the
690
+ * persistent stream-json engine (config.engine selects; legacy remains as
691
+ * fallback). */
292
692
  export function createStreamSimple(
293
693
  deps: StreamSimpleDeps,
294
694
  ): (model: Model<Api>, context: Context, options?: SimpleStreamOptions) => AssistantMessageEventStream {
295
- const { entries, store, runAgyTurn: runFn = runAgyTurn } = deps;
695
+ const { entries, store, runAgyTurn: runFn = runAgyTurn, driver, roundTrips } = deps;
296
696
 
297
697
  return function streamSimple(model, context, options) {
298
698
  const stream = createAssistantMessageEventStream();
299
699
  // Fire the async turn; return the stream synchronously per pi's contract.
300
- void runTurn(stream, model, context, options, entries, store, runFn);
700
+ const config = loadConfig();
701
+ if (driver && roundTrips && config.engine === "stream-json") {
702
+ void runTurnDriver(stream, model, context, options, entries, store, {
703
+ driver,
704
+ roundTrips,
705
+ replay: deps.replay,
706
+ nativeActive: deps.nativeActive,
707
+ });
708
+ } else {
709
+ void runTurn(stream, model, context, options, entries, store, runFn);
710
+ }
301
711
  return stream;
302
712
  };
303
713
  }
@@ -317,15 +727,11 @@ async function runTurn(
317
727
  // Direct emit helpers. agy streams deltas that may not align to line
318
728
  // boundaries; pi's TUI renders partial lines fine, so we append and push
319
729
  // each delta straight through (no filtering, no buffering).
320
- const appendText = (delta: string): void => {
321
- ensureTextOpen(stream, blocks);
322
- textAt(partial, blocks.textIdx!).text += delta;
323
- stream.push({ type: "text_delta", contentIndex: blocks.textIdx!, delta, partial });
730
+ const appendTextDelta = (delta: string): void => {
731
+ appendText(stream, blocks, delta);
324
732
  };
325
- const appendThinking = (delta: string): void => {
326
- ensureThinkingOpen(stream, blocks);
327
- thinkingAt(partial, blocks.thinkingIdx!).thinking += delta;
328
- stream.push({ type: "thinking_delta", contentIndex: blocks.thinkingIdx!, delta, partial });
733
+ const appendThinkingDelta = (delta: string): void => {
734
+ appendThinking(stream, blocks, delta);
329
735
  };
330
736
 
331
737
  // Signal the turn has begun IMMEDIATELY. pi's native Working indicator is
@@ -345,11 +751,16 @@ async function runTurn(
345
751
  return;
346
752
  }
347
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
+
348
758
  // G1: inject a delta digest of pi-side context agy was not spawned for
349
- // (compaction summaries, other-provider turns). agy keeps its own history,
350
- // so this is a delta, not a replay. See docs/PI-BRIDGE-GAPS.md (G1).
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).
351
762
  const watermark = existing?.lastMessageCount ?? 0;
352
- const digest = buildContextDigest(context.messages, watermark);
763
+ const digest = config.digest ? buildContextDigest(context.messages, watermark) : "";
353
764
  const fullPrompt = digest ? `${DIGEST_PREAMBLE}\n\n${digest}\n\n---\n\n${prompt}` : prompt;
354
765
 
355
766
  // Resolve the pi model id to its catalog entry. On a miss, fall through to
@@ -358,9 +769,6 @@ async function runTurn(
358
769
  const entry = entries.find((e) => e.id === model.id) ?? null;
359
770
  const agyModel = entry?.full ?? model.id;
360
771
 
361
- // Runtime config (mode, permissions). Loaded fresh each turn so /agy
362
- // toggles take effect immediately without a reload.
363
- const config = loadConfig();
364
772
 
365
773
  // Effort-driven bases always need --effort (a base slug is invalid on its
366
774
  // own); fixed models never get it (agy rejects --effort for them). For an
@@ -387,10 +795,10 @@ async function runTurn(
387
795
  const onEvent = (event: AgyEvent) => {
388
796
  switch (event.kind) {
389
797
  case "text":
390
- appendText(event.text);
798
+ appendTextDelta(event.text);
391
799
  break;
392
800
  case "thinking":
393
- appendThinking(event.text);
801
+ appendThinkingDelta(event.text);
394
802
  break;
395
803
  case "tool": {
396
804
  // G8: if agy wrote a file, surface a git-sourced diff; else the plain
@@ -400,10 +808,10 @@ async function runTurn(
400
808
  const absFile = path.isAbsolute(edit.file) ? edit.file : path.resolve(cwd, edit.file);
401
809
  const outcome = diffCtx.diffEdit(absFile, edit.content);
402
810
  const label = edit.description ?? path.basename(absFile);
403
- appendThinking(`[agy edit: ${label}]\n`);
404
- if (outcome.text) appendThinking(`${outcome.text}\n`);
811
+ appendThinkingDelta(`[agy edit: ${label}]\n`);
812
+ if (outcome.text) appendThinkingDelta(`${outcome.text}\n`);
405
813
  } else {
406
- appendThinking(`[agy tool: ${event.name}]\n`);
814
+ appendThinkingDelta(`[agy tool: ${event.name}]\n`);
407
815
  }
408
816
  break;
409
817
  }
@@ -484,6 +892,21 @@ function ensureStarted(stream: AssistantMessageEventStream, b: BlockState): void
484
892
  stream.push({ type: "start", partial: b.partial });
485
893
  }
486
894
 
895
+ /** Append a text delta (opens the block on first use). Module-level so both
896
+ * engines share it. */
897
+ function appendText(stream: AssistantMessageEventStream, b: BlockState, delta: string): void {
898
+ ensureTextOpen(stream, b);
899
+ textAt(b.partial, b.textIdx!).text += delta;
900
+ stream.push({ type: "text_delta", contentIndex: b.textIdx!, delta, partial: b.partial });
901
+ }
902
+
903
+ /** Append a thinking delta (opens the block on first use). */
904
+ function appendThinking(stream: AssistantMessageEventStream, b: BlockState, delta: string): void {
905
+ ensureThinkingOpen(stream, b);
906
+ thinkingAt(b.partial, b.thinkingIdx!).thinking += delta;
907
+ stream.push({ type: "thinking_delta", contentIndex: b.thinkingIdx!, delta, partial: b.partial });
908
+ }
909
+
487
910
  /** Open the text block, closing the thinking block first if it's open. */
488
911
  function ensureTextOpen(stream: AssistantMessageEventStream, b: BlockState): void {
489
912
  if (b.textIdx !== null) return;
package/src/skills.ts ADDED
@@ -0,0 +1,116 @@
1
+ // pi Agent Skills catalog + activate_skill bridge.
2
+ //
3
+ // The MCP bridge exposes ONE `activate_skill` tool to agy whose JSON-schema
4
+ // enum is the catalog; the description carries each skill's one-liner so agy
5
+ // can tell when a skill applies. Calling it returns the full SKILL.md plus the
6
+ // bundled resource dir. Nothing is appended to the prompt: agy sees the
7
+ // catalog in tools/list on every spawn, including after pi compaction.
8
+ // Shape borrowed from tianzuo/pi-antigravity lib/skills.ts (MIT).
9
+
10
+ import fs from "node:fs";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+
14
+ export const ACTIVATE_SKILL_TOOL_NAME = "activate_skill";
15
+
16
+ export interface SkillLite {
17
+ name: string;
18
+ description: string;
19
+ /** Absolute path to SKILL.md. */
20
+ filePath: string;
21
+ /** Absolute directory containing SKILL.md and bundled resources. */
22
+ dir: string;
23
+ }
24
+
25
+ /** Parse `name:` / `description:` out of SKILL.md frontmatter. */
26
+ function parseFrontmatter(raw: string): { name?: string; description?: string } {
27
+ const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
28
+ if (!m) return {};
29
+ const out: { name?: string; description?: string } = {};
30
+ for (const line of m[1].split(/\r?\n/)) {
31
+ const name = line.match(/^name:\s*(.+)$/);
32
+ if (name && !out.name) out.name = name[1].trim();
33
+ const desc = line.match(/^description:\s*(.+)$/);
34
+ if (desc && !out.description) out.description = desc[1].trim();
35
+ }
36
+ return out;
37
+ }
38
+
39
+ function scanDir(dir: string): SkillLite[] {
40
+ let entries: string[];
41
+ try {
42
+ entries = fs.readdirSync(dir, { withFileTypes: true }).map((e) => e.name);
43
+ } catch {
44
+ return [];
45
+ }
46
+ const found: SkillLite[] = [];
47
+ for (const entry of entries) {
48
+ const skillDir = path.join(dir, entry);
49
+ const skillFile = path.join(skillDir, "SKILL.md");
50
+ try {
51
+ if (!fs.statSync(skillFile).isFile()) continue;
52
+ } catch {
53
+ continue;
54
+ }
55
+ let raw = "";
56
+ try {
57
+ raw = fs.readFileSync(skillFile, "utf8");
58
+ } catch {
59
+ continue;
60
+ }
61
+ const fm = parseFrontmatter(raw);
62
+ found.push({
63
+ name: fm.name?.trim() || entry,
64
+ description: (fm.description ?? "").replace(/\s+/g, " ").slice(0, 160),
65
+ filePath: skillFile,
66
+ dir: skillDir,
67
+ });
68
+ }
69
+ return found;
70
+ }
71
+
72
+ /** Unique skills with a file path; first name wins (same as pi collisions). */
73
+ export function scanSkills(projectDir?: string): SkillLite[] {
74
+ const globalDir = path.join(os.homedir(), ".pi", "agent", "skills");
75
+ const seen = new Set<string>();
76
+ const out: SkillLite[] = [];
77
+ for (const skill of [...scanDir(globalDir), ...(projectDir ? scanDir(path.join(projectDir, ".pi", "skills")) : [])]) {
78
+ if (!skill.filePath || seen.has(skill.name)) continue;
79
+ seen.add(skill.name);
80
+ out.push(skill);
81
+ }
82
+ return out;
83
+ }
84
+
85
+ export function findSkillByName(skills: SkillLite[], name: string): SkillLite | undefined {
86
+ return skills.find((s) => s.name === name);
87
+ }
88
+
89
+ /** One-liner list for the tool description: `- name: description`. */
90
+ export function catalogSummary(skills: SkillLite[]): string {
91
+ return skills.map((s) => `- ${s.name}: ${s.description}`).join("\n");
92
+ }
93
+
94
+ /** Full body handed to agy when it activates a skill. */
95
+ export function readSkillBody(skill: SkillLite): string {
96
+ try {
97
+ return fs.readFileSync(skill.filePath, "utf8");
98
+ } catch (err) {
99
+ return `failed to read skill: ${err instanceof Error ? err.message : String(err)}`;
100
+ }
101
+ }
102
+
103
+ /** JSON schema for the activate_skill bridge tool. */
104
+ export function activateSkillSchema(skills: SkillLite[]): Record<string, unknown> {
105
+ return {
106
+ type: "object",
107
+ properties: {
108
+ name: {
109
+ type: "string",
110
+ enum: skills.map((s) => s.name),
111
+ description: "Skill name from the catalog in this tool's description.",
112
+ },
113
+ },
114
+ required: ["name"],
115
+ };
116
+ }