// src/cymbal.ts import { spawn } from "node:child_process"; import { accessSync, constants, existsSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { join as join2 } from "node:path"; // src/spill.ts import { closeSync, mkdtempSync, openSync, rmSync, writeSync } from "node:fs"; import { writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; var DEFAULT_CAPTURE_MAX_BYTES = 256 * 1024; var defaultCaptureFs = { open: (path, flags, mode) => openSync(path, flags, mode), write: (fd, buffer) => writeSync(fd, buffer), close: closeSync }; var spillRoot; var spillSequence = 0; var acceptingSpills = true; var spillPaths = /* @__PURE__ */ new Set(); var activeFinalizers = /* @__PURE__ */ new Set(); function startSpillSession() { acceptingSpills = true; } function stopSpillSession() { acceptingSpills = false; } function ensureSpillRoot() { if (!acceptingSpills) throw new Error("Cymbal spill creation is disabled during session shutdown"); spillRoot ??= mkdtempSync(join(tmpdir(), "pi-cymbal-")); return spillRoot; } function nextSpillPath(label) { const path = join(ensureSpillRoot(), `${String(++spillSequence).padStart(4, "0")}-${label}`); spillPaths.add(path); return path; } function writeAll(fs, fd, buffer) { let offset = 0; while (offset < buffer.byteLength) { const written = fs.write(fd, buffer.subarray(offset)); if (!Number.isInteger(written) || written <= 0) throw new Error("Spill write made no progress"); offset += written; } } function decodeUtf8Prefix(buffer) { for (let trim = 0; trim <= Math.min(3, buffer.byteLength); trim += 1) { try { return new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, buffer.byteLength - trim)); } catch { } } return buffer.toString("utf8"); } function createBoundedCapture(options) { const maxBytes = options.maxBytes ?? DEFAULT_CAPTURE_MAX_BYTES; if (!Number.isInteger(maxBytes) || maxBytes < 0) throw new RangeError("capture maxBytes must be a non-negative integer"); const fs = options.fs ?? defaultCaptureFs; const preview = []; let previewBytes = 0; let totalBytes = 0; let path; let fd; let closed = false; let failure; let resolveFinalizer; trackSpillFinalizer(new Promise((resolve2) => { resolveFinalizer = resolve2; })); function finishFinalizer() { const finalize = resolveFinalizer; resolveFinalizer = void 0; finalize?.(); } function activateSpill() { path = nextSpillPath(options.label); try { fd = fs.open(path, "wx", 384); for (const chunk of preview) writeAll(fs, fd, chunk); } catch (error) { spillPaths.delete(path); failure = error; throw error; } } function captureResult() { return { text: decodeUtf8Prefix(Buffer.concat(preview)), bytes: totalBytes, truncated: Boolean(path), path }; } return { append(chunkValue) { if (closed) throw new Error("Capture is already closed"); if (failure) throw failure; const chunk = Buffer.from(chunkValue); totalBytes += chunk.byteLength; if (fd === void 0 && totalBytes > maxBytes) activateSpill(); if (previewBytes < maxBytes) { const retained = chunk.subarray(0, Math.min(chunk.byteLength, maxBytes - previewBytes)); if (retained.byteLength) { preview.push(retained); previewBytes += retained.byteLength; } } if (fd !== void 0) { try { writeAll(fs, fd, chunk); } catch (error) { failure = error; throw error; } } }, close() { if (closed) { if (failure) throw failure; return captureResult(); } closed = true; try { if (fd !== void 0) { try { fs.close(fd); } catch (error) { failure ??= error; } finally { fd = void 0; } } if (failure) throw failure; return captureResult(); } finally { finishFinalizer(); } } }; } function writeManagedSpill(content, label = "output.txt") { const path = nextSpillPath(label); const operation = (async () => { try { await writeFile(path, content, { flag: "wx", mode: 384 }); return path; } catch (error) { spillPaths.delete(path); throw error; } })(); return trackSpillFinalizer(operation); } function trackSpillFinalizer(promise) { activeFinalizers.add(promise); void promise.finally(() => activeFinalizers.delete(promise)).catch(() => void 0); return promise; } async function waitForSpillFinalizers() { const failures = []; while (activeFinalizers.size) { const results = await Promise.allSettled([...activeFinalizers]); for (const result of results) { if (result.status === "rejected") failures.push(result.reason); } await Promise.resolve(); } if (failures.length) throw new AggregateError(failures, "Cymbal spill finalization failed"); } async function cleanupSpills() { let finalizerError; try { await waitForSpillFinalizers(); } catch (error) { finalizerError = error; } finally { if (spillRoot) { const root = spillRoot; spillRoot = void 0; spillPaths.clear(); spillSequence = 0; rmSync(root, { recursive: true, force: true }); } } if (finalizerError) throw finalizerError; } // src/cymbal.ts var ProcessError = class extends Error { constructor(message, result, cause) { super(message); this.result = result; this.cause = cause; this.name = "ProcessError"; } result; cause; }; var ProcessCwdError = class extends ProcessError { constructor(message, result, cause) { super(message, result, cause); this.name = "ProcessCwdError"; } }; var ProcessBinaryError = class extends ProcessError { constructor(message, result, cause) { super(message, result, cause); this.name = "ProcessBinaryError"; } }; var ProcessCaptureError = class extends ProcessError { constructor(message, result, cause) { super(message, result, cause); this.name = "ProcessCaptureError"; } }; var ProcessTimeoutError = class extends ProcessError { constructor(message, result, cause) { super(message, result, cause); this.name = "ProcessTimeoutError"; } }; var CymbalError = class extends ProcessError { constructor(message, result, cause) { super(message, result, cause); this.name = "CymbalError"; } }; function resolveCymbalBinary(options = {}) { const env = options.env ?? process.env; const configured = env.CYMBAL_BIN?.trim(); if (configured) return configured; const home = options.home ?? homedir(); const exists = options.exists ?? existsSync; const local = join2(home, ".local", "bin", "cymbal"); const platform = options.platform ?? process.platform; const executable = options.executable ?? ((path) => { if (options.exists) return exists(path); try { accessSync(path, constants.X_OK); return true; } catch { return false; } }); if (platform === "win32" ? exists(local) : executable(local)) return local; return "cymbal"; } function normalizePathArg(value) { return value.startsWith("@") ? value.slice(1) : value; } function buildCymbalEnv(base = process.env) { return { ...base, CYMBAL_NO_UPDATE_NOTIFIER: "1", NO_COLOR: "1", TERM: "dumb" }; } function shellQuote(value) { if (/^[A-Za-z0-9_./:@=-]+$/.test(value)) return value; return `'${value.replaceAll("'", `'\\''`)}'`; } function formatCommand(bin, args) { return [bin, ...args].map(shellQuote).join(" "); } function missingCymbalMessage() { return [ "Cymbal is unavailable because the `cymbal` command was not found.", "Install Cymbal, set CYMBAL_BIN, add ~/.local/bin/cymbal, or put cymbal on PATH." ].join("\n"); } function isNoRepoDetected(result) { const output = `${result.stderr} ${result.stdout}`; return output.includes("not inside a git repository") || output.includes("no repo detected"); } function noRepoDetectedMessage(result) { return [ "pi-cymbal requires the current working directory to be inside a Git repository.", `Current cwd: ${result.cwd}`, "This extension intentionally relies on Cymbal's Git repo auto-detection and does not pass --db.", "Use local file tools for non-Git directories, or run Pi from inside a Git repository." ].join("\n"); } function emptyResult(command, options, code = 1) { return { command, args: [...options.args], cwd: options.cwd, stdout: "", stderr: "", code }; } function abortReason(signal) { return signal.reason ?? new DOMException("The operation was aborted", "AbortError"); } function delay(milliseconds) { return new Promise((resolve2) => setTimeout(resolve2, milliseconds)); } function defaultTaskkill(pid) { return new Promise((resolve2) => { const killer = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true }); killer.once("error", () => resolve2(false)); killer.once("close", (code) => resolve2(code === 0)); }); } function processOperations(overrides) { return { platform: overrides?.platform ?? process.platform, killProcessGroup: overrides?.killProcessGroup ?? ((pid, signal) => process.kill(-pid, signal)), taskkill: overrides?.taskkill ?? defaultTaskkill, killChild: overrides?.killChild ?? ((child, signal) => child.kill(signal)) }; } async function runProcess(options) { const command = formatCommand(options.bin, options.args); try { if (!statSync(options.cwd).isDirectory()) throw new Error("not a directory"); } catch (error) { throw new ProcessCwdError(`Cannot run ${command}: cwd is unavailable or not a directory: ${options.cwd}`, emptyResult(command, options), error); } if (options.signal?.aborted) throw abortReason(options.signal); const maxBytes = options.captureMaxBytes ?? DEFAULT_CAPTURE_MAX_BYTES; const ops = processOperations(options.processOperations); const graceMs = options.terminationGraceMs ?? 100; const captures = []; try { const stdoutSink = createBoundedCapture({ label: "stdout.txt", maxBytes, fs: options.captureFs }); captures.push(stdoutSink); const stderrSink = createBoundedCapture({ label: "stderr.txt", maxBytes, fs: options.captureFs }); captures.push(stderrSink); return await new Promise((resolve2, reject) => { const child = spawn(options.bin, options.args, { cwd: options.cwd, env: options.env ?? process.env, stdio: ["pipe", "pipe", "pipe"], detached: ops.platform !== "win32", windowsHide: true }); let closeCode = null; let spawnError; let terminalError; let captureError; let terminalCause; let termination; let timer; const terminateTree = () => { if (termination) return termination; termination = (async () => { const pid = child.pid; if (!pid) return; if (ops.platform === "win32") { const killed = await ops.taskkill(pid); if (!killed) ops.killChild(child, "SIGKILL"); return; } try { ops.killProcessGroup(pid, "SIGTERM"); } catch { ops.killChild(child, "SIGTERM"); } await delay(graceMs); try { ops.killProcessGroup(pid, "SIGKILL"); } catch { if (closeCode === null) ops.killChild(child, "SIGKILL"); } })(); return termination; }; const recordCaptureError = (error) => { captureError ??= error; terminalCause ??= "capture"; void terminateTree(); }; function closeCapture(capture) { try { return capture.close(); } catch (error) { recordCaptureError(error); return { text: "", bytes: 0, truncated: false }; } } let stdoutResult; let stderrResult; const closeStdoutCapture = () => { stdoutResult ??= closeCapture(stdoutSink); }; const closeStderrCapture = () => { stderrResult ??= closeCapture(stderrSink); }; function appendCapture(capture, chunk) { try { capture.append(chunk); } catch (error) { recordCaptureError(error); } } child.stdout.on("data", (chunk) => appendCapture(stdoutSink, chunk)); child.stderr.on("data", (chunk) => appendCapture(stderrSink, chunk)); child.stdout.on("error", recordCaptureError); child.stderr.on("error", recordCaptureError); child.stdout.once("end", closeStdoutCapture); child.stderr.once("end", closeStderrCapture); function recordTerminalError(error) { if (terminalCause === void 0) { terminalError = error; terminalCause = "terminal"; } void terminateTree(); } child.stdin.on("error", (error) => { if (error.code === "EPIPE" || error.code === "ERR_STREAM_DESTROYED") return; recordTerminalError(error); }); const onAbort = () => { recordTerminalError(abortReason(options.signal)); }; options.signal?.addEventListener("abort", onAbort, { once: true }); if (options.timeoutMs !== void 0) { timer = setTimeout(() => { terminalCause ??= "timeout"; void terminateTree(); }, options.timeoutMs); timer.unref(); } child.once("error", (error) => { spawnError = error; terminalCause ??= "spawn"; }); async function settle(code) { if (timer) clearTimeout(timer); options.signal?.removeEventListener("abort", onAbort); if (termination) await termination; closeStdoutCapture(); closeStderrCapture(); if (termination) await termination; const stdout = stdoutResult; const stderr = stderrResult; const exitCode = terminalCause === "timeout" ? 124 : code ?? (spawnError?.code === "ENOENT" ? 127 : 1); const result = { command, args: [...options.args], cwd: options.cwd, stdout: stdout.text, stderr: stderr.text, code: exitCode, stdoutBytes: stdout.bytes, stderrBytes: stderr.bytes, stdoutTruncated: stdout.truncated, stderrTruncated: stderr.truncated, stdoutPath: stdout.path, stderrPath: stderr.path }; switch (terminalCause) { case "terminal": reject(terminalError); return; case "capture": reject(new ProcessCaptureError(`${command} output capture failed`, result, captureError)); return; case "timeout": reject(new ProcessTimeoutError(`${command} timed out`, result)); return; case "spawn": { const ErrorType = spawnError?.code === "ENOENT" ? ProcessBinaryError : ProcessError; reject(new ErrorType(spawnError?.message ?? `${command} failed to spawn`, result, spawnError)); return; } } if (exitCode !== 0) { reject(new ProcessError(`${command} failed (exit ${exitCode})`, result)); return; } resolve2(result); } child.once("close", (code) => { closeCode = code; void settle(code).catch(reject); }); child.stdin.end(options.input); }); } catch (error) { for (const capture of captures) { try { capture.close(); } catch { } } throw error; } } var sessionController; var activeCymbalOperations = /* @__PURE__ */ new Set(); function startCymbalSession() { sessionController?.abort(new DOMException("Cymbal session replaced", "AbortError")); sessionController = new AbortController(); } function abortCymbalSession(reason = new DOMException("Cymbal session shut down", "AbortError")) { sessionController?.abort(reason); } function cymbalSessionSignal(signal) { const sessionSignal = sessionController?.signal; if (!sessionSignal) return signal; return signal ? AbortSignal.any([signal, sessionSignal]) : sessionSignal; } async function executeCymbal(options) { const bin = resolveCymbalBinary(); try { return await runProcess({ bin, args: options.args, cwd: options.cwd, env: buildCymbalEnv(), signal: cymbalSessionSignal(options.signal), timeoutMs: options.timeoutMs, input: options.input, captureMaxBytes: options.captureMaxBytes }); } catch (error) { if (error instanceof ProcessCwdError) throw error; if (error instanceof ProcessError) { if (error instanceof ProcessBinaryError) { throw new CymbalError(missingCymbalMessage(), error.result, error); } if (isNoRepoDetected(error.result)) { throw new CymbalError(noRepoDetectedMessage(error.result), error.result, error); } const text = [ `${error.result.command} failed (exit ${error.result.code}).`, error.result.stdout ? `stdout: ${error.result.stdout}` : void 0, error.result.stderr ? `stderr: ${error.result.stderr}` : void 0, error.result.stdoutPath ? `Full stdout: ${error.result.stdoutPath}` : void 0, error.result.stderrPath ? `Full stderr: ${error.result.stderrPath}` : void 0 ].filter(Boolean).join("\n\n"); throw new CymbalError(text, error.result, error); } throw error; } } async function runCymbal(options) { const operation = executeCymbal(options); activeCymbalOperations.add(operation); try { return await operation; } finally { activeCymbalOperations.delete(operation); } } async function waitForCymbalOperations() { while (activeCymbalOperations.size) { await Promise.allSettled([...activeCymbalOperations]); await Promise.resolve(); } } // src/hooks.ts function isRecord(value) { return typeof value === "object" && value !== null; } function buildNudgePayload(toolName, input) { if (!isRecord(input)) return void 0; if (toolName === "bash") { const { command } = input; if (typeof command !== "string" || !command.trim()) return void 0; return JSON.stringify({ tool_name: "bash", tool_input: { command } }); } if (toolName === "grep") { const { pattern, glob, literal } = input; if (literal === true) return void 0; if (typeof pattern !== "string" || !pattern.trim()) return void 0; const toolInput = { pattern }; if (typeof glob === "string" && glob.trim()) toolInput.glob = glob; return JSON.stringify({ tool_name: "Grep", tool_input: toolInput }); } if (toolName === "find") { const { pattern } = input; if (typeof pattern !== "string" || !pattern.trim()) return void 0; return JSON.stringify({ tool_name: "Glob", tool_input: { pattern } }); } if (toolName === "read") { const { path } = input; if (typeof path !== "string" || !path.trim()) return void 0; return JSON.stringify({ tool_name: "Read", tool_input: { file_path: path } }); } return void 0; } var STALE_NUDGE_SUGGESTIONS = /* @__PURE__ */ new Map([["cymbal ls --names", "cymbal ls"]]); function normalizeNudgeSuggestion(suggest) { const trimmed = suggest.trim(); if (!trimmed) return void 0; return STALE_NUDGE_SUGGESTIONS.get(trimmed) ?? trimmed; } function parseNudgeResponse(output) { const trimmed = output.trim(); if (!trimmed) return void 0; try { const value = JSON.parse(trimmed); if (typeof value.suggest !== "string") return void 0; const suggest = normalizeNudgeSuggestion(value.suggest); if (!suggest) return void 0; return { suggest, why: typeof value.why === "string" ? value.why : void 0, tool: typeof value.tool === "string" ? value.tool : void 0 }; } catch { return void 0; } } function buildNudgeMessage(suggestion) { const parts = [ `Cymbal suggests: ${suggestion.suggest}`, "Use this if it fits; ignore it if your original tool is intentional." ]; if (suggestion.why) parts.push(`Why: ${suggestion.why}`); if (suggestion.tool) parts.push(`Tool: ${suggestion.tool}`); return parts.join("\n"); } var NUDGE_SUPPRESSION_MS = 6e4; var CYMBAL_TOOL_PREFIX = "cymbal_"; function isCymbalToolName(toolName) { return toolName.startsWith(CYMBAL_TOOL_PREFIX); } function createCymbalHooks(deps = {}) { const run = deps.run ?? runCymbal; const now = deps.now ?? Date.now; const seenSuggestions = /* @__PURE__ */ new Map(); const inFlight = /* @__PURE__ */ new Map(); const tracked = /* @__PURE__ */ new Set(); let sessionController2 = new AbortController(); let acceptingWork = true; let reminderText = ""; function track(promise) { tracked.add(promise); void promise.finally(() => tracked.delete(promise)).catch(() => void 0); return promise; } async function settleTracked() { while (tracked.size) await Promise.allSettled([...tracked]); } function combinedSignal(signal) { return signal ? AbortSignal.any([signal, sessionController2.signal]) : sessionController2.signal; } function shouldSuppressSuggestion(cwd, suggest, toolName) { const currentTime = now(); for (const [key2, expiresAt2] of seenSuggestions) { if (expiresAt2 <= currentTime) seenSuggestions.delete(key2); } const suppressionKey = toolName === "read" ? "tool:Read" : toolName === "find" ? "tool:Glob" : `suggest:${suggest}`; const key = `${cwd}\0${suppressionKey}`; const expiresAt = seenSuggestions.get(key); if (expiresAt !== void 0 && expiresAt > currentTime) return true; seenSuggestions.set(key, currentTime + NUDGE_SUPPRESSION_MS); return false; } async function runNudge(event, ctx, payload) { try { const result = await run({ cwd: ctx.cwd, args: ["hook", "nudge", "--format=json"], input: payload, timeoutMs: 5e3, signal: combinedSignal(ctx.signal) }); const suggestion = parseNudgeResponse(result.stdout); if (!suggestion || shouldSuppressSuggestion(ctx.cwd, suggestion.suggest, event.toolName)) return; const content = buildNudgeMessage(suggestion); await deps.sendMessage?.({ customType: "pi-cymbal-nudge", content, display: false }); if (ctx.hasUI && ctx.ui?.notify) ctx.ui.notify(content, "info"); } catch { return; } } const hooks = { async startSession() { acceptingWork = false; if (!sessionController2.signal.aborted) sessionController2.abort(new DOMException("Cymbal session replaced", "AbortError")); await settleTracked(); inFlight.clear(); seenSuggestions.clear(); reminderText = ""; sessionController2 = new AbortController(); acceptingWork = true; }, async shutdown() { acceptingWork = false; if (!sessionController2.signal.aborted) sessionController2.abort(new DOMException("Cymbal session shut down", "AbortError")); await settleTracked(); inFlight.clear(); seenSuggestions.clear(); reminderText = ""; }, async refreshReminder(ctx) { if (!acceptingWork) return false; const operation = (async () => { try { const result = await run({ cwd: ctx.cwd, args: ["hook", "remind", "--format=text", "--update=if-stale"], timeoutMs: 5e3, signal: combinedSignal(ctx.signal) }); reminderText = result.stdout.trim(); return true; } catch { reminderText = ""; return false; } })(); return await track(operation); }, injectReminder(event) { if (!reminderText) return { systemPrompt: event.systemPrompt }; return { systemPrompt: `${event.systemPrompt} # Cymbal navigation guidance ${reminderText}` }; }, collapseCymbalTool(event, ctx) { if (!isCymbalToolName(event.toolName)) return; ctx.ui?.setToolsExpanded?.(false); }, handleToolCall(event, ctx) { const payload = buildNudgePayload(event.toolName, event.input); if (!payload || !acceptingWork) return Promise.resolve(); const key = `${ctx.cwd}\0${payload}`; const existing = inFlight.get(key); if (existing) return existing; const task = track(runNudge(event, ctx, payload)); inFlight.set(key, task); void task.finally(() => { if (inFlight.get(key) === task) inFlight.delete(key); }).catch(() => void 0); return task; }, startToolCall(event, ctx) { void hooks.handleToolCall(event, ctx); } }; return hooks; } function registerCymbalHooks(pi) { const hooks = createCymbalHooks({ sendMessage: async (message) => { await pi.sendMessage(message); } }); pi.on("before_agent_start", (event) => hooks.injectReminder(event)); pi.on("tool_call", (event, ctx) => { hooks.startToolCall(event, ctx); }); pi.on("tool_execution_start", (event, ctx) => { hooks.collapseCymbalTool(event, ctx); }); pi.registerCommand("cymbal:remind", { description: "Refresh Cymbal navigation reminder guidance", handler: async (_args, ctx) => { const refreshed = await hooks.refreshReminder(ctx); if (ctx.hasUI && ctx.ui?.notify) { ctx.ui.notify(refreshed ? "Cymbal reminder refreshed" : "Cymbal reminder unavailable", refreshed ? "info" : "warning"); } } }); return hooks; } // src/params.ts import { StringEnum } from "@earendil-works/pi-ai"; import { Type } from "typebox"; var FormatParam = Type.Optional(StringEnum(["agent", "json"], { description: "Output format. Defaults to agent-native Cymbal output." })); var GraphFormatParam = Type.Optional(StringEnum(["mermaid", "dot", "json"])); var ResolveScopeParam = Type.Optional(StringEnum(["same", "family", "all"], { description: "Cross-language resolution scope. Defaults to family." })); var BatchStrings = (description, minItems = 1) => Type.Array(Type.String(), { minItems, maxItems: 32, description }); var PathValues = (description) => Type.Union([Type.String(), BatchStrings(description)], { description }); var ResultLimit = (description) => Type.Integer({ minimum: 1, maximum: 1e4, description }); var ContextLines = (description) => Type.Integer({ minimum: 0, maximum: 1e3, description }); var GraphLimit = Type.Optional(Type.Integer({ minimum: 0, maximum: 1e4, description: "Graph limit." })); var MapParams = Type.Object({ path: Type.Optional(Type.String({ description: "Directory scope. Defaults to ." })), depth: Type.Optional(Type.Integer({ minimum: 0, maximum: 100, description: "Tree depth passed to --depth." })), stats: Type.Optional(Type.Boolean({ description: "Include repository stats. Defaults to true." })), repos: Type.Optional(Type.Boolean({ description: "List indexed repositories instead of tree." })), format: FormatParam }); var StructureParams = Type.Object({ limit: Type.Optional(ResultLimit("Maximum items per section.")), format: FormatParam }); var DiffParams = Type.Object({ symbol: Type.String({ description: "Symbol to diff." }), base: Type.Optional(Type.String({ description: "Git base revision. Defaults to HEAD." })), stat: Type.Optional(Type.Boolean({ description: "Show diffstat instead of full diff." })), format: FormatParam }); var IndexParams = Type.Object({ path: Type.Optional(Type.String({ description: "Directory to index. Defaults to the current directory." })), force: Type.Optional(Type.Boolean({ description: "Force re-index all files." })), workers: Type.Optional(Type.Integer({ minimum: 0, maximum: 256, description: "Number of parallel workers." })), exclude: Type.Optional(PathValues("Exclude files matching this glob during indexing.")), includeGenerated: Type.Optional(Type.Boolean({ description: "Index generated files that are skipped by default." })), includeLargeFiles: Type.Optional(Type.Boolean({ description: "Index large source files that are skipped by default." })), format: FormatParam }); var SearchParams = Type.Object({ query: Type.Optional(Type.String({ description: "Symbol query, or text query when text is true." })), queries: Type.Optional(BatchStrings("Additional symbol queries.")), text: Type.Optional(Type.Boolean({ description: "Use Cymbal full-text search." })), exact: Type.Optional(Type.Boolean({ description: "Exact symbol match." })), ignoreCase: Type.Optional(Type.Boolean({ description: "Case-insensitive exact symbol match. Implies exact matching and is not supported with text search." })), kind: Type.Optional(Type.String({ description: "Filter by symbol kind." })), lang: Type.Optional(Type.String({ description: "Filter by language." })), limit: Type.Optional(ResultLimit("Maximum results.")), path: Type.Optional(PathValues("Include path filter.")), exclude: Type.Optional(PathValues("Exclude path filter.")), format: FormatParam }); var OutlineParams = Type.Object({ files: BatchStrings("Files to outline."), names: Type.Optional(Type.Boolean({ description: "Emit one symbol name per line." })), signatures: Type.Optional(Type.Boolean({ description: "Include signatures." })), format: FormatParam }); var ShowParams = Type.Object({ target: Type.Optional(Type.String({ description: "Symbol, file path, or file range." })), targets: Type.Optional(BatchStrings("Symbols, file paths, or file ranges to show.")), all: Type.Optional(Type.Boolean({ description: "Show all matching symbol definitions." })), context: Type.Optional(ContextLines("Context lines.")), path: Type.Optional(PathValues("Include path filter.")), exclude: Type.Optional(PathValues("Exclude path filter.")), format: FormatParam }); var RefsParams = Type.Object({ symbol: Type.Optional(Type.String({ description: "Target symbol." })), symbols: Type.Optional(BatchStrings("Additional target symbols.")), limit: Type.Optional(ResultLimit("Maximum results.")), importers: Type.Optional(Type.Boolean({ description: "Include importers." })), impact: Type.Optional(Type.Boolean({ description: "Impact mode." })), depth: Type.Optional(Type.Integer({ minimum: 1, maximum: 5, description: "Depth for importers or impact mode." })), context: Type.Optional(ContextLines("Lines of context around each call site.")), file: Type.Optional(Type.String({ description: "Restrict refs to files that import or include the given path fragment." })), path: Type.Optional(PathValues("Include path filter.")), exclude: Type.Optional(PathValues("Exclude path filter.")), format: FormatParam }); var ImpactParams = Type.Object({ symbol: Type.Optional(Type.String({ description: "Target symbol." })), symbols: Type.Optional(BatchStrings("Additional target symbols.")), context: Type.Optional(ContextLines("Lines of context around each call site.")), depth: Type.Optional(Type.Integer({ minimum: 1, maximum: 5, description: "Impact depth." })), limit: Type.Optional(ResultLimit("Maximum results.")), noTests: Type.Optional(Type.Boolean({ description: "Exclude callers in test files from the impact set." })), resolveScope: ResolveScopeParam, graph: Type.Optional(Type.Boolean({ description: "Emit graph output." })), graphFormat: GraphFormatParam, graphLimit: GraphLimit, includeUnresolved: Type.Optional(Type.Boolean({ description: "Include unresolved graph nodes." })), format: FormatParam }); var ImportersParams = Type.Object({ target: Type.String({ description: "File or package target." }), depth: Type.Optional(Type.Integer({ minimum: 1, maximum: 3, description: "Importer depth." })), limit: Type.Optional(ResultLimit("Maximum results.")), graph: Type.Optional(Type.Boolean({ description: "Emit graph output." })), graphFormat: GraphFormatParam, graphLimit: GraphLimit, includeUnresolved: Type.Optional(Type.Boolean({ description: "Include unresolved graph nodes." })), format: FormatParam }); var ImplsParams = Type.Object({ symbol: Type.Optional(Type.String({ description: "Symbol to query." })), symbols: Type.Optional(BatchStrings("Additional symbols to query.")), of: Type.Optional(Type.String({ description: "Find implementations of this symbol." })), lang: Type.Optional(Type.String({ description: "Filter by language." })), path: Type.Optional(PathValues("Include path filter.")), exclude: Type.Optional(PathValues("Exclude path filter.")), limit: Type.Optional(ResultLimit("Maximum results.")), graph: Type.Optional(Type.Boolean({ description: "Emit graph output." })), graphFormat: GraphFormatParam, graphLimit: GraphLimit, includeUnresolved: Type.Optional(Type.Boolean({ description: "Include unresolved graph nodes." })), resolved: Type.Optional(Type.Boolean({ description: "Only show targets whose declaration is in the index." })), unresolved: Type.Optional(Type.Boolean({ description: "Only show external or unresolved targets." })), format: FormatParam }); var InvestigateParams = Type.Object({ symbol: Type.Optional(Type.String({ description: "Target symbol." })), symbols: Type.Optional(BatchStrings("Additional target symbols.")), resolveScope: ResolveScopeParam, format: FormatParam }); var TraceParams = Type.Object({ symbol: Type.Optional(Type.String({ description: "Target symbol." })), symbols: Type.Optional(BatchStrings("Additional target symbols.")), depth: Type.Optional(Type.Integer({ minimum: 1, maximum: 5, description: "Trace depth." })), kinds: Type.Optional(Type.String({ description: "Comma-separated ref kinds to follow." })), limit: Type.Optional(ResultLimit("Maximum results per symbol.")), includeUnresolved: Type.Optional(Type.Boolean({ description: "Include unresolved targets in text, JSON, and graph output." })), resolveScope: ResolveScopeParam, graph: Type.Optional(Type.Boolean({ description: "Emit graph output." })), graphFormat: GraphFormatParam, graphLimit: GraphLimit, format: FormatParam }); var ChangedParams = Type.Object({ staged: Type.Optional(Type.Boolean({ description: "Use staged changes instead of the working tree. Cannot be combined with base." })), base: Type.Optional(Type.String({ description: "Diff against this git base ref. Cannot be combined with staged." })), depth: Type.Optional(Type.Integer({ minimum: 1, maximum: 5, description: "Impact depth." })), limit: Type.Optional(ResultLimit("Maximum results.")), maxSymbols: Type.Optional(Type.Integer({ minimum: 0, maximum: 1e4, description: "Maximum changed symbols to analyze." })), maxImpact: Type.Optional(Type.Integer({ minimum: 0, maximum: 1e5, description: "Maximum impacted symbols to report." })), noTests: Type.Optional(Type.Boolean({ description: "Exclude callers in test files from the impact set." })), resolveScope: ResolveScopeParam, format: FormatParam }); var ContextParams = Type.Object({ symbol: Type.String({ description: "Target symbol." }), callers: Type.Optional(ResultLimit("Maximum callers to show.")), format: FormatParam }); function addJson(args, format) { if (format === "json") args.push("--json"); return args; } function asArray(value) { if (!value) return []; return Array.isArray(value) ? value : [value]; } function assertInteger(name, value, minimum, maximum) { if (value !== void 0 && (!Number.isInteger(value) || value < minimum || value > maximum)) { throw new RangeError(`${name} must be an integer between ${minimum} and ${maximum}`); } } function pushNumber(args, flag, value, minimum, maximum) { assertInteger(flag, value, minimum, maximum); if (value !== void 0) args.push(flag, String(value)); } function graphRequested(options) { return options.graph === true || options.graphFormat !== void 0 || options.graphLimit !== void 0; } function effectiveOutputFormat(options) { const requested = graphRequested(options); if (options.graph === false && (options.graphFormat !== void 0 || options.graphLimit !== void 0)) { throw new Error("graph: false cannot be combined with graphFormat or graphLimit"); } if ((options.graphFormat === "mermaid" || options.graphFormat === "dot") && options.format === "json") { throw new Error("format: json cannot be combined with Mermaid or DOT graph output"); } if (!requested) return options.format ?? "agent"; return options.graphFormat === "mermaid" || options.graphFormat === "dot" ? "agent" : "json"; } function pushGraphArgs(args, options) { effectiveOutputFormat(options); if (!graphRequested(options)) return; args.push("--graph", "--graph-format", options.graphFormat ?? "json"); pushNumber(args, "--graph-limit", options.graphLimit, 0, 1e4); } function pushResolveScope(args, scope) { if (scope) args.push("--resolve-scope", scope); } function pushString(args, flag, value) { if (value.startsWith("-")) args.push(`${flag}=${value}`); else args.push(flag, value); } function pushRepeatedPaths(args, flag, values) { for (const value of asArray(values).map(normalizePathArg)) pushString(args, flag, value); } function boundedOperands(label, values, required = true) { const operands = values.filter((candidate) => Boolean(candidate)); if (required && operands.length === 0) throw new Error(`${label} is required`); if (operands.length > 32) throw new RangeError(`${label} accepts at most 32 operands`); return operands; } function collectSymbols(symbol, symbols) { return boundedOperands("symbol or symbols", [symbol, ...symbols ?? []]); } function escapeSymbolSearchQuery(query) { if (!/[^A-Za-z0-9_\s]/.test(query)) return query; if (query.startsWith('"') && query.endsWith('"')) return query; return `"${query.replaceAll('"', '""')}"`; } function buildMapArgs(params) { if (params.repos && (params.path !== void 0 || params.depth !== void 0 || params.stats !== void 0)) { throw new Error("repos cannot be combined with path, depth, or stats"); } const args = ["ls"]; if (params.repos) { args.push("--repos"); return addJson(args, params.format); } if (params.stats ?? true) args.push("--stats"); pushNumber(args, "--depth", params.depth, 0, 100); addJson(args, params.format); args.push("--", normalizePathArg(params.path ?? ".")); return args; } function buildStructureArgs(params) { const args = ["structure"]; pushNumber(args, "--limit", params.limit, 1, 1e4); return addJson(args, params.format); } function buildDiffArgs(params) { const args = ["diff"]; if (params.stat) args.push("--stat"); addJson(args, params.format); args.push("--", params.symbol); if (params.base) args.push(params.base); return args; } function buildIndexArgs(params) { const args = ["index"]; if (params.force) args.push("--force"); pushNumber(args, "--workers", params.workers, 0, 256); pushRepeatedPaths(args, "--exclude", params.exclude); if (params.includeGenerated) args.push("--include-generated"); if (params.includeLargeFiles) args.push("--include-large-files"); addJson(args, params.format); if (params.path) args.push("--", normalizePathArg(params.path)); return args; } function buildSearchArgs(params) { if (params.ignoreCase && params.text) throw new Error("ignoreCase cannot be combined with text search"); if (params.ignoreCase && params.exact === false) throw new Error("ignoreCase requires exact matching"); const queries = boundedOperands(params.text ? "text mode query or queries" : "query or queries", [params.query, ...params.queries ?? []]); const args = ["search"]; const exactSymbolSearch = params.exact || params.ignoreCase; if (params.text) args.push("--text"); if (exactSymbolSearch) args.push("--exact"); if (params.ignoreCase) args.push("--ignore-case"); if (params.kind) pushString(args, "--kind", params.kind); if (params.lang) pushString(args, "--lang", params.lang); pushNumber(args, "--limit", params.limit, 1, 1e4); pushRepeatedPaths(args, "--path", params.path); pushRepeatedPaths(args, "--exclude", params.exclude); addJson(args, params.format); if (params.text) args.push("--", queries.join(" ")); else args.push("--", ...exactSymbolSearch ? queries : queries.map(escapeSymbolSearchQuery)); return args; } function buildOutlineArgs(params) { const files = boundedOperands("files", params.files); const args = ["outline"]; if (params.names) args.push("--names"); if (params.signatures) args.push("--signatures"); addJson(args, params.format); args.push("--", ...files.map(normalizePathArg)); return args; } function showTargets(params) { const targets = params.targets ?? []; if (params.target && targets.length) throw new Error("target and targets cannot be combined"); return boundedOperands("target or targets", [params.target, ...targets]); } function buildShowArgs(params) { const targets = showTargets(params); const args = ["show"]; if (params.all) args.push("--all"); pushNumber(args, "--context", params.context, 0, 1e3); pushRepeatedPaths(args, "--path", params.path); pushRepeatedPaths(args, "--exclude", params.exclude); addJson(args, params.format); args.push("--", ...targets.map(normalizePathArg)); return args; } function buildRefsArgs(params) { const symbols = collectSymbols(params.symbol, params.symbols); const args = ["refs"]; if (params.importers) args.push("--importers"); if (params.impact) args.push("--impact"); if (params.importers || params.impact) pushNumber(args, "--depth", params.depth, 1, params.importers ? 3 : 5); pushNumber(args, "--context", params.context, 0, 1e3); if (params.file) pushString(args, "--file", normalizePathArg(params.file)); pushNumber(args, "--limit", params.limit, 1, 1e4); pushRepeatedPaths(args, "--path", params.path); pushRepeatedPaths(args, "--exclude", params.exclude); addJson(args, params.format); args.push("--", ...symbols); return args; } function buildImpactArgs(params) { const symbols = collectSymbols(params.symbol, params.symbols); const args = ["impact"]; pushNumber(args, "--context", params.context, 0, 1e3); pushNumber(args, "--depth", params.depth, 1, 5); pushNumber(args, "--limit", params.limit, 1, 1e4); if (params.noTests) args.push("--no-tests"); pushResolveScope(args, params.resolveScope); pushGraphArgs(args, params); if (params.includeUnresolved) args.push("--include-unresolved"); addJson(args, params.format); args.push("--", ...symbols); return args; } function buildImportersArgs(params) { const args = ["importers"]; pushNumber(args, "--depth", params.depth, 1, 3); pushNumber(args, "--limit", params.limit, 1, 1e4); pushGraphArgs(args, params); if (params.includeUnresolved) args.push("--include-unresolved"); addJson(args, params.format); args.push("--", params.target); return args; } function buildImplsArgs(params) { if (params.resolved && params.unresolved) throw new Error("resolved cannot be combined with unresolved"); if (params.of && (params.symbol || params.symbols?.length)) throw new Error("of cannot be combined with symbol or symbols"); const symbols = boundedOperands("symbol or of", [params.symbol, ...params.symbols ?? []], Boolean(!params.of)); const args = ["impls"]; if (params.of) pushString(args, "--of", params.of); if (params.lang) pushString(args, "--lang", params.lang); pushRepeatedPaths(args, "--path", params.path); pushRepeatedPaths(args, "--exclude", params.exclude); pushNumber(args, "--limit", params.limit, 1, 1e4); pushGraphArgs(args, params); if (params.includeUnresolved) args.push("--include-unresolved"); if (params.resolved) args.push("--resolved"); if (params.unresolved) args.push("--unresolved"); addJson(args, params.format); if (symbols.length) args.push("--", ...symbols); return args; } function buildInvestigateArgs(params) { const symbols = collectSymbols(params.symbol, params.symbols); const args = ["investigate"]; pushResolveScope(args, params.resolveScope); addJson(args, params.format); args.push("--", ...symbols); return args; } function buildTraceArgs(params) { const symbols = collectSymbols(params.symbol, params.symbols); const args = ["trace"]; pushNumber(args, "--depth", params.depth, 1, 5); if (params.kinds) pushString(args, "--kinds", params.kinds); pushNumber(args, "--limit", params.limit, 1, 1e4); if (params.includeUnresolved) args.push("--include-unresolved"); pushResolveScope(args, params.resolveScope); pushGraphArgs(args, params); addJson(args, params.format); args.push("--", ...symbols); return args; } function buildChangedArgs(params) { if (params.staged && params.base) throw new Error("staged cannot be combined with base"); const args = ["changed"]; if (params.staged) args.push("--staged"); if (params.base) pushString(args, "--base", params.base); pushNumber(args, "--depth", params.depth, 1, 5); pushNumber(args, "--limit", params.limit, 1, 1e4); pushNumber(args, "--max-symbols", params.maxSymbols, 0, 1e4); pushNumber(args, "--max-impact", params.maxImpact, 0, 1e5); if (params.noTests) args.push("--no-tests"); pushResolveScope(args, params.resolveScope); return addJson(args, params.format); } function buildContextArgs(params) { const args = ["context"]; pushNumber(args, "--callers", params.callers, 1, 1e4); addJson(args, params.format); args.push("--", params.symbol); return args; } // src/tools/common.ts import { defineTool as defineTool2 } from "@earendil-works/pi-coding-agent"; // src/output.ts import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead } from "@earendil-works/pi-coding-agent"; var MINIMUM_JSON_ENVELOPE = '{"status":"error"}'; var MINIMUM_JSON_BYTES = Buffer.byteLength(MINIMUM_JSON_ENVELOPE); function lineCount(value) { return value === "" ? 0 : value.split("\n").length; } function fits(value, maxBytes, maxLines) { return Buffer.byteLength(value) <= maxBytes && lineCount(value) <= maxLines; } function validateBudget(maxBytes, maxLines) { const validBytes = Number.isInteger(maxBytes) && maxBytes >= MINIMUM_JSON_BYTES; const validLines = Number.isInteger(maxLines) && maxLines >= 1; if (validBytes && validLines) return; throw new RangeError(`JSON output requires maxLines >= 1 and maxBytes >= ${MINIMUM_JSON_BYTES}`); } function baseDetails(result, format) { const details = { command: result.command, args: [...result.args], cwd: result.cwd, exitCode: result.code, outputFormat: format, status: result.status ?? (result.code === 0 ? "ok" : "error"), truncated: false }; if (result.stdoutPath) details.fullOutputPath = result.stdoutPath; if (result.stderrPath) details.stderrSpillPath = result.stderrPath; if (result.diagnostics?.length) details.diagnostics = [...result.diagnostics]; if (result.suggestions?.length) details.suggestions = [...result.suggestions]; if (result.requestedTarget) details.requestedTarget = result.requestedTarget; if (result.resolvedCwd) details.resolvedCwd = result.resolvedCwd; if (result.resolvedTarget) details.resolvedTarget = result.resolvedTarget; return details; } function truncatePreview(envelope, maxBytes, maxLines) { if (typeof envelope.preview !== "string") return void 0; const points = Array.from(envelope.preview); let low = 0; let high = points.length; let best; while (low <= high) { const middle = Math.floor((low + high) / 2); const serialized = JSON.stringify({ ...envelope, preview: points.slice(0, middle).join("") }); if (fits(serialized, maxBytes, maxLines)) { best = serialized; low = middle + 1; } else { high = middle - 1; } } return best; } async function serializeEnvelope(envelope, details, maxBytes, maxLines, completeContent) { validateBudget(maxBytes, maxLines); let serialized = JSON.stringify(envelope); if (fits(serialized, maxBytes, maxLines)) return serialized; if (!details.fullOutputPath && completeContent !== void 0) { details.fullOutputPath = await writeManagedSpill(completeContent, "output.json"); } if (details.fullOutputPath) envelope = { ...envelope, fullOutputPath: details.fullOutputPath }; serialized = JSON.stringify(envelope); if (fits(serialized, maxBytes, maxLines)) return serialized; const previewLimited = truncatePreview(envelope, maxBytes, maxLines); if (previewLimited !== void 0) { details.truncated = true; return previewLimited; } const sourceOutputPath = details.fullOutputPath; details.fullOutputPath = await writeManagedSpill(serialized, "envelope.json"); if (sourceOutputPath) details.sourceOutputPath = sourceOutputPath; details.status = "error"; details.parsedJson = false; details.truncated = true; details.error = { code: "cymbal_json_envelope_limited", message: "Cymbal JSON metadata exceeded the output budget." }; details.diagnostics = [...details.diagnostics ?? [], "Complete JSON envelope saved to the managed spill path."]; return MINIMUM_JSON_ENVELOPE; } async function formatJson(options, details, maxBytes, maxLines) { validateBudget(maxBytes, maxLines); if (options.result.stdoutTruncated) { const error2 = { code: "cymbal_json_truncated", message: "Cymbal JSON exceeded the process capture limit." }; details.status = "partial"; details.parsedJson = false; details.truncated = true; details.error = error2; const envelope = { results: {}, status: "partial", error: error2, preview: options.result.stdout }; if (details.fullOutputPath) envelope.fullOutputPath = details.fullOutputPath; return await serializeEnvelope(envelope, details, maxBytes, maxLines, options.result.stdout); } let parsed; try { parsed = JSON.parse(options.result.stdout); } catch { const error2 = { code: "malformed_cymbal_json", message: "Cymbal returned malformed JSON." }; details.status = "error"; details.parsedJson = false; details.error = error2; return await serializeEnvelope( { results: {}, status: "error", error: error2, preview: options.result.stdout }, details, maxBytes, maxLines, options.result.stdout ); } details.parsedJson = true; const pretty = JSON.stringify(parsed, null, 2); if (fits(pretty, maxBytes, maxLines)) return pretty; details.fullOutputPath = await writeManagedSpill(pretty, "output.json"); details.status = "partial"; details.truncated = true; const error = { code: "cymbal_json_output_limited", message: "Cymbal JSON exceeded the final output limit." }; details.error = error; return await serializeEnvelope( { results: {}, status: "partial", error, preview: truncateHead(pretty, { maxBytes, maxLines }).content, fullOutputPath: details.fullOutputPath }, details, maxBytes, maxLines, pretty ); } async function formatAgent(options, details, maxBytes, maxLines) { const visible = options.result.stdout; const truncation = truncateHead(visible, { maxBytes, maxLines }); if (!truncation.truncated && !options.result.stdoutTruncated) return visible; details.fullOutputPath ??= await writeManagedSpill(visible, "output.txt"); details.truncated = true; details.truncation = truncation; if (options.result.stdoutTruncated && details.status === "ok") details.status = "partial"; const truncatedLines = Math.max(0, truncation.totalLines - truncation.outputLines); const truncatedBytes = Math.max(0, truncation.totalBytes - truncation.outputBytes); const marker = `[Output truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}). ${truncatedLines} lines (${formatSize(truncatedBytes)}) omitted. Full output saved to: ${details.fullOutputPath}]`; const headBudget = Math.max(0, maxBytes - Buffer.byteLength(marker) - 2); const headLines = Math.max(1, maxLines - 2); const head = truncateHead(visible, { maxBytes: headBudget, maxLines: headLines }).content; const combined = head ? `${head} ${marker}` : marker; return fits(combined, maxBytes, maxLines) ? combined : truncateHead(marker, { maxBytes, maxLines }).content; } async function formatCymbalOutput(options) { const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; const maxLines = options.maxLines ?? DEFAULT_MAX_LINES; const details = baseDetails(options.result, options.format); const visible = options.format === "json" ? await formatJson(options, details, maxBytes, maxLines) : await formatAgent(options, details, maxBytes, maxLines); return { content: [{ type: "text", text: visible }], details }; } function aggregateStatus(statuses) { if (!statuses.length) return "error"; for (const status of ["ok", "not_found", "empty", "unsupported", "error"]) { if (statuses.every((candidate) => candidate === status)) return status; } return "partial"; } function batchCommand(item, index) { return { index, target: item.target, command: item.result.command, args: [...item.result.args], cwd: item.result.cwd, exitCode: item.result.code }; } function batchCommandText(commands) { return commands.map((command) => command.command).join("; "); } async function formatCymbalBatch(options) { if (options.items.length < 1 || options.items.length > 32) throw new RangeError("batch must contain between 1 and 32 targets"); const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; const maxLines = options.maxLines ?? DEFAULT_MAX_LINES; if (options.format === "agent") { const itemDetails = options.items.map(({ result }) => { const details = baseDetails(result, "agent"); if (result.stdoutTruncated) { details.truncated = true; if (details.status === "ok") details.status = "partial"; } return details; }); const stdout = options.items.map(({ target, result }) => { const captureNotice = result.stdoutTruncated && result.stdoutPath ? ` [Process output truncated. Full output saved to: ${result.stdoutPath}]` : ""; return `## ${target} ${result.stdout}${captureNotice}`; }).join("\n\n---\n\n"); const commands2 = options.items.map(batchCommand); const synthetic2 = { command: batchCommandText(commands2), args: [], cwd: options.items[0].result.cwd, stdout, stderr: "", code: itemDetails.some((details) => details.exitCode !== 0) ? 1 : 0, status: aggregateStatus(itemDetails.map((details) => details.status)) }; const aggregate = await formatCymbalOutput({ result: synthetic2, format: "agent", maxBytes, maxLines }); aggregate.details.results = itemDetails; aggregate.details.commands = commands2; return aggregate; } const commands = []; const entries = []; const statuses = []; for (const [index, item] of options.items.entries()) { const { target, result } = item; const details = baseDetails(result, "json"); let status2 = details.status; let payload2; let error; if (result.stdoutTruncated) { status2 = "partial"; error = { code: "cymbal_json_truncated", message: "Cymbal JSON exceeded the process capture limit." }; details.parsedJson = false; details.truncated = true; details.error = error; } else { try { payload2 = JSON.parse(result.stdout); details.parsedJson = true; } catch { status2 = "error"; error = { code: "malformed_cymbal_json", message: "Cymbal returned malformed JSON." }; details.parsedJson = false; details.error = error; } } details.status = status2; statuses.push(status2); commands.push(batchCommand(item, index)); entries.push({ index, target, status: status2, ...payload2 !== void 0 ? { payload: payload2 } : {}, ...error ? { error, preview: result.stdout } : {}, details }); } const status = aggregateStatus(statuses); const payload = { status, results: entries, commands }; const synthetic = { command: batchCommandText(commands), args: [], cwd: options.items[0].result.cwd, stdout: JSON.stringify(payload), stderr: "", code: status === "error" ? 1 : 0, status }; const formatted = await formatCymbalOutput({ result: synthetic, format: "json", maxBytes, maxLines }); formatted.details.results = entries.map((entry) => entry.details); formatted.details.commands = commands; return formatted; } // src/render.ts import { keyHint } from "@earendil-works/pi-coding-agent"; import { Text } from "@earendil-works/pi-tui"; var MAX_COLLAPSED_ARGS = 4; var MAX_VALUE_LENGTH = 80; function textComponent(lastComponent) { return lastComponent instanceof Text ? lastComponent : new Text("", 0, 0); } function truncate(value, maxLength) { if (value.length <= maxLength) return value; return `${value.slice(0, Math.max(0, maxLength - 1))}\u2026`; } function formatArgValue(value) { if (value === void 0 || value === null) return void 0; if (typeof value === "string") return truncate(JSON.stringify(value), MAX_VALUE_LENGTH); if (typeof value === "number" || typeof value === "boolean") return String(value); if (Array.isArray(value)) { return truncate(`[${value.map((item) => formatArgValue(item) ?? JSON.stringify(item)).join(", ")}]`, MAX_VALUE_LENGTH); } return truncate(JSON.stringify(value), MAX_VALUE_LENGTH); } function formatCollapsedArgs(args) { if (typeof args !== "object" || args === null) return ""; const parts = []; for (const [key, value] of Object.entries(args)) { const formatted = formatArgValue(value); if (formatted === void 0) continue; parts.push(`${key}=${formatted}`); if (parts.length === MAX_COLLAPSED_ARGS) break; } const omitted = Object.entries(args).filter(([, value]) => formatArgValue(value) !== void 0).length - parts.length; if (omitted > 0) parts.push("\u2026"); return parts.join(" "); } function countOutputLines(output) { if (!output) return 0; return output.endsWith("\n") ? output.slice(0, -1).split("\n").length : output.split("\n").length; } function getTextOutput(result) { return result.content.filter((item) => item.type === "text").map((item) => item.text).join("\n"); } function statusLabel(status, isError) { if (isError) return "error"; if (!status) return "ok"; return status.replace(/_/g, " "); } function statusIcon(status, isError) { if (isError || status === "error") return "\u2717"; if (["not_found", "no_repo", "partial", "empty", "unsupported"].includes(status ?? "")) return "!"; return "\u2713"; } function resultColor(status, isError) { if (isError || status === "error") return "error"; if (["not_found", "no_repo", "partial", "empty", "unsupported"].includes(status ?? "")) return "warning"; return "success"; } function outputSummary(result, output) { const details = result.details; if (details.truncated && details.truncation) { const shownLines = details.truncation.outputLines; const shownLabel = shownLines === 1 ? "line" : "lines"; return `${shownLines} ${shownLabel} shown of ${details.truncation.totalLines}`; } const lineCount2 = countOutputLines(output); const lineLabel = lineCount2 === 1 ? "line" : "lines"; return lineCount2 === 0 ? "no output" : `${lineCount2} ${lineLabel}`; } function expandHint() { try { return keyHint("app.tools.expand", "to expand"); } catch { return "expand for details"; } } function renderCymbalCall(toolName, args, theme, context) { const text = textComponent(context.lastComponent); const title = theme.fg("toolTitle", theme.bold(toolName)); if (context.expanded) { const renderedArgs2 = JSON.stringify(args, null, 2); text.setText(renderedArgs2 && renderedArgs2 !== "{}" ? `${title} ${renderedArgs2}` : title); return text; } const renderedArgs = formatCollapsedArgs(args); text.setText(renderedArgs ? `${title} ${theme.fg("muted", renderedArgs)}` : title); return text; } function renderCymbalResult(result, options, theme, context) { const text = textComponent(context.lastComponent); const output = getTextOutput(result); if (options.isPartial) { text.setText(theme.fg("muted", "Running Cymbal\u2026")); return text; } if (options.expanded) { text.setText(output || theme.fg("muted", "No output")); return text; } const status = result.details.status; const summary = [ `${statusIcon(status, context.isError)} ${statusLabel(status, context.isError)}`, outputSummary(result, output) ].join(" \xB7 "); text.setText(`${theme.fg(resultColor(status, context.isError), summary)} \xB7 ${expandHint()}`); return text; } function cymbalToolRenderers(toolName) { return { renderCall(args, theme, context) { return renderCymbalCall(toolName, args, theme, context); }, renderResult(result, options, theme, context) { return renderCymbalResult(result, options, theme, context); } }; } // src/tools/optional.ts import { defineTool } from "@earendil-works/pi-coding-agent"; // src/tools/path.ts import { existsSync as existsSync2, readdirSync, realpathSync, statSync as statSync2 } from "node:fs"; import { basename, dirname, isAbsolute, join as join3, relative, resolve } from "node:path"; var defaultFs = { stat: statSync2, exists: existsSync2, realpath: realpathSync.native, readdir: readdirSync }; function isDirectory(path, fs) { try { return fs.stat(path).isDirectory(); } catch { return false; } } function canonicalPath(path, fs) { if (!fs.realpath) return path; const unresolved = []; let current = path; for (; ; ) { try { const canonical = fs.realpath(current); return unresolved.length ? join3(canonical, ...unresolved.reverse()) : canonical; } catch { const parent = dirname(current); if (parent === current) return path; unresolved.push(basename(current)); current = parent; } } } function repoRootKey(path, fs = defaultFs) { const canonical = canonicalPath(path, fs); return (fs.platform ?? process.platform) === "win32" ? canonical.toLowerCase() : canonical; } function findRepoRoot(path, fs = defaultFs) { let current = isDirectory(path, fs) ? path : dirname(path); for (; ; ) { if (fs.exists(join3(current, ".git"))) return canonicalPath(current, fs); const parent = dirname(current); if (parent === current) return void 0; current = parent; } } var SOURCE_EXTENSION = /\.(?:c|cc|cpp|cs|css|go|h|hpp|html?|java|js|jsx|kt|kts|lua|md|mjs|php|proto|py|rb|rs|scala|sh|sql|swift|toml|ts|tsx|vue|yaml|yml)$/i; function looksLikePath(path) { return isAbsolute(path) || path.startsWith("./") || path.startsWith("../") || path.includes("/") || path.includes("\\") || SOURCE_EXTENSION.test(path); } function splitPathRangeSuffix(value) { const range = /^(.*?)(:\d+(?:-\d+)?)$/.exec(value); if (range) return { path: range[1], suffix: range[2] }; const lastColon = value.lastIndexOf(":"); if (lastColon > 0 && !(lastColon === 1 && /^[A-Za-z]:[\\/]/.test(value))) { const path = value.slice(0, lastColon); if (looksLikePath(path)) return { path, suffix: value.slice(lastColon) }; } return { path: value, suffix: "" }; } function isShowPath(value) { return looksLikePath(splitPathRangeSuffix(value).path); } function resolvePathOperand(value, cwd, classification, fs = defaultFs) { const normalized = classification === "importer" ? value : normalizePathArg(value); const parts = splitPathRangeSuffix(normalized); const explicitImporterPath = isAbsolute(parts.path) || parts.path.startsWith("./") || parts.path.startsWith("../"); const importerPath = explicitImporterPath || !normalized.startsWith("@") && fs.exists(resolve(cwd, parts.path)); const isPath = classification === "always" || classification === "absolute" && isAbsolute(parts.path) || classification === "show" && isShowPath(normalized) || classification === "importer" && importerPath; if (!isPath) return normalized; return `${isAbsolute(parts.path) ? parts.path : resolve(cwd, parts.path)}${parts.suffix}`; } function absolutePathPart(value) { const parts = splitPathRangeSuffix(value); return isAbsolute(parts.path) ? parts.path : void 0; } function scopedValue(value, repoRoot, omitRepoRoot, fs) { const parts = splitPathRangeSuffix(value); if (!isAbsolute(parts.path)) return value; const operationalPath = canonicalPath(parts.path, fs); const scopedPath = relative(repoRoot, operationalPath) || "."; if (scopedPath.startsWith("..") || isAbsolute(scopedPath)) return value; const nextPath = omitRepoRoot && scopedPath === "." ? "." : scopedPath; return `${nextPath}${parts.suffix}`; } function rootFor(value, fs) { const absolute = absolutePathPart(value); return absolute ? findRepoRoot(absolute, fs) : void 0; } function sameRepoRoot(values, fs) { const roots = values.map((value) => rootFor(value, fs)); const root = roots[0]; if (!root) return void 0; const key = repoRootKey(root, fs); return roots.every((candidate) => candidate !== void 0 && repoRootKey(candidate, fs) === key) ? root : void 0; } function asPathArray(value) { if (!value) return []; return Array.isArray(value) ? value : [value]; } function pathFilterValue(paths) { if (!paths.length) return void 0; return paths.length === 1 ? paths[0] : paths; } function absoluteFilterValues(value, cwd, fs) { return asPathArray(value).map((path) => resolvePathOperand(path, cwd, "always", fs)); } function scopedFilterValue(values, repoRoot, omitRepoRoot, fs) { const scoped = values.map((value) => { const next = scopedValue(value, repoRoot, omitRepoRoot, fs); return omitRepoRoot && next === "." ? void 0 : next; }).filter((path) => Boolean(path)); return pathFilterValue(scoped); } function resolvePathFilterRun(params, cwd, options) { const fs = options.fs ?? defaultFs; const includeAbsolutes = absoluteFilterValues(options.path, cwd, fs); const excludeAbsolutes = absoluteFilterValues(options.exclude, cwd, fs); const targetRepoRoot2 = options.targetRepoRoot ? canonicalPath(options.targetRepoRoot, fs) : void 0; if (targetRepoRoot2) { const targetKey = repoRootKey(targetRepoRoot2, fs); for (const value of [...includeAbsolutes, ...excludeAbsolutes]) { const filterRoot = rootFor(value, fs); if (filterRoot && repoRootKey(filterRoot, fs) !== targetKey) { throw new Error(`${options.errorPrefix ?? "Cymbal tool"} path filters resolve to a different repository than the target; split them into separate calls.`); } } } const filterAbsolutes = [...includeAbsolutes, ...excludeAbsolutes]; const filterRootKeys = new Set( filterAbsolutes.map((value) => rootFor(value, fs)).filter((root) => Boolean(root)).map((root) => repoRootKey(root, fs)) ); if (filterRootKeys.size > 1) { throw new Error(`${options.errorPrefix ?? "Cymbal tool"} path filters resolve to different repositories; split them into separate calls.`); } const filterRepoRoot = filterAbsolutes.length ? sameRepoRoot(filterAbsolutes, fs) : void 0; const repoRoot = targetRepoRoot2 ?? filterRepoRoot; if (!repoRoot) { const withPath2 = options.applyPath(params, pathFilterValue(includeAbsolutes)); return { cwd, params: options.applyExclude(withPath2, pathFilterValue(excludeAbsolutes)) }; } const withPath = options.applyPath(params, scopedFilterValue(includeAbsolutes, repoRoot, true, fs)); const withExclude = options.applyExclude(withPath, scopedFilterValue(excludeAbsolutes, repoRoot, false, fs)); return { cwd: repoRoot, params: withExclude }; } function resolveSinglePathRun(params, cwd, value, apply, options = {}) { if (!value) return { cwd, params }; const fs = options.fs ?? defaultFs; const resolved = resolvePathOperand(value, cwd, options.classification ?? "absolute", fs); const absolute = absolutePathPart(resolved); if (!absolute) return { cwd, params: apply(params, resolved) }; const repoRoot = findRepoRoot(absolute, fs); if (!repoRoot) return { cwd, params: apply(params, resolved) }; return { cwd: repoRoot, params: apply(params, scopedValue(resolved, repoRoot, options.omitRepoRoot ?? false, fs)) }; } function resolveMultiPathRun(params, cwd, values, apply, options = {}) { const fs = options.fs ?? defaultFs; const resolved = values.map((value) => resolvePathOperand(value, cwd, options.classification ?? "absolute", fs)); const rooted = resolved.map((value) => ({ value, root: rootFor(value, fs) })).filter((item) => item.root !== void 0); const rootKeys = new Set(rooted.map((item) => repoRootKey(item.root, fs))); if (rootKeys.size > 1) throw new Error("Cymbal path operands resolve to different repositories; split them into separate calls."); const repoRoot = rooted.length === resolved.length ? sameRepoRoot(resolved, fs) : void 0; if (!repoRoot) return { cwd, params: apply(params, resolved) }; return { cwd: repoRoot, params: apply( params, resolved.map((value) => scopedValue(value, repoRoot, options.omitRepoRoot ?? false, fs)) ) }; } function nearestExistingDirectory(path, fs) { let current = isDirectory(path, fs) ? path : dirname(path); for (; ; ) { if (isDirectory(current, fs)) return current; const parent = dirname(current); if (parent === current) return void 0; current = parent; } } function collectFiles(root, fs, maxDepth, maxFiles) { const files = []; function visit(dir, depth) { if (depth > maxDepth || files.length >= maxFiles) return; let entries; try { entries = fs.readdir(dir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { if (files.length >= maxFiles) return; if (entry.name === ".git" || entry.name === "node_modules") continue; const fullPath = join3(dir, entry.name); if (entry.isFile()) files.push(fullPath); else if (entry.isDirectory()) visit(fullPath, depth + 1); } } visit(root, 0); return files; } function tokens(value) { return value.toLowerCase().split(/[^a-z0-9]+/).filter((token) => token.length >= 2); } function scoreCandidate(candidate, target) { const candidateBase = basename(candidate).toLowerCase(); const targetBase = basename(target).toLowerCase(); let score = 0; if (candidateBase === targetBase) score += 200; if (candidateBase.includes(targetBase)) score += 120; if (targetBase.includes(candidateBase)) score += 80; for (const token of tokens(targetBase)) { if (candidateBase.includes(token)) score += 20; } const candidateLower = candidate.toLowerCase(); for (const token of tokens(target)) { if (candidateLower.includes(token)) score += 5; } return score; } function suggestNearbyFiles(cwd, target, limit = 5, fs = defaultFs) { const normalized = normalizePathArg(target); const targetPath = splitPathRangeSuffix(normalized).path; const absoluteTarget = isAbsolute(targetPath) ? targetPath : join3(cwd, targetPath); const searchRoot = nearestExistingDirectory(absoluteTarget, fs); if (!searchRoot) return []; return collectFiles(searchRoot, fs, 3, 250).map((file) => ({ file, score: scoreCandidate(file, absoluteTarget) })).filter((entry) => entry.score > 0).sort((a, b) => b.score - a.score || a.file.localeCompare(b.file)).slice(0, limit).map((entry) => { const relativePath = relative(cwd, entry.file); return relativePath && !relativePath.startsWith("..") ? relativePath : entry.file; }); } // src/tools/recovery.ts function visibleOutput(result) { return [result.stdout, result.stderr].filter(Boolean).join("\n"); } function diagnostics(output) { return output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); } function isOutsideRepoOutput(output) { return /refusing to read file outside repository/i.test(output); } function isNotFoundOutput(output) { if (isOutsideRepoOutput(output)) return false; return /no results found/i.test(output) || /no references found/i.test(output) || /no implementors found/i.test(output) || /no importers found/i.test(output) || /no callers found/i.test(output) || /no outgoing calls found/i.test(output) || /no changed symbols found/i.test(output) || /file not found/i.test(output) || /symbol not found/i.test(output) || /no requested symbol or file resolved/i.test(output); } function buildStatusJson(status, target, suggestions, lines) { return `${JSON.stringify({ results: {}, status, requestedTarget: target, suggestions, diagnostics: lines })} `; } function buildNotFoundText(target, suggestions) { const label = target ? ` for \`${target}\`` : ""; const lines = [`No Cymbal target resolved${label}.`]; if (suggestions.length) { lines.push("", "Nearby files:"); for (const suggestion of suggestions) lines.push(`- ${suggestion}`); } lines.push("", "Try:"); if (target) lines.push(`- cymbal_search query=${JSON.stringify(target)} text=true`); lines.push("- cymbal_map the nearest parent directory"); return `${lines.join("\n")} `; } function normalizeEmptyCymbalNotFound(result, format) { if (result.code !== 0) return result; const output = visibleOutput(result); const hasRecoverableOutput = result.stdout.trim() === "" || isNotFoundOutput(result.stdout); if (!hasRecoverableOutput || !isNoRepoDetected(result) && !isNotFoundOutput(output)) return result; const lines = diagnostics(output); const status = isNoRepoDetected(result) ? "no_repo" : "not_found"; return { ...result, stdout: format === "json" ? buildStatusJson(status, void 0, [], lines) : `${lines.join("\n")} `, stderr: "", status, diagnostics: lines }; } function recoverCymbalNotFound(error, options) { if (!(error instanceof ProcessError)) return void 0; if (isNoRepoDetected(error.result)) return void 0; const output = visibleOutput(error.result); if (isOutsideRepoOutput(output) || !isNotFoundOutput(output)) return void 0; const requestedTarget = options.requestedTarget ?? options.args[1]; const suggestions = requestedTarget ? suggestNearbyFiles(options.cwd, requestedTarget) : []; const lines = diagnostics(output); return { ...error.result, stdout: options.format === "json" ? buildStatusJson("not_found", requestedTarget, suggestions, lines) : buildNotFoundText(requestedTarget, suggestions), stderr: "", status: "not_found", requestedTarget, suggestions, diagnostics: lines }; } // src/tools/optional.ts var UnsupportedCymbalCommandError = class extends Error { constructor(command, diagnostics3, result) { super(`The installed Cymbal version does not support \`cymbal ${command}\`. Use documented Cymbal tools instead.`); this.command = command; this.diagnostics = diagnostics3; this.result = result; this.name = "UnsupportedCymbalCommandError"; } command; diagnostics; result; }; var availabilityCache = /* @__PURE__ */ new Map(); var runnerSequence = 0; var runnerIds = /* @__PURE__ */ new WeakMap(); function runnerId(runner) { let id = runnerIds.get(runner); if (id === void 0) { id = ++runnerSequence; runnerIds.set(runner, id); } return id; } function clearAvailabilityCache() { availabilityCache.clear(); runnerIds = /* @__PURE__ */ new WeakMap(); runnerSequence = 0; } function diagnosticLines(error) { return [error.result.stderr, error.result.stdout, error.message].filter(Boolean).flatMap((value) => value.split(/\r?\n/)).map((line) => line.trim()).filter(Boolean); } function isUnsupportedCommandError(error, command) { if (!(error instanceof ProcessError)) return false; const escaped = command.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return diagnosticLines(error).some((line) => new RegExp(`unknown command(?:\\s+|:\\s*)['"]?${escaped}(?:['"]|\\b)`, "i").test(line)); } function isInterruptedPreflight(error, signal) { if (signal?.aborted) return true; if (!(error instanceof ProcessError)) return false; const messages = [error.message]; if (error instanceof CymbalError && error.cause instanceof ProcessError) messages.push(error.cause.message); return error.result.code === 124 || messages.some((message) => /aborted|timed out/i.test(message)); } function ensureCommandAvailable(command, runner = runCymbal, cwd = process.cwd(), signal) { const key = `${runnerId(runner)}\0${cwd}\0${command}`; const cached = availabilityCache.get(key); if (cached) return cached; const probe = (async () => { try { await runner({ cwd, args: [command, "--help"], timeoutMs: 5e3, signal }); } catch (error) { if (isInterruptedPreflight(error, signal)) throw error; if (isUnsupportedCommandError(error, command)) { throw new UnsupportedCymbalCommandError(command, diagnosticLines(error), error.result); } throw error; } })(); availabilityCache.set(key, probe); void probe.catch(() => { if (availabilityCache.get(key) === probe) availabilityCache.delete(key); }); return probe; } function unsupportedCommandResult(error, cwd, format) { const diagnostics3 = error.diagnostics; return { ...error.result, command: `cymbal ${error.command} --help`, args: [error.command, "--help"], cwd, stdout: format === "json" ? JSON.stringify({ results: {}, status: "unsupported", command: error.command, diagnostics: diagnostics3 }) : `${error.message} ${diagnostics3.join("\n")} `, stderr: "", code: error.result.code || 1, status: "unsupported", diagnostics: diagnostics3 }; } function registerOptionalTool(pi, spec) { pi.registerTool( defineTool({ name: spec.name, label: spec.label, description: spec.description, parameters: spec.parameters, promptSnippet: `${spec.name}: Optional Cymbal ${spec.command} helper. It checks command availability first.`, promptGuidelines: [`Use ${spec.name} only when Cymbal supports the ${spec.command} command.`], ...cymbalToolRenderers(spec.name), async execute(_toolCallId, params, signal, _onUpdate, ctx) { const runner = ctx.runCymbal ?? runCymbal; const format = spec.outputFormat?.(params) ?? params.format ?? "agent"; try { await ensureCommandAvailable(spec.command, runner, ctx.cwd, signal); } catch (error) { if (!(error instanceof UnsupportedCymbalCommandError)) throw error; return await formatCymbalOutput({ result: unsupportedCommandResult(error, ctx.cwd, format), format }); } const args = spec.buildArgs(params); try { const result = normalizeEmptyCymbalNotFound(await runner({ cwd: ctx.cwd, args, signal }), format); return await formatCymbalOutput({ result, format }); } catch (error) { const recovered = recoverCymbalNotFound(error, { cwd: ctx.cwd, args, requestedTarget: spec.recoverTarget(params), format }); if (!recovered) throw error; return await formatCymbalOutput({ result: recovered, format }); } } }) ); } function registerOptionalTools(pi) { registerOptionalTool(pi, { name: "cymbal_investigate", label: "Cymbal Investigate", command: "investigate", description: "Run optional guide-mentioned `cymbal investigate ` when supported by the installed Cymbal version.", parameters: InvestigateParams, buildArgs: buildInvestigateArgs, recoverTarget: (params) => params.symbol ?? params.symbols?.join(" ") }); registerOptionalTool(pi, { name: "cymbal_trace", label: "Cymbal Trace", command: "trace", description: "Run optional guide-mentioned `cymbal trace ` when supported by the installed Cymbal version.", parameters: TraceParams, buildArgs: buildTraceArgs, recoverTarget: (params) => params.symbol ?? params.symbols?.join(" "), outputFormat: effectiveOutputFormat }); registerOptionalTool(pi, { name: "cymbal_context", label: "Cymbal Context", command: "context", description: "Run optional guide-mentioned `cymbal context ` when supported by the installed Cymbal version.", parameters: ContextParams, buildArgs: buildContextArgs, recoverTarget: (params) => params.symbol }); } // src/tools/common.ts function registerCymbalTool(pi, spec) { pi.registerTool( defineTool2({ name: spec.name, label: spec.label, description: spec.description, parameters: spec.parameters, promptSnippet: spec.promptSnippet, promptGuidelines: spec.promptGuidelines, ...cymbalToolRenderers(spec.name), async execute(_toolCallId, params, signal, _onUpdate, ctx) { const run = spec.resolveRun?.(params, ctx.cwd) ?? { cwd: ctx.cwd, params }; const args = spec.buildArgs(run.params); const format = spec.outputFormat?.(run.params) ?? run.params.format ?? "agent"; const runner = ctx.runCymbal ?? runCymbal; if (spec.availabilityCommand) { try { await ensureCommandAvailable(spec.availabilityCommand, runner, run.cwd, signal); } catch (error) { if (!(error instanceof UnsupportedCymbalCommandError)) throw error; return await formatCymbalOutput({ result: unsupportedCommandResult(error, run.cwd, format), format }); } } try { const result = normalizeEmptyCymbalNotFound(await runner({ cwd: run.cwd, args, signal }), format); return await formatCymbalOutput({ result, format }); } catch (error) { const recovered = recoverCymbalNotFound(error, { cwd: run.cwd, args, requestedTarget: spec.recoverTarget?.(run.params, args), format }); if (!recovered) throw error; return await formatCymbalOutput({ result: recovered, format }); } } }) ); } // src/tools/changed.ts function registerChangedTool(pi) { registerCymbalTool(pi, { name: "cymbal_changed", label: "Cymbal Changed", description: "Review what your current git diff affects in one call with `cymbal changed`. Reports the changed symbols and their references plus transitive impact.", parameters: ChangedParams, buildArgs: buildChangedArgs, availabilityCommand: "changed", promptSnippet: "cymbal_changed: Review what your current git diff affects (changed symbols, references, transitive impact) in one call.", promptGuidelines: [ "Use cymbal_changed to see the references and transitive impact of your uncommitted changes before refactors or PRs.", "Use staged to scope to staged changes, or base to diff against a git ref; the two cannot be combined." ] }); } // src/tools/diff.ts function registerDiffTool(pi) { registerCymbalTool(pi, { name: "cymbal_diff", label: "Cymbal Diff", description: "Review symbol-scoped changes with Cymbal using `cymbal diff`.", parameters: DiffParams, buildArgs: buildDiffArgs, recoverTarget: (params) => params.symbol, availabilityCommand: "diff", promptSnippet: "cymbal_diff: Review symbol-scoped diffs using Cymbal diff.", promptGuidelines: ["Use cymbal_diff when reviewing changes to a specific local symbol."] }); } // src/tools/impact.ts function registerImpactTool(pi) { registerCymbalTool(pi, { name: "cymbal_impact", label: "Cymbal Impact", description: "Analyze upstream impact for a symbol with `cymbal impact`.", parameters: ImpactParams, buildArgs: buildImpactArgs, outputFormat: effectiveOutputFormat, promptSnippet: "cymbal_impact: Analyze symbol impact through Cymbal impact.", promptGuidelines: ["Use cymbal_impact before changing a local symbol with likely upstream callers."] }); } // src/tools/impls.ts function withScopedFilter(params, key, value) { const next = { ...params }; if (value === void 0) delete next[key]; else next[key] = value; return next; } function resolveImplsRun(params, cwd) { return resolvePathFilterRun(params, cwd, { path: params.path, exclude: params.exclude, applyPath: (next, value) => withScopedFilter(next, "path", value), applyExclude: (next, value) => withScopedFilter(next, "exclude", value), errorPrefix: "cymbal_impls" }); } function registerImplsTool(pi) { registerCymbalTool(pi, { name: "cymbal_impls", label: "Cymbal Impls", description: "Find implementations or interface relationships with `cymbal impls`.", parameters: ImplsParams, buildArgs: buildImplsArgs, resolveRun: resolveImplsRun, outputFormat: effectiveOutputFormat, promptSnippet: "cymbal_impls: Find implementations and interface relationships through Cymbal.", promptGuidelines: ["Use cymbal_impls before changing implementation or interface relationships."] }); } // src/tools/importers.ts function resolveImportersRun(params, cwd) { return resolveSinglePathRun(params, cwd, params.target, (next, target) => ({ ...next, target: target ?? next.target }), { classification: "importer" }); } function registerImportersTool(pi) { registerCymbalTool(pi, { name: "cymbal_importers", label: "Cymbal Importers", description: "Find files that import a file or package with `cymbal importers`.", parameters: ImportersParams, buildArgs: buildImportersArgs, resolveRun: resolveImportersRun, outputFormat: effectiveOutputFormat, recoverTarget: (params) => params.target, promptSnippet: "cymbal_importers: Find import relationships through Cymbal.", promptGuidelines: ["Use cymbal_importers before changing file or package import relationships."] }); } // src/tools/index.ts function resolveIndexRun(params, cwd) { return resolveSinglePathRun(params, cwd, params.path, (next, path) => ({ ...next, path }), { omitRepoRoot: true, classification: "always" }); } function registerIndexTool(pi) { registerCymbalTool(pi, { name: "cymbal_index", label: "Cymbal Index", description: "Refresh Cymbal's local index cache with `cymbal index`. Use only when index freshness is suspected or explicitly requested; this is cache-mutating but local-only.", parameters: IndexParams, buildArgs: buildIndexArgs, resolveRun: resolveIndexRun, availabilityCommand: "index", promptSnippet: "cymbal_index: Refresh Cymbal's local index cache. Use only when index freshness is suspected or explicitly requested; local cache mutation only.", promptGuidelines: [ "Use cymbal_index only when a stale index is suspected or the user explicitly asks to refresh indexing.", "Do not use cymbal_index for routine navigation because Cymbal auto-indexes repositories." ] }); } // src/tools/map.ts function resolveMapRun(params, cwd) { if (params.repos) return { cwd, params }; return resolveSinglePathRun(params, cwd, params.path, (next, path) => ({ ...next, path }), { omitRepoRoot: true, classification: "always" }); } function registerMapTool(pi) { registerCymbalTool(pi, { name: "cymbal_map", label: "Cymbal Map", description: "Map Git repository structure with Cymbal using `cymbal ls`. Requires cwd to be inside a Git repository.", parameters: MapParams, buildArgs: buildMapArgs, resolveRun: resolveMapRun, promptSnippet: "cymbal_map: Repo overview using Cymbal. Requires the current directory to be inside a Git repository.", promptGuidelines: [ "Use cymbal_map first when the relevant local repository area is unknown.", "If Cymbal reports no repo detected, fall back to local find/grep tools instead of retrying Cymbal." ] }); } // src/tools/outline.ts import { defineTool as defineTool3 } from "@earendil-works/pi-coding-agent"; function resolveOutlineRun(params, cwd) { return resolveMultiPathRun(params, cwd, params.files, (next, files) => ({ ...next, files }), { classification: "always" }); } function diagnostics2(output) { return output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); } function hasEmptyOutline(result) { return result.code === 0 && result.stdout.trim() === "" && /No symbols found\. Is the file indexed\?/i.test(result.stderr); } function emptyOutlineResult(result, target, format) { const lines = diagnostics2(result.stderr); const stdout = format === "json" ? `${JSON.stringify({ results: {}, diagnostics: lines, status: "empty" })} ` : `No Cymbal outline symbols resolved for \`${target}\`. ${lines.join("\n")} `; return { ...result, stdout, stderr: "", status: "empty", requestedTarget: target, diagnostics: lines }; } function normalizeOutlineResult(result, target, format) { const normalized = normalizeEmptyCymbalNotFound(result, format); if (normalized !== result) return normalized; return hasEmptyOutline(result) ? emptyOutlineResult(result, target, format) : result; } function registerOutlineTool(pi) { pi.registerTool( defineTool3({ name: "cymbal_outline", label: "Cymbal Outline", description: "Inspect symbols defined in one or more files with Cymbal using `cymbal outline`.", parameters: OutlineParams, promptSnippet: "cymbal_outline: Inspect file structure with Cymbal before reading full files.", promptGuidelines: ["Use cymbal_outline before reading a whole local code file when file structure is enough."], ...cymbalToolRenderers("cymbal_outline"), async execute(_toolCallId, params, signal, _onUpdate, ctx) { if (params.files.length < 1 || params.files.length > 32) throw new RangeError("files must contain between 1 and 32 paths"); const runner = ctx.runCymbal ?? runCymbal; const items = []; for (const file of params.files) { const run = resolveOutlineRun({ ...params, files: [file] }, ctx.cwd); const scopedFile = run.params.files[0]; const args = buildOutlineArgs({ files: [scopedFile], names: params.names, signatures: params.signatures, format: params.format }); try { const result = normalizeOutlineResult(await runner({ cwd: run.cwd, args, signal }), scopedFile, params.format); items.push({ target: file, result }); } catch (error) { const recovered = recoverCymbalNotFound(error, { cwd: run.cwd, args, requestedTarget: scopedFile, format: params.format }); if (!recovered) throw error; items.push({ target: file, result: recovered }); } } if (items.length === 1) return await formatCymbalOutput({ result: items[0].result, format: params.format ?? "agent" }); return await formatCymbalBatch({ items, format: params.format ?? "agent" }); } }) ); } // src/tools/refs.ts function withScopedFilter2(params, key, value) { const next = { ...params }; if (value === void 0) delete next[key]; else next[key] = value; return next; } function resolveRefsRun(params, cwd) { return resolvePathFilterRun(params, cwd, { path: params.path, exclude: params.exclude, applyPath: (next, value) => withScopedFilter2(next, "path", value), applyExclude: (next, value) => withScopedFilter2(next, "exclude", value), errorPrefix: "cymbal_refs" }); } function registerRefsTool(pi) { registerCymbalTool(pi, { name: "cymbal_refs", label: "Cymbal Refs", description: "Find references, importers, or shallow impact for a symbol with `cymbal refs`.", parameters: RefsParams, buildArgs: buildRefsArgs, resolveRun: resolveRefsRun, promptSnippet: "cymbal_refs: Find references, importers, or impact for a symbol using Cymbal.", promptGuidelines: ["Use cymbal_refs before changing symbol references."] }); } // src/tools/search.ts import { defineTool as defineTool4 } from "@earendil-works/pi-coding-agent"; function withScopedFilter3(params, key, value) { const next = { ...params }; if (value === void 0) delete next[key]; else next[key] = value; return next; } function resolveSearchRun(params, cwd, fs) { return resolvePathFilterRun(params, cwd, { path: params.path, exclude: params.exclude, applyPath: (next, value) => withScopedFilter3(next, "path", value), applyExclude: (next, value) => withScopedFilter3(next, "exclude", value), fs, errorPrefix: "cymbal_search" }); } function noResultsSearchResult(error, format) { if (!(error instanceof ProcessError)) return void 0; if (isNoRepoDetected(error.result)) return void 0; const visibleOutput2 = [error.result.stdout, error.result.stderr].filter(Boolean).join(""); if (!/no results found/i.test(visibleOutput2)) return void 0; return { ...error.result, stdout: format === "json" ? '{"results":[]}\n' : visibleOutput2 || "No results found.\n", stderr: "", status: "not_found", diagnostics: visibleOutput2.split(/\r?\n/).map((line) => line.trim()).filter(Boolean) }; } function registerSearchTool(pi) { pi.registerTool( defineTool4({ name: "cymbal_search", label: "Cymbal Search", description: "Search symbols or full text with Cymbal using `cymbal search`.", parameters: SearchParams, promptSnippet: "cymbal_search: Search symbols or text with Cymbal. Prefer before broad grep for local code.", promptGuidelines: ["Use cymbal_search before broad local grep when looking for symbols or text in a repository."], ...cymbalToolRenderers("cymbal_search"), async execute(_toolCallId, params, signal, _onUpdate, ctx) { const run = resolveSearchRun(params, ctx.cwd); const args = buildSearchArgs(run.params); let result; try { result = await runCymbal({ cwd: run.cwd, args, signal }); } catch (error) { const noResults = noResultsSearchResult(error, params.format); if (!noResults) throw error; result = noResults; } return await formatCymbalOutput({ result, format: params.format ?? "agent" }); } }) ); } // src/tools/show.ts import { isAbsolute as isAbsolute2 } from "node:path"; import { defineTool as defineTool5 } from "@earendil-works/pi-coding-agent"; function showTargets2(params) { const targets = params.targets ?? []; if (params.target && targets.length) throw new Error("target and targets cannot be combined"); if (params.target) return [params.target]; if (targets.length) return targets; throw new Error("target or targets is required"); } function withScopedFilter4(params, key, value) { const next = { ...params }; if (value === void 0) delete next[key]; else next[key] = value; return next; } function targetRepoRoot(targets, cwd, fs) { const resolved = targets.map((target) => resolvePathOperand(target, cwd, "show", fs)); const absolutePaths = resolved.map(absolutePathTarget).filter((target) => Boolean(target)); if (!absolutePaths.length) return void 0; const roots = absolutePaths.map((target) => findRepoRoot(target, fs)).filter((root2) => Boolean(root2)); const root = roots[0]; if (!root) return void 0; const key = repoRootKey(root, fs); return roots.every((candidate) => repoRootKey(candidate, fs) === key) ? root : void 0; } function resolveShowRun(params, cwd, fs) { const targets = showTargets2(params); const targetRoot = targetRepoRoot(targets, cwd, fs); const targetRun = params.targets?.length ? resolveMultiPathRun(params, cwd, params.targets, (next, values) => ({ ...next, targets: values }), { fs, classification: "show" }) : resolveSinglePathRun(params, cwd, params.target, (next, target) => ({ ...next, target }), { fs, classification: "show" }); return resolvePathFilterRun(targetRun.params, cwd, { path: params.path, exclude: params.exclude, applyPath: (next, value) => withScopedFilter4(next, "path", value), applyExclude: (next, value) => withScopedFilter4(next, "exclude", value), targetRepoRoot: targetRoot, fs, errorPrefix: "cymbal_show" }); } function absolutePathTarget(target) { const path = splitPathRangeSuffix(normalizePathArg(target)).path; return isAbsolute2(path) ? path : void 0; } function validateJsonTargetScope(targets, cwd) { const resolved = targets.map((target) => resolvePathOperand(target, cwd, "show")); const absolutePaths = resolved.map(absolutePathTarget).filter((target) => Boolean(target)); if (!absolutePaths.length) return; const repoRoots = absolutePaths.map((target) => findRepoRoot(target)); const keys = new Set(repoRoots.filter((root) => Boolean(root)).map((root) => repoRootKey(root))); if (keys.size > 1 || repoRoots.some((root) => !root)) { throw new Error("cymbal_show cannot combine cross-repo or no-repo JSON targets; split them into separate calls."); } } function showJsonStatus(stdout) { let parsed; try { parsed = JSON.parse(stdout); } catch { return void 0; } if (!parsed || typeof parsed !== "object" || !("results" in parsed)) return void 0; const results = parsed.results; if (!results || typeof results !== "object" || Array.isArray(results)) return void 0; const entries = Object.values(results); if (!entries.length) return void 0; const errorCount = entries.filter((entry) => Boolean(entry && typeof entry === "object" && "error" in entry)).length; if (!errorCount) return void 0; return errorCount === entries.length ? "not_found" : "partial"; } function normalizeShowJsonResult(result, format) { if (format !== "json") return result; const status = showJsonStatus(result.stdout); return status ? { ...result, status } : result; } async function runShowWithParams(params, signal, ctx) { const runner = ctx.runCymbal ?? runCymbal; const run = resolveShowRun(params, ctx.cwd); const args = buildShowArgs(run.params); try { return normalizeShowJsonResult(await runner({ cwd: run.cwd, args, signal }), params.format); } catch (error) { const requestedTarget = run.params.target ?? run.params.targets?.join(" "); const recovered = recoverCymbalNotFound(error, { cwd: run.cwd, args, requestedTarget, format: params.format }); if (!recovered) throw error; return recovered; } } async function showWithParams(params, signal, ctx) { const result = await runShowWithParams(params, signal, ctx); return await formatCymbalOutput({ result, format: params.format ?? "agent" }); } async function showOneResult(target, params, signal, ctx) { return await runShowWithParams({ ...params, target, targets: void 0 }, signal, ctx); } function registerShowTool(pi) { pi.registerTool( defineTool5({ name: "cymbal_show", label: "Cymbal Show", description: "Read source by symbol, file, or file range with Cymbal using `cymbal show`.", parameters: ShowParams, promptSnippet: "cymbal_show: Read one or more symbols, files, or file ranges using Cymbal.", promptGuidelines: ["Use cymbal_show for targeted local reads by symbol or line range."], ...cymbalToolRenderers("cymbal_show"), async execute(_toolCallId, params, signal, _onUpdate, ctx) { const targets = showTargets2(params); if (params.format === "json" && params.targets) { validateJsonTargetScope(targets, ctx.cwd); } if (targets.length === 1) { const result = await showOneResult(targets[0], params, signal, ctx); return await formatCymbalOutput({ result, format: params.format ?? "agent" }); } if (params.format === "json") { return await showWithParams({ ...params, target: void 0, targets }, signal, ctx); } const items = []; for (const target of targets) { items.push({ target, result: await showOneResult(target, params, signal, ctx) }); } return await formatCymbalBatch({ items, format: "agent" }); } }) ); } // src/tools/structure.ts function registerStructureTool(pi) { registerCymbalTool(pi, { name: "cymbal_structure", label: "Cymbal Structure", description: "Summarize repository structure with Cymbal using `cymbal structure`.", parameters: StructureParams, buildArgs: buildStructureArgs, availabilityCommand: "structure", promptSnippet: "cymbal_structure: Summarize repository modules, files, and symbols using Cymbal structure.", promptGuidelines: ["Use cymbal_structure for a concise structural overview before deeper symbol navigation."] }); } // src/index.ts function cymbalExtension(pi) { registerMapTool(pi); registerStructureTool(pi); registerDiffTool(pi); registerIndexTool(pi); registerSearchTool(pi); registerOutlineTool(pi); registerShowTool(pi); registerRefsTool(pi); registerImpactTool(pi); registerImportersTool(pi); registerImplsTool(pi); registerChangedTool(pi); registerOptionalTools(pi); const hooks = registerCymbalHooks(pi); pi.on("session_start", async (_event, ctx) => { startSpillSession(); startCymbalSession(); await hooks.startSession(); await hooks.refreshReminder(ctx); }); pi.on("session_shutdown", async () => { const failures = []; abortCymbalSession(new DOMException("Cymbal session shut down", "AbortError")); stopSpillSession(); try { for (const result of await Promise.allSettled([hooks.shutdown(), waitForCymbalOperations(), waitForSpillFinalizers()])) { if (result.status === "rejected") failures.push(result.reason); } } finally { try { await cleanupSpills(); } catch (error) { failures.push(error); } finally { clearAvailabilityCache(); } } if (failures.length) throw new AggregateError(failures, "Cymbal session cleanup failed"); }); } export { cymbalExtension as default };