Skip to content
This repository was archived by the owner on Mar 28, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@
"bin": {
"buddy": "dist/index.js"
},
"exports": {
"./plugin": {
"types": "./dist/plugin.d.ts",
"default": "./dist/plugin.js"
}
},
"files": [
"dist",
"LICENSE",
Expand Down
58 changes: 41 additions & 17 deletions src/channels/discord.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -105,7 +106,7 @@ const pendingApprovals = new Map<

interface ToolTranscriptState {
entries: ToolRuntimeEvent[];
activeEntryByInvocation: Map<string, number>;
activeEntryById: Map<string, number>;
}

interface StreamingDiscordReply {
Expand Down Expand Up @@ -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);
}
}
}
Expand All @@ -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 [];
Expand Down Expand Up @@ -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<ButtonBuilder>().addComponents(
new ButtonBuilder().setCustomId(approveId).setLabel("Approve").setStyle(ButtonStyle.Success),
Expand Down
3 changes: 2 additions & 1 deletion src/config/store.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -38,6 +38,7 @@ function mergeConfig(input: Partial<BuddyConfig> | undefined): BuddyConfig {

export async function ensureBuddyHome(): Promise<void> {
await fs.mkdir(buddyHome, { recursive: true });
await fs.mkdir(pluginsPath, { recursive: true });
await fs.mkdir(workspacePath, { recursive: true });
}

Expand Down
Loading
Loading