import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import type { AgentToolResult, ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Type, type Static } from "typebox"; export const GUIDE_TOPICS = [ "overview", "tools", "contracts", "gate", "budgets", "fleetview", "mail", "troubleshooting", ] as const; export type GuideTopic = (typeof GUIDE_TOPICS)[number]; const GUIDE_DOCUMENT = join("docs", "operator-guide.md"); const DEFAULT_EXCERPT_BYTES = 12 * 1024; const TOPIC_HEADINGS: Readonly> = { overview: "Overview", tools: "Tools", contracts: "Contracts", gate: "Gate", budgets: "Budgets", fleetview: "FleetView", mail: "Mail", troubleshooting: "Troubleshooting", }; export interface GuideExcerpt { readonly topic: GuideTopic; readonly available_topics: readonly GuideTopic[]; readonly source: string; readonly excerpt: string; readonly truncated: boolean; } export interface GuideReader { read(topic: string | undefined): Promise; } export interface InstalledPackageGuideOptions { readonly packageRoot?: string; readonly maxExcerptBytes?: number; } export class InstalledPackageGuide implements GuideReader { readonly #packageRoot: string; readonly #maxExcerptBytes: number; constructor(options: InstalledPackageGuideOptions = {}) { this.#packageRoot = options.packageRoot ?? defaultPackageRoot(); this.#maxExcerptBytes = options.maxExcerptBytes ?? DEFAULT_EXCERPT_BYTES; if (!Number.isSafeInteger(this.#maxExcerptBytes) || this.#maxExcerptBytes < 128) { throw new TypeError("Guide excerpt byte limit must be an integer of at least 128"); } } async read(rawTopic: string | undefined): Promise { const topic = normalizeTopic(rawTopic); const document = await readFile(join(this.#packageRoot, GUIDE_DOCUMENT), "utf8"); const section = extractTopic(document, TOPIC_HEADINGS[topic]); const bounded = boundUtf8(section, this.#maxExcerptBytes); return { topic, available_topics: GUIDE_TOPICS, source: `${GUIDE_DOCUMENT}#${topic}`, excerpt: bounded.text, truncated: bounded.truncated, }; } } const GuideParameters = Type.Object({ topic: Type.Optional(Type.String()), }); export type GuideToolParameters = Static; export function registerGuideTool(pi: ExtensionAPI, guide: GuideReader): void { pi.registerTool({ name: "guide", label: "pi-amq Guide", description: "Read one bounded topic from the installed pi-amq operator guide.", promptSnippet: "Read installed pi-amq operating guidance", parameters: GuideParameters, async execute(_id, params) { try { return rendered(await guide.read(params.topic)); } catch (error) { return rendered({ error: { category: "guide", message: error instanceof Error ? error.message : String(error), }, }); } }, }); } function rendered(details: unknown): AgentToolResult { return { content: [{ type: "text", text: JSON.stringify(details, null, 2) }], details, }; } function normalizeTopic(rawTopic: string | undefined): GuideTopic { const normalized = rawTopic?.trim().toLowerCase() || "overview"; if ((GUIDE_TOPICS as readonly string[]).includes(normalized)) return normalized as GuideTopic; throw new Error(`Unknown guide topic ${JSON.stringify(rawTopic)}; choose ${GUIDE_TOPICS.join(", ")}`); } function extractTopic(document: string, heading: string): string { const marker = `## ${heading}`; const start = document.split("\n").findIndex((line) => line.trim() === marker); if (start < 0) throw new Error(`Installed guide is missing the ${heading} topic`); const lines = document.split("\n"); let end = lines.length; for (let index = start + 1; index < lines.length; index += 1) { if (lines[index]!.startsWith("## ")) { end = index; break; } } return lines.slice(start, end).join("\n").trim(); } function boundUtf8(value: string, maxBytes: number): { readonly text: string; readonly truncated: boolean } { const encoded = Buffer.from(value, "utf8"); if (encoded.byteLength <= maxBytes) return { text: value, truncated: false }; let end = maxBytes; while (end > 0 && (encoded[end] ?? 0) >= 0x80 && (encoded[end] ?? 0) < 0xc0) end -= 1; const suffix = "\n\n[excerpt truncated]"; const suffixBytes = Buffer.byteLength(suffix); let safeEnd = Math.max(0, Math.min(end, maxBytes - suffixBytes)); while (safeEnd > 0 && (encoded[safeEnd] ?? 0) >= 0x80 && (encoded[safeEnd] ?? 0) < 0xc0) { safeEnd -= 1; } return { text: `${encoded.subarray(0, safeEnd).toString("utf8")}${suffix}`, truncated: true }; } function defaultPackageRoot(): string { const adjacent = fileURLToPath(new URL("../", import.meta.url)); if (existsSync(join(adjacent, GUIDE_DOCUMENT))) return adjacent; const parent = fileURLToPath(new URL("../../", import.meta.url)); if (existsSync(join(parent, GUIDE_DOCUMENT))) return parent; throw new Error("Cannot locate installed pi-amq package guide"); }