import { RESERVED_TOOL_NAMES } from "../bridge/reserved.ts"; /** * JSON Schema → TypeScript declaration renderer for the eval prompt. * * Port of `render_json_schema_to_typescript` from the Codex codebase * (`codex-rs/code-mode-protocol/src/description.rs`). The algorithm is a * rendering rule; this port is original code and is MIT-compatible. See * `renderToolDeclarations` for the pi-specific `declare const tools` shape * (pi tools are all function-style: `tool.(args)`). * * Deliberate deviations from the Codex source: * - Output types are always `unknown` (pi tools return `AgentToolResult`; no * MCP `CallToolResult` preamble is rendered). * - Property names that are reserved words are quoted (Codex quotes only on * invalid identifier characters; reserved words render as invalid TS). * - `renderToolDeclarations` bounds the output at 2000 characters and appends * a truncation note (the Codex description is not bounded). */ const MAX_DECLARATIONS_LENGTH = 2000; const TRUNCATION_NOTE = "(tool declarations truncated; use tool_schema() for full schemas)"; /** * Renders a JSON Schema value as a TypeScript type. Unknown shapes render as * `unknown`; boolean schemas render as `unknown` (true) or `never` (false). */ export function jsonSchemaToTypeScript(schema: unknown): string { if (typeof schema === "boolean") { return schema ? "unknown" : "never"; } if (schema === null || typeof schema !== "object" || Array.isArray(schema)) { return "unknown"; } const map = schema as Record; if ("const" in map) { return renderLiteral(map.const); } if (Array.isArray(map.enum) && map.enum.length > 0) { return map.enum.map(renderLiteral).join(" | "); } for (const key of ["anyOf", "oneOf"] as const) { if (Array.isArray(map[key]) && map[key].length > 0) { return map[key].map(jsonSchemaToTypeScript).join(" | "); } } if (Array.isArray(map.allOf) && map.allOf.length > 0) { return map.allOf.map(jsonSchemaToTypeScript).join(" & "); } if (typeof map.type === "string") { return renderTypeKeyword(map, map.type); } if (Array.isArray(map.type)) { const rendered = map.type .filter((t): t is string => typeof t === "string") .map((t) => renderTypeKeyword(map, t)); if (rendered.length > 0) { return rendered.join(" | "); } } if ( "properties" in map || "additionalProperties" in map || "required" in map ) { return renderObject(map); } if ("items" in map || "prefixItems" in map) { return renderArray(map); } return "unknown"; } function renderTypeKeyword( map: Record, schemaType: string ): string { switch (schemaType) { case "string": return "string"; case "number": case "integer": return "number"; case "boolean": return "boolean"; case "null": return "null"; case "array": return renderArray(map); case "object": return renderObject(map); default: return "unknown"; } } function renderArray(map: Record): string { if ("items" in map) { return `Array<${jsonSchemaToTypeScript(map.items)}>`; } if (Array.isArray(map.prefixItems) && map.prefixItems.length > 0) { return `[${map.prefixItems.map(jsonSchemaToTypeScript).join(", ")}]`; } return "unknown[]"; } function renderObject(map: Record): string { const required = new Set(requiredNames(map)); const properties = objectProperties(map); const sortedNames = [...properties.keys()].sort(); const hasDescriptions = sortedNames.some((name) => hasPropertyDescription(properties.get(name)) ); if (hasDescriptions) { const lines = ["{"]; for (const name of sortedNames) { const value = properties.get(name); const description = typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record).description : undefined; if (typeof description === "string" && description.length > 0) { for (const line of description.split("\n")) { const trimmed = line.trim(); if (trimmed !== "") { lines.push(` // ${trimmed}`); } } } lines.push(` ${renderProperty(name, value, required)}`); } appendAdditionalProperties(lines, map, sortedNames.length, " "); lines.push("}"); return lines.join("\n"); } const inline = sortedNames.map((name) => renderProperty(name, properties.get(name), required) ); appendAdditionalProperties(inline, map, sortedNames.length, ""); if (inline.length === 0) { return "{}"; } return `{ ${inline.join(" ")} }`; } function requiredNames(map: Record): readonly string[] { if (!Array.isArray(map.required)) { return []; } return map.required.filter( (name): name is string => typeof name === "string" ); } function objectProperties(map: Record): Map { const raw = map.properties; if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { return new Map(); } return new Map(Object.entries(raw as Record)); } function hasPropertyDescription(value: unknown): boolean { if (value === null || typeof value !== "object" || Array.isArray(value)) { return false; } const description = (value as Record).description; return typeof description === "string" && description.length > 0; } function renderProperty( name: string, value: unknown, required: ReadonlySet ): string { const optional = required.has(name) ? "" : "?"; return `${renderPropertyName(name)}${optional}: ${jsonSchemaToTypeScript(value)};`; } function appendAdditionalProperties( lines: string[], map: Record, propertyCount: number, prefix: string ): void { if ("additionalProperties" in map) { const value = map.additionalProperties; if (value === false) { return; } const propertyType = value === true ? "unknown" : jsonSchemaToTypeScript(value); lines.push(`${prefix}[key: string]: ${propertyType};`); } else if (propertyCount === 0) { lines.push(`${prefix}[key: string]: unknown;`); } } /** * Renders a property name, quoting it when it is not a valid TypeScript * property identifier (invalid characters or a reserved word). */ export function renderPropertyName(name: string): string { if (isValidIdentifier(name) && !RESERVED_WORDS.has(name)) { return name; } return JSON.stringify(name); } export const RESERVED_WORDS = new Set([ "break", "case", "catch", "class", "const", "continue", "debugger", "default", "delete", "do", "else", "enum", "export", "extends", "false", "finally", "for", "function", "if", "import", "in", "instanceof", "new", "null", "return", "super", "switch", "this", "throw", "true", "try", "typeof", "var", "void", "while", "with", "implements", "interface", "let", "package", "private", "protected", "public", "static", "yield", "await", "type", ]); /** ASCII identifier check matching `normalize_code_mode_identifier` in Codex. */ export function isValidIdentifier(name: string): boolean { if (name.length === 0) { return false; } const [first, ...rest] = name; if (first !== "_" && first !== "$" && !/[a-zA-Z]/.test(first)) { return false; } return rest.every((ch) => ch === "_" || ch === "$" || /[a-zA-Z0-9]/.test(ch)); } function renderLiteral(value: unknown): string { if (value === undefined) { return "unknown"; } const rendered = JSON.stringify(value); return rendered === undefined ? "unknown" : rendered; } export interface ToolDeclarationInput { readonly name: string; readonly parameters?: unknown; } export interface ToolDeclarationPlan { readonly declaredNames: readonly string[]; readonly omittedNames: readonly string[]; readonly text: string; } /** * Renders typed declarations for the active tools, e.g.: * * ```ts * declare const tools: { * read(path: { offset?: number; limit?: number; }): Promise; * }; * ``` * * The output is bounded at 2000 characters; when the bound is hit, a note * points at `tool_schema()` for full schemas. */ export function renderToolDeclarations( tools: readonly ToolDeclarationInput[] ): string { const plan = planToolDeclarations(tools); const notes: string[] = []; if (plan.omittedNames.length > 0) { notes.push(TRUNCATION_NOTE); } if ( tools.some( (tool) => tool.name.length > 0 && RESERVED_TOOL_NAMES.has(tool.name) ) ) { notes.push("(reserved kernel bridge tool names omitted)"); } return notes.length > 0 ? `${plan.text}\n${notes.join("\n")}` : plan.text; } /** * Selects the declarations that fit in the prompt bound. Snapshot capture uses * this same implementation, so its omitted names exactly match the rendered * declarations (including quoting and parameter-schema expansion). */ export function planToolDeclarations( tools: readonly ToolDeclarationInput[] ): ToolDeclarationPlan { let body = "declare const tools: {\n"; const declaredNames: string[] = []; const omittedNames: string[] = []; const seen = new Set(); let truncated = false; for (const tool of tools) { // Keep this defensive dedupe in sync with the tool-listing-collisions // contract test, even though pi's normal registry listing is unique. if (seen.has(tool.name)) { continue; } seen.add(tool.name); if (tool.name.length === 0 || RESERVED_TOOL_NAMES.has(tool.name)) { continue; } if (truncated) { omittedNames.push(tool.name); continue; } const argumentType = tool.parameters === undefined ? "unknown" : jsonSchemaToTypeScript(tool.parameters); const declaration = ` ${renderPropertyName(tool.name)}(args: ${argumentType}): Promise;\n`; if (body.length + declaration.length + 3 > MAX_DECLARATIONS_LENGTH) { truncated = true; omittedNames.push(tool.name); continue; } body += declaration; declaredNames.push(tool.name); } return { declaredNames, omittedNames, text: `${body}};` }; }