feat: pi extension
This commit is contained in:
@@ -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",
|
||||
|
||||
384
packages/cli/src/__tests__/extension.test.ts
Normal file
384
packages/cli/src/__tests__/extension.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
443
packages/cli/src/extension.ts
Normal file
443
packages/cli/src/extension.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user