import type { Artifact } from "@danypops/papyrus"; import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; import type { AutocompleteItem } from "@earendil-works/pi-tui"; import { artifactActivationEnabled, showArtifactBrowser, showArtifactDetails } from "../artifact/artifact-browser.ts"; import { PLAYBOOK_STATUS_PRESENTATION } from "../artifact/artifact-status-presentation.ts"; import { parseLabelInput } from "../artifact/binder-navigation.ts"; import { matchArtifactByName } from "../domain-tools.ts"; import { callService } from "../service-client.ts"; const PLAYBOOK_COMPLETION_MAX_CANDIDATES = 100; async function activePlaybooks(): Promise { return callService, Artifact[]>("playbooks.list", { status: "active", limit: PLAYBOOK_COMPLETION_MAX_CANDIDATES, }); } /** `/playbook ` completions -- title-prefix match, since that's what a human actually types, not a full-text search of body content. */ export async function playbookArgumentCompletions(argumentPrefix: string): Promise { try { const needle = argumentPrefix.trim().toLowerCase(); const rows = await activePlaybooks(); return rows .filter((row) => row.title.toLowerCase().startsWith(needle)) .sort((a, b) => a.title.localeCompare(b.title)) .map((row) => ({ value: row.title, label: row.title, description: typeof row.extra.trigger === "string" ? row.extra.trigger : undefined, })); } catch { return null; // a Papyrus daemon hiccup degrades to "no suggestions", never breaks the command line } } interface PlaybookInvocationResponse { entryTaskId?: string; missingArguments?: string[]; } /** Shared by /playbook and the browser's own "Invoke" action: materializes real Tasks and focuses the entry one, then reports it -- invoke no longer returns rendered text (that's playbooks.preview now). */ async function invokeAndReport(id: string, label: string, ctx: ExtensionCommandContext): Promise { const invocation = await callService, PlaybookInvocationResponse>("playbooks.invoke", { id }); if (invocation.missingArguments) { ctx.ui.notify(`"${label}" needs: ${invocation.missingArguments.join(", ")}`, "error"); return; } ctx.ui.setEditorText(`Run the "${label}" playbook -- work on the currently focused task.`); ctx.ui.notify(`"${label}" invoked: entry task ${invocation.entryTaskId} focused`, "info"); } /** `/playbook ` (no args opens the full browser instead): resolves by exact title, then invokes it directly -- one step, not browse-then-select-then-invoke. */ export async function openPlaybookByName(name: string, ctx: ExtensionCommandContext): Promise { if (!name.trim()) { await showPlaybooks(ctx); return; } try { const id = matchArtifactByName(await activePlaybooks(), name); await invokeAndReport(id, name.trim(), ctx); } catch (error) { ctx.ui.notify(error instanceof Error ? error.message : String(error), "error"); } } const PLAYBOOK_RELATIONS = ["references", "documents", "relates_to", "contains", "part_of"]; function strings(value: unknown): string[] { return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; } export function playbookRowMeta(playbook: Artifact): string { const trigger = typeof playbook.extra?.trigger === "string" ? `when ${playbook.extra.trigger}` : "manual invocation"; const tools = strings(playbook.extra?.tools); return [trigger, tools.join(", ")].filter(Boolean).join(" \u00b7 "); } export async function showPlaybooks(ctx: ExtensionCommandContext): Promise { await showArtifactBrowser(ctx, { kind: "playbook", title: "Playbooks", listOperation: "playbooks.list", statusOrder: ["active", "deprecated"], presentation: PLAYBOOK_STATUS_PRESENTATION, hierarchical: true, rowMeta: playbookRowMeta, actions: (playbook) => [ "Show details", "Edit", "Invoke", "Toggle activation flag", "Link artifact", playbook.status === "active" ? "Disable" : "Enable", ], handleAction: async (choice, playbook, commandCtx) => { if (choice === "Show details") { await showArtifactDetails(commandCtx, playbook.id, "playbooks.show"); return; } if (choice === "Edit") { const current = await callService, Artifact>("playbooks.show", { id: playbook.id }); const title = await commandCtx.ui.input("Title:", current.title); if (title === undefined) return; // canceled const body = await commandCtx.ui.input("Body:", current.body); if (body === undefined) return; // canceled const labels = await commandCtx.ui.input("Direct labels (comma-separated):", current.labels.join(", ")); if (labels === undefined) return; // canceled const updated = await callService, Artifact>("playbooks.update", { id: playbook.id, title, body, labels: parseLabelInput(labels), }); commandCtx.ui.notify(`Updated "${updated.title}"`, "info"); return; } if (choice === "Invoke") { await invokeAndReport(playbook.id, playbook.title, commandCtx); return; } if (choice === "Toggle activation flag") { const current = await callService, Artifact>("playbooks.show", { id: playbook.id }); const enabled = artifactActivationEnabled(current); await callService("playbooks.update", { id: playbook.id, activation_enabled: !enabled }); commandCtx.ui.notify(`${current.title} activation ${enabled ? "paused" : "resumed"}`, "info"); return; } if (choice === "Link artifact") { const targetId = await commandCtx.ui.input("Target artifact id:", ""); if (!targetId) return; const relation = await commandCtx.ui.select("Relation", PLAYBOOK_RELATIONS); if (!relation) return; await callService("graph.link", { from: playbook.id, relation, to: targetId }); commandCtx.ui.notify(`Linked "${playbook.title}" via ${relation}`, "info"); return; } const operation = choice === "Disable" ? "playbooks.disable" : "playbooks.enable"; const updated = await callService, Artifact>(operation, { id: playbook.id }); commandCtx.ui.notify(`${updated.title} \u2192 [${updated.status}]`, "info"); }, }); }