/** * The single definition of the tool report: every fact a tool result can state, * in the order the model reads it, each error code with its facts and recovery, * and the recovery that follows from a successful result's facts. * * One report describes every result of every tool, success or failure. A * failure report is the entire result; a success report follows the tool's * content payload (read rows, grep rows). Each field has one * owning layer, so no fact can be stated twice. report.ts * omits the call's tool and path from result text; render.ts uses the full report for the TUI. * * Types derive from these schemas. src/core imports only types from this module, * so engines stay independent of Pi. Workers do not load TypeBox; the host validates their replies. * * @module pi-hashline-edit/pi */ import { Type, type Static, type TObject, type TProperties } from "typebox"; const count = (description: string) => Type.Integer({ minimum: 1, description }); const opt = Type.Optional; /** A failure reported beneath the step that failed: our code or Node's errno, its own message, and its causes. */ export const CAUSE_SCHEMA = Type.Cyclic( { Cause: Type.Object( { error: opt(Type.String({ description: "Error code of a classified cause." })), code: opt(Type.String({ description: "Node errno code, such as EACCES." })), message: Type.String(), cause: opt(Type.Ref("Cause")), }, { additionalProperties: false }, ), }, "Cause", ); export type Cause = Static; const PUBLICATION_SCHEMA = Type.Union([ Type.Literal("NOT_PUBLISHED"), Type.Literal("PUBLISHED"), Type.Literal("UNKNOWN"), ]); export type PublicationStatus = Static; const FRESHNESS_SCHEMA = Type.Union([ Type.Literal("unchanged"), Type.Literal("changed"), Type.Literal("missing"), Type.Literal("unknown"), ]); export type Freshness = Static; /** A then_run command's final status; `skipped` never started, `cancelled` may have. */ const COMMAND_STATUS_SCHEMA = Type.Union([ Type.Literal("succeeded"), Type.Literal("failed"), Type.Literal("timeout"), Type.Literal("cancelled"), Type.Literal("skipped"), ]); export type CommandStatus = Static; /** A then_run command's session tool result, as the TUI renders it. */ const COMMAND_RESULT_SCHEMA = Type.Object( { content: Type.Array( Type.Union([ Type.Object( { type: Type.Literal("text"), text: Type.String(), textSignature: opt(Type.String()), }, { additionalProperties: false }, ), Type.Object( { type: Type.Literal("image"), data: Type.String(), mimeType: Type.String() }, { additionalProperties: false }, ), ]), ), details: opt(Type.Unknown()), }, { additionalProperties: false }, ); export type CommandResult = Static; /** Fusion updates cross RPC and transcript boundaries before a final tool report exists. */ export const ACTION_FUSION_PROGRESS_SCHEMA = Type.Object( { toolCallId: Type.String(), commandText: Type.String(), command: Type.Union([COMMAND_STATUS_SCHEMA, Type.Literal("waiting"), Type.Literal("running")]), freshness: FRESHNESS_SCHEMA, result: opt(COMMAND_RESULT_SCHEMA), mutationCompleted: Type.Boolean(), timing: opt( Type.Object( { timeoutSeconds: Type.Number({ exclusiveMinimum: 0 }), remainingSeconds: Type.Integer({ minimum: 0 }), }, { additionalProperties: false }, ), ), }, { additionalProperties: false }, ); export type ActionFusionProgress = Static; const ANCHOR_FAILURE_SCHEMA = Type.Object( { field: Type.String(), action: Type.String(), cited: Type.String(), result: Type.Union([ Type.Literal("shifted"), Type.Literal("ambiguous"), Type.Literal("unresolved"), ]), candidate: opt(Type.String()), content: opt(Type.String()), candidates: opt(Type.Array(Type.String())), omittedCandidates: opt(Type.Integer({ minimum: 1 })), observed: opt(Type.String()), notShown: opt( Type.String({ description: "Why content (shifted) or observed (unresolved) is absent." }), ), }, { additionalProperties: false }, ); export type AnchorFailureFact = Static; const ARGUMENT_ISSUE_SCHEMA = Type.Object( { field: Type.String({ description: "Path of the field in Pi's prepared arguments; `$` for none.", }), reason: Type.String({ description: "What is wrong with the value; facts only." }), fix: opt( Type.String({ description: "How to correct it, when the reason alone does not say." }), ), }, { additionalProperties: false }, ); export type ArgumentIssue = Static; /** * Every fact a report can carry beside the envelope, in output order: failure * facts first, then each tool's outcome, freshness, and the command. */ const FACT_FIELDS = { // Argument rejection (argument-error.ts). issues: Type.Array(ARGUMENT_ISSUE_SCHEMA), omittedIssues: count("Issues omitted from the byte budget."), schemaLimited: Type.Literal(true, { description: "A native diagnostic allowance was reached; more issues may exist.", }), missing: Type.Array(Type.String(), { description: "Resolved paths that do not exist; shows where a relative path led.", }), // Anchor verification (failure-context.ts). failures: Type.Array(ANCHOR_FAILURE_SCHEMA), omittedFailures: count("Failures omitted from the byte budget."), candidateNeighborhoods: Type.Array( Type.Object( { lines: Type.String(), rows: Type.Array(Type.String()) }, { additionalProperties: false }, ), ), omittedNeighborhoodRows: count("Neighborhood rows omitted from the byte budget."), // Overlap (core/apply.ts, core/replace.ts). conflict: Type.Array(Type.String(), { description: "The first conflicting pair of inputs found; more conflicts may exist.", }), // Replacement rules (core/replace.ts). unmatched: Type.Array(Type.String(), { description: "Rules that match nothing." }), line: Type.Integer({ minimum: 1, description: "Line where the conflicting match starts." }), // Regex workers (regex-worker.ts). timeoutMs: Type.Integer({ minimum: 1 }), exitCode: Type.Union([Type.Integer(), Type.Null()]), // read outcome; `outputLimit` below is shared with grep. pagination: Type.Object( { start: Type.Integer(), end: Type.Integer(), totalLines: Type.Integer(), nextOffset: Type.Integer(), }, { additionalProperties: false }, ), lineTooLong: Type.Object( { line: Type.Integer(), limit: Type.String() }, { additionalProperties: false }, ), // grep outcome; `matches` is also replace's match count. matches: Type.Integer({ minimum: 0 }), files: Type.Integer({ minimum: 0 }), scanned: Type.Union([Type.Literal("files"), Type.Literal("all")], { description: "Without line candidates, the files searched whole: those containing text every match needs, or every file in scope.", }), matchLimit: Type.Integer({ minimum: 1, description: "The match limit that stopped the search." }), scanLimit: Type.String({ description: "Bytes of files read when every file was a candidate; later files were not searched.", }), outputLimit: Type.String({ description: "The output byte budget that cut the result." }), linePreviewLimit: Type.Integer({ description: "UTF-16 units kept in a cut line preview." }), invalidUtf8: Type.Array(Type.String(), { description: "Files shown as plain previews; their rows are not edit anchors.", }), diagnostics: Type.String({ description: "Search diagnostics; results cover confirmed matches." }), literalPipe: Type.Literal(true, { description: "A literal pattern contains |, which matches only the | character.", }), // Mutation outcome. created: Type.Literal(true), // Commit-layer observation after publication. freshness: FRESHNESS_SCHEMA, freshnessError: CAUSE_SCHEMA, // Action Fusion. then_run: Type.Object( { status: COMMAND_STATUS_SCHEMA, output: opt(Type.String()) }, { additionalProperties: false }, ), progressError: CAUSE_SCHEMA, } satisfies TProperties; type FactName = keyof typeof FACT_FIELDS; /** Facts of one code: the named fields, each optional unless listed as required. */ function facts( required: R, optional?: O, ) { const properties: TProperties = {}; for (const name of required) properties[name] = FACT_FIELDS[name]; for (const name of optional ?? []) properties[name] = opt(FACT_FIELDS[name]); // A single mapped property set preserves required keys in TypeBox's static inference. return Type.Object( properties as { [K in R[number] | O[number]]: K extends R[number] ? (typeof FACT_FIELDS)[K] : ReturnType>; }, { additionalProperties: false }, ); } const NONE = facts([]); /** * Each error code: its facts and its recovery. `next.default` is the code's * recovery; other keys are the only alternatives a throw site may select. * Once a mutation reached or may have reached the file, PUBLICATION_NEXT replaces them. */ export const ERROR_DEFINITIONS = { INVALID_ARGUMENTS: { facts: facts(["issues"], ["omittedIssues", "schemaLimited"]) }, OPERATION_ABORTED: { facts: NONE }, PATH_NOT_FOUND: { facts: facts([], ["missing"]), next: { default: "Check the path.", useCreate: 'Use mode "create" to create a new file.', useGlob: "Use an existing directory as path and a filename wildcard as glob.", }, }, FILESYSTEM_ERROR: { facts: NONE }, FILE_CHANGED: { facts: NONE, next: { default: "Retry the call." }, }, UNSUPPORTED_ENCODING: { facts: NONE }, UNSUPPORTED_TEXT: { facts: NONE }, INVALID_UNICODE: { facts: NONE }, ANCHOR_MISMATCH: { facts: facts( ["failures"], ["omittedFailures", "candidateNeighborhoods", "omittedNeighborhoodRows"], ), next: { default: "Confirm a candidate or observed anchor is the intended target before reusing it; otherwise use read or grep.", }, }, OVERLAPPING_EDITS: { facts: facts(["conflict"]), next: { default: "Merge them into one edit per range or position.", }, }, NO_MATCH: { facts: facts(["unmatched"]), next: { default: "Verify the target text with read or grep; check case sensitivity or regex flags if applicable.", }, }, OVERLAPPING_MATCHES: { facts: facts(["conflict", "line"]), next: { default: "Change the conflicting rules so their matches do not overlap, or merge them into one rule.", }, }, REGEX_TIMEOUT: { facts: facts(["timeoutMs"]) }, REGEX_WORKER_FAILED: { facts: facts([], ["exitCode"]) }, TARGET_EXISTS: { facts: NONE, next: { default: 'Check the path and read the existing file. Use mode "overwrite" only if replacing that file is intended.', }, }, NOT_REGULAR_FILE: { facts: NONE, next: { default: "Use the path of a regular file." }, }, MULTIPLE_HARD_LINKS: { facts: NONE }, SYMLINK_UNRESOLVED: { facts: NONE }, PUBLISH_FAILED: { facts: NONE }, POST_PROCESS_FAILED: { facts: NONE }, SEARCH_INCOMPLETE: { facts: facts(["diagnostics"], ["scanned", "scanLimit"]) }, RIPGREP_FAILED: { facts: NONE }, UNSUPPORTED_PATH: { facts: NONE }, UNCLASSIFIED: { facts: NONE }, } as const satisfies Record< string, { facts: TObject; next?: { default: string } & Record } >; type Definitions = typeof ERROR_DEFINITIONS; export type ErrorCode = keyof Definitions; export const ERROR_CODES = Object.keys(ERROR_DEFINITIONS) as ErrorCode[]; /** The facts a failure of `code` carries. */ export type ErrorFacts = C extends ErrorCode ? keyof Static extends never ? Readonly> : Static : never; /** Recovery alternatives a throw site may select for `code`. */ export type NextVariant = Definitions[C] extends { next: infer N; } ? Exclude : never; /** The guideline that ranks a report's own `next` above the general guidelines, which never restate it. */ export const NEXT_GUIDELINE = "When a tool report includes next, follow it for that result; it takes precedence over these guidelines."; /** Text shared by success and failure: a file whose current anchors are unknown must be read first. */ export const REREAD = "Read the file before further edits."; /** Once a mutation reached or may have reached the file, a retry could apply it twice. */ export const PUBLICATION_NEXT = { PUBLISHED: `The change is saved; do not repeat it. ${REREAD}`, UNKNOWN: "The change may or may not be saved. Read the file, and repeat the change only if it is missing.", } as const satisfies Record, string>; /** One fact's recovery; `except` is a value of that fact which needs none. */ type OutcomeRule = { [F in FactName]: readonly [fact: F, next: string, except?: Static<(typeof FACT_FIELDS)[F]>]; }[FactName]; /** * Recovery after a successful result, by the first fact present in this order. * A fact absent from this list, or holding the value its rule excepts, needs no recovery beyond itself. */ export const OUTCOME_NEXT: readonly OutcomeRule[] = [ // A file that no longer exists has no anchors to refresh. ["freshness", REREAD, "missing"], [ "lineTooLong", "Inspect the line in chunks with bash, or use replace for a known literal or regex change; a smaller limit cannot split a line.", ], ["pagination", "If you need more lines, set offset to pagination.nextOffset."], // Ahead of matchLimit: once the byte budget cuts the output, raising limit shows nothing more. ["outputLimit", "Narrow path, glob, or pattern to see the omitted output."], ["matchLimit", "If you need more matches, raise limit to include rows after the last one shown."], [ "literalPipe", "If you meant alternatives, give each as its own pattern item. Otherwise, keep literal matching.", ], [ "scanLimit", "Narrow path or glob, or add text every match must contain, to search the remaining files.", ], ["linePreviewLimit", "Read the full line before rewriting it from a partial preview."], ]; /** The report envelope and facts, in output order; `next` is always last. */ export const REPORT_SCHEMA = Type.Object({ error: opt(Type.Union(ERROR_CODES.map((code) => Type.Literal(code)))), tool: Type.String(), path: opt(Type.Union([Type.String(), Type.Array(Type.String())])), publication: opt(PUBLICATION_SCHEMA), message: opt(Type.String()), cause: opt(CAUSE_SCHEMA), ...(Object.fromEntries( Object.entries(FACT_FIELDS).map(([name, schema]) => [name, opt(schema)]), ) as { [K in FactName]: ReturnType> }), next: opt(Type.String()), }); type ReportFields = Omit, "error"> & { error?: ErrorCode }; const SUCCESS_DEFINITIONS = { read: facts([], ["pagination", "outputLimit", "lineTooLong"]), grep: facts( [], [ "matches", "files", "scanned", "matchLimit", "scanLimit", "outputLimit", "linePreviewLimit", "invalidUtf8", "diagnostics", "literalPipe", ], ), edit: facts([]), replace: facts([], ["matches"]), write: facts([], ["created"]), } as const; export type ReportTool = keyof typeof SUCCESS_DEFINITIONS; /** Tools whose reports carry publication, freshness, and the then_run outcome. */ const MUTATION_TOOLS = ["edit", "replace", "write"] as const satisfies readonly ReportTool[]; export type MutationToolName = (typeof MUTATION_TOOLS)[number]; /** Argument rejections may omit message; Pi supplies none for them. */ const MESSAGE_OPTIONAL = "INVALID_ARGUMENTS" satisfies ErrorCode; const TOOL_SCHEMA = Type.Union(Object.keys(SUCCESS_DEFINITIONS).map((tool) => Type.Literal(tool))); const COMMON_WIRE_FIELDS = { tool: TOOL_SCHEMA, path: REPORT_SCHEMA.properties.path, next: REPORT_SCHEMA.properties.next, }; const MUTATION_WIRE_FIELDS = { publication: REPORT_SCHEMA.properties.publication, freshness: REPORT_SCHEMA.properties.freshness, freshnessError: REPORT_SCHEMA.properties.freshnessError, then_run: REPORT_SCHEMA.properties.then_run, progressError: REPORT_SCHEMA.properties.progressError, }; const FAILURE_SCHEMAS = ERROR_CODES.map((code) => Type.Object( { ...COMMON_WIRE_FIELDS, ...MUTATION_WIRE_FIELDS, cause: REPORT_SCHEMA.properties.cause, error: Type.Literal(code), message: code === MESSAGE_OPTIONAL ? opt(Type.String()) : Type.String(), ...ERROR_DEFINITIONS[code].facts.properties, }, { additionalProperties: false }, ), ); const SUCCESS_SCHEMAS = Object.entries(SUCCESS_DEFINITIONS).map(([tool, schema]) => Type.Object( { ...COMMON_WIRE_FIELDS, tool: Type.Literal(tool), ...((MUTATION_TOOLS as readonly string[]).includes(tool) ? MUTATION_WIRE_FIELDS : {}), ...schema.properties, }, { additionalProperties: false }, ), ); /** Closed records after the originating call supplies the tool; failure facts remain code-specific. */ export const WIRE_REPORT_SCHEMA = Type.Union([...FAILURE_SCHEMAS, ...SUCCESS_SCHEMAS]); // Static counterparts of the per-tool and per-code wire schemas above, built from the same fields. type Fields

= Static>; type Envelope = Omit, "tool">; type MutationFields = Fields; /** Facts owned by one successful tool, derived from its wire schema. */ export type SuccessFacts = Static<(typeof SUCCESS_DEFINITIONS)[T]>; // Absent fields stay readable as undefined without admitting another variant's facts. type ReportVariant = T & { [K in Exclude]?: never }; /** A success report a tool may produce: its own facts, and mutation facts only for mutation tools. */ export type SuccessReport = { [T in ReportTool]: ReportVariant< Envelope & { tool: T } & (T extends MutationToolName ? MutationFields : unknown) & SuccessFacts >; }[ReportTool]; /** A failure report: its code's facts, with message required except where MESSAGE_OPTIONAL allows. */ export type FailureReport = { [C in ErrorCode]: ReportVariant< Envelope & { tool: ReportTool; error: C } & MutationFields & Pick & (C extends typeof MESSAGE_OPTIONAL ? { message?: string } : { message: string }) & Static >; }[ErrorCode]; /** A checked report retains the tool or error code that determines its facts. */ export type Report = SuccessReport | FailureReport;