@estebanforge/pi-antigravity-bridge 1.2.6 → 1.3.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 +28 -0
- package/README.md +30 -2
- package/docs/ARCHITECTURE.md +0 -1
- package/extensions/index.ts +199 -139
- package/package.json +1 -1
- package/src/config.ts +55 -5
- package/src/driver.ts +644 -0
- package/src/mcp-server.ts +28 -48
- package/src/native-tools.ts +104 -0
- package/src/{patcher.ts → patch-cleanup.ts} +25 -197
- package/src/provider.ts +447 -24
- package/src/skills.ts +116 -0
- package/src/stream-events.ts +123 -0
- package/docs/PI-INVOKETOOL-PATCH.md +0 -254
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
|
-
|
|
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
|
|
321
|
-
|
|
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
|
|
326
|
-
|
|
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)
|
|
350
|
-
//
|
|
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
|
-
|
|
798
|
+
appendTextDelta(event.text);
|
|
391
799
|
break;
|
|
392
800
|
case "thinking":
|
|
393
|
-
|
|
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
|
-
|
|
404
|
-
if (outcome.text)
|
|
811
|
+
appendThinkingDelta(`[agy edit: ${label}]\n`);
|
|
812
|
+
if (outcome.text) appendThinkingDelta(`${outcome.text}\n`);
|
|
405
813
|
} else {
|
|
406
|
-
|
|
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
|
+
}
|