diff --git a/README.md b/README.md index 47bc7bb..f264cdc 100644 --- a/README.md +++ b/README.md @@ -17,3 +17,136 @@ After onboarding: - Run `buddy` to open the chat UI. - Run `buddy config` to tweak advanced settings like Discord and blocked directories. - Run `buddy server start` to launch the local server in the background when you want a dedicated daemon. + +## Plugins + +Buddy can auto-load custom tool plugins from `~/.buddy/plugins`. Each plugin lives in its own folder, ships a compiled ESM entrypoint, and can expose one or more tools to the model. + +### Folder layout + +```text +~/.buddy/plugins/ + weather-tools/ + package.json + dist/ + index.js +``` + +Buddy scans each direct child directory of `~/.buddy/plugins` on every chat turn. The folder contents are the source of truth in v1, so there are no extra enable or disable flags yet. + +### `package.json` + +Each plugin folder must include a `package.json` with `name`, `version`, and `buddy.entry`: + +```json +{ + "name": "@acme/weather-tools", + "version": "1.0.0", + "type": "module", + "buddy": { + "entry": "./dist/index.js" + } +} +``` + +### Authoring API + +Buddy exposes a TypeScript SDK at `@teichai/buddy/plugin`. + +```ts +import { definePlugin, defineTool } from "@teichai/buddy/plugin"; + +export default definePlugin({ + id: "weather-tools", + name: "Weather Tools", + description: "Weather helpers for Buddy", + author: "Acme, Inc.", + repositoryUrl: "https://github.com/acme/weather-tools", + tools: [ + defineTool({ + id: "forecast", + description: "Fetch a weather forecast for a city.", + parameters: { + type: "object", + properties: { + city: { type: "string", description: "City to look up." } + }, + required: ["city"], + additionalProperties: false + }, + summarize(args) { + return { + summary: `Fetch forecast for ${String(args.city ?? "unknown city")}`, + path: `weather:${String(args.city ?? "unknown")}` + }; + }, + async execute(_context, args) { + return `Sunny in ${String(args.city ?? "unknown")}`; + } + }) + ] +}); +``` + +Plugin metadata: + +- Required: `id`, `tools` +- Optional: `name`, `version`, `description`, `author`, `repositoryUrl` + +`repositoryUrl` must be an absolute `http` or `https` URL if you provide it. + +### Approval behavior + +Tools can opt into approval in two ways. + +Static approval: + +```ts +defineTool({ + id: "dangerous-action", + description: "Run a risky action.", + requiresApproval: true, + parameters: { type: "object", additionalProperties: false }, + summarize() { + return { summary: "Run dangerous action", path: "dangerous-action" }; + }, + async execute() { + return "done"; + } +}); +``` + +Conditional approval from inside the tool: + +```ts +import { defineTool, requestApproval } from "@teichai/buddy/plugin"; + +defineTool({ + id: "deploy", + description: "Deploy the current release.", + parameters: { + type: "object", + properties: { + force: { type: "boolean" } + }, + additionalProperties: false + }, + summarize() { + return { summary: "Deploy release", path: "release" }; + }, + async execute(_context, args) { + if (args.force === true) { + return requestApproval({ + summary: "Force deploy release", + path: "release", + reason: "Force mode bypasses the normal deployment checks.", + continueWith: async () => "forced deploy complete" + }); + } + + return "deploy complete"; + } +}); +``` + +In v1, plugin permissions are Buddy approval semantics for tool calls. Plugins are still trusted in-process code. diff --git a/package-lock.json b/package-lock.json index 3d246f1..54bb426 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@teichai/buddy", - "version": "0.0.1", + "version": "0.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@teichai/buddy", - "version": "0.0.1", + "version": "0.0.3", "license": "MIT", "dependencies": { "@mariozechner/pi-tui": "^0.62.0", diff --git a/package.json b/package.json index 1340f53..38e6601 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,12 @@ "bin": { "buddy": "dist/index.js" }, + "exports": { + "./plugin": { + "types": "./dist/plugin.d.ts", + "default": "./dist/plugin.js" + } + }, "files": [ "dist", "LICENSE", diff --git a/src/channels/discord.ts b/src/channels/discord.ts index a8ea7af..ec9ea7e 100644 --- a/src/channels/discord.ts +++ b/src/channels/discord.ts @@ -29,6 +29,7 @@ import { import { loadConfig } from "../config/store.js"; import type { DiscordChannelConfig } from "../config/schema.js"; import { executeChatTurn } from "../server/chat.js"; +import type { ToolSourceMetadata } from "../tools/registry.js"; import { createOrLoadDiscordConversationForTurn, getActiveDiscordConversationId, @@ -105,7 +106,7 @@ const pendingApprovals = new Map< interface ToolTranscriptState { entries: ToolRuntimeEvent[]; - activeEntryByInvocation: Map; + activeEntryById: Map; } interface StreamingDiscordReply { @@ -214,25 +215,32 @@ function splitDiscordMessage(content: string, maxLength = 1900): string[] { function createToolTranscriptState(): ToolTranscriptState { return { entries: [], - activeEntryByInvocation: new Map() + activeEntryById: new Map() }; } +function toolSourceLabel(source?: ToolSourceMetadata): string { + if (source?.kind !== "plugin") { + return ""; + } + + return source.pluginName || source.pluginId || "plugin"; +} + function trackToolEvent(state: ToolTranscriptState, event: ToolRuntimeEvent): void { - const invocationKey = `${event.id}\u0000${event.toolName}\u0000${event.path}\u0000${event.summary}`; - const existingIndex = state.activeEntryByInvocation.get(invocationKey); + const existingIndex = state.activeEntryById.get(event.id); if (existingIndex === undefined) { state.entries.push(event); if (event.status === "running" || event.status === "awaiting_approval") { - state.activeEntryByInvocation.set(invocationKey, state.entries.length - 1); + state.activeEntryById.set(event.id, state.entries.length - 1); } } else { state.entries[existingIndex] = event; if (event.status === "completed" || event.status === "denied" || event.status === "failed") { - state.activeEntryByInvocation.delete(invocationKey); + state.activeEntryById.delete(event.id); } } } @@ -253,24 +261,27 @@ function summarizeToolFailure(event: ToolRuntimeEvent): string { function buildToolTranscriptLines(state: ToolTranscriptState): string[] { return state.entries .flatMap((event) => { + const sourceLabel = toolSourceLabel(event.source); + const prefix = sourceLabel ? `[${sourceLabel}] ` : ""; + if (event.status === "running") { - return [`> Running: ${event.summary}`]; + return [`> Running: ${prefix}${event.summary}`]; } if (event.status === "awaiting_approval") { - return [`> Approval needed: ${event.summary}`]; + return [`> Approval needed: ${prefix}${event.summary}`]; } if (event.status === "completed") { - return [`> ${event.summary}`]; + return [`> ${prefix}${event.summary}`]; } if (event.status === "denied") { - return [`> Denied: ${event.summary}`]; + return [`> Denied: ${prefix}${event.summary}`]; } if (event.status === "failed") { - return [`> Failed: ${summarizeToolFailure(event)}`]; + return [`> Failed: ${prefix}${summarizeToolFailure(event)}`]; } return []; @@ -421,12 +432,25 @@ async function sendApprovalEmbed(params: { const embed = new EmbedBuilder() .setTitle("Tool approval needed") - .setDescription("A supervised tool call needs your choice before the chat can continue.") - .addFields( - { name: "Tool", value: `\`${params.request.toolName}\``, inline: true }, - { name: "Path", value: `\`${params.request.path}\``, inline: false }, - { name: "Action", value: params.request.summary, inline: false } - ); + .setDescription("Buddy needs your approval before the chat can continue.") + .addFields({ name: "Tool", value: `\`${params.request.toolName}\``, inline: true }); + + if (params.request.source?.kind === "plugin") { + embed.addFields({ + name: "Plugin", + value: params.request.source.pluginName || params.request.source.pluginId || "plugin", + inline: true + }); + } + + embed.addFields( + { name: "Path", value: `\`${params.request.path}\``, inline: false }, + { name: "Action", value: params.request.summary, inline: false } + ); + + if (params.request.reason) { + embed.addFields({ name: "Reason", value: params.request.reason, inline: false }); + } const actions = new ActionRowBuilder().addComponents( new ButtonBuilder().setCustomId(approveId).setLabel("Approve").setStyle(ButtonStyle.Success), diff --git a/src/config/store.ts b/src/config/store.ts index 6c5b2bf..16fe58a 100644 --- a/src/config/store.ts +++ b/src/config/store.ts @@ -1,6 +1,6 @@ import fs from "node:fs/promises"; import crypto from "node:crypto"; -import { buddyHome, serverConfigPath, serverSecretTokenPath, workspacePath } from "../utils/paths.js"; +import { buddyHome, pluginsPath, serverConfigPath, serverSecretTokenPath, workspacePath } from "../utils/paths.js"; import { defaultConfig } from "./defaults.js"; import type { BuddyConfig } from "./schema.js"; @@ -38,6 +38,7 @@ function mergeConfig(input: Partial | undefined): BuddyConfig { export async function ensureBuddyHome(): Promise { await fs.mkdir(buddyHome, { recursive: true }); + await fs.mkdir(pluginsPath, { recursive: true }); await fs.mkdir(workspacePath, { recursive: true }); } diff --git a/src/llm/agent.ts b/src/llm/agent.ts index 4d8b6a3..f422293 100644 --- a/src/llm/agent.ts +++ b/src/llm/agent.ts @@ -5,6 +5,7 @@ import type { ChatCompletionToolMessageParam } from "openai/resources/chat/completions"; import type { BuddyConfig } from "../config/schema.js"; +import type { ToolRegistry } from "../tools/registry.js"; import type { ToolRuntime } from "../tools/runtime.js"; export interface AgentTurnResult { @@ -12,127 +13,6 @@ export interface AgentTurnResult { assistantText: string; } -const fileToolDefinitions = [ - { - type: "function" as const, - function: { - name: "read_file", - description: "Read a text file from disk before making edits or answering questions about it.", - parameters: { - type: "object", - properties: { - path: { type: "string", description: "Path to the file to read." } - }, - required: ["path"], - additionalProperties: false - } - } - }, - { - type: "function" as const, - function: { - name: "list_directory", - description: "List directory contents so you can discover files and folders before reading or editing them.", - parameters: { - type: "object", - properties: { - path: { type: "string", description: "Directory path to inspect." } - }, - required: ["path"], - additionalProperties: false - } - } - }, - { - type: "function" as const, - function: { - name: "write_file", - description: "Create or fully replace a file with the provided content.", - parameters: { - type: "object", - properties: { - path: { type: "string", description: "Path to write." }, - content: { type: "string", description: "Complete file contents." } - }, - required: ["path", "content"], - additionalProperties: false - } - } - }, - { - type: "function" as const, - function: { - name: "edit_file", - description: "Edit a file after reading it first. Provide the full new contents.", - parameters: { - type: "object", - properties: { - path: { type: "string", description: "Path to edit." }, - newContent: { type: "string", description: "The complete updated file contents." } - }, - required: ["path", "newContent"], - additionalProperties: false - } - } - }, - { - type: "function" as const, - function: { - name: "delete_file", - description: "Delete a file from disk.", - parameters: { - type: "object", - properties: { - path: { type: "string", description: "Path to delete." } - }, - required: ["path"], - additionalProperties: false - } - } - }, - { - type: "function" as const, - function: { - name: "create_directory", - description: "Create a directory. Relative paths are resolved inside the workspace by default.", - parameters: { - type: "object", - properties: { - path: { type: "string", description: "Directory path to create." } - }, - required: ["path"], - additionalProperties: false - } - } - } -]; - -function buildToolDefinitions(config: BuddyConfig) { - if (!config.tools.webSearch.enabled) { - return fileToolDefinitions; - } - - return [ - ...fileToolDefinitions, - { - type: "function" as const, - function: { - name: "web_search", - description: - "Search the web with DuckDuckGo HTML, then fetch and return readable text from the top 3 result pages.", - parameters: { - type: "object", - properties: { - query: { type: "string", description: "Search query to run on the web." } - }, - required: ["query"], - additionalProperties: false - } - } - } - ]; -} - function assistantContentToText(content: unknown): string { if (typeof content === "string") { return content.trim(); @@ -163,8 +43,9 @@ export async function runAgentTurn(params: { messages: ChatCompletionMessageParam[]; userInput: string; toolRuntime: ToolRuntime; + toolRegistry: ToolRegistry; }): Promise { - const { config, toolRuntime, userInput } = params; + const { config, toolRuntime, toolRegistry, userInput } = params; if (!config.providers.baseUrl || !config.providers.apiKey || !config.providers.model) { throw new Error("Provider is not fully configured. Run `buddy config` and complete Providers."); @@ -184,7 +65,7 @@ export async function runAgentTurn(params: { const response = await client.chat.completions.create({ model: config.providers.model, messages: workingMessages, - tools: buildToolDefinitions(config), + tools: toolRegistry.definitions, tool_choice: "auto", temperature: 0.3 }); diff --git a/src/llm/system-prompt.ts b/src/llm/system-prompt.ts index e378f0e..78940ac 100644 --- a/src/llm/system-prompt.ts +++ b/src/llm/system-prompt.ts @@ -3,11 +3,15 @@ import { workspacePath } from "../utils/paths.js"; export type PromptChannel = "local" | "discord"; -export function buildSystemPrompt(config: BuddyConfig, channel: PromptChannel = "local"): string { +export function buildSystemPrompt( + config: BuddyConfig, + channel: PromptChannel = "local", + availableToolLines?: string[] +): string { const botName = config.personalization.botName || "buddy"; const userName = config.personalization.userName.trim(); const instructions = config.personalization.systemInstructions.trim(); - const availableToolLines = [ + const toolLines = availableToolLines ?? [ "- `read_file`: read a file before making decisions about its contents.", "- `list_directory`: inspect a directory when you need to discover which files or subdirectories exist.", "- `write_file`: create or fully replace a file with provided content.", @@ -16,12 +20,6 @@ export function buildSystemPrompt(config: BuddyConfig, channel: PromptChannel = "- `create_directory`: create a directory, including nested directories when needed." ]; - if (config.tools.webSearch.enabled) { - availableToolLines.push( - "- `web_search`: search DuckDuckGo HTML and return readable text from the top three result pages." - ); - } - return [ `You are ${botName}, a AI assistant that helps the user.`, userName ? `The user's name is ${userName}.` : "The user's name has not been configured.", @@ -41,7 +39,7 @@ export function buildSystemPrompt(config: BuddyConfig, channel: PromptChannel = "- Do not interpret generic references to 'desktop' as `~/Desktop` unless the user explicitly asks for the Desktop folder or gives a concrete path there.", "", "Available tools:", - ...availableToolLines, + ...toolLines, "", "Tool usage expectations:", "- Use `list_directory` when you need to discover filenames or locate files in the workspace instead of guessing names.", diff --git a/src/plugin.ts b/src/plugin.ts new file mode 100644 index 0000000..c603877 --- /dev/null +++ b/src/plugin.ts @@ -0,0 +1,15 @@ +export { + definePlugin, + defineTool, + isBuddyToolDeferredApproval, + requestApproval, + type BuddyJsonSchema, + type BuddyPlugin, + type BuddyTool, + type BuddyToolApprovalRequest, + type BuddyToolContext, + type BuddyToolDeferredApproval, + type BuddyToolDisplay, + type BuddyToolHandler, + type BuddyToolResult +} from "./plugins/sdk.js"; diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts new file mode 100644 index 0000000..7a87d86 --- /dev/null +++ b/src/plugins/loader.test.ts @@ -0,0 +1,140 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { loadPlugins } from "./loader.js"; + +async function writePlugin(params: { + pluginDirectory: string; + directoryName: string; + packageName?: string; + version?: string; + source: string; +}): Promise { + const pluginPath = path.join(params.pluginDirectory, params.directoryName); + await fs.mkdir(pluginPath, { recursive: true }); + await fs.writeFile( + path.join(pluginPath, "package.json"), + `${JSON.stringify( + { + name: params.packageName ?? params.directoryName, + version: params.version ?? "1.0.0", + type: "module", + buddy: { + entry: "./plugin.js" + } + }, + null, + 2 + )}\n`, + "utf8" + ); + await fs.writeFile(path.join(pluginPath, "plugin.js"), `${params.source}\n`, "utf8"); +} + +test("loadPlugins reads optional plugin metadata from the plugin export", async () => { + const pluginDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "buddy-loader-")); + + try { + await writePlugin({ + pluginDirectory, + directoryName: "weather", + packageName: "@acme/weather", + version: "2.0.0", + source: ` + export default { + id: "weather", + name: "Weather Tools", + description: "Weather helpers", + author: "Teich AI", + repositoryUrl: "https://github.com/teichai/weather", + tools: [ + { + id: "forecast", + description: "Get a forecast.", + parameters: { type: "object", additionalProperties: false }, + summarize() { + return "Forecast"; + }, + async execute() { + return "ok"; + } + } + ] + }; + ` + }); + + const result = await loadPlugins(pluginDirectory); + assert.equal(result.diagnostics.length, 0); + assert.equal(result.plugins.length, 1); + assert.equal(result.plugins[0]?.plugin.author, "Teich AI"); + assert.equal(result.plugins[0]?.plugin.repositoryUrl, "https://github.com/teichai/weather"); + assert.equal(result.plugins[0]?.plugin.name, "Weather Tools"); + assert.equal(result.plugins[0]?.plugin.version, "2.0.0"); + } finally { + await fs.rm(pluginDirectory, { recursive: true, force: true }); + } +}); + +test("loadPlugins reports invalid metadata and duplicate plugin ids without crashing", async () => { + const pluginDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "buddy-loader-dupes-")); + + try { + await writePlugin({ + pluginDirectory, + directoryName: "first", + source: ` + export default { + id: "shared", + tools: [ + { + id: "one", + description: "One.", + parameters: { type: "object", additionalProperties: false }, + summarize() { + return "One"; + }, + async execute() { + return "ok"; + } + } + ] + }; + ` + }); + + await writePlugin({ + pluginDirectory, + directoryName: "second", + source: ` + export default { + id: "shared", + repositoryUrl: "notaurl", + tools: [ + { + id: "two", + description: "Two.", + parameters: { type: "object", additionalProperties: false }, + summarize() { + return "Two"; + }, + async execute() { + return "ok"; + } + } + ] + }; + ` + }); + + const result = await loadPlugins(pluginDirectory); + assert.equal(result.plugins.length, 1); + assert.equal(result.plugins[0]?.plugin.id, "shared"); + assert.equal(result.diagnostics.length, 1); + assert.match(result.diagnostics[0]?.message ?? "", /repositoryUrl/); + } finally { + await fs.rm(pluginDirectory, { recursive: true, force: true }); + } +}); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts new file mode 100644 index 0000000..4ba2ebb --- /dev/null +++ b/src/plugins/loader.ts @@ -0,0 +1,221 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import type { BuddyPlugin, BuddyTool } from "./sdk.js"; +import { pluginsPath } from "../utils/paths.js"; + +interface BuddyPackageMetadata { + entry: string; +} + +interface PluginPackageJson { + name?: unknown; + version?: unknown; + buddy?: BuddyPackageMetadata | unknown; +} + +export interface PluginLoadDiagnostic { + pluginPath: string; + message: string; +} + +export interface LoadedPlugin { + directoryPath: string; + manifestName: string; + manifestVersion: string; + plugin: BuddyPlugin; +} + +function requireNonEmptyString(value: unknown, label: string): string { + if (typeof value !== "string" || !value.trim()) { + throw new Error(`${label} must be a non-empty string.`); + } + + return value.trim(); +} + +function validateOptionalString(value: unknown, label: string): string | undefined { + if (value === undefined) { + return undefined; + } + + return requireNonEmptyString(value, label); +} + +function validateRepositoryUrl(value: unknown): string | undefined { + if (value === undefined) { + return undefined; + } + + const raw = requireNonEmptyString(value, "repositoryUrl"); + let parsed: URL; + + try { + parsed = new URL(raw); + } catch { + throw new Error("repositoryUrl must be an absolute http or https URL."); + } + + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error("repositoryUrl must use http or https."); + } + + return parsed.toString(); +} + +function validateTool(value: unknown, pluginId: string): BuddyTool { + if (!value || typeof value !== "object") { + throw new Error(`Plugin "${pluginId}" contains an invalid tool definition.`); + } + + const tool = value as Partial; + const id = requireNonEmptyString(tool.id, "tool.id"); + const description = requireNonEmptyString(tool.description, `tool "${id}" description`); + + if (!tool.parameters || typeof tool.parameters !== "object" || Array.isArray(tool.parameters)) { + throw new Error(`Tool "${id}" parameters must be an object.`); + } + + if (typeof tool.summarize !== "function") { + throw new Error(`Tool "${id}" must provide a summarize(args) function.`); + } + + if (typeof tool.execute !== "function") { + throw new Error(`Tool "${id}" must provide an execute(context, args) function.`); + } + + if (tool.requiresApproval !== undefined && typeof tool.requiresApproval !== "boolean") { + throw new Error(`Tool "${id}" requiresApproval must be a boolean when provided.`); + } + + return { + id, + description, + parameters: tool.parameters, + requiresApproval: tool.requiresApproval, + summarize: tool.summarize, + execute: tool.execute + }; +} + +function validatePlugin(value: unknown, manifestName: string, manifestVersion: string): BuddyPlugin { + if (!value || typeof value !== "object") { + throw new Error("Plugin entrypoint must default export a plugin object."); + } + + const plugin = value as Partial; + const id = requireNonEmptyString(plugin.id, "plugin.id"); + + if (!Array.isArray(plugin.tools)) { + throw new Error(`Plugin "${id}" must export a tools array.`); + } + + const validatedTools = plugin.tools.map((tool) => validateTool(tool, id)); + const seenToolIds = new Set(); + + for (const tool of validatedTools) { + if (seenToolIds.has(tool.id)) { + throw new Error(`Plugin "${id}" defines duplicate tool id "${tool.id}".`); + } + seenToolIds.add(tool.id); + } + + return { + id, + name: validateOptionalString(plugin.name, "plugin.name") ?? manifestName, + version: validateOptionalString(plugin.version, "plugin.version") ?? manifestVersion, + description: validateOptionalString(plugin.description, "plugin.description"), + author: validateOptionalString(plugin.author, "plugin.author"), + repositoryUrl: validateRepositoryUrl(plugin.repositoryUrl), + tools: validatedTools + }; +} + +async function readPluginPackage(pluginDirectory: string): Promise<{ + manifestName: string; + manifestVersion: string; + entryPath: string; +}> { + const packageJsonPath = path.join(pluginDirectory, "package.json"); + const raw = await fs.readFile(packageJsonPath, "utf8"); + const parsed = JSON.parse(raw) as PluginPackageJson; + const manifestName = requireNonEmptyString(parsed.name, "package.json name"); + const manifestVersion = requireNonEmptyString(parsed.version, "package.json version"); + + if (!parsed.buddy || typeof parsed.buddy !== "object" || Array.isArray(parsed.buddy)) { + throw new Error("package.json must contain a buddy object."); + } + + const entry = requireNonEmptyString((parsed.buddy as BuddyPackageMetadata).entry, "package.json buddy.entry"); + return { + manifestName, + manifestVersion, + entryPath: path.resolve(pluginDirectory, entry) + }; +} + +async function importPlugin(entryPath: string): Promise { + const stat = await fs.stat(entryPath); + const moduleUrl = `${pathToFileURL(entryPath).href}?mtime=${stat.mtimeMs}`; + const loaded = (await import(moduleUrl)) as { default?: unknown }; + return loaded.default; +} + +export async function loadPlugins( + pluginDirectoryPath: string = pluginsPath +): Promise<{ plugins: LoadedPlugin[]; diagnostics: PluginLoadDiagnostic[] }> { + let directoryEntries; + + try { + directoryEntries = await fs.readdir(pluginDirectoryPath, { withFileTypes: true }); + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code === "ENOENT") { + return { + plugins: [], + diagnostics: [] + }; + } + + throw error; + } + + const diagnostics: PluginLoadDiagnostic[] = []; + const loadedPlugins: LoadedPlugin[] = []; + const seenPluginIds = new Set(); + + for (const entry of directoryEntries.filter((candidate) => candidate.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) { + const pluginPath = path.join(pluginDirectoryPath, entry.name); + + try { + const { manifestName, manifestVersion, entryPath } = await readPluginPackage(pluginPath); + const plugin = validatePlugin(await importPlugin(entryPath), manifestName, manifestVersion); + + if (seenPluginIds.has(plugin.id)) { + diagnostics.push({ + pluginPath, + message: `Duplicate plugin id "${plugin.id}".` + }); + continue; + } + + seenPluginIds.add(plugin.id); + loadedPlugins.push({ + directoryPath: pluginPath, + manifestName, + manifestVersion, + plugin + }); + } catch (error) { + diagnostics.push({ + pluginPath, + message: error instanceof Error ? error.message : String(error) + }); + } + } + + return { + plugins: loadedPlugins, + diagnostics + }; +} diff --git a/src/plugins/sdk.ts b/src/plugins/sdk.ts new file mode 100644 index 0000000..9222d49 --- /dev/null +++ b/src/plugins/sdk.ts @@ -0,0 +1,103 @@ +export interface BuddyJsonSchema { + [key: string]: unknown; + type?: string; + description?: string; + properties?: Record; + required?: string[]; + additionalProperties?: boolean; + items?: BuddyJsonSchema | BuddyJsonSchema[]; + enum?: Array; + oneOf?: BuddyJsonSchema[]; + anyOf?: BuddyJsonSchema[]; + allOf?: BuddyJsonSchema[]; + const?: unknown; + default?: unknown; + format?: string; + minimum?: number; + maximum?: number; + minLength?: number; + maxLength?: number; +} + +export interface BuddyToolDisplay { + summary: string; + path?: string; +} + +export interface BuddyToolContext { + buddyHome: string; + workspacePath: string; + callId: string; + pluginId: string; + toolId: string; + toolName: string; +} + +export interface BuddyToolApprovalRequest { + summary: string; + path?: string; + reason?: string; + continueWith: () => Promise | T; +} + +export interface BuddyToolDeferredApproval extends BuddyToolApprovalRequest { + __buddyType: "approval_request"; +} + +export type BuddyToolResult = string | BuddyToolDeferredApproval; + +export type BuddyToolHandler = ( + context: BuddyToolContext, + args: Record +) => Promise | BuddyToolResult; + +export interface BuddyTool { + id: string; + description: string; + parameters: BuddyJsonSchema; + requiresApproval?: boolean; + summarize: (args: Record) => BuddyToolDisplay | string; + execute: BuddyToolHandler; +} + +export interface BuddyPlugin { + id: string; + name?: string; + version?: string; + description?: string; + author?: string; + repositoryUrl?: string; + tools: BuddyTool[]; +} + +export function definePlugin(plugin: T): T { + return plugin; +} + +export function defineTool(tool: T): T { + return tool; +} + +export function requestApproval( + request: BuddyToolApprovalRequest +): BuddyToolDeferredApproval { + return { + __buddyType: "approval_request", + ...request + }; +} + +export function isBuddyToolDeferredApproval( + value: unknown +): value is BuddyToolDeferredApproval { + return ( + typeof value === "object" && + value !== null && + "__buddyType" in value && + value.__buddyType === "approval_request" && + "summary" in value && + typeof value.summary === "string" && + "continueWith" in value && + typeof value.continueWith === "function" + ); +} diff --git a/src/server/chat.ts b/src/server/chat.ts index 2786457..b1c3fde 100644 --- a/src/server/chat.ts +++ b/src/server/chat.ts @@ -2,9 +2,26 @@ import { loadConfig } from "../config/store.js"; import { runAgentTurn, type AgentTurnResult } from "../llm/agent.js"; import { buildSystemPrompt, type PromptChannel } from "../llm/system-prompt.js"; import { createToolContext, type ToolContext } from "../tools/file-tools.js"; +import { createToolRegistry } from "../tools/registry.js"; import { createToolRuntime, type ToolApprovalRequest, type ToolRuntimeEvent } from "../tools/runtime.js"; import type { ChatCompletionMessageParam } from "openai/resources/chat/completions"; +let lastPluginDiagnostics = ""; + +function reportPluginDiagnostics(diagnostics: { pluginPath: string; message: string }[]): void { + const next = diagnostics + .map((diagnostic) => `${diagnostic.pluginPath}: ${diagnostic.message}`) + .sort() + .join("\n"); + + if (!next || next === lastPluginDiagnostics) { + return; + } + + lastPluginDiagnostics = next; + console.warn(`Buddy plugin load warnings:\n${next}`); +} + function withCurrentSystemPrompt(messages: ChatCompletionMessageParam[], systemPrompt: string): ChatCompletionMessageParam[] { const nextMessages = messages.filter((message) => message.role !== "system"); return [ @@ -25,19 +42,26 @@ export async function executeChatTurn(params: { requestApproval?: (request: ToolApprovalRequest) => Promise; }): Promise { const config = await loadConfig(); + const toolContext = params.toolContext ?? createToolContext(); + const toolRegistry = await createToolRegistry(config, toolContext); + reportPluginDiagnostics(toolRegistry.diagnostics); const toolRuntime = createToolRuntime( config, + toolRegistry, { requestApproval: params.requestApproval ?? (async () => false), onEvent: params.onToolEvent - }, - params.toolContext ?? createToolContext() + } ); return await runAgentTurn({ config, - messages: withCurrentSystemPrompt(params.messages, buildSystemPrompt(config, params.channel ?? "local")), + messages: withCurrentSystemPrompt( + params.messages, + buildSystemPrompt(config, params.channel ?? "local", toolRegistry.promptLines) + ), userInput: params.userInput, - toolRuntime + toolRuntime, + toolRegistry }); } diff --git a/src/tools/path-utils.ts b/src/tools/path-utils.ts new file mode 100644 index 0000000..74f31d4 --- /dev/null +++ b/src/tools/path-utils.ts @@ -0,0 +1,80 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { workspacePath } from "../utils/paths.js"; + +export interface ResolvedToolPath { + resolvedPath: string; + displayPath: string; +} + +export function expandHome(inputPath: string): string { + if (inputPath === "~") { + return os.homedir(); + } + + if (inputPath.startsWith("~/")) { + return path.join(os.homedir(), inputPath.slice(2)); + } + + return inputPath; +} + +export function normalizeDir(dirPath: string): string { + return path.resolve(expandHome(dirPath)); +} + +export function isPathInsideDirectory(targetPath: string, directoryPath: string): boolean { + const target = path.resolve(targetPath); + const directory = path.resolve(directoryPath); + return target === directory || target.startsWith(`${directory}${path.sep}`); +} + +export async function resolvePolicyPath(targetPath: string): Promise { + const absoluteTarget = path.resolve(targetPath); + const missingSegments: string[] = []; + let currentPath = absoluteTarget; + + while (true) { + try { + const resolvedExistingPath = await fs.realpath(currentPath); + return missingSegments.length > 0 + ? path.join(resolvedExistingPath, ...missingSegments.reverse()) + : resolvedExistingPath; + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? error.code : undefined; + if (code !== "ENOENT") { + throw error; + } + + const parentPath = path.dirname(currentPath); + if (parentPath === currentPath) { + return absoluteTarget; + } + + missingSegments.push(path.basename(currentPath)); + currentPath = parentPath; + } + } +} + +export async function isPathBlocked(filePath: string, blockedDirectories: string[]): Promise { + const target = await resolvePolicyPath(filePath); + const normalizedBlockedDirectories = await Promise.all( + blockedDirectories.map(async (blockedDir) => resolvePolicyPath(normalizeDir(blockedDir))) + ); + + return normalizedBlockedDirectories.some((blockedDir) => isPathInsideDirectory(target, blockedDir)); +} + +export function resolveToolPath(inputPath: string): ResolvedToolPath { + const expanded = expandHome(inputPath.trim()); + const resolvedPath = path.isAbsolute(expanded) + ? path.resolve(expanded) + : path.resolve(workspacePath, expanded); + + return { + resolvedPath, + displayPath: inputPath.trim() || resolvedPath + }; +} diff --git a/src/tools/registry.test.ts b/src/tools/registry.test.ts new file mode 100644 index 0000000..cf26938 --- /dev/null +++ b/src/tools/registry.test.ts @@ -0,0 +1,134 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { defaultConfig } from "../config/defaults.js"; +import { createToolContext } from "./file-tools.js"; +import { createToolRegistry } from "./registry.js"; + +async function writePlugin(params: { + pluginDirectory: string; + directoryName: string; + packageName?: string; + source: string; +}): Promise { + const pluginPath = path.join(params.pluginDirectory, params.directoryName); + await fs.mkdir(pluginPath, { recursive: true }); + await fs.writeFile( + path.join(pluginPath, "package.json"), + `${JSON.stringify( + { + name: params.packageName ?? params.directoryName, + version: "1.0.0", + type: "module", + buddy: { + entry: "./plugin.js" + } + }, + null, + 2 + )}\n`, + "utf8" + ); + await fs.writeFile(path.join(pluginPath, "plugin.js"), `${params.source}\n`, "utf8"); +} + +test("createToolRegistry exposes plugin tools under normalized tool names", async () => { + const pluginDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "buddy-registry-")); + + try { + await writePlugin({ + pluginDirectory, + directoryName: "weather-suite", + source: ` + export default { + id: "weather-suite", + tools: [ + { + id: "daily forecast", + description: "Daily forecast.", + parameters: { type: "object", additionalProperties: false }, + summarize() { + return "Daily forecast"; + }, + async execute() { + return "ok"; + } + } + ] + }; + ` + }); + + const registry = await createToolRegistry(defaultConfig, createToolContext(), { pluginDirectory }); + const toolNames = registry.definitions.map((definition) => definition.function.name); + + assert.ok(toolNames.includes("weather_suite__daily_forecast")); + assert.ok( + registry.promptLines.some((line) => line.includes("weather_suite__daily_forecast")) + ); + } finally { + await fs.rm(pluginDirectory, { recursive: true, force: true }); + } +}); + +test("createToolRegistry rejects duplicate normalized plugin tool names deterministically", async () => { + const pluginDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "buddy-registry-dupes-")); + + try { + await writePlugin({ + pluginDirectory, + directoryName: "alpha", + source: ` + export default { + id: "weather-suite", + tools: [ + { + id: "forecast", + description: "Alpha forecast.", + parameters: { type: "object", additionalProperties: false }, + summarize() { + return "Alpha forecast"; + }, + async execute() { + return "alpha"; + } + } + ] + }; + ` + }); + + await writePlugin({ + pluginDirectory, + directoryName: "beta", + source: ` + export default { + id: "weather_suite", + tools: [ + { + id: "forecast", + description: "Beta forecast.", + parameters: { type: "object", additionalProperties: false }, + summarize() { + return "Beta forecast"; + }, + async execute() { + return "beta"; + } + } + ] + }; + ` + }); + + const registry = await createToolRegistry(defaultConfig, createToolContext(), { pluginDirectory }); + const toolNames = registry.definitions.map((definition) => definition.function.name); + + assert.equal(toolNames.filter((name) => name === "weather_suite__forecast").length, 1); + assert.ok(registry.diagnostics.some((diagnostic) => diagnostic.message.includes("collides"))); + } finally { + await fs.rm(pluginDirectory, { recursive: true, force: true }); + } +}); diff --git a/src/tools/registry.ts b/src/tools/registry.ts new file mode 100644 index 0000000..5aec508 --- /dev/null +++ b/src/tools/registry.ts @@ -0,0 +1,430 @@ +import type { BuddyConfig } from "../config/schema.js"; +import { buddyHome, pluginsPath, workspacePath } from "../utils/paths.js"; +import { + createDirectoryTool, + deleteFileTool, + editFileTool, + listDirectoryTool, + readFileTool, + type ToolContext, + writeFileTool +} from "./file-tools.js"; +import { resolveToolPath } from "./path-utils.js"; +import { webSearchTool } from "./web-search.js"; +import { + isBuddyToolDeferredApproval, + type BuddyJsonSchema, + type BuddyPlugin, + type BuddyToolDeferredApproval, + type BuddyToolDisplay +} from "../plugins/sdk.js"; +import { + loadPlugins, + type LoadedPlugin, + type PluginLoadDiagnostic +} from "../plugins/loader.js"; + +export interface ToolSourceMetadata { + kind: "builtin" | "plugin"; + pluginId?: string; + pluginName?: string; + version?: string; + author?: string; + repositoryUrl?: string; +} + +export interface ToolDisplayMetadata { + summary: string; + path: string; +} + +export interface ToolExecutionContext { + callId: string; +} + +export interface RegisteredTool { + name: string; + description: string; + parameters: BuddyJsonSchema; + requiresApproval: boolean; + source: ToolSourceMetadata; + summarize: (args: Record) => ToolDisplayMetadata; + resolvePolicyPath?: (args: Record) => string; + execute: ( + args: Record, + context: ToolExecutionContext + ) => Promise>; +} + +export interface ToolRegistry { + diagnostics: PluginLoadDiagnostic[]; + definitions: Array<{ + type: "function"; + function: { + name: string; + description: string; + parameters: BuddyJsonSchema; + }; + }>; + promptLines: string[]; + getTool(name: string): RegisteredTool | undefined; +} + +function requireString(args: Record, key: string): string { + const value = args[key]; + if (typeof value !== "string" || !value.trim()) { + throw new Error(`Tool argument "${key}" must be a non-empty string.`); + } + + return value; +} + +function normalizeToolDisplay(display: BuddyToolDisplay | string, fallbackPath: string): ToolDisplayMetadata { + if (typeof display === "string") { + const summary = display.trim(); + if (!summary) { + throw new Error("Tool summary must be a non-empty string."); + } + + return { + summary, + path: fallbackPath + }; + } + + if (!display || typeof display !== "object") { + throw new Error("Tool summarize(args) must return a string or { summary, path? }."); + } + + const summary = typeof display.summary === "string" ? display.summary.trim() : ""; + if (!summary) { + throw new Error("Tool summarize(args) must return a non-empty summary."); + } + + const path = typeof display.path === "string" && display.path.trim() ? display.path.trim() : fallbackPath; + return { summary, path }; +} + +function normalizePluginToolName(pluginId: string, toolId: string): string { + const normalize = (value: string) => + value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") || "tool"; + + return `${normalize(pluginId)}__${normalize(toolId)}`; +} + +function createBuiltInTools(config: BuddyConfig, context: ToolContext): RegisteredTool[] { + const tools: RegisteredTool[] = [ + { + name: "read_file", + description: "Read a text file from disk before making edits or answering questions about it.", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "Path to the file to read." } + }, + required: ["path"], + additionalProperties: false + }, + requiresApproval: false, + source: { kind: "builtin" }, + summarize: (args) => { + const rawPath = requireString(args, "path"); + const resolved = resolveToolPath(rawPath); + return { + path: resolved.displayPath, + summary: `Read ${resolved.displayPath}` + }; + }, + resolvePolicyPath: (args) => resolveToolPath(requireString(args, "path")).resolvedPath, + execute: async (args) => await readFileTool({ path: resolveToolPath(requireString(args, "path")).resolvedPath }, context) + }, + { + name: "list_directory", + description: "List directory contents so you can discover files and folders before reading or editing them.", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "Directory path to inspect." } + }, + required: ["path"], + additionalProperties: false + }, + requiresApproval: false, + source: { kind: "builtin" }, + summarize: (args) => { + const rawPath = requireString(args, "path"); + const resolved = resolveToolPath(rawPath); + return { + path: resolved.displayPath, + summary: `List ${resolved.displayPath}` + }; + }, + resolvePolicyPath: (args) => resolveToolPath(requireString(args, "path")).resolvedPath, + execute: async (args) => + await listDirectoryTool({ path: resolveToolPath(requireString(args, "path")).resolvedPath }) + }, + { + name: "write_file", + description: "Create or fully replace a file with the provided content.", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "Path to write." }, + content: { type: "string", description: "Complete file contents." } + }, + required: ["path", "content"], + additionalProperties: false + }, + requiresApproval: false, + source: { kind: "builtin" }, + summarize: (args) => { + const rawPath = requireString(args, "path"); + const resolved = resolveToolPath(rawPath); + const content = typeof args.content === "string" ? args.content : ""; + return { + path: resolved.displayPath, + summary: `Write ${content.length} chars to ${resolved.displayPath}` + }; + }, + resolvePolicyPath: (args) => resolveToolPath(requireString(args, "path")).resolvedPath, + execute: async (args) => { + const content = requireString(args, "content"); + return await writeFileTool( + { path: resolveToolPath(requireString(args, "path")).resolvedPath, content } + ); + } + }, + { + name: "edit_file", + description: "Edit a file after reading it first. Provide the full new contents.", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "Path to edit." }, + newContent: { type: "string", description: "The complete updated file contents." } + }, + required: ["path", "newContent"], + additionalProperties: false + }, + requiresApproval: false, + source: { kind: "builtin" }, + summarize: (args) => { + const rawPath = requireString(args, "path"); + const resolved = resolveToolPath(rawPath); + const content = typeof args.newContent === "string" ? args.newContent : ""; + return { + path: resolved.displayPath, + summary: `Edit ${resolved.displayPath} with ${content.length} chars` + }; + }, + resolvePolicyPath: (args) => resolveToolPath(requireString(args, "path")).resolvedPath, + execute: async (args) => { + const newContent = requireString(args, "newContent"); + return await editFileTool( + { path: resolveToolPath(requireString(args, "path")).resolvedPath, newContent }, + context + ); + } + }, + { + name: "delete_file", + description: "Delete a file from disk.", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "Path to delete." } + }, + required: ["path"], + additionalProperties: false + }, + requiresApproval: false, + source: { kind: "builtin" }, + summarize: (args) => { + const rawPath = requireString(args, "path"); + const resolved = resolveToolPath(rawPath); + return { + path: resolved.displayPath, + summary: `Delete ${resolved.displayPath}` + }; + }, + resolvePolicyPath: (args) => resolveToolPath(requireString(args, "path")).resolvedPath, + execute: async (args) => + await deleteFileTool({ path: resolveToolPath(requireString(args, "path")).resolvedPath }) + }, + { + name: "create_directory", + description: "Create a directory. Relative paths are resolved inside the workspace by default.", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "Directory path to create." } + }, + required: ["path"], + additionalProperties: false + }, + requiresApproval: false, + source: { kind: "builtin" }, + summarize: (args) => { + const rawPath = requireString(args, "path"); + const resolved = resolveToolPath(rawPath); + return { + path: resolved.displayPath, + summary: `Create directory ${resolved.displayPath}` + }; + }, + resolvePolicyPath: (args) => resolveToolPath(requireString(args, "path")).resolvedPath, + execute: async (args) => + await createDirectoryTool({ path: resolveToolPath(requireString(args, "path")).resolvedPath }) + } + ]; + + if (config.tools.webSearch.enabled) { + tools.push({ + name: "web_search", + description: + "Search the web with DuckDuckGo HTML, then fetch and return readable text from the top 3 result pages.", + parameters: { + type: "object", + properties: { + query: { type: "string", description: "Search query to run on the web." } + }, + required: ["query"], + additionalProperties: false + }, + requiresApproval: false, + source: { kind: "builtin" }, + summarize: (args) => { + const query = requireString(args, "query"); + return { + path: `search: ${query}`, + summary: `Search the web for ${query}` + }; + }, + execute: async (args) => await webSearchTool(requireString(args, "query")) + }); + } + + return tools; +} + +function createPluginTools( + loadedPlugins: LoadedPlugin[], + diagnostics: PluginLoadDiagnostic[] +): RegisteredTool[] { + const tools: RegisteredTool[] = []; + const seenToolNames = new Set(); + + for (const loadedPlugin of loadedPlugins) { + const plugin = loadedPlugin.plugin; + for (const tool of plugin.tools) { + const name = normalizePluginToolName(plugin.id, tool.id); + if (seenToolNames.has(name)) { + diagnostics.push({ + pluginPath: loadedPlugin.directoryPath, + message: `Tool "${tool.id}" collides with another registered tool name as "${name}".` + }); + continue; + } + + seenToolNames.add(name); + + tools.push({ + name, + description: tool.description, + parameters: tool.parameters, + requiresApproval: tool.requiresApproval === true, + source: { + kind: "plugin", + pluginId: plugin.id, + pluginName: plugin.name, + version: plugin.version, + author: plugin.author, + repositoryUrl: plugin.repositoryUrl + }, + summarize: (args) => normalizePluginToolDisplay(tool, args, name), + execute: async (args, context) => { + const result = await tool.execute( + { + buddyHome, + workspacePath, + callId: context.callId, + pluginId: plugin.id, + toolId: tool.id, + toolName: name + }, + args + ); + + if (isBuddyToolDeferredApproval(result)) { + return result; + } + + if (typeof result !== "string") { + throw new Error(`Plugin tool "${plugin.id}/${tool.id}" must return a string output.`); + } + + return result; + } + }); + } + } + + return tools; +} + +function normalizePluginToolDisplay( + tool: BuddyPlugin["tools"][number], + args: Record, + fallbackPath: string +): ToolDisplayMetadata { + return normalizeToolDisplay(tool.summarize(args), fallbackPath); +} + +export async function createToolRegistry( + config: BuddyConfig, + context: ToolContext, + options?: { pluginDirectory?: string } +): Promise { + const { plugins, diagnostics } = await loadPlugins(options?.pluginDirectory ?? pluginsPath); + const builtIns = createBuiltInTools(config, context); + const pluginTools = createPluginTools(plugins, diagnostics); + const tools = [...builtIns]; + const seenNames = new Set(builtIns.map((tool) => tool.name)); + + for (const tool of pluginTools) { + if (seenNames.has(tool.name)) { + diagnostics.push({ + pluginPath: tool.source.pluginId ?? "plugin", + message: `Tool name "${tool.name}" collides with an existing tool.` + }); + continue; + } + + seenNames.add(tool.name); + tools.push(tool); + } + + const definitions = tools.map((tool) => ({ + type: "function" as const, + function: { + name: tool.name, + description: tool.description, + parameters: tool.parameters + } + })); + + const promptLines = tools.map((tool) => `- \`${tool.name}\`: ${tool.description}`); + const toolMap = new Map(tools.map((tool) => [tool.name, tool] as const)); + + return { + diagnostics, + definitions, + promptLines, + getTool(name: string): RegisteredTool | undefined { + return toolMap.get(name); + } + }; +} diff --git a/src/tools/runtime.test.ts b/src/tools/runtime.test.ts index 50cb516..5e54324 100644 --- a/src/tools/runtime.test.ts +++ b/src/tools/runtime.test.ts @@ -4,46 +4,86 @@ import os from "node:os"; import path from "node:path"; import test from "node:test"; import { defaultConfig } from "../config/defaults.js"; -import { workspacePath } from "../utils/paths.js"; +import { createToolContext } from "./file-tools.js"; +import { createToolRegistry } from "./registry.js"; import { createToolRuntime } from "./runtime.js"; import type { ToolRuntimeEvent } from "./runtime.js"; +import { workspacePath } from "../utils/paths.js"; -function createSupervisedRuntime(params?: { +async function createSupervisedRuntime(params?: { requestApproval?: () => Promise; onEvent?: (event: ToolRuntimeEvent) => void; config?: typeof defaultConfig; + pluginDirectory?: string; }) { - return createToolRuntime( - { - ...defaultConfig, - ...params?.config, - restrictions: { - blockedDirectories: [], - accessLevel: "supervised" + const pluginDirectory = + params?.pluginDirectory ?? (await fs.mkdtemp(path.join(os.tmpdir(), "buddy-runtime-plugins-"))); + const config = { + ...defaultConfig, + ...params?.config, + restrictions: { + blockedDirectories: [], + accessLevel: "supervised" as const + } + }; + const registry = await createToolRegistry(config, createToolContext(), { pluginDirectory }); + const runtime = createToolRuntime(config, registry, { + requestApproval: params?.requestApproval ?? (async () => false), + onEvent: params?.onEvent + }); + + return { + runtime, + async cleanup() { + if (!params?.pluginDirectory) { + await fs.rm(pluginDirectory, { recursive: true, force: true }); } - }, - { - requestApproval: params?.requestApproval ?? (async () => false), - onEvent: params?.onEvent } + }; +} + +async function writePlugin(params: { + pluginDirectory: string; + directoryName: string; + packageName?: string; + version?: string; + source: string; +}): Promise { + const pluginPath = path.join(params.pluginDirectory, params.directoryName); + await fs.mkdir(pluginPath, { recursive: true }); + await fs.writeFile( + path.join(pluginPath, "package.json"), + `${JSON.stringify( + { + name: params.packageName ?? params.directoryName, + version: params.version ?? "1.0.0", + type: "module", + buddy: { + entry: "./plugin.js" + } + }, + null, + 2 + )}\n`, + "utf8" ); + await fs.writeFile(path.join(pluginPath, "plugin.js"), `${params.source}\n`, "utf8"); } test("supervised mode requests approval before listing directories outside the workspace", async () => { const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "buddy-runtime-outside-")); const events: ToolRuntimeEvent[] = []; let approvalRequested = false; + const { runtime, cleanup } = await createSupervisedRuntime({ + requestApproval: async () => { + approvalRequested = true; + return false; + }, + onEvent: (event) => events.push(event) + }); try { await fs.writeFile(path.join(outsideDir, "secret.txt"), "classified\n", "utf8"); - - const runtime = createSupervisedRuntime({ - requestApproval: async () => { - approvalRequested = true; - return false; - }, - onEvent: (event) => events.push(event) - }); const result = await runtime.executeTool("list_directory", JSON.stringify({ path: outsideDir })); assert.equal(approvalRequested, true); @@ -52,6 +92,7 @@ test("supervised mode requests approval before listing directories outside the w assert.equal(events[0]?.status, "awaiting_approval"); assert.equal(events[1]?.status, "denied"); } finally { + await cleanup(); await fs.rm(outsideDir, { recursive: true, force: true }); } }); @@ -60,22 +101,22 @@ test("supervised mode requests approval before reading files outside the workspa const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "buddy-runtime-file-")); const outsideFile = path.join(outsideDir, "secret.txt"); let approvalRequested = false; + const { runtime, cleanup } = await createSupervisedRuntime({ + requestApproval: async () => { + approvalRequested = true; + return false; + } + }); try { await fs.writeFile(outsideFile, "classified\n", "utf8"); - - const runtime = createSupervisedRuntime({ - requestApproval: async () => { - approvalRequested = true; - return false; - } - }); const result = await runtime.executeTool("read_file", JSON.stringify({ path: outsideFile })); assert.equal(approvalRequested, true); assert.equal(result.ok, false); assert.match(result.output, /User denied approval/); } finally { + await cleanup(); await fs.rm(outsideDir, { recursive: true, force: true }); } }); @@ -86,19 +127,20 @@ test("supervised mode treats symlink escapes as outside-workspace access and req const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "buddy-runtime-symlink-target-")); const workspaceDir = await fs.mkdtemp(path.join(workspacePath, "runtime-symlink-")); const symlinkPath = path.join(workspaceDir, "desktop-link"); + const { runtime, cleanup } = await createSupervisedRuntime({ + requestApproval: async () => false + }); try { await fs.writeFile(path.join(outsideDir, "secret.txt"), "classified\n", "utf8"); await fs.symlink(outsideDir, symlinkPath); - const runtime = createSupervisedRuntime({ - requestApproval: async () => false - }); const result = await runtime.executeTool("list_directory", JSON.stringify({ path: symlinkPath })); assert.equal(result.ok, false); assert.match(result.output, /User denied approval/); } finally { + await cleanup(); await fs.rm(workspaceDir, { recursive: true, force: true }); await fs.rm(outsideDir, { recursive: true, force: true }); } @@ -106,24 +148,25 @@ test("supervised mode treats symlink escapes as outside-workspace access and req test("supervised mode allows outside-workspace access after approval", async () => { const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "buddy-runtime-approved-")); + const { runtime, cleanup } = await createSupervisedRuntime({ + requestApproval: async () => true + }); try { await fs.writeFile(path.join(outsideDir, "secret.txt"), "classified\n", "utf8"); - const runtime = createSupervisedRuntime({ - requestApproval: async () => true - }); const result = await runtime.executeTool("list_directory", JSON.stringify({ path: outsideDir })); assert.equal(result.ok, true); assert.match(result.output, /\[file\] secret.txt/); } finally { + await cleanup(); await fs.rm(outsideDir, { recursive: true, force: true }); } }); -test("web_search returns a config error when the tool is disabled", async () => { - const runtime = createSupervisedRuntime({ +test("disabled tools are omitted from the registry", async () => { + const { runtime, cleanup } = await createSupervisedRuntime({ config: { ...defaultConfig, tools: { @@ -133,10 +176,14 @@ test("web_search returns a config error when the tool is disabled", async () => } } }); - const result = await runtime.executeTool("web_search", JSON.stringify({ query: "latest TypeScript release" })); - assert.equal(result.ok, false); - assert.equal(result.output, "Web search is disabled in buddy config."); + try { + const result = await runtime.executeTool("web_search", JSON.stringify({ query: "latest TypeScript release" })); + assert.equal(result.ok, false); + assert.equal(result.output, "Unknown tool: web_search"); + } finally { + await cleanup(); + } }); test("web_search scrapes DuckDuckGo HTML and fetches only the top 3 result pages", async () => { @@ -200,18 +247,18 @@ test("web_search scrapes DuckDuckGo HTML and fetches only the top 3 result pages throw new Error(`Unexpected fetch: ${url}`); }) as typeof globalThis.fetch; - try { - const runtime = createSupervisedRuntime({ - config: { - ...defaultConfig, - tools: { - webSearch: { - enabled: true - } + const { runtime, cleanup } = await createSupervisedRuntime({ + config: { + ...defaultConfig, + tools: { + webSearch: { + enabled: true } } - }); + } + }); + try { const result = await runtime.executeTool("web_search", JSON.stringify({ query: "buddy search" })); assert.equal(result.ok, true); @@ -230,6 +277,111 @@ test("web_search scrapes DuckDuckGo HTML and fetches only the top 3 result pages "https://example.com/three" ]); } finally { + await cleanup(); globalThis.fetch = originalFetch; } }); + +test("plugin tools can require approval before execution", async () => { + const pluginDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "buddy-plugin-static-")); + let approvalCount = 0; + + await writePlugin({ + pluginDirectory, + directoryName: "weather", + source: ` + export default { + id: "weather", + tools: [ + { + id: "forecast", + description: "Get the forecast.", + requiresApproval: true, + parameters: { type: "object", properties: { city: { type: "string" } }, required: ["city"], additionalProperties: false }, + summarize(args) { + return { summary: "Fetch weather forecast", path: String(args.city ?? "weather") }; + }, + async execute(_context, args) { + return "Forecast for " + String(args.city ?? "unknown"); + } + } + ] + }; + ` + }); + + const { runtime } = await createSupervisedRuntime({ + pluginDirectory, + requestApproval: async () => { + approvalCount += 1; + return true; + } + }); + + try { + const result = await runtime.executeTool("weather__forecast", JSON.stringify({ city: "Austin" })); + assert.equal(result.ok, true); + assert.equal(result.output, "Forecast for Austin"); + assert.equal(approvalCount, 1); + } finally { + await fs.rm(pluginDirectory, { recursive: true, force: true }); + } +}); + +test("plugin tools can request approval conditionally after their own checks", async () => { + const pluginDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "buddy-plugin-conditional-")); + const events: ToolRuntimeEvent[] = []; + + await writePlugin({ + pluginDirectory, + directoryName: "deployments", + source: ` + export default { + id: "deployments", + tools: [ + { + id: "ship_it", + description: "Ship a release.", + parameters: { type: "object", properties: { force: { type: "boolean" } }, additionalProperties: false }, + summarize() { + return { summary: "Ship release", path: "release" }; + }, + async execute(_context, args) { + if (args.force === true) { + return { + __buddyType: "approval_request", + summary: "Force ship release", + path: "release", + reason: "Force mode bypasses the normal release checks.", + continueWith: async () => "forced release" + }; + } + + return "standard release"; + } + } + ] + }; + ` + }); + + const { runtime } = await createSupervisedRuntime({ + pluginDirectory, + requestApproval: async () => true, + onEvent: (event) => events.push(event) + }); + + try { + const result = await runtime.executeTool("deployments__ship_it", JSON.stringify({ force: true })); + assert.equal(result.ok, true); + assert.equal(result.output, "forced release"); + assert.deepEqual( + events.map((event) => event.status), + ["running", "awaiting_approval", "running", "completed"] + ); + assert.equal(events[1]?.summary, "Force ship release"); + assert.equal(events[1]?.source?.pluginId, "deployments"); + } finally { + await fs.rm(pluginDirectory, { recursive: true, force: true }); + } +}); diff --git a/src/tools/runtime.ts b/src/tools/runtime.ts index f21bd9d..c8e156e 100644 --- a/src/tools/runtime.ts +++ b/src/tools/runtime.ts @@ -1,25 +1,20 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; import type { BuddyConfig } from "../config/schema.js"; +import { isBuddyToolDeferredApproval } from "../plugins/sdk.js"; import { workspacePath } from "../utils/paths.js"; +import type { ToolDisplayMetadata, ToolRegistry, ToolSourceMetadata } from "./registry.js"; import { - createDirectoryTool, - createToolContext, - deleteFileTool, - editFileTool, - listDirectoryTool, - readFileTool, - type ToolContext, - writeFileTool -} from "./file-tools.js"; -import { webSearchTool } from "./web-search.js"; + isPathBlocked, + isPathInsideDirectory, + resolvePolicyPath +} from "./path-utils.js"; export interface ToolApprovalRequest { id: string; toolName: string; path: string; summary: string; + reason?: string; + source?: ToolSourceMetadata; } export type ToolEventStatus = @@ -36,6 +31,7 @@ export interface ToolRuntimeEvent { summary: string; status: ToolEventStatus; output?: string; + source?: ToolSourceMetadata; } export interface ToolRuntimeCallbacks { @@ -53,154 +49,74 @@ export interface ToolRuntime { executeTool(name: string, rawArgs: string, options?: { callId?: string }): Promise; } -interface ResolvedPolicy { - resolvedPath: string; - displayPath: string; -} - -const pathToolNames = new Set([ - "read_file", - "write_file", - "edit_file", - "delete_file", - "create_directory", - "list_directory" -]); - -function isPathToolName(name: string): boolean { - return pathToolNames.has(name); -} - -function expandHome(inputPath: string): string { - if (inputPath === "~") { - return os.homedir(); - } - - if (inputPath.startsWith("~/")) { - return path.join(os.homedir(), inputPath.slice(2)); - } - - return inputPath; -} - -function normalizeDir(dirPath: string): string { - return path.resolve(expandHome(dirPath)); -} - -function isPathInsideDirectory(targetPath: string, directoryPath: string): boolean { - const target = path.resolve(targetPath); - const directory = path.resolve(directoryPath); - return target === directory || target.startsWith(`${directory}${path.sep}`); -} - -async function resolvePolicyPath(targetPath: string): Promise { - const absoluteTarget = path.resolve(targetPath); - const missingSegments: string[] = []; - let currentPath = absoluteTarget; - - while (true) { - try { - const resolvedExistingPath = await fs.realpath(currentPath); - return missingSegments.length > 0 - ? path.join(resolvedExistingPath, ...missingSegments.reverse()) - : resolvedExistingPath; - } catch (error) { - const code = error && typeof error === "object" && "code" in error ? error.code : undefined; - if (code !== "ENOENT") { - throw error; - } - - const parentPath = path.dirname(currentPath); - if (parentPath === currentPath) { - return absoluteTarget; - } - - missingSegments.push(path.basename(currentPath)); - currentPath = parentPath; - } - } -} - -async function isPathBlocked(filePath: string, blockedDirectories: string[]): Promise { - const target = await resolvePolicyPath(filePath); - const normalizedBlockedDirectories = await Promise.all( - blockedDirectories.map(async (blockedDir) => resolvePolicyPath(normalizeDir(blockedDir))) - ); - - return normalizedBlockedDirectories.some((blockedDir) => isPathInsideDirectory(target, blockedDir)); -} - -function resolveToolPath(inputPath: string): ResolvedPolicy { - const expanded = expandHome(inputPath.trim()); - const resolvedPath = path.isAbsolute(expanded) - ? path.resolve(expanded) - : path.resolve(workspacePath, expanded); - - return { - resolvedPath, - displayPath: inputPath.trim() || resolvedPath - }; -} - function stringifyError(error: unknown): string { return error instanceof Error ? error.message : String(error); } function parseArguments(rawArgs: string): Record { try { - return JSON.parse(rawArgs) as Record; + const parsed = JSON.parse(rawArgs) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Tool arguments must be a JSON object."); + } + + return parsed as Record; } catch (error) { throw new Error(`Invalid tool arguments: ${stringifyError(error)}`); } } -function requireString(args: Record, key: string): string { - const value = args[key]; - if (typeof value !== "string" || !value.trim()) { - throw new Error(`Tool argument "${key}" must be a non-empty string.`); - } - return value; +function buildFailureEvent(params: { + id: string; + toolName: string; + display: ToolDisplayMetadata; + source?: ToolSourceMetadata; +}): Pick { + return { + id: params.id, + toolName: params.toolName, + path: params.display.path, + summary: params.display.summary, + source: params.source + }; } -function summarizeMutation(toolName: string, displayPath: string, args: Record): string { - if (toolName === "read_file") { - return `Read ${displayPath}`; - } - - if (toolName === "write_file") { - const content = typeof args.content === "string" ? args.content : ""; - return `Write ${content.length} chars to ${displayPath}`; - } - - if (toolName === "edit_file") { - const content = typeof args.newContent === "string" ? args.newContent : ""; - return `Edit ${displayPath} with ${content.length} chars`; - } - - if (toolName === "delete_file") { - return `Delete ${displayPath}`; - } - - if (toolName === "create_directory") { - return `Create directory ${displayPath}`; - } - - if (toolName === "list_directory") { - return `List ${displayPath}`; - } - - if (toolName === "web_search") { - const query = typeof args.query === "string" ? args.query : displayPath; - return `Search the web for ${query}`; - } +function buildDenialMessage(toolName: string, displayPath: string): string { + return `User denied approval for ${toolName} on ${displayPath}.`; +} - return `${toolName} on ${displayPath}`; +async function requestUserApproval(params: { + callbacks: ToolRuntimeCallbacks; + callId: string; + toolName: string; + display: ToolDisplayMetadata; + source?: ToolSourceMetadata; + reason?: string; + emit: (event: ToolRuntimeEvent) => void; +}): Promise { + params.emit({ + id: params.callId, + toolName: params.toolName, + path: params.display.path, + summary: params.display.summary, + source: params.source, + status: "awaiting_approval" + }); + + return await params.callbacks.requestApproval({ + id: params.callId, + toolName: params.toolName, + path: params.display.path, + summary: params.display.summary, + reason: params.reason, + source: params.source + }); } export function createToolRuntime( config: BuddyConfig, - callbacks: ToolRuntimeCallbacks, - context: ToolContext = createToolContext() + registry: ToolRegistry, + callbacks: ToolRuntimeCallbacks ): ToolRuntime { const workspacePolicyPathPromise = resolvePolicyPath(workspacePath); @@ -214,168 +130,206 @@ export function createToolRuntime( rawArgs: string, options?: { callId?: string } ): Promise { + const callId = options?.callId ?? name; let failureEvent: - | Pick + | Pick | undefined; try { - const args = parseArguments(rawArgs); - let resolvedPath: string | undefined; - let displayPath: string; - - if (isPathToolName(name)) { - const rawPath = requireString(args, "path"); - const resolved = resolveToolPath(rawPath); - resolvedPath = resolved.resolvedPath; - displayPath = resolved.displayPath; - } else if (name === "web_search") { - const query = requireString(args, "query"); - displayPath = `search: ${query}`; - } else { - displayPath = name; + const tool = registry.getTool(name); + if (!tool) { + emit({ + id: callId, + toolName: name, + path: name, + summary: name, + status: "failed", + output: `Unknown tool: ${name}` + }); + return { + ok: false, + output: `Unknown tool: ${name}`, + displayPath: name + }; } - const callId = options?.callId ?? `${name}:${displayPath}`; - const summary = summarizeMutation(name, displayPath, args); - failureEvent = { id: callId, toolName: name, path: displayPath, summary }; + const args = parseArguments(rawArgs); + let currentDisplay = tool.summarize(args); + failureEvent = buildFailureEvent({ + id: callId, + toolName: name, + display: currentDisplay, + source: tool.source + }); - if (resolvedPath) { + const policyTarget = tool.resolvePolicyPath?.(args); + if (policyTarget) { const workspacePolicyPath = await workspacePolicyPathPromise; - const policyPath = await resolvePolicyPath(resolvedPath); + const policyPath = await resolvePolicyPath(policyTarget); - if (await isPathBlocked(resolvedPath, config.restrictions.blockedDirectories)) { + if (await isPathBlocked(policyTarget, config.restrictions.blockedDirectories)) { emit({ id: callId, toolName: name, - path: displayPath, - summary, + path: currentDisplay.path, + summary: currentDisplay.summary, + source: tool.source, status: "failed", - output: `Blocked by guardrails: ${displayPath} is inside a blocked directory.` + output: `Blocked by guardrails: ${currentDisplay.path} is inside a blocked directory.` }); return { ok: false, - output: `Blocked by guardrails: ${displayPath} is inside a blocked directory.`, - displayPath + output: `Blocked by guardrails: ${currentDisplay.path} is inside a blocked directory.`, + displayPath: currentDisplay.path }; } const outsideWorkspace = !isPathInsideDirectory(policyPath, workspacePolicyPath); - if (config.restrictions.accessLevel === "supervised" && outsideWorkspace) { - emit({ - id: callId, - toolName: name, - path: displayPath, - summary, - status: "awaiting_approval" - }); - - const approved = await callbacks.requestApproval({ - id: callId, + const approved = await requestUserApproval({ + callbacks, + callId, toolName: name, - path: displayPath, - summary + display: currentDisplay, + source: tool.source, + emit }); if (!approved) { + const output = buildDenialMessage(name, currentDisplay.path); emit({ id: callId, toolName: name, - path: displayPath, - summary, + path: currentDisplay.path, + summary: currentDisplay.summary, + source: tool.source, status: "denied", - output: `User denied approval for ${name} on ${displayPath}.` + output }); return { ok: false, - output: `User denied approval for ${name} on ${displayPath}.`, - displayPath + output, + displayPath: currentDisplay.path }; } } } - if (name === "web_search" && !config.tools.webSearch.enabled) { - emit({ - id: callId, + if (tool.requiresApproval) { + const approved = await requestUserApproval({ + callbacks, + callId, toolName: name, - path: displayPath, - summary, - status: "failed", - output: "Web search is disabled in buddy config." + display: currentDisplay, + source: tool.source, + emit }); - return { - ok: false, - output: "Web search is disabled in buddy config.", - displayPath - }; + + if (!approved) { + const output = buildDenialMessage(name, currentDisplay.path); + emit({ + id: callId, + toolName: name, + path: currentDisplay.path, + summary: currentDisplay.summary, + source: tool.source, + status: "denied", + output + }); + return { + ok: false, + output, + displayPath: currentDisplay.path + }; + } } emit({ id: callId, toolName: name, - path: displayPath, - summary, + path: currentDisplay.path, + summary: currentDisplay.summary, + source: tool.source, status: "running" }); - if (name === "read_file") { - const output = await readFileTool({ path: resolvedPath! }, context); - emit({ id: callId, toolName: name, path: displayPath, summary, status: "completed", output }); - return { ok: true, output, displayPath }; - } + let output = await tool.execute(args, { callId }); - if (name === "list_directory") { - const output = await listDirectoryTool({ path: resolvedPath! }); - emit({ id: callId, toolName: name, path: displayPath, summary, status: "completed", output }); - return { ok: true, output, displayPath }; - } + while (isBuddyToolDeferredApproval(output)) { + const summary = output.summary.trim(); + if (!summary) { + throw new Error(`Tool "${name}" requested approval without a summary.`); + } - if (name === "write_file") { - const content = requireString(args, "content"); - const output = await writeFileTool({ path: resolvedPath!, content }); - emit({ id: callId, toolName: name, path: displayPath, summary, status: "completed", output }); - return { ok: true, output, displayPath }; - } + currentDisplay = { + path: output.path?.trim() || currentDisplay.path, + summary + }; + failureEvent = buildFailureEvent({ + id: callId, + toolName: name, + display: currentDisplay, + source: tool.source + }); - if (name === "edit_file") { - const newContent = requireString(args, "newContent"); - const output = await editFileTool({ path: resolvedPath!, newContent }, context); - emit({ id: callId, toolName: name, path: displayPath, summary, status: "completed", output }); - return { ok: true, output, displayPath }; - } + const approved = await requestUserApproval({ + callbacks, + callId, + toolName: name, + display: currentDisplay, + source: tool.source, + reason: output.reason, + emit + }); - if (name === "delete_file") { - const output = await deleteFileTool({ path: resolvedPath! }); - emit({ id: callId, toolName: name, path: displayPath, summary, status: "completed", output }); - return { ok: true, output, displayPath }; - } + if (!approved) { + const deniedOutput = buildDenialMessage(name, currentDisplay.path); + emit({ + id: callId, + toolName: name, + path: currentDisplay.path, + summary: currentDisplay.summary, + source: tool.source, + status: "denied", + output: deniedOutput + }); + return { + ok: false, + output: deniedOutput, + displayPath: currentDisplay.path + }; + } - if (name === "create_directory") { - const output = await createDirectoryTool({ path: resolvedPath! }); - emit({ id: callId, toolName: name, path: displayPath, summary, status: "completed", output }); - return { ok: true, output, displayPath }; + emit({ + id: callId, + toolName: name, + path: currentDisplay.path, + summary: currentDisplay.summary, + source: tool.source, + status: "running" + }); + + output = await output.continueWith(); } - if (name === "web_search") { - const query = requireString(args, "query"); - const output = await webSearchTool(query); - emit({ id: callId, toolName: name, path: displayPath, summary, status: "completed", output }); - return { ok: true, output, displayPath }; + if (typeof output !== "string") { + throw new Error(`Tool "${name}" returned an invalid output.`); } emit({ id: callId, toolName: name, - path: displayPath, - summary, - status: "failed", - output: `Unknown tool: ${name}` + path: currentDisplay.path, + summary: currentDisplay.summary, + source: tool.source, + status: "completed", + output }); + return { - ok: false, - output: `Unknown tool: ${name}`, - displayPath + ok: true, + output, + displayPath: currentDisplay.path }; } catch (error) { const message = stringifyError(error); diff --git a/src/tui/app.ts b/src/tui/app.ts index 29b9412..9371139 100644 --- a/src/tui/app.ts +++ b/src/tui/app.ts @@ -3,6 +3,7 @@ import { CombinedAutocompleteProvider, ProcessTerminal, TUI, Text } from "@mario import type { ChatCompletionMessageParam } from "openai/resources/chat/completions"; import type { BuddyConfig } from "../config/schema.js"; import { BuddySocketClient } from "../server/client.js"; +import type { ToolSourceMetadata } from "../tools/registry.js"; import { getCliCurrentConversationId, setCliCurrentConversationId } from "../current/store.js"; import { ChatLog } from "./components/chat-log.js"; import { ApprovalDialog } from "./components/approval-dialog.js"; @@ -196,7 +197,8 @@ export async function runChatTui(): Promise { path: event.path, summary: event.summary, status: event.status, - output: event.output + output: event.output, + source: event.source }); tui.requestRender(); }; @@ -205,12 +207,16 @@ export async function runChatTui(): Promise { toolName: string; path: string; summary: string; + reason?: string; + source?: ToolSourceMetadata; }): Promise => new Promise((resolve) => { const dialog = new ApprovalDialog({ toolName: params.toolName, path: params.path, summary: params.summary, + reason: params.reason, + source: params.source, onSelect: (value) => { overlay.hide(); tui.setFocus(editor); diff --git a/src/tui/components/approval-dialog.ts b/src/tui/components/approval-dialog.ts index 7433f97..c174aef 100644 --- a/src/tui/components/approval-dialog.ts +++ b/src/tui/components/approval-dialog.ts @@ -1,5 +1,6 @@ import type { Component, SelectItem } from "@mariozechner/pi-tui"; import { SelectList, Text } from "@mariozechner/pi-tui"; +import type { ToolSourceMetadata } from "../../tools/registry.js"; import { Frame } from "./frame.js"; import { selectTheme, theme } from "../theme.js"; @@ -55,17 +56,30 @@ export class ApprovalDialog implements Component { toolName: string; path: string; summary: string; + reason?: string; + source?: ToolSourceMetadata; onSelect: (value: "approve" | "deny") => void; onCancel: () => void; }) { + const details = [ + params.summary, + `Path: ${params.path}`, + params.source?.kind === "plugin" + ? `Plugin: ${params.source.pluginName || params.source.pluginId || "plugin"}` + : undefined, + params.reason ? `Reason: ${params.reason}` : undefined + ] + .filter(Boolean) + .join("\n\n"); + this.frame = new Frame( - `Approve ${params.toolName}`, + `Approve ${params.toolName}${params.source?.kind === "plugin" ? " (plugin)" : ""}`, new ApprovalContent({ - summary: `${params.summary}\n\nPath: ${params.path}`, + summary: details, onSelect: params.onSelect, onCancel: params.onCancel }), - "Supervised mode requires approval for outside-workspace tool calls." + "Buddy needs your approval before this tool action can continue." ); } diff --git a/src/tui/components/chat-log.ts b/src/tui/components/chat-log.ts index fbb03c7..fff8883 100644 --- a/src/tui/components/chat-log.ts +++ b/src/tui/components/chat-log.ts @@ -1,5 +1,6 @@ import type { Component } from "@mariozechner/pi-tui"; import { Box, Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui"; +import type { ToolSourceMetadata } from "../../tools/registry.js"; import { markdownTheme, theme } from "../theme.js"; import { ToolCard, type ToolCardStatus } from "./tool-card.js"; @@ -84,6 +85,7 @@ export class ChatLog extends Container { summary: string; status: ToolCardStatus; output?: string; + source?: ToolSourceMetadata; }): void { const existing = this.toolCards.get(params.id); if (existing) { diff --git a/src/tui/components/tool-card.ts b/src/tui/components/tool-card.ts index 365b186..3c97d56 100644 --- a/src/tui/components/tool-card.ts +++ b/src/tui/components/tool-card.ts @@ -1,5 +1,6 @@ import path from "node:path"; import { Container, Text } from "@mariozechner/pi-tui"; +import type { ToolSourceMetadata } from "../../tools/registry.js"; import { theme } from "../theme.js"; import { workspacePath } from "../../utils/paths.js"; @@ -63,6 +64,14 @@ function previewOutput(status: ToolCardStatus, output?: string): string { return collapsed.length > 140 ? `${collapsed.slice(0, 137)}...` : collapsed; } +function sourceLabel(source?: ToolSourceMetadata): string { + if (source?.kind !== "plugin") { + return ""; + } + + return source.pluginName || source.pluginId || "plugin"; +} + export class ToolCard extends Container { private readonly titleText: Text; private readonly pathText: Text; @@ -74,6 +83,7 @@ export class ToolCard extends Container { summary: string; status: ToolCardStatus; output?: string; + source?: ToolSourceMetadata; }) { super(); this.titleText = new Text("", 0, 0); @@ -91,6 +101,7 @@ export class ToolCard extends Container { summary: string; status: ToolCardStatus; output?: string; + source?: ToolSourceMetadata; }): void { const statusColor = params.status === "completed" @@ -99,8 +110,11 @@ export class ToolCard extends Container { ? theme.error : theme.accent; + const source = sourceLabel(params.source); this.titleText.setText(statusColor(`• ${statusLabel(params.status)}`) + theme.text(` ${params.summary}`)); - this.pathText.setText(theme.muted(` └ ${compactPath(params.path)}`)); + this.pathText.setText( + theme.muted(` └ ${compactPath(params.path)}${source ? ` · plugin ${source}` : ""}`) + ); const preview = previewOutput(params.status, params.output); this.outputText.setText(preview ? theme.muted(` ${preview}`) : ""); diff --git a/src/utils/paths.ts b/src/utils/paths.ts index 7453cc3..f3c1489 100644 --- a/src/utils/paths.ts +++ b/src/utils/paths.ts @@ -2,6 +2,7 @@ import os from "node:os"; import path from "node:path"; export const buddyHome = path.join(os.homedir(), ".buddy"); +export const pluginsPath = path.join(buddyHome, "plugins"); export const serverConfigPath = path.join(buddyHome, "config.json"); export const currentPath = path.join(buddyHome, "current.json"); export const conversationsPath = path.join(buddyHome, "conversations"); diff --git a/tsconfig.json b/tsconfig.json index 8881e70..f405263 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,7 @@ "skipLibCheck": true, "rootDir": "src", "outDir": "dist", + "declaration": true, "types": ["node"], "resolveJsonModule": true, "esModuleInterop": true