/** * Runtime promotion for pi-ast-grep. * * A bounded guidance block appended to the system prompt (via * `before_agent_start`) so models reach for the ast-grep tools at the right * moments. Toggleable through `.pi/ast-grep.json`: * * { "promotion": { "enabled": false } } * * The default is enabled. The block is only appended when at least one * ast-grep tool is active in the session, so it never advertises tools the * model cannot call. */ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent"; import { TOOL_NAMES } from "./tools.ts"; export const PROMOTION_DEFAULT_ENABLED = true; export const MAX_CONFIG_CHARS = 16 * 1024; /** The bounded guidance block; keep it a few lines and pointer-only. */ export function promotionBlock(): string { return [ "[pi-ast-grep] Structural code tools are active:", "- ast_grep_outline: map a file or directory (items/view/match) before reading it.", "- ast_grep_run: structural search (pattern/kind) instead of text grep when shape matters.", "- ast_grep_scan: relational/composite rules (inside, has, any, not; stopBy: end) or project rules.", "- ast_grep_debug_query: when a pattern fails to match, dump node kinds first.", "- ast_grep_rewrite: preview with apply=false, then apply=true for the same arguments.", "- ast_grep_languages: check supported language aliases before run/rewrite/scan.", ].join("\n"); } /** * Parses the `promotion.enabled` flag from a raw config value. Non-object * shapes, wrong types, and unknown keys fall back to the default rather than * failing the extension load; a strictly invalid structure is treated as * "not configured" so a malformed file cannot disable promotion silently. */ export function parsePromotionEnabled(value: unknown): boolean { if (value === null || typeof value !== "object" || Array.isArray(value)) { return PROMOTION_DEFAULT_ENABLED; } const promotion = (value as Record).promotion; if (promotion === null || typeof promotion !== "object" || Array.isArray(promotion)) { return PROMOTION_DEFAULT_ENABLED; } const enabled = (promotion as Record).enabled; return typeof enabled === "boolean" ? enabled : PROMOTION_DEFAULT_ENABLED; } function readConfigText(cwd: string): string | undefined { const path = join(cwd, CONFIG_DIR_NAME, "ast-grep.json"); try { return readFileSync(path, "utf8"); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") { throw error; } } } /** * Loads the promotion toggle for the session. Reads `.pi/ast-grep.json` from * the working directory; untrusted projects and missing/invalid files use the * default (enabled). */ export function loadPromotionEnabled(cwd: string, projectTrusted: boolean): boolean { if (!projectTrusted) { return PROMOTION_DEFAULT_ENABLED; } const text = readConfigText(cwd); if (text === undefined) { return PROMOTION_DEFAULT_ENABLED; } if (text.length > MAX_CONFIG_CHARS) { return PROMOTION_DEFAULT_ENABLED; } try { return parsePromotionEnabled(JSON.parse(text)); } catch { return PROMOTION_DEFAULT_ENABLED; } } /** True when at least one ast-grep tool is among the active tool names. */ export function hasActiveAstGrepTools(activeTools: readonly string[] | undefined): boolean { if (activeTools === undefined) { return false; } const active = new Set(activeTools); return TOOL_NAMES.some((name) => active.has(name)); }