Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/smart-ravens-smile.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
dist/
node_modules/
benchmark-results/live/
benchmark-results/
.pi-lens/
3 changes: 3 additions & 0 deletions .opencode/opencode.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"plugin": ["@caplets/opencode@file:./packages/opencode"]
}
3 changes: 3 additions & 0 deletions .pi/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extensions": ["../packages/pi/dist/index.js"]
}
8 changes: 6 additions & 2 deletions packages/pi/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -46,5 +45,10 @@
},
"engines": {
"node": ">=22"
},
"pi": {
"extensions": [
"dist/index.js"
]
}
}
1 change: 0 additions & 1 deletion packages/pi/rolldown.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,5 @@ export default defineConfig({
"@caplets/core/generated-tool-input-schema",
"@caplets/core/native",
"@earendil-works/pi-coding-agent",
"@sinclair/typebox",
],
});
108 changes: 87 additions & 21 deletions packages/pi/src/index.ts
Original file line number Diff line number Diff line change
@@ -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<ExtensionAPI, "registerTool"> &
Partial<Pick<ExtensionAPI, "getActiveTools" | "setActiveTools" | "on">>;

type PiToolDefinition = Parameters<ExtensionAPI["registerTool"]>[0];

export type CapletsPiOptions = {
service?: NativeCapletsService;
Expand All @@ -25,11 +24,11 @@ export default function capletsPiExtension(pi: PiExtensionApi, options: CapletsP
}

const registeredCapletToolSignatures = new Map<string, string>();
let knownCapletTools = new Set<string>(
pi.getActiveTools?.().filter((name) => name.startsWith("caplets_")) ?? [],
);
let currentCapletTools = new Set<string>();
let knownCapletTools = new Set<string>();
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)) {
Expand All @@ -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) {
Expand All @@ -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 {
Expand All @@ -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<string, unknown>)[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" };
Expand Down
16 changes: 0 additions & 16 deletions packages/pi/src/schema.ts

This file was deleted.

Loading