From ffd2f7ac5a05b898e00c32669de7c45f3d59cc6c Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Mon, 18 May 2026 06:35:25 -0400 Subject: [PATCH] feat(pi): improve caplet tool integration --- .changeset/smart-ravens-smile.md | 5 + .gitignore | 3 +- .opencode/opencode.json | 3 + .pi/settings.json | 3 + packages/pi/package.json | 8 +- packages/pi/rolldown.config.ts | 1 - packages/pi/src/index.ts | 108 +++++++++++++---- packages/pi/src/schema.ts | 16 --- packages/pi/test/pi.test.ts | 192 ++++++++++++++++++++++++++++--- pnpm-lock.yaml | 3 - 10 files changed, 280 insertions(+), 62 deletions(-) create mode 100644 .changeset/smart-ravens-smile.md create mode 100644 .opencode/opencode.json create mode 100644 .pi/settings.json delete mode 100644 packages/pi/src/schema.ts diff --git a/.changeset/smart-ravens-smile.md b/.changeset/smart-ravens-smile.md new file mode 100644 index 00000000..10097def --- /dev/null +++ b/.changeset/smart-ravens-smile.md @@ -0,0 +1,5 @@ +--- +"@caplets/pi": patch +--- + +Improve Pi caplet extension registration by using the generated core input schema, declaring the built extension in the package manifest, deferring active-tool synchronization until session start, and adding compact tool call/result rendering. diff --git a/.gitignore b/.gitignore index 98071fcd..dd8e7beb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ dist/ node_modules/ -benchmark-results/live/ +benchmark-results/ +.pi-lens/ diff --git a/.opencode/opencode.json b/.opencode/opencode.json new file mode 100644 index 00000000..b10a6ba0 --- /dev/null +++ b/.opencode/opencode.json @@ -0,0 +1,3 @@ +{ + "plugin": ["@caplets/opencode@file:./packages/opencode"] +} diff --git a/.pi/settings.json b/.pi/settings.json new file mode 100644 index 00000000..55031ead --- /dev/null +++ b/.pi/settings.json @@ -0,0 +1,3 @@ +{ + "extensions": ["../packages/pi/dist/index.js"] +} diff --git a/packages/pi/package.json b/packages/pi/package.json index 99765f7e..33eef50e 100644 --- a/packages/pi/package.json +++ b/packages/pi/package.json @@ -32,8 +32,7 @@ "test": "vitest run" }, "dependencies": { - "@caplets/core": "workspace:*", - "@sinclair/typebox": "^0.34.49" + "@caplets/core": "workspace:*" }, "devDependencies": { "@types/node": "^25.7.0", @@ -46,5 +45,10 @@ }, "engines": { "node": ">=22" + }, + "pi": { + "extensions": [ + "dist/index.js" + ] } } diff --git a/packages/pi/rolldown.config.ts b/packages/pi/rolldown.config.ts index 78222afe..217c4944 100644 --- a/packages/pi/rolldown.config.ts +++ b/packages/pi/rolldown.config.ts @@ -11,6 +11,5 @@ export default defineConfig({ "@caplets/core/generated-tool-input-schema", "@caplets/core/native", "@earendil-works/pi-coding-agent", - "@sinclair/typebox", ], }); diff --git a/packages/pi/src/index.ts b/packages/pi/src/index.ts index d0118bd0..14522397 100644 --- a/packages/pi/src/index.ts +++ b/packages/pi/src/index.ts @@ -1,17 +1,16 @@ +import { keyText, type ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { generatedToolInputJsonSchema } from "@caplets/core/generated-tool-input-schema"; import { createNativeCapletsService, registerNativeCapletsProcessCleanup, type NativeCapletTool, type NativeCapletsService, } from "@caplets/core/native"; -import { capletsPiParameters } from "./schema.js"; -export type PiExtensionApi = { - registerTool(definition: unknown): void; - getActiveTools?(): string[]; - setActiveTools?(names: string[]): void; - on?(event: "session_shutdown", handler: () => void): void; -}; +export type PiExtensionApi = Pick & + Partial>; + +type PiToolDefinition = Parameters[0]; export type CapletsPiOptions = { service?: NativeCapletsService; @@ -25,11 +24,11 @@ export default function capletsPiExtension(pi: PiExtensionApi, options: CapletsP } const registeredCapletToolSignatures = new Map(); - let knownCapletTools = new Set( - pi.getActiveTools?.().filter((name) => name.startsWith("caplets_")) ?? [], - ); + let currentCapletTools = new Set(); + let knownCapletTools = new Set(); + let canSyncActiveTools = false; - const syncTools = (caplets = service.listTools()) => { + const syncToolRegistrations = (caplets = service.listTools()) => { const nextCapletTools = new Set(caplets.map((caplet) => caplet.toolName)); for (const [toolName] of registeredCapletToolSignatures) { if (!nextCapletTools.has(toolName)) { @@ -45,18 +44,33 @@ export default function capletsPiExtension(pi: PiExtensionApi, options: CapletsP pi.registerTool(createPiTool(service, caplet)); } - if (pi.getActiveTools && pi.setActiveTools) { - const activeNonCaplets = pi - .getActiveTools() - .filter((name) => !knownCapletTools.has(name) && !nextCapletTools.has(name)); - pi.setActiveTools([...activeNonCaplets, ...nextCapletTools]); + currentCapletTools = nextCapletTools; + return nextCapletTools; + }; + + const syncActiveTools = (nextCapletTools = currentCapletTools) => { + if (!canSyncActiveTools || !pi.getActiveTools || !pi.setActiveTools) { + return; } + const activeNonCaplets = pi + .getActiveTools() + .filter((name) => !knownCapletTools.has(name) && !nextCapletTools.has(name)); + pi.setActiveTools([...activeNonCaplets, ...nextCapletTools]); knownCapletTools = nextCapletTools; }; - syncTools(); - const unsubscribe = service.onToolsChanged(syncTools); + currentCapletTools = syncToolRegistrations(); + const unsubscribe = service.onToolsChanged((caplets) => { + syncActiveTools(syncToolRegistrations(caplets)); + }); + pi.on?.("session_start", () => { + canSyncActiveTools = true; + knownCapletTools = new Set( + pi.getActiveTools?.().filter((name) => name.startsWith("caplets_")) ?? [], + ); + syncActiveTools(); + }); pi.on?.("session_shutdown", () => { unsubscribe(); if (ownsService) { @@ -74,15 +88,15 @@ function piToolSignature(caplet: NativeCapletTool): string { }); } -function createPiTool(service: NativeCapletsService, caplet: NativeCapletTool): unknown { +function createPiTool(service: NativeCapletsService, caplet: NativeCapletTool): PiToolDefinition { return { name: caplet.toolName, label: caplet.title, description: caplet.description, promptSnippet: `Use ${caplet.toolName} for the ${caplet.title} Caplet capability domain.`, promptGuidelines: caplet.promptGuidance, - parameters: capletsPiParameters(), - async execute(_toolCallId: string, params: unknown) { + parameters: generatedToolInputJsonSchema() as PiToolDefinition["parameters"], + async execute(_toolCallId, params) { const result = await service.execute(caplet.caplet, params); const serialized = serializeResult(result); return { @@ -92,9 +106,61 @@ function createPiTool(service: NativeCapletsService, caplet: NativeCapletTool): : { result }, }; }, + renderCall(args, theme) { + const operation = stringProperty(args, "operation"); + const downstreamTool = stringProperty(args, "tool"); + const suffix = [operation, downstreamTool].filter(Boolean).join(" "); + return textComponent( + theme.fg("toolTitle", theme.bold(caplet.title)) + + (suffix ? ` ${theme.fg("muted", suffix)}` : ""), + ); + }, + renderResult(result, { expanded, isPartial }, theme) { + if (isPartial) { + return textComponent(theme.fg("warning", `${caplet.title} running...`)); + } + + const output = result.content + .filter((item) => item.type === "text") + .map((item) => item.text) + .join("\n"); + if (expanded) { + return textComponent( + theme.fg("success", `✓ ${caplet.title} complete`) + + theme.fg("dim", ` (${toolExpandKeyText()} to collapse)`) + + (output ? `\n${theme.fg("toolOutput", output)}` : ""), + ); + } + + return textComponent( + theme.fg("success", `✓ ${caplet.title} complete`) + + theme.fg("dim", ` (${toolExpandKeyText()} to expand)`), + ); + }, }; } +function toolExpandKeyText(): string { + return keyText("app.tools.expand") || "ctrl+o"; +} + +function textComponent(text: string): { render(width: number): string[]; invalidate(): void } { + return { + render(_width) { + return text.split("\n"); + }, + invalidate() {}, + }; +} + +function stringProperty(value: unknown, key: string): string | undefined { + if (!value || typeof value !== "object" || !(key in value)) { + return undefined; + } + const property = (value as Record)[key]; + return typeof property === "string" && property.length > 0 ? property : undefined; +} + function serializeResult(result: unknown): { text: string; serializationError?: string } { try { return { text: JSON.stringify(result, null, 2) ?? "null" }; diff --git a/packages/pi/src/schema.ts b/packages/pi/src/schema.ts deleted file mode 100644 index 5bd7e112..00000000 --- a/packages/pi/src/schema.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Type } from "@sinclair/typebox"; -import { operations } from "@caplets/core/generated-tool-input-schema"; - -export function capletsPiParameters() { - return Type.Object( - { - operation: Type.Union(operations.map((operation) => Type.Literal(operation))), - query: Type.Optional(Type.String()), - limit: Type.Optional(Type.Integer({ minimum: 1 })), - tool: Type.Optional(Type.String()), - arguments: Type.Optional(Type.Record(Type.String(), Type.Unknown())), - fields: Type.Optional(Type.Array(Type.String({ minLength: 1 }), { minItems: 1 })), - }, - { additionalProperties: false }, - ); -} diff --git a/packages/pi/test/pi.test.ts b/packages/pi/test/pi.test.ts index e42233b3..4c534bb0 100644 --- a/packages/pi/test/pi.test.ts +++ b/packages/pi/test/pi.test.ts @@ -1,6 +1,9 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { describe, expect, it, vi, type Mock } from "vitest"; +import { generatedToolInputJsonSchema } from "@caplets/core/generated-tool-input-schema"; import type { NativeCapletTool, NativeCapletsService } from "@caplets/core/native"; -import capletsPiExtension from "../src/index.js"; +import capletsPiExtension, { type PiExtensionApi } from "../src/index.js"; const nativeMocks = vi.hoisted(() => ({ createNativeCapletsService: vi.fn(), @@ -9,6 +12,16 @@ const nativeMocks = vi.hoisted(() => ({ vi.mock("@caplets/core/native", () => nativeMocks); +type RenderTheme = { + bold(text: string): string; + fg(_key: string, text: string): string; +}; + +type RenderComponent = { + render(width: number): string[]; + invalidate(): void; +}; + type RegisteredTool = { name: string; label: string; @@ -23,13 +36,19 @@ type RegisteredTool = { content: Array<{ type: string; text: string }>; details: { result: unknown; serializationError?: string }; }>; + renderCall(args: unknown, theme: RenderTheme): RenderComponent; + renderResult( + result: { content: Array<{ type: string; text: string }>; details: unknown }, + options: { expanded: boolean; isPartial: boolean }, + theme: RenderTheme, + ): RenderComponent; }; type MockPiApi = { registerTool: Mock<(definition: unknown) => void>; getActiveTools: Mock<() => string[]>; setActiveTools: Mock<(names: string[]) => void>; - on: Mock<(event: "session_shutdown", handler: () => void) => void>; + on: Mock<(event: "session_start" | "session_shutdown", handler: () => void) => void>; }; type MockService = NativeCapletsService & { @@ -49,6 +68,34 @@ describe("@caplets/pi", () => { expect(Object.keys(exports).sort()).toEqual(["default"]); }); + it("declares the built extension in the Pi package manifest", () => { + const packageJson = JSON.parse( + readFileSync(resolve(import.meta.dirname, "../package.json"), "utf8"), + ) as { pi?: { extensions?: string[] } }; + + expect(packageJson.pi?.extensions).toEqual(["dist/index.js"]); + }); + + it("uses the core generated schema as Pi tool parameters", () => { + const service = mockService([ + { + caplet: "git-hub", + toolName: "caplets_git_hub", + title: "GitHub", + description: "GitHub Caplet", + promptGuidance: ["Use caplets_git_hub for GitHub."], + }, + ]); + const registered: RegisteredTool[] = []; + + capletsPiExtension( + { registerTool: (definition) => registered.push(definition as unknown as RegisteredTool) }, + { service }, + ); + + expect(registered[0]?.parameters).toEqual(generatedToolInputJsonSchema()); + }); + it("registers prefixed native tools with explicit prompt guidance", async () => { const service = mockService([ { @@ -62,7 +109,7 @@ describe("@caplets/pi", () => { const registered: RegisteredTool[] = []; capletsPiExtension( - { registerTool: (definition) => registered.push(definition as RegisteredTool) }, + { registerTool: (definition) => registered.push(definition as unknown as RegisteredTool) }, { service }, ); @@ -96,7 +143,7 @@ describe("@caplets/pi", () => { const registered: RegisteredTool[] = []; capletsPiExtension( - { registerTool: (definition) => registered.push(definition as RegisteredTool) }, + { registerTool: (definition) => registered.push(definition as unknown as RegisteredTool) }, { service }, ); @@ -112,7 +159,7 @@ describe("@caplets/pi", () => { const registered: RegisteredTool[] = []; capletsPiExtension( - { registerTool: (definition) => registered.push(definition as RegisteredTool) }, + { registerTool: (definition) => registered.push(definition as unknown as RegisteredTool) }, { service }, ); @@ -136,7 +183,7 @@ describe("@caplets/pi", () => { const registered: RegisteredTool[] = []; capletsPiExtension( - { registerTool: (definition) => registered.push(definition as RegisteredTool) }, + { registerTool: (definition) => registered.push(definition as unknown as RegisteredTool) }, { service }, ); @@ -160,7 +207,7 @@ describe("@caplets/pi", () => { const registered: RegisteredTool[] = []; capletsPiExtension( - { registerTool: (definition) => registered.push(definition as RegisteredTool) }, + { registerTool: (definition) => registered.push(definition as unknown as RegisteredTool) }, { service }, ); @@ -183,7 +230,7 @@ describe("@caplets/pi", () => { const registered: RegisteredTool[] = []; capletsPiExtension( - { registerTool: (definition) => registered.push(definition as RegisteredTool) }, + { registerTool: (definition) => registered.push(definition as unknown as RegisteredTool) }, { service }, ); @@ -192,6 +239,94 @@ describe("@caplets/pi", () => { expect(result?.details).toEqual({ result: undefined }); }); + it("renders caplet tool calls and collapsed results compactly", async () => { + const service = mockService([ + { + caplet: "context7", + toolName: "caplets_context7", + title: "Context7", + description: "Context7 Caplet", + promptGuidance: ["Use caplets_context7 for Context7."], + }, + ]); + service.execute.mockResolvedValueOnce({ content: [{ type: "text", text: "very long docs" }] }); + const registered: RegisteredTool[] = []; + + capletsPiExtension( + { registerTool: (definition) => registered.push(definition as unknown as RegisteredTool) }, + { service }, + ); + + const tool = registered[0]; + const result = await tool?.execute("call-1", { + operation: "call_tool", + tool: "query-docs", + }); + + expect( + renderText(tool?.renderCall({ operation: "call_tool", tool: "query-docs" }, plainTheme)), + ).toBe("Context7 call_tool query-docs"); + expect( + renderText(tool?.renderResult(result!, { expanded: false, isPartial: false }, plainTheme)), + ).toBe("✓ Context7 complete (ctrl+o to expand)"); + }); + + it("renders expanded caplet results with serialized output", async () => { + const service = mockService([ + { + caplet: "context7", + toolName: "caplets_context7", + title: "Context7", + description: "Context7 Caplet", + promptGuidance: ["Use caplets_context7 for Context7."], + }, + ]); + service.execute.mockResolvedValueOnce({ content: [{ type: "text", text: "very long docs" }] }); + const registered: RegisteredTool[] = []; + + capletsPiExtension( + { registerTool: (definition) => registered.push(definition as unknown as RegisteredTool) }, + { service }, + ); + + const tool = registered[0]; + const result = await tool?.execute("call-1", { operation: "call_tool" }); + + expect( + renderText(tool?.renderResult(result!, { expanded: true, isPartial: false }, plainTheme)), + ).toContain('"very long docs"'); + expect( + renderText(tool?.renderResult(result!, { expanded: true, isPartial: false }, plainTheme)), + ).toContain("(ctrl+o to collapse)"); + }); + + it("does not execute active-tool actions during extension loading", () => { + const service = mockService([ + { + caplet: "git-hub", + toolName: "caplets_git_hub", + title: "GitHub", + description: "GitHub Caplet", + promptGuidance: ["Use caplets_git_hub for GitHub."], + }, + ]); + const api = { + registerTool: vi.fn(), + getActiveTools: vi.fn(() => { + throw new Error("Extension runtime not initialized"); + }), + setActiveTools: vi.fn(() => { + throw new Error("Extension runtime not initialized"); + }), + on: vi.fn(), + }; + + expect(() => capletsPiExtension(api as unknown as PiExtensionApi, { service })).not.toThrow(); + expect(api.registerTool).toHaveBeenCalledOnce(); + expect(api.getActiveTools).not.toHaveBeenCalled(); + expect(api.setActiveTools).not.toHaveBeenCalled(); + }); + it("registers process cleanup for owned services", () => { const service = mockService([]); nativeMocks.createNativeCapletsService.mockReturnValueOnce(service); @@ -214,7 +349,8 @@ describe("@caplets/pi", () => { ]); const { api, registered } = mockPiApi(["read", "caplets_git_hub"]); - capletsPiExtension(api, { service }); + capletsPiExtension(api as unknown as PiExtensionApi, { service }); + triggerSessionStart(api); expect(api.setActiveTools).toHaveBeenNthCalledWith(1, ["read", "caplets_git_hub"]); service.setTools([ { @@ -254,7 +390,8 @@ describe("@caplets/pi", () => { ]); const { api, registered } = mockPiApi(["read", "caplets_git_hub"]); - capletsPiExtension(api, { service }); + capletsPiExtension(api as unknown as PiExtensionApi, { service }); + triggerSessionStart(api); service.setTools([ { caplet: "git-hub", @@ -287,7 +424,8 @@ describe("@caplets/pi", () => { ]); const { api, registered } = mockPiApi(["read", "caplets_git_hub"]); - capletsPiExtension(api, { service }); + capletsPiExtension(api as unknown as PiExtensionApi, { service }); + triggerSessionStart(api); service.setTools([ { caplet: "github-v2", @@ -315,7 +453,8 @@ describe("@caplets/pi", () => { ]); const { api, registered } = mockPiApi(["read", "caplets_git_hub"]); - capletsPiExtension(api, { service }); + capletsPiExtension(api as unknown as PiExtensionApi, { service }); + triggerSessionStart(api); service.setTools([]); service.emitToolsChanged(); service.setTools([ @@ -352,7 +491,8 @@ describe("@caplets/pi", () => { ]); const { api } = mockPiApi(["read", "bash", "caplets_git_hub", "caplets_linear"]); - capletsPiExtension(api, { service }); + capletsPiExtension(api as unknown as PiExtensionApi, { service }); + triggerSessionStart(api); service.setTools([ { caplet: "linear", @@ -379,7 +519,8 @@ describe("@caplets/pi", () => { ]); const { api } = mockPiApi(["read", "caplets_stale", "caplets_git_hub"]); - capletsPiExtension(api, { service }); + capletsPiExtension(api as unknown as PiExtensionApi, { service }); + triggerSessionStart(api); expect(api.setActiveTools).toHaveBeenCalledWith(["read", "caplets_git_hub"]); }); @@ -389,7 +530,7 @@ describe("@caplets/pi", () => { const registered: RegisteredTool[] = []; capletsPiExtension( - { registerTool: (definition) => registered.push(definition as RegisteredTool) }, + { registerTool: (definition) => registered.push(definition as unknown as RegisteredTool) }, { service }, ); @@ -419,7 +560,8 @@ describe("@caplets/pi", () => { ]); const { api, registered } = mockPiApi(); - capletsPiExtension(api, { service }); + capletsPiExtension(api as unknown as PiExtensionApi, { service }); + triggerSessionStart(api); const shutdown = api.on.mock.calls.find(([event]) => event === "session_shutdown")?.[1]; shutdown?.(); service.setTools([ @@ -449,7 +591,7 @@ describe("@caplets/pi", () => { const { api } = mockPiApi(); nativeMocks.createNativeCapletsService.mockReturnValueOnce(service); - capletsPiExtension(api); + capletsPiExtension(api as unknown as PiExtensionApi); const shutdown = api.on.mock.calls.find(([event]) => event === "session_shutdown")?.[1]; shutdown?.(); @@ -457,12 +599,26 @@ describe("@caplets/pi", () => { }); }); +const plainTheme: RenderTheme = { + bold: (text) => text, + fg: (_key, text) => text, +}; + +function renderText(component: RenderComponent | undefined): string { + return component?.render(120).join("\n") ?? ""; +} + +function triggerSessionStart(api: MockPiApi): void { + const sessionStart = api.on.mock.calls.find(([event]) => event === "session_start")?.[1]; + sessionStart?.(); +} + function mockPiApi(activeTools: string[] = []): { api: MockPiApi; registered: RegisteredTool[] } { const registered: RegisteredTool[] = []; let currentActiveTools = [...activeTools]; const api: MockPiApi = { registerTool: vi.fn((definition) => { - registered.push(definition as RegisteredTool); + registered.push(definition as unknown as RegisteredTool); }), getActiveTools: vi.fn(() => [...currentActiveTools]), setActiveTools: vi.fn((names) => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1b4607ca..ccab869c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -343,9 +343,6 @@ importers: '@earendil-works/pi-coding-agent': specifier: ^0.74.0 version: 0.74.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) - '@sinclair/typebox': - specifier: ^0.34.49 - version: 0.34.49 devDependencies: '@types/node': specifier: ^25.7.0