mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
292 lines
10 KiB
TypeScript
292 lines
10 KiB
TypeScript
import { readFile } from "node:fs/promises";
|
|
import { createServer, type ServerResponse } from "node:http";
|
|
import { dirname, join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import {
|
|
AgentHarness,
|
|
type AgentHarnessEvent,
|
|
type AgentHarnessTool,
|
|
InMemorySessionStorage,
|
|
Session,
|
|
} from "@earendil-works/pi-agent-core";
|
|
import { createModels, type Model } from "@earendil-works/pi-ai";
|
|
import { anthropicProvider } from "@earendil-works/pi-ai/providers/anthropic";
|
|
import { openaiProvider } from "@earendil-works/pi-ai/providers/openai";
|
|
import { RpcSession, type RpcStub, RpcTarget } from "capnweb";
|
|
import { Value } from "typebox/value";
|
|
import { WebSocketServer } from "ws";
|
|
import { BindingRegistry, type ConnectedClient } from "./binding-registry.ts";
|
|
import {
|
|
type MessageEndHandler,
|
|
type NotifyExtensionContext,
|
|
notifyExtension,
|
|
type ServerCommand,
|
|
type ServerExtensionApi,
|
|
} from "./extension.ts";
|
|
import {
|
|
type ClientBindingApi,
|
|
type ClientRegistration,
|
|
clientRegistrationSchema,
|
|
type ServerApi,
|
|
type ServerEvent,
|
|
} from "./protocol.ts";
|
|
import { NodeWebSocketTransport } from "./ws-transport.ts";
|
|
|
|
const host = process.env.PI_CAPNWEB_SPIKE_HOST ?? "127.0.0.1";
|
|
const port = Number(process.env.PI_CAPNWEB_SPIKE_PORT ?? "8788");
|
|
const modelSpec = process.env.PI_CAPNWEB_SPIKE_MODEL ?? "openai/gpt-5.4-nano";
|
|
const [providerId, modelId] = modelSpec.split("/", 2);
|
|
if (!providerId || !modelId) {
|
|
throw new Error(`PI_CAPNWEB_SPIKE_MODEL must use provider/model syntax, received ${modelSpec}`);
|
|
}
|
|
|
|
const models = createModels();
|
|
models.setProvider(openaiProvider());
|
|
models.setProvider(anthropicProvider());
|
|
|
|
function createFallbackModel(provider: string, id: string): Model<"openai-responses" | "anthropic-messages"> {
|
|
const common = {
|
|
id,
|
|
name: id,
|
|
reasoning: false,
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 128_000,
|
|
maxTokens: 16_384,
|
|
};
|
|
if (provider === "openai") {
|
|
return {
|
|
...common,
|
|
api: "openai-responses",
|
|
provider: "openai",
|
|
baseUrl: "https://api.openai.com/v1",
|
|
input: ["text"],
|
|
};
|
|
}
|
|
if (provider === "anthropic") {
|
|
return {
|
|
...common,
|
|
api: "anthropic-messages",
|
|
provider: "anthropic",
|
|
baseUrl: "https://api.anthropic.com",
|
|
input: ["text"],
|
|
};
|
|
}
|
|
throw new Error(`Unsupported provider ${provider}. This spike registers openai and anthropic.`);
|
|
}
|
|
|
|
const model = models.getModel(providerId, modelId) ?? createFallbackModel(providerId, modelId);
|
|
const registry = new BindingRegistry();
|
|
const tools: AgentHarnessTool<NotifyExtensionContext>[] = [];
|
|
const commands = new Map<string, ServerCommand>();
|
|
const messageEndHandlers: MessageEndHandler[] = [];
|
|
const extensionApi: ServerExtensionApi = {
|
|
registerTool(tool) {
|
|
tools.push(tool);
|
|
},
|
|
registerCommand(name, command) {
|
|
commands.set(name, command);
|
|
},
|
|
on(type, handler) {
|
|
if (type === "message_end") messageEndHandlers.push(handler);
|
|
},
|
|
};
|
|
notifyExtension(extensionApi);
|
|
|
|
let activeOriginClient: ConnectedClient | undefined;
|
|
let promptInFlight = false;
|
|
|
|
function contextFor(client: ConnectedClient): NotifyExtensionContext {
|
|
return {
|
|
clientBindings: {
|
|
notify: (input, options) => registry.notify(client, input, options),
|
|
},
|
|
};
|
|
}
|
|
|
|
const harness = new AgentHarness({
|
|
session: new Session(new InMemorySessionStorage()),
|
|
models,
|
|
model,
|
|
thinkingLevel: "off",
|
|
tools,
|
|
toolContext: () => {
|
|
if (!activeOriginClient) throw new Error("No originating client is bound to this turn");
|
|
return contextFor(activeOriginClient);
|
|
},
|
|
systemPrompt: [
|
|
"You are a concise assistant.",
|
|
"When asked for the capital of France, respond with exactly Paris and nothing else.",
|
|
"When the user asks for a notification, call the notify tool with the exact message they want shown.",
|
|
].join(" "),
|
|
});
|
|
|
|
harness.subscribe(async (event, signal) => {
|
|
for (const wireEvent of toServerEvents(event)) await registry.broadcast(wireEvent);
|
|
if (event.type === "message_end" && messageEndHandlers.length > 0) {
|
|
if (!activeOriginClient) throw new Error("No originating client is bound to this assistant response");
|
|
const context = contextFor(activeOriginClient);
|
|
for (const handler of messageEndHandlers) await handler(event, context, signal);
|
|
}
|
|
});
|
|
|
|
function toServerEvents(event: AgentHarnessEvent): ServerEvent[] {
|
|
switch (event.type) {
|
|
case "agent_start":
|
|
return [{ type: "run_start" }];
|
|
case "agent_end":
|
|
return [{ type: "run_end" }];
|
|
case "message_start":
|
|
return event.message.role === "assistant" ? [{ type: "assistant_start" }] : [];
|
|
case "message_update":
|
|
return event.assistantMessageEvent.type === "text_delta"
|
|
? [{ type: "assistant_delta", text: event.assistantMessageEvent.delta }]
|
|
: [];
|
|
case "message_end":
|
|
if (event.message.role !== "assistant") return [];
|
|
return [
|
|
{ type: "assistant_end" },
|
|
...(event.message.errorMessage ? [{ type: "error" as const, message: event.message.errorMessage }] : []),
|
|
];
|
|
case "tool_execution_start":
|
|
return [{ type: "tool_start", name: event.toolName }];
|
|
case "tool_execution_end":
|
|
return [{ type: "tool_end", name: event.toolName, isError: event.isError }];
|
|
default:
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async function runPrompt(client: ConnectedClient, text: string): Promise<void> {
|
|
if (promptInFlight) throw new Error("The shared session is already processing a prompt");
|
|
promptInFlight = true;
|
|
activeOriginClient = client;
|
|
await registry.broadcast({ type: "user_prompt", clientId: client.id, text });
|
|
try {
|
|
const assistant = await harness.prompt(text);
|
|
if (assistant.stopReason === "error") throw new Error(assistant.errorMessage ?? "Agent run failed");
|
|
} finally {
|
|
activeOriginClient = undefined;
|
|
promptInFlight = false;
|
|
}
|
|
}
|
|
|
|
class PiServerConnection extends RpcTarget implements ServerApi {
|
|
readonly #remoteClient: () => RpcStub<ClientBindingApi>;
|
|
#client: ConnectedClient | undefined;
|
|
|
|
constructor(remoteClient: () => RpcStub<ClientBindingApi>) {
|
|
super();
|
|
this.#remoteClient = remoteClient;
|
|
}
|
|
|
|
async connect(registration: ClientRegistration): Promise<void> {
|
|
if (this.#client) throw new Error("This RPC session is already registered");
|
|
if (!Value.Check(clientRegistrationSchema, registration)) throw new Error("Invalid client registration");
|
|
const client = registry.register(registration, this.#remoteClient());
|
|
this.#client = client;
|
|
try {
|
|
await registry.send(client, { type: "ready", clientId: client.id, bindings: registration.bindings });
|
|
} catch (error) {
|
|
registry.unregister(client);
|
|
this.#client = undefined;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async prompt(text: string): Promise<void> {
|
|
const client = this.#requireClient();
|
|
if (typeof text !== "string" || text.trim().length === 0) throw new Error("Prompt is required");
|
|
await runPrompt(client, text.trim());
|
|
}
|
|
|
|
async command(name: string, input: unknown): Promise<void> {
|
|
const client = this.#requireClient();
|
|
if (typeof name !== "string") throw new Error("Command name must be a string");
|
|
const command = commands.get(name);
|
|
if (!command) throw new Error(`Unknown command ${name}`);
|
|
await command(input, contextFor(client));
|
|
}
|
|
|
|
[Symbol.dispose](): void {
|
|
if (!this.#client) return;
|
|
registry.unregister(this.#client);
|
|
this.#client = undefined;
|
|
}
|
|
|
|
#requireClient(): ConnectedClient {
|
|
if (!this.#client?.connected) throw new Error("RPC client has not connected");
|
|
return this.#client;
|
|
}
|
|
}
|
|
|
|
function sendStatic(response: ServerResponse, path: string, type: string): Promise<void> {
|
|
return readFile(path).then((body) => {
|
|
response.writeHead(200, { "content-type": type, "cache-control": "no-store" });
|
|
response.end(body);
|
|
});
|
|
}
|
|
|
|
function sendJson(response: ServerResponse, status: number, body: unknown): void {
|
|
response.writeHead(status, {
|
|
"content-type": "application/json; charset=utf-8",
|
|
"cache-control": "no-store",
|
|
});
|
|
response.end(JSON.stringify(body));
|
|
}
|
|
|
|
const exampleRoot = dirname(fileURLToPath(import.meta.url));
|
|
const webRoot = join(exampleRoot, "web");
|
|
const capnwebBrowserModule = fileURLToPath(import.meta.resolve("capnweb"));
|
|
const staticFiles = new Map([
|
|
["/", { path: join(webRoot, "index.html"), type: "text/html; charset=utf-8" }],
|
|
["/app.js", { path: join(webRoot, "app.js"), type: "text/javascript; charset=utf-8" }],
|
|
["/styles.css", { path: join(webRoot, "styles.css"), type: "text/css; charset=utf-8" }],
|
|
["/vendor/capnweb.js", { path: capnwebBrowserModule, type: "text/javascript; charset=utf-8" }],
|
|
]);
|
|
|
|
const httpServer = createServer(async (request, response) => {
|
|
try {
|
|
const url = new URL(request.url ?? "/", `http://${request.headers.host ?? `${host}:${port}`}`);
|
|
const staticFile = request.method === "GET" ? staticFiles.get(url.pathname) : undefined;
|
|
if (staticFile) {
|
|
await sendStatic(response, staticFile.path, staticFile.type);
|
|
return;
|
|
}
|
|
sendJson(response, 404, { ok: false, error: "Not found" });
|
|
} catch (error) {
|
|
sendJson(response, 500, { ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
}
|
|
});
|
|
|
|
const wsServer = new WebSocketServer({
|
|
server: httpServer,
|
|
path: "/api/rpc",
|
|
maxPayload: 64 * 1024,
|
|
perMessageDeflate: false,
|
|
});
|
|
wsServer.on("connection", (socket) => {
|
|
let remoteClient: RpcStub<ClientBindingApi> | undefined;
|
|
const connection = new PiServerConnection(() => {
|
|
if (!remoteClient) throw new Error("Remote client binding is not ready");
|
|
return remoteClient;
|
|
});
|
|
const session = new RpcSession<ClientBindingApi>(new NodeWebSocketTransport(socket), connection, {
|
|
limits: { maxMessageSize: 64 * 1024 },
|
|
});
|
|
remoteClient = session.getRemoteMain();
|
|
socket.once("close", () => connection[Symbol.dispose]());
|
|
});
|
|
|
|
httpServer.listen(port, host, async () => {
|
|
const auth = await models.checkAuth(providerId);
|
|
console.log(`Cap'n Web capability spike listening at http://${host}:${port}`);
|
|
console.log(`Model: ${modelSpec}${auth ? ` (${auth.source})` : " (provider auth not configured)"}`);
|
|
console.log("Run the TUI with: npm run tui --prefix packages/coding-agent/examples/capability-capnweb-spike");
|
|
});
|
|
|
|
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
|
process.once(signal, () => {
|
|
wsServer.close();
|
|
httpServer.close(() => process.exit(0));
|
|
});
|
|
}
|