// Configurable tool prompt-surface overrides. // // Resolution order: package defaults ← global pi-ask config ← trusted project pi-ask config. // Project overrides are trust-gated; they require PI project trust and a PI-recognized // trust-requiring resource (e.g. .pi/settings.json). import { CONFIG_DIR_NAME, type ExtensionContext, hasTrustRequiringProjectResources, } from "@earendil-works/pi-coding-agent"; import { hasProjectConfig, loadPiAskConfigSectionForScope, type PiAskConfigOptions, } from "./config.ts"; // ── Public types ─────────────────────────────────────────────────────────── /** Model-facing text that PI adds to one registered tool's prompt surface. */ export interface SuiPiToolPromptSurface { description: string; promptSnippet: string; promptGuidelines: string[]; } export type ToolPromptSurfaceDiagnosticCode = | "invalidPromptSurfaceConfig" | "invalidPromptSurfaceField" | "invalidAskUserBehaviorConfig" | "projectPromptSurfaceIgnored"; export interface ToolPromptSurfaceDiagnostic { code: ToolPromptSurfaceDiagnosticCode; scope: "global" | "project"; section: string; toolName: string; message: string; } /** Options shared by every resolver that reads scoped pi-ask config sections. */ interface ScopedSectionReadOptions extends PiAskConfigOptions { section: string; toolName: string; ctx: Pick; } export interface ResolveToolPromptSurfaceOptions extends ScopedSectionReadOptions { defaults: SuiPiToolPromptSurface; } export interface ResolveToolPromptSurfaceResult { surface: SuiPiToolPromptSurface; diagnostics: ToolPromptSurfaceDiagnostic[]; } // ── Private types ────────────────────────────────────────────────────────── type PromptSurfaceField = keyof SuiPiToolPromptSurface; type PromptSurfaceScope = ToolPromptSurfaceDiagnostic["scope"]; const PROMPT_SURFACE_FIELDS = new Set(["description", "promptSnippet", "promptGuidelines"]); const PROMPT_SURFACE_CONFIG_KEYS = new Set([ "description", "promptSnippet", "promptGuidelines", "prependPromptGuidelines", "appendPromptGuidelines", "$reset", ]); const TOOL_ENTRY_KEYS = new Set(["promptSurface"]); // Section-level keys outside `tools`: `bell` is consumed by // resolveAskUserBehavior below, so it must not trip the unknown-key checks. const SECTION_EXTRA_KEYS = new Set(["bell"]); const PROMPT_SURFACE_DIAGNOSTICS_KEY = Symbol.for( "pi-ask/core/tool-prompt-surface/notified-diagnostics", ); // ── Resolution ───────────────────────────────────────────────────────────── /** Resolve a tool's model-facing prompt surface from defaults + pi-ask config overrides. */ export function resolveToolPromptSurface( options: ResolveToolPromptSurfaceOptions, ): ResolveToolPromptSurfaceResult { const { globalSection, projectSection, diagnostics } = loadScopedConfigSections(options); let surface = clonePromptSurface(options.defaults); surface = applyPromptSurfaceScope(surface, options, "global", globalSection, diagnostics); if (projectSection) { const projectPromptSurface = getPromptSurfaceConfig(projectSection, options.toolName, { diagnostics, options, scope: "project", }); if (projectPromptSurface) { // $reset at project scope restores fields to the state as resolved from global // scope (package defaults + global overrides), then the project's other // overrides apply on top. surface = applyPromptSurfaceConfig( surface, surface, projectPromptSurface, options, "project", diagnostics, ); } } return { surface, diagnostics }; } /** * Load one config section from both scopes with the shared trust gate: the * project config is read, parsed, and validated only when the project is * trusted — an untrusted project's config is never touched (no read, no parse, * no diagnostics beyond the refusal below). */ function loadScopedConfigSections(options: ScopedSectionReadOptions): { globalSection: Record | null; projectSection: Record | null; diagnostics: ToolPromptSurfaceDiagnostic[]; } { const diagnostics: ToolPromptSurfaceDiagnostic[] = []; const globalSection = loadPiAskConfigSectionForScope(options.section, options.ctx.cwd, { scope: "global", homeDir: options.homeDir, }); const hasTrustMarker = hasTrustRequiringProjectResources(options.ctx.cwd); const projectTrusted = options.ctx.isProjectTrusted(); let projectSection: Record | null = null; if (hasTrustMarker && projectTrusted) { projectSection = loadPiAskConfigSectionForScope(options.section, options.ctx.cwd, { scope: "project", homeDir: options.homeDir, }); } else if (hasProjectConfig(options.ctx.cwd)) { diagnostics.push({ code: "projectPromptSurfaceIgnored", scope: "project", section: options.section, toolName: options.toolName, message: hasTrustMarker ? `Project prompt-surface overrides for ${options.toolName} were ignored because the project is not trusted in PI.` : `Project prompt-surface overrides for ${options.toolName} were ignored because ${options.ctx.cwd}/${CONFIG_DIR_NAME}/pi-ask/config.json is not PI trust-gated. Add ${CONFIG_DIR_NAME}/settings.json and trust the project to enable them.`, }); } return { globalSection, projectSection, diagnostics }; } /** Notify prompt-surface diagnostics once per session/tool/diagnostic code. */ export function notifyToolPromptSurfaceDiagnostics( ctx: Pick, diagnostics: readonly ToolPromptSurfaceDiagnostic[], ): void { const globalRecord = globalThis as Record< symbol, { sessionId: string; notified: Set } | undefined >; const sessionId = ctx.sessionManager.getSessionId(); let state = globalRecord[PROMPT_SURFACE_DIAGNOSTICS_KEY]; // Key the dedup set per session runtime so it cannot grow unboundedly: when a // new session starts, the previous session's set is replaced. if (!state || state.sessionId !== sessionId) { state = { sessionId, notified: new Set() }; globalRecord[PROMPT_SURFACE_DIAGNOSTICS_KEY] = state; } for (const diagnostic of diagnostics) { const key = `${diagnostic.section}:${diagnostic.toolName}:${diagnostic.code}`; if (state.notified.has(key)) continue; state.notified.add(key); ctx.ui.notify(diagnostic.message, "warning"); } } // ── Behavior settings ────────────────────────────────────────────────────── /** Non-prompt extension behavior settings resolved from pi-ask config. */ export interface AskUserBehavior { /** Sound the terminal bell (BEL) when a form opens. */ bell: boolean; } export interface ResolveAskUserBehaviorOptions extends ScopedSectionReadOptions { defaults: AskUserBehavior; } export interface ResolveAskUserBehaviorResult { behavior: AskUserBehavior; diagnostics: ToolPromptSurfaceDiagnostic[]; } /** Resolve extension behavior settings (e.g. `bell`) from defaults + config. */ export function resolveAskUserBehavior( options: ResolveAskUserBehaviorOptions, ): ResolveAskUserBehaviorResult { const { globalSection, projectSection, diagnostics } = loadScopedConfigSections(options); let behavior = { ...options.defaults }; behavior = applyBellScope(behavior, options, "global", globalSection, diagnostics); if (projectSection) { behavior = applyBellScope(behavior, options, "project", projectSection, diagnostics); } return { behavior, diagnostics }; } function applyBellScope( current: AskUserBehavior, options: ResolveAskUserBehaviorOptions, scope: PromptSurfaceScope, sectionConfig: Record | null, diagnostics: ToolPromptSurfaceDiagnostic[], ): AskUserBehavior { if (!sectionConfig) return current; const value = sectionConfig.bell; if (value === undefined) return current; if (typeof value === "boolean") return { ...current, bell: value }; diagnostics.push({ code: "invalidAskUserBehaviorConfig", scope, section: options.section, toolName: options.toolName, message: `Invalid ${options.section} config: bell must be a boolean (got ${typeof value}).`, }); return current; } // ── Scope helpers ────────────────────────────────────────────────────────── function applyPromptSurfaceScope( current: SuiPiToolPromptSurface, options: ResolveToolPromptSurfaceOptions, scope: PromptSurfaceScope, sectionConfig: Record | null, diagnostics: ToolPromptSurfaceDiagnostic[], ): SuiPiToolPromptSurface { const promptSurface = getPromptSurfaceConfig(sectionConfig, options.toolName, { diagnostics, options, scope, }); if (!promptSurface) return current; return applyPromptSurfaceConfig( current, options.defaults, promptSurface, options, scope, diagnostics, ); } function applyPromptSurfaceConfig( current: SuiPiToolPromptSurface, resetBase: SuiPiToolPromptSurface, config: Record, options: ResolveToolPromptSurfaceOptions, scope: PromptSurfaceScope, diagnostics: ToolPromptSurfaceDiagnostic[], ): SuiPiToolPromptSurface { let next = clonePromptSurface(current); for (const field of getResetFields(config.$reset, options, scope, diagnostics)) { next = { ...next, [field]: clonePromptSurfaceField(resetBase[field]) }; } const description = getOptionalNonEmptyString( config.description, "description", options, scope, diagnostics, ); if (description !== undefined) next.description = description; const promptSnippet = getOptionalNonEmptyString( config.promptSnippet, "promptSnippet", options, scope, diagnostics, ); if (promptSnippet !== undefined) next.promptSnippet = promptSnippet; const promptGuidelines = getOptionalStringArray( config.promptGuidelines, "promptGuidelines", options, scope, diagnostics, ); if (promptGuidelines !== undefined) next.promptGuidelines = promptGuidelines; const prepend = getOptionalStringArray( config.prependPromptGuidelines, "prependPromptGuidelines", options, scope, diagnostics, ); if (prepend !== undefined) next.promptGuidelines = [...prepend, ...next.promptGuidelines]; const append = getOptionalStringArray( config.appendPromptGuidelines, "appendPromptGuidelines", options, scope, diagnostics, ); if (append !== undefined) next.promptGuidelines = [...next.promptGuidelines, ...append]; return next; } // ── Config extraction ────────────────────────────────────────────────────── function getPromptSurfaceConfig( sectionConfig: Record | null, toolName: string, deps: { diagnostics: ToolPromptSurfaceDiagnostic[]; options: ResolveToolPromptSurfaceOptions; scope: PromptSurfaceScope; }, ): Record | null { if (!sectionConfig) return null; pushUnknownKeyDiagnostics( deps, deps.options.section, new Set(["tools", ...SECTION_EXTRA_KEYS]), Object.keys(sectionConfig), ); if (sectionConfig.tools === undefined) return null; if (!isRecord(sectionConfig.tools)) { pushInvalidConfig(deps, "tools must be an object."); return null; } pushUnknownKeyDiagnostics( deps, `${deps.options.section}.tools`, new Set([toolName]), Object.keys(sectionConfig.tools), ); const toolConfig = sectionConfig.tools[toolName]; if (toolConfig === undefined) return null; if (!isRecord(toolConfig)) { pushInvalidConfig(deps, `tools.${toolName} must be an object.`); return null; } pushUnknownKeyDiagnostics( deps, `${deps.options.section}.tools.${toolName}`, TOOL_ENTRY_KEYS, Object.keys(toolConfig), ); if (toolConfig.promptSurface === undefined) return null; if (!isRecord(toolConfig.promptSurface)) { pushInvalidConfig(deps, `tools.${toolName}.promptSurface must be an object.`); return null; } for (const key of Object.keys(toolConfig.promptSurface)) { if (PROMPT_SURFACE_CONFIG_KEYS.has(key)) continue; pushInvalidField( deps.options, deps.scope, deps.diagnostics, key, `unknown field; expected one of: ${formatKnownKeys(PROMPT_SURFACE_CONFIG_KEYS)}.`, ); } return toolConfig.promptSurface; } // ── Field validation ─────────────────────────────────────────────────────── function getResetFields( value: unknown, options: ResolveToolPromptSurfaceOptions, scope: PromptSurfaceScope, diagnostics: ToolPromptSurfaceDiagnostic[], ): PromptSurfaceField[] { if (value === undefined) return []; if (!Array.isArray(value)) { pushInvalidField(options, scope, diagnostics, "$reset", "must be an array."); return []; } const fields: PromptSurfaceField[] = []; for (const item of value) { if (typeof item === "string" && PROMPT_SURFACE_FIELDS.has(item)) { fields.push(item as PromptSurfaceField); } else { pushInvalidField( options, scope, diagnostics, "$reset", `contains unsupported field ${JSON.stringify(item)}.`, ); } } return fields; } function getOptionalNonEmptyString( value: unknown, field: string, options: ResolveToolPromptSurfaceOptions, scope: PromptSurfaceScope, diagnostics: ToolPromptSurfaceDiagnostic[], ): string | undefined { if (value === undefined) return undefined; if (typeof value === "string" && value.length > 0) return value; pushInvalidField(options, scope, diagnostics, field, "must be a non-empty string."); return undefined; } function getOptionalStringArray( value: unknown, field: string, options: ResolveToolPromptSurfaceOptions, scope: PromptSurfaceScope, diagnostics: ToolPromptSurfaceDiagnostic[], ): string[] | undefined { if (value === undefined) return undefined; if (Array.isArray(value) && value.every((item) => typeof item === "string")) { return [...value]; } pushInvalidField(options, scope, diagnostics, field, "must be an array of strings."); return undefined; } // ── Diagnostics ──────────────────────────────────────────────────────────── function pushUnknownKeyDiagnostics( deps: { diagnostics: ToolPromptSurfaceDiagnostic[]; options: ResolveToolPromptSurfaceOptions; scope: PromptSurfaceScope; }, location: string, knownKeys: ReadonlySet, keys: readonly string[], ): void { for (const key of keys) { if (knownKeys.has(key)) continue; pushInvalidConfig( deps, `Unknown key ${JSON.stringify(key)} in ${location}; expected one of: ${formatKnownKeys(knownKeys)}.`, ); } } function formatKnownKeys(keys: ReadonlySet): string { return [...keys].map((key) => JSON.stringify(key)).join(", "); } function pushInvalidConfig( deps: { diagnostics: ToolPromptSurfaceDiagnostic[]; options: ResolveToolPromptSurfaceOptions; scope: PromptSurfaceScope; }, detail: string, ): void { deps.diagnostics.push({ code: "invalidPromptSurfaceConfig", scope: deps.scope, section: deps.options.section, toolName: deps.options.toolName, message: `Invalid prompt-surface config for ${deps.options.section}.${deps.options.toolName}: ${detail}`, }); } function pushInvalidField( options: ResolveToolPromptSurfaceOptions, scope: PromptSurfaceScope, diagnostics: ToolPromptSurfaceDiagnostic[], field: string, detail: string, ): void { diagnostics.push({ code: "invalidPromptSurfaceField", scope, section: options.section, toolName: options.toolName, message: `Invalid prompt-surface field ${field} for ${options.section}.${options.toolName}: ${detail}`, }); } // ── Cloning ──────────────────────────────────────────────────────────────── function clonePromptSurface(surface: SuiPiToolPromptSurface): SuiPiToolPromptSurface { return { description: surface.description, promptSnippet: surface.promptSnippet, promptGuidelines: [...surface.promptGuidelines], }; } function clonePromptSurfaceField(value: T): T { return (Array.isArray(value) ? [...value] : value) as T; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); }