feat: pi extension

This commit is contained in:
Dustin Byrne
2026-03-28 22:14:40 -04:00
parent 51c4afdcae
commit 2d13b82d7c
12 changed files with 915 additions and 216 deletions

View File

@@ -2,9 +2,16 @@
"name": "@dustinbyrne/kb",
"version": "0.3.1",
"type": "module",
"keywords": ["pi-package"],
"bin": {
"kb": "./dist/bin.js"
},
"pi": {
"extensions": ["./dist/extension.js"]
},
"publishConfig": {
"access": "public"
},
"files": [
"dist/**/*.js",
"dist/**/*.d.ts",
@@ -27,10 +34,21 @@
"express": "^5.1.0",
"multer": "^2.1.1"
},
"peerDependencies": {
"@mariozechner/pi-ai": "*",
"@mariozechner/pi-coding-agent": "*",
"@sinclair/typebox": "*"
},
"peerDependenciesMeta": {
"@mariozechner/pi-ai": { "optional": true },
"@mariozechner/pi-coding-agent": { "optional": true },
"@sinclair/typebox": { "optional": true }
},
"devDependencies": {
"@kb/core": "workspace:*",
"@kb/dashboard": "workspace:*",
"@kb/engine": "workspace:*",
"@sinclair/typebox": "^0.34.0",
"tsup": "^8.5.1",
"tsx": "^4.19.0",
"typescript": "^5.7.0",

View File

@@ -0,0 +1,384 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import kbExtension from "../extension.js";
import { TaskStore } from "@kb/core";
// ── Mock ExtensionAPI that captures registrations ──────────────────
interface RegisteredTool {
name: string;
label: string;
description: string;
execute: (
toolCallId: string,
params: any,
signal: AbortSignal | undefined,
onUpdate: ((update: any) => void) | undefined,
ctx: any,
) => Promise<any>;
}
interface RegisteredCommand {
description: string;
handler: (args: string, ctx: any) => Promise<void>;
}
function createMockAPI() {
const tools = new Map<string, RegisteredTool>();
const commands = new Map<string, RegisteredCommand>();
const events = new Map<string, Function>();
const api = {
registerTool(def: any) {
tools.set(def.name, def);
},
registerCommand(name: string, def: any) {
commands.set(name, def);
},
registerShortcut: vi.fn(),
registerFlag: vi.fn(),
on(event: string, handler: Function) {
events.set(event, handler);
},
tools,
commands,
events,
};
return api as any;
}
function makeCtx(cwd: string) {
return { cwd } as any;
}
// ── Tests ──────────────────────────────────────────────────────────
describe("kb pi extension", () => {
let tmpDir: string;
let api: ReturnType<typeof createMockAPI>;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "kb-ext-test-"));
api = createMockAPI();
kbExtension(api);
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
describe("registration", () => {
it("registers all expected tools", () => {
const expected = [
"kb_task_create",
"kb_task_list",
"kb_task_show",
"kb_task_attach",
"kb_task_pause",
"kb_task_unpause",
];
for (const name of expected) {
expect(api.tools.has(name), `missing tool: ${name}`).toBe(true);
}
expect(api.tools.size).toBe(expected.length);
});
it("does not register engine-internal tools", () => {
expect(api.tools.has("kb_task_move")).toBe(false);
expect(api.tools.has("kb_task_update_step")).toBe(false);
expect(api.tools.has("kb_task_log")).toBe(false);
expect(api.tools.has("kb_task_merge")).toBe(false);
});
it("registers the /kb command", () => {
expect(api.commands.has("kb")).toBe(true);
expect(api.commands.get("kb")!.description).toContain("dashboard");
});
it("registers session_shutdown listener", () => {
expect(api.events.has("session_shutdown")).toBe(true);
});
});
describe("kb_task_create", () => {
it("creates a task and returns its ID", async () => {
const tool = api.tools.get("kb_task_create")!;
const result = await tool.execute(
"call-1",
{ description: "Fix the login button" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.content[0].text).toContain("KB-001");
expect(result.content[0].text).toContain("Fix the login button");
expect(result.content[0].text).toContain("triage");
expect(result.details.taskId).toBe("KB-001");
expect(result.details.column).toBe("triage");
});
it("creates a task with dependencies", async () => {
const tool = api.tools.get("kb_task_create")!;
await tool.execute(
"call-1",
{ description: "First task" },
undefined,
undefined,
makeCtx(tmpDir),
);
const result = await tool.execute(
"call-2",
{ description: "Second task", depends: ["KB-001"] },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.details.taskId).toBe("KB-002");
expect(result.details.dependencies).toEqual(["KB-001"]);
expect(result.content[0].text).toContain("Dependencies: KB-001");
});
});
describe("kb_task_list", () => {
it("returns empty message when no tasks", async () => {
const tool = api.tools.get("kb_task_list")!;
const result = await tool.execute(
"call-1",
{},
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.content[0].text).toBe("No tasks yet.");
expect(result.details.count).toBe(0);
});
it("lists tasks grouped by column", async () => {
const createTool = api.tools.get("kb_task_create")!;
await createTool.execute(
"c1",
{ description: "Task A" },
undefined,
undefined,
makeCtx(tmpDir),
);
await createTool.execute(
"c2",
{ description: "Task B" },
undefined,
undefined,
makeCtx(tmpDir),
);
const listTool = api.tools.get("kb_task_list")!;
const result = await listTool.execute(
"call-1",
{},
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.content[0].text).toContain("Triage (2)");
expect(result.content[0].text).toContain("KB-001");
expect(result.content[0].text).toContain("KB-002");
expect(result.details.count).toBe(2);
});
it("filters by column", async () => {
const createTool = api.tools.get("kb_task_create")!;
await createTool.execute(
"c1",
{ description: "Task A" },
undefined,
undefined,
makeCtx(tmpDir),
);
const listTool = api.tools.get("kb_task_list")!;
const triageResult = await listTool.execute(
"call-1",
{ column: "triage" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(triageResult.content[0].text).toContain("Triage (1)");
expect(triageResult.content[0].text).toContain("KB-001");
const todoResult = await listTool.execute(
"call-2",
{ column: "todo" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(todoResult.content[0].text).toBe("");
});
it("respects per-column limit", async () => {
const createTool = api.tools.get("kb_task_create")!;
for (let i = 0; i < 5; i++) {
await createTool.execute(
`c${i}`,
{ description: `Task ${i}` },
undefined,
undefined,
makeCtx(tmpDir),
);
}
const listTool = api.tools.get("kb_task_list")!;
const result = await listTool.execute(
"call-1",
{ limit: 2 },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.content[0].text).toContain("Triage (5)");
expect(result.content[0].text).toContain("KB-001");
expect(result.content[0].text).toContain("KB-002");
expect(result.content[0].text).not.toContain("KB-003");
expect(result.content[0].text).toContain("... and 3 more");
});
});
describe("kb_task_show", () => {
it("shows task details", async () => {
const createTool = api.tools.get("kb_task_create")!;
await createTool.execute(
"c1",
{ description: "Implement caching layer" },
undefined,
undefined,
makeCtx(tmpDir),
);
const showTool = api.tools.get("kb_task_show")!;
const result = await showTool.execute(
"call-1",
{ id: "KB-001" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.content[0].text).toContain("KB-001");
expect(result.content[0].text).toContain("Implement caching layer");
expect(result.content[0].text).toContain("Triage");
expect(result.details.task).toBeDefined();
expect(result.details.task.id).toBe("KB-001");
});
});
describe("kb_task_attach", () => {
it("attaches a file to a task", async () => {
const createTool = api.tools.get("kb_task_create")!;
await createTool.execute(
"c1",
{ description: "A task" },
undefined,
undefined,
makeCtx(tmpDir),
);
const testFile = join(tmpDir, "test.txt");
await writeFile(testFile, "hello world");
const attachTool = api.tools.get("kb_task_attach")!;
const result = await attachTool.execute(
"call-1",
{ id: "KB-001", path: "test.txt" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.content[0].text).toContain("Attached to KB-001");
expect(result.content[0].text).toContain("test.txt");
expect(result.details.attachment).toBeDefined();
expect(result.details.attachment.originalName).toBe("test.txt");
});
it("rejects unsupported file types", async () => {
const createTool = api.tools.get("kb_task_create")!;
await createTool.execute(
"c1",
{ description: "A task" },
undefined,
undefined,
makeCtx(tmpDir),
);
const testFile = join(tmpDir, "file.exe");
await writeFile(testFile, "binary");
const attachTool = api.tools.get("kb_task_attach")!;
await expect(
attachTool.execute(
"call-1",
{ id: "KB-001", path: "file.exe" },
undefined,
undefined,
makeCtx(tmpDir),
),
).rejects.toThrow("Unsupported file type");
});
});
describe("kb_task_pause / unpause", () => {
it("pauses and unpauses a task", async () => {
const createTool = api.tools.get("kb_task_create")!;
await createTool.execute(
"c1",
{ description: "A task" },
undefined,
undefined,
makeCtx(tmpDir),
);
const pauseTool = api.tools.get("kb_task_pause")!;
const pauseResult = await pauseTool.execute(
"call-1",
{ id: "KB-001" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(pauseResult.content[0].text).toContain("Paused KB-001");
// Verify it's paused
const showTool = api.tools.get("kb_task_show")!;
const show = await showTool.execute(
"call-2",
{ id: "KB-001" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(show.content[0].text).toContain("PAUSED");
// Unpause
const unpauseTool = api.tools.get("kb_task_unpause")!;
const unpauseResult = await unpauseTool.execute(
"call-3",
{ id: "KB-001" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(unpauseResult.content[0].text).toContain("Unpaused KB-001");
});
});
});

View File

@@ -0,0 +1,443 @@
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
import { Type, type Static } from "@sinclair/typebox";
import { StringEnum } from "@mariozechner/pi-ai";
import {
TaskStore,
COLUMNS,
COLUMN_LABELS,
type Column,
type Task,
} from "@kb/core";
import { resolve, basename, extname } from "node:path";
import { readFile } from "node:fs/promises";
import { spawn, type ChildProcess } from "node:child_process";
// ── Helpers ────────────────────────────────────────────────────────
const MIME_TYPES: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".txt": "text/plain",
".log": "text/plain",
".json": "application/json",
".yaml": "text/yaml",
".yml": "text/yaml",
".toml": "text/x-toml",
".csv": "text/csv",
".xml": "application/xml",
};
/** Cache stores per cwd to avoid re-init on every tool call. */
const storeCache = new Map<string, TaskStore>();
async function getStore(cwd: string): Promise<TaskStore> {
const existing = storeCache.get(cwd);
if (existing) return existing;
const store = new TaskStore(cwd);
await store.init();
storeCache.set(cwd, store);
return store;
}
function formatTaskLine(t: Task): string {
const label =
t.title || t.description.slice(0, 60) + (t.description.length > 60 ? "…" : "");
const deps = t.dependencies.length ? ` [deps: ${t.dependencies.join(", ")}]` : "";
const paused = t.paused ? " (paused)" : "";
return `${t.id} ${label}${deps}${paused}`;
}
// ── Extension entry point ──────────────────────────────────────────
export default function kbExtension(pi: ExtensionAPI) {
// ── kb_task_create ───────────────────────────────────────────────
pi.registerTool({
name: "kb_task_create",
label: "KB: Create Task",
description:
"Create a new task on the kb task board. The task enters the triage column " +
"where the AI triage agent will specify it into a full prompt with steps, " +
"file scope, and acceptance criteria.",
promptSnippet: "Create a task on the kb AI-orchestrated task board",
promptGuidelines: [
"Use kb_task_create for task tracking — be descriptive so the triage agent can write a good spec.",
"Include the problem AND desired outcome. For bugs, describe current vs expected behavior.",
],
parameters: Type.Object({
description: Type.String({ description: "What needs to be done — be descriptive" }),
depends: Type.Optional(
Type.Array(Type.String(), {
description: "Task IDs this depends on (e.g. ['KB-001', 'KB-002'])",
}),
),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const task = await store.createTask({
description: params.description.trim(),
dependencies: params.depends,
});
const label =
task.description.length > 80
? task.description.slice(0, 80) + "…"
: task.description;
return {
content: [
{
type: "text",
text:
`Created ${task.id}: ${label}\n` +
`Column: triage\n` +
(task.dependencies.length
? `Dependencies: ${task.dependencies.join(", ")}\n`
: "") +
`Path: .kb/tasks/${task.id}/`,
},
],
details: { taskId: task.id, column: task.column, dependencies: task.dependencies },
};
},
});
// ── kb_task_list ─────────────────────────────────────────────────
pi.registerTool({
name: "kb_task_list",
label: "KB: List Tasks",
description: "List all tasks on the kb board, grouped by column.",
promptSnippet: "List all tasks on the kb board grouped by column",
parameters: Type.Object({
column: Type.Optional(
StringEnum([...COLUMNS] as unknown as string[], {
description: "Filter to a specific column",
}) as any,
),
limit: Type.Optional(
Type.Number({
description: "Max tasks to show per column (default: 10)",
}),
),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const tasks = await store.listTasks();
if (tasks.length === 0) {
return {
content: [{ type: "text", text: "No tasks yet." }],
details: { count: 0 },
};
}
const perColumn = params.limit ?? 10;
const lines: string[] = [];
for (const col of COLUMNS) {
if (params.column && params.column !== col) continue;
const colTasks = tasks.filter((t) => t.column === col);
if (colTasks.length === 0) continue;
lines.push(`${COLUMN_LABELS[col]} (${colTasks.length}):`);
const shown = colTasks.slice(0, perColumn);
for (const t of shown) {
lines.push(` ${formatTaskLine(t)}`);
}
const hidden = colTasks.length - shown.length;
if (hidden > 0) {
lines.push(` ... and ${hidden} more`);
}
lines.push("");
}
return {
content: [{ type: "text", text: lines.join("\n").trimEnd() }],
details: { count: tasks.length },
};
},
});
// ── kb_task_show ─────────────────────────────────────────────────
pi.registerTool({
name: "kb_task_show",
label: "KB: Show Task",
description: "Show full details for a task including steps, progress, and log entries.",
promptSnippet: "Show full details for a kb task",
parameters: Type.Object({
id: Type.String({ description: "Task ID (e.g. KB-001)" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const task = await store.getTask(params.id);
const lines: string[] = [];
lines.push(`${task.id}: ${task.title || task.description}`);
lines.push(
`Column: ${COLUMN_LABELS[task.column]}` +
(task.size ? ` · Size: ${task.size}` : "") +
(task.reviewLevel !== undefined ? ` · Review: ${task.reviewLevel}` : ""),
);
if (task.dependencies.length) {
lines.push(`Dependencies: ${task.dependencies.join(", ")}`);
}
if (task.paused) lines.push("Status: PAUSED");
lines.push("");
// Steps
if (task.steps.length > 0) {
const done = task.steps.filter((s) => s.status === "done").length;
lines.push(`Steps (${done}/${task.steps.length}):`);
for (let i = 0; i < task.steps.length; i++) {
const s = task.steps[i];
const icon =
s.status === "done"
? "✓"
: s.status === "in-progress"
? "▸"
: s.status === "skipped"
? ""
: " ";
const marker =
i === task.currentStep && s.status !== "done" ? " ◀" : "";
lines.push(` [${icon}] ${i}: ${s.name}${marker}`);
}
lines.push("");
}
// Prompt (truncated)
if (task.prompt) {
const promptPreview =
task.prompt.length > 500
? task.prompt.slice(0, 500) + "\n... (truncated)"
: task.prompt;
lines.push("Prompt:");
lines.push(promptPreview);
lines.push("");
}
// Recent log
if (task.log.length > 0) {
const recent = task.log.slice(-5);
lines.push(`Log (last ${recent.length}):`);
for (const l of recent) {
const ts = new Date(l.timestamp).toLocaleTimeString();
lines.push(
` ${ts} ${l.action}${l.outcome ? `${l.outcome}` : ""}`,
);
}
}
return {
content: [{ type: "text", text: lines.join("\n").trimEnd() }],
details: { task },
};
},
});
// ── kb_task_attach ───────────────────────────────────────────────
pi.registerTool({
name: "kb_task_attach",
label: "KB: Attach File",
description:
"Attach a file to a task. Supports images (png, jpg, gif, webp) and " +
"text files (txt, log, json, yaml, yml, toml, csv, xml).",
promptSnippet: "Attach a file to a kb task",
parameters: Type.Object({
id: Type.String({ description: "Task ID (e.g. KB-001)" }),
path: Type.String({ description: "Path to the file to attach" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const filePath = resolve(ctx.cwd, params.path.replace(/^@/, ""));
const filename = basename(filePath);
const ext = extname(filename).toLowerCase();
const mimeType = MIME_TYPES[ext];
if (!mimeType) {
throw new Error(
`Unsupported file type: ${ext}. Supported: ${Object.keys(MIME_TYPES).join(", ")}`,
);
}
let content: Buffer;
try {
content = await readFile(filePath);
} catch {
throw new Error(`Cannot read file: ${params.path}`);
}
const store = await getStore(ctx.cwd);
const attachment = await store.addAttachment(params.id, filename, content, mimeType);
const sizeKB = (attachment.size / 1024).toFixed(1);
return {
content: [
{
type: "text",
text:
`Attached to ${params.id}: ${attachment.originalName} (${sizeKB} KB)\n` +
`Path: .kb/tasks/${params.id}/attachments/${attachment.filename}`,
},
],
details: { taskId: params.id, attachment },
};
},
});
// ── kb_task_pause ────────────────────────────────────────────────
pi.registerTool({
name: "kb_task_pause",
label: "KB: Pause Task",
description:
"Pause a task — stops all automated agent and scheduler interaction for this task.",
promptSnippet: "Pause a kb task (stops automation)",
parameters: Type.Object({
id: Type.String({ description: "Task ID (e.g. KB-001)" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const task = await store.pauseTask(params.id, true);
return {
content: [{ type: "text", text: `Paused ${task.id}` }],
details: { taskId: task.id },
};
},
});
// ── kb_task_unpause ──────────────────────────────────────────────
pi.registerTool({
name: "kb_task_unpause",
label: "KB: Unpause Task",
description:
"Unpause a task — resumes automated agent and scheduler interaction.",
promptSnippet: "Unpause a kb task (resumes automation)",
parameters: Type.Object({
id: Type.String({ description: "Task ID (e.g. KB-001)" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const task = await store.pauseTask(params.id, false);
return {
content: [{ type: "text", text: `Unpaused ${task.id}` }],
details: { taskId: task.id },
};
},
});
// ── /kb command — start the dashboard + engine ───────────────────
let dashboardProcess: ChildProcess | null = null;
let dashboardPort: number | null = null;
pi.registerCommand("kb", {
description: "Start (or stop) the kb dashboard and AI engine",
handler: async (args, ctx) => {
const trimmed = (args ?? "").trim();
// /kb stop — kill the dashboard
if (trimmed === "stop") {
if (dashboardProcess) {
dashboardProcess.kill("SIGINT");
dashboardProcess = null;
dashboardPort = null;
ctx.ui.setStatus("kb", "");
ctx.ui.notify("kb dashboard stopped", "info");
} else {
ctx.ui.notify("kb dashboard is not running", "warning");
}
return;
}
// /kb status
if (trimmed === "status") {
if (dashboardProcess && !dashboardProcess.killed) {
ctx.ui.notify(`kb dashboard running on http://localhost:${dashboardPort}`, "info");
} else {
dashboardProcess = null;
dashboardPort = null;
ctx.ui.notify("kb dashboard is not running", "info");
}
return;
}
// /kb [port] — start the dashboard
if (dashboardProcess && !dashboardProcess.killed) {
ctx.ui.notify(
`kb dashboard already running on http://localhost:${dashboardPort}. Use /kb stop first.`,
"warning",
);
return;
}
const port = trimmed ? parseInt(trimmed, 10) || 4040 : 4040;
// Find the kb binary: prefer local node_modules, then global
const child = spawn("kb", ["dashboard", "--port", String(port), "--no-open"], {
cwd: ctx.cwd,
stdio: ["ignore", "pipe", "pipe"],
detached: false,
env: { ...process.env },
});
dashboardProcess = child;
dashboardPort = port;
// Watch for early exit (e.g. kb not found)
child.on("error", (err) => {
dashboardProcess = null;
dashboardPort = null;
ctx.ui.setStatus("kb", "");
ctx.ui.notify(`Failed to start kb dashboard: ${err.message}`, "error");
});
child.on("exit", (code) => {
if (dashboardProcess === child) {
dashboardProcess = null;
dashboardPort = null;
ctx.ui.setStatus("kb", "");
if (code !== 0 && code !== null) {
ctx.ui.notify(`kb dashboard exited with code ${code}`, "warning");
}
}
});
// Wait briefly to see if it crashes immediately
await new Promise((r) => setTimeout(r, 500));
if (dashboardProcess && !dashboardProcess.killed) {
const url = `http://localhost:${port}`;
ctx.ui.notify(`kb dashboard started on ${url} (AI engine active)`, "info");
const link = `\x1b]8;;${url}\x1b\\${url}\x1b]8;;\x1b\\`;
ctx.ui.setStatus("kb", `kb ● ${link}`);
}
},
});
// ── Cleanup on session end ───────────────────────────────────────
pi.on("session_shutdown", async () => {
if (dashboardProcess) {
dashboardProcess.kill("SIGINT");
dashboardProcess = null;
dashboardPort = null;
}
storeCache.clear();
});
}

View File

@@ -8,7 +8,7 @@ const dashboardClientSrc = join(__dirname, "..", "dashboard", "dist", "client");
const dashboardClientDest = join(__dirname, "dist", "client");
export default defineConfig({
entry: ["src/bin.ts"],
entry: ["src/bin.ts", "src/extension.ts"],
format: ["esm"],
platform: "node",
target: "node22",