import { fitsClassifierRequest, MAX_CLASSIFIER_IMAGES, MAX_IMAGE_BYTES, SAI_IMAGE_MIME_TYPES, saiImageRejectionMessage, validateSaiImages, type SaiImageMimeType, type SaiImageRejection, } from "./classifier-context.ts"; /** * ask-tool — the agent-callable `ask_sai` tool. * * The host does not decide when Sai runs here. The agent assembles one state * from its own prose (`state`), code read from `paths`, and the output of one * `command`, hands over a block of typed questions, and gets answers back. The * file contents and command output it supplied are never returned, and only the * answers reach the transcript. * * On by default; `saiAdvisory.routes.ask.enabled: false` turns it off. Files must resolve * inside the working directory and obvious secret files are refused by name; the * command runs through the same local shell operations as the bash tool, on the * tool's own abort signal. Every text part is clipped to the route's payload budget * before the call, so an oversized request is trimmed rather than abstained. * * `imagePaths` adds up to four bounded local PNG/JPEG/WebP files, read whole only after the * credential and the classifier's image input are confirmed; a refused image set rejects the * whole call instead of clipping an image, dropping it, or asking text-only. */ import { execFile } from "node:child_process"; import { closeSync, constants, fstatSync, openSync, readSync, realpathSync, statSync, type Stats } from "node:fs"; import { basename, extname, isAbsolute, normalize, relative, resolve, sep } from "node:path"; import { StringEnum, type ImageContent } from "@earendil-works/pi-ai"; import { createLocalBashOperations, ensureTool, type ExtensionAPI, type ExtensionContext, getSettingsPath, } from "@selesai/code"; import { Type } from "typebox"; import { askSaiAnswers, confidenceBucket, emitSaiTelemetry, saiConnection, saiUnavailable, readSaiAdvisoryConfig, UNTRUSTED_MATERIAL_FOCUS, warnSaiUnavailableOnce, type SaiAbstainReason, type SaiAnswers, type SaiFailureDiagnostic, } from "./decisions.ts"; import { isScriptPath, keywordWindows, loadTypeScript, type SourceUnit, splitSourceUnits } from "./find-source.ts"; /** The tool's registered name. */ export const ASK_SAI_TOOL = "ask_sai"; /** Question types the tool accepts, one per Sai primitive. */ export const ASK_SAI_QUESTION_TYPES = ["choice", "score", "noul"] as const; export type AskSaiQuestionType = (typeof ASK_SAI_QUESTION_TYPES)[number]; /** Ceilings applied before anything leaves the process. */ export const MAX_ASK_QUESTIONS = 8; export const MAX_ASK_PATHS = 8; export const MAX_ASK_STATE_CHARS = 4_000; export const MAX_ASK_FILE_BYTES = 16 * 1024; export const MAX_ASK_COMMAND_BYTES = 16 * 1024; export const ASK_COMMAND_TIMEOUT_SECONDS = 60; /** Fixed JSON scaffolding (`{"state":{"files":{},"command":{}}}` and friends). */ const ASK_ENVELOPE_BYTES = 512; /** Marks code Sai is shown as incomplete, so it judges a clipped file as clipped. */ const TRUNCATION_MARKER = "\n… [truncated]"; /** File names never sent to Sai, whatever the agent asks for. */ const SECRET_BASENAME = /^(?:\.env(?:\..+)?|\.npmrc|\.netrc|auth\.json|credentials(?:\..+)?|id_(?:rsa|dsa|ecdsa|ed25519)(?:\.pub)?|.+\.(?:pem|key|p12|pfx|keystore))$/i; /** File extensions accepted for local images, each claiming one of the shared gate's MIME types. */ const ASK_IMAGE_EXTENSIONS = new Map([ [".png", "image/png"], [".jpg", "image/jpeg"], [".jpeg", "image/jpeg"], [".webp", "image/webp"], ]); /** The accepted formats for local refusals, named in the shared gate's own terms. */ const ASK_IMAGE_FORMATS = SAI_IMAGE_MIME_TYPES.map((mimeType) => mimeType.slice("image/".length).toUpperCase()).join("/"); function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } // --------------------------------------------------------------------------- // Questions // --------------------------------------------------------------------------- export interface AskSaiQuestion { name: string; type: AskSaiQuestionType; instructions: string; criteria?: unknown; focus?: string; } export interface ParsedAskQuestions { questions: AskSaiQuestion[]; /** Set when the block cannot be asked; `questions` is empty then. */ error?: string; } /** Validate the agent's question block, or explain why it cannot be asked. */ export function parseAskQuestions(raw: Record | undefined): ParsedAskQuestions { const entries = Object.entries(raw ?? {}); if (entries.length === 0) return { questions: [], error: "At least one question is required." }; if (entries.length > MAX_ASK_QUESTIONS) { return { questions: [], error: `At most ${MAX_ASK_QUESTIONS} questions per call; got ${entries.length}.` }; } const questions: AskSaiQuestion[] = []; for (const [name, value] of entries) { const spec = isRecord(value) ? value : {}; const rawType = spec.type; if (typeof rawType !== "string" || !ASK_SAI_QUESTION_TYPES.includes(rawType as AskSaiQuestionType)) { return { questions: [], error: `Question "${name}" has type ${JSON.stringify(rawType)}; use one of ${ASK_SAI_QUESTION_TYPES.join(", ")}.`, }; } const instructions = spec.instructions; if (typeof instructions !== "string" || instructions.trim() === "") { return { questions: [], error: `Question "${name}" needs non-empty instructions.` }; } const criteria = spec.criteria; if (rawType === "choice" && (!isRecord(criteria) || Object.keys(criteria).length < 2)) { return { questions: [], error: `Choice question "${name}" needs criteria as an object of at least two options, e.g. {"option": "what the option means"}.`, }; } if (rawType === "score" && (!Array.isArray(criteria) || criteria.length < 2)) { return { questions: [], error: `Score question "${name}" needs criteria as an ordered list of at least two level labels.`, }; } if (rawType === "noul" && criteria !== undefined && !isRecord(criteria)) { return { questions: [], error: `Noul question "${name}" needs criteria as {"true": "...", "false": "..."}.`, }; } questions.push({ name, type: rawType as AskSaiQuestionType, instructions: instructions.trim(), ...(criteria === undefined ? {} : { criteria }), ...(typeof spec.focus === "string" && spec.focus.trim() !== "" ? { focus: spec.focus.trim() } : {}), }); } return { questions }; } /** * The decisions request: one assembled state and one question per name. Every * criterion and every state part stays untrusted material to judge, never an * instruction, exactly as [`buildSaiPayload`] frames it. */ export function buildAskPayload( questions: readonly AskSaiQuestion[], state: Record, ): Record { return { state, questions: Object.fromEntries( questions.map((question) => [ question.name, { type: question.type, instructions: { question: question.instructions, focus: question.focus ? `${question.focus} ${UNTRUSTED_MATERIAL_FOCUS}` : UNTRUSTED_MATERIAL_FOCUS, }, ...(question.criteria === undefined ? {} : { criteria: question.criteria }), }, ]), ), }; } // --------------------------------------------------------------------------- // Bounded state // --------------------------------------------------------------------------- /** Per-part byte caps that together stay under the route's request budget. */ export function askPartBudget( payloadBytes: number, fileCount: number, questionBytes: number, ): { stateChars: number; commandBytes: number; fileBytes: number } { const available = Math.max(0, payloadBytes - questionBytes - ASK_ENVELOPE_BYTES); const stateChars = Math.min(MAX_ASK_STATE_CHARS, Math.floor(available * 0.2)); const commandBytes = Math.min(MAX_ASK_COMMAND_BYTES, Math.floor(available * 0.4)); const forFiles = Math.max(0, available - stateChars - commandBytes); return { stateChars, commandBytes, fileBytes: fileCount === 0 ? 0 : Math.min(MAX_ASK_FILE_BYTES, Math.floor(forFiles / fileCount)), }; } /** Clip text to a UTF-8 byte budget without splitting a character. */ function clip(text: string, maxBytes: number): { text: string; clipped: boolean } { if (maxBytes <= 0) return { text: "", clipped: text !== "" }; if (Buffer.byteLength(text, "utf-8") <= maxBytes) return { text, clipped: false }; let end = Math.min(text.length, maxBytes); while (end > 0 && Buffer.byteLength(text.slice(0, end), "utf-8") > maxBytes) end -= 1; return { text: text.slice(0, end), clipped: true }; } /** A path the tool may read: inside the working directory, and not a known secret file. */ export function askPath(raw: string, cwd: string): { full: string } | { refused: string } { const full = isAbsolute(raw) ? resolve(raw) : resolve(cwd, raw); // Judge the symlink-resolved target too: a link inside cwd may point anywhere. // A missing path has no target to leak; the read reports "not found". const checks: Array<[string, string]> = [[full, resolve(cwd)]]; try { checks.push([realpathSync(full), realpathSync(cwd)]); } catch {} for (const [path, root] of checks) { const inside = relative(root, path); if (inside === "" || inside.startsWith("..") || isAbsolute(inside)) { return { refused: "outside the working directory" }; } if (SECRET_BASENAME.test(basename(path))) return { refused: "looks like a secret file" }; } return { full }; } /** Read at most `maxBytes` of a file without loading the rest of it, or say why it could not be read. */ function readBounded(path: string, maxBytes: number): { text: string; clipped: boolean } | { error: string } { let size: number; try { const stats = statSync(path); if (!stats.isFile()) return { error: "not a file" }; size = stats.size; } catch (error) { return { error: isRecord(error) && error.code === "ENOENT" ? "not found" : "unreadable" }; } const length = Math.max(0, Math.min(size, maxBytes)); const buffer = Buffer.alloc(length); try { const fd = openSync(path, "r"); try { const read = readSync(fd, buffer, 0, length, 0); return { text: buffer.subarray(0, read).toString("utf-8"), clipped: size > read }; } finally { closeSync(fd); } } catch { return { error: "unreadable" }; } } /** One shell command through the same local operations the bash tool uses. */ export async function runAskCommand( command: string, cwd: string, signal: AbortSignal | undefined, ): Promise<{ output: string; exitCode: number | null; clipped: boolean; failed: boolean }> { const chunks: string[] = []; let bytes = 0; let clipped = false; let failed = false; let exitCode: number | null = null; try { const code = await createLocalBashOperations().exec(command, cwd, { onData: (data) => { if (clipped) return; const text = data.toString("utf-8"); chunks.push(text); bytes += Buffer.byteLength(text, "utf-8"); if (bytes >= MAX_ASK_COMMAND_BYTES) clipped = true; }, signal, timeout: ASK_COMMAND_TIMEOUT_SECONDS, }); exitCode = code.exitCode; } catch { // A failed, timed-out, or cancelled command still reports what it printed. failed = true; } return { output: chunks.join(""), exitCode, clipped, failed }; } // --------------------------------------------------------------------------- // Bounded local images // --------------------------------------------------------------------------- /** The image inputs the agent asked for, or why the block cannot be used. No path is resolved here. */ export function parseAskImagePaths(raw: unknown): { paths: string[] } | { error: string } { if (raw === undefined) return { paths: [] }; if (!Array.isArray(raw)) return { error: "`imagePaths` must be an array of local PNG, JPEG, or WebP file paths." }; if (raw.length > MAX_CLASSIFIER_IMAGES) { return { error: `\`imagePaths\` accepts at most ${MAX_CLASSIFIER_IMAGES} images per call; got ${raw.length}.` }; } const paths: string[] = []; for (const value of raw) { if (typeof value !== "string" || value.trim() === "") return { error: "every `imagePaths` entry must be a non-empty path string." }; paths.push(value); } return { paths }; } /** A local image that passed path and metadata checks, before any byte is read. */ export interface AskImageFile { /** The canonical resolved path every read and fd check uses, never the requested string. */ full: string; /** The canonical working directory the path was judged against, revalidated before the read. */ root: string; /** The MIME type the file's extension claims; the shared gate verifies it against the bytes. */ mimeType: SaiImageMimeType; /** Size in bytes at metadata time; both the preflight and the read are bounded by it. */ size: number; /** Device and inode of the file judged at metadata time: the read accepts only this exact inode. */ dev: number; ino: number; /** Modification and status-change times at metadata time; a later change refuses the read. */ mtimeMs: number; ctimeMs: number; } /** * Resolve one requested image path under the same boundary and secret rules as every other file * the tool reads ([`askPath`]), then require a target that can be sent whole: a successful * realpath inside the working directory, a nonempty regular file, and a PNG/JPEG/WebP extension. * Nothing is opened here; a path — or symlink — that resolves outside the working directory or * onto a secret name is refused instead of followed. The canonical path, root, and inode metadata * are carried into [`readAskImageFile`], which refuses to read a file that no longer matches them. */ export function askImageFile(raw: string, cwd: string): AskImageFile | { refused: string } { const checked = askPath(raw, cwd); if ("refused" in checked) return checked; let full: string; try { full = realpathSync(checked.full); } catch { return { refused: "not found" }; } let root: string; try { root = realpathSync(cwd); } catch { return { refused: "unreadable" }; } // askPath judged the resolved target when it could resolve; judge it again against the // canonical root so a target that moved between the two resolutions is refused rather than opened. const inside = relative(root, full); if (inside === "" || inside.startsWith("..") || isAbsolute(inside)) return { refused: "outside the working directory" }; if (SECRET_BASENAME.test(basename(full))) return { refused: "looks like a secret file" }; const mimeType = ASK_IMAGE_EXTENSIONS.get(extname(full).toLowerCase()); if (mimeType === undefined) return { refused: `not a ${ASK_IMAGE_FORMATS} file` }; let stats: Stats; try { stats = statSync(full); } catch { return { refused: "unreadable" }; } if (!stats.isFile()) return { refused: "not a regular file" }; if (stats.size === 0) return { refused: "empty" }; return { full, root, mimeType, size: stats.size, dev: stats.dev, ino: stats.ino, mtimeMs: stats.mtimeMs, ctimeMs: stats.ctimeMs }; } /** The base64 length `size` bytes become, computed before encoding so no large file is loaded to measure it. */ function encodedImageLength(size: number): number { return 4 * Math.ceil(size / 3); } /** * `O_NOFOLLOW` keeps an open from following a final symlink that appeared after the preflight; Windows * has no such flag, where the fd identity check below is what refuses a swapped target. `O_NONBLOCK` * keeps a path swapped for a FIFO or device from blocking the open; the fd must still be the * preflighted regular file before any byte is read from it. */ const ASK_IMAGE_OPEN_FLAGS = constants.O_RDONLY | constants.O_NONBLOCK | (process.platform === "win32" ? 0 : constants.O_NOFOLLOW); /** True when the opened fd is still the exact file — inode, size, and both timestamps — the preflight judged. */ function askImageIsPreflightFile(stats: Stats, file: AskImageFile): boolean { return ( stats.isFile() && stats.dev === file.dev && stats.ino === file.ino && stats.size === file.size && stats.mtimeMs === file.mtimeMs && stats.ctimeMs === file.ctimeMs ); } /** True when the canonical path still resolves to itself inside the canonical root it was judged with. */ function askImagePathIsCurrent(file: AskImageFile): boolean { let canonical: string; let root: string; try { canonical = realpathSync(file.full); root = realpathSync(file.root); } catch { return false; } if (canonical !== file.full || root !== file.root) return false; const inside = relative(root, canonical); return inside !== "" && !inside.startsWith("..") && !isAbsolute(inside); } /** * Read one resolved image whole from the exact file the preflight judged. * * The open never follows a final symlink (`O_NOFOLLOW`, where the platform has it) and never blocks * on a FIFO or device (`O_NONBLOCK`). Before any byte is read, the fd must still be the preflighted * inode — same device, inode, size, mtime, and ctime — and the canonical path must still resolve to * itself inside the canonical root, so a same-size replacement, a symlink swapped in after the * metadata, or a replaced parent directory is refused instead of read. The same identity check runs * again after the read, so a file rewritten or grown in place during the read is refused rather than * half-sent; the read itself is bounded by the preflight size, and a record whose size cannot fit * the shared cap is refused without allocating. The helper refuses a missing, replaced, or changed * file; what it cannot do is make open, validation, and read one atomic operation — a hardlink that * aliases an outside file under a name inside the root is indistinguishable from one that was * always there, as it is for every other path the tool reads. */ export function readAskImageFile(file: AskImageFile): { data: string } | { refused: string } { // A caller-constructed record must not be able to make the reader allocate or read without bound. if (!Number.isSafeInteger(file.size) || file.size <= 0 || encodedImageLength(file.size) > MAX_IMAGE_BYTES) { return { refused: "changed while it was being read" }; } const buffer = Buffer.alloc(file.size); let fd: number; try { fd = openSync(file.full, ASK_IMAGE_OPEN_FLAGS); } catch (error) { // ELOOP is the no-follow guard refusing a final symlink that appeared after the preflight. return { refused: isRecord(error) && error.code === "ELOOP" ? "changed while it was being read" : "could not be read" }; } try { // Identity first: a FIFO or device is not the preflighted regular file, and no byte is read // from anything that is not. The path check then refuses a swapped parent directory that // still reaches the same inode. if (!askImageIsPreflightFile(fstatSync(fd), file) || !askImagePathIsCurrent(file)) { return { refused: "changed while it was being read" }; } let read = 0; while (read < file.size) { const chunk = readSync(fd, buffer, read, file.size - read, read); if (chunk <= 0) return { refused: "changed while it was being read" }; read += chunk; } // A grown file would otherwise be silently clipped to the preflight size. if (readSync(fd, Buffer.alloc(1), 0, 1, file.size) > 0) return { refused: "changed while it was being read" }; // In-place changes during the read move mtime/ctime even when the size is identical. if (!askImageIsPreflightFile(fstatSync(fd), file)) return { refused: "changed while it was being read" }; } catch { return { refused: "could not be read" }; } finally { closeSync(fd); } return { data: buffer.toString("base64") }; } /** A local image set the tool refused whole. Content-free: a rule, never a path, byte, or credential. */ export interface AskImageRefusal { /** The abstention the tool reports for the whole request. */ failure: "invalid-images" | "images-unsupported"; /** The shared gate rule when the whole set failed its byte-level validation. */ rejection?: SaiImageRejection; /** The local rule that refused one file, e.g. "Image 2 of 3 was refused (not a regular file)". */ note?: string; /** True once any image file was read, so the refusal names exactly what did not happen. */ read: boolean; } /** The content-free diagnostic for a refused local image set. */ export function askImageRefusalDiagnostic(refusal: AskImageRefusal): SaiFailureDiagnostic { if (refusal.failure === "images-unsupported") return { kind: "image-unsupported" }; return { kind: "image-invalid", ...(refusal.rejection ? { imageRejection: refusal.rejection } : {}) }; } /** * Resolve, preflight, read, and gate every requested image before any of it is used. The set is * refused whole — one unsafe, unreadable, unsupported, changed, or oversized file refuses the * request rather than dropping or clipping that image — and no byte is read until every path * resolved and the aggregate encoded size is known to fit the shared cap. */ export function readAskImages(paths: readonly string[], cwd: string): { images: ImageContent[] } | { refusal: AskImageRefusal } { const files: AskImageFile[] = []; for (const [index, raw] of paths.entries()) { const file = askImageFile(raw, cwd); if ("refused" in file) { return { refusal: { failure: "invalid-images", note: `Image ${index + 1} of ${paths.length} was refused (${file.refused}).`, read: false } }; } files.push(file); } // Preflight from metadata: 4*ceil(size/3) is exactly what base64 will produce, so the // aggregate is known before any file is loaded. if (files.reduce((total, file) => total + encodedImageLength(file.size), 0) > MAX_IMAGE_BYTES) { return { refusal: { failure: "invalid-images", rejection: "size", read: false } }; } const contents: string[] = []; for (const [index, file] of files.entries()) { const read = readAskImageFile(file); if ("refused" in read) { return { refusal: { failure: "invalid-images", note: `Image ${index + 1} of ${files.length} was refused (${read.refused}).`, read: true } }; } contents.push(read.data); } // The shared gate verifies each extension's claim against the actual bytes; the set was // already checked, so a refusal here still rejects the whole request, before any command. const validation = validateSaiImages(files.map((file, index) => ({ type: "image" as const, data: contents[index], mimeType: file.mimeType }))); if (!validation.ok) return { refusal: { failure: "invalid-images", rejection: validation.reason, read: true } }; return { images: validation.images }; } // --------------------------------------------------------------------------- // Answers // --------------------------------------------------------------------------- function formatValue(value: unknown): string { if (typeof value === "number") return Number.isInteger(value) ? String(value) : value.toFixed(2); if (typeof value === "string") return value; return JSON.stringify(value); } /** A score answer as the agent reads it: the nearest level label, then the raw position. */ export function formatScore(answer: Record, levels: unknown): string | undefined { const score = answer.score; if (typeof score !== "number") return undefined; const legend = isRecord(answer.legend) ? answer.legend : undefined; const nearest = Math.round(score); const label = legend?.[String(nearest)] ?? (Array.isArray(levels) ? levels[nearest] : undefined); return typeof label === "string" ? `${label} (${formatValue(score)})` : formatValue(score); } /** * Render answers for the agent: each value and its confidence, never the raw * probability distribution, the file contents, or the command output. */ export function renderAskAnswers(input: { questions: readonly AskSaiQuestion[]; answers: Record; rejected: Record; failure?: SaiAbstainReason; model: string; elapsedMs: number; refused: readonly string[]; notes: readonly string[]; /** How many paths the agent passed, and the directory they resolve against. */ pathsRequested?: number; cwd?: string; /** Safe, content-free context for a failed request; never provider text or image data. */ diagnostic?: SaiFailureDiagnostic; }): string { const lines: string[] = []; let answered = 0; for (const question of input.questions) { const answer = input.answers[question.name]; if (!isRecord(answer)) { lines.push(`- ${question.name}: no answer (${input.rejected[question.name] ?? input.failure ?? "missing"})`); continue; } answered += 1; // The type is already in the label, the legend is folded into the score, and raw // distributions stay out of the agent's context. const fields = Object.entries(answer) .filter(([key]) => !["probabilities", "type", "legend"].includes(key)) .map(([key, value]) => key === "score" ? `score=${formatScore(answer, question.criteria)}` : `${key}=${formatValue(value)}`, ); lines.push(`- ${question.name} (${question.type}): ${fields.length > 0 ? fields.join(", ") : "answered"}`); } const header = [`ask_sai: ${answered}/${input.questions.length} answered via ${input.model} in ${input.elapsedMs}ms`]; if (answered === 0 && input.failure) header.push(`Sai was unavailable or abstained: ${input.failure}.`); if (input.diagnostic) header.push(formatSaiDiagnostic(input.diagnostic)); // Missing material changes what the answers mean, so it leads rather than trails them. const requested = input.pathsRequested ?? 0; if (requested > 0 && input.refused.length >= requested) { header.push( `WARNING: Sai answered without any of the ${requested} files you passed (${input.refused.join("; ")}). ` + `Paths resolve inside ${input.cwd ?? "the working directory"}; treat these answers as judged from your prose alone.`, ); } else if (input.refused.length > 0) { header.push(`Not sent to Sai: ${input.refused.join("; ")}.`); } const footer = [ "Only these answers are returned: the file contents and command output you supplied were not sent back to you.", ...(input.notes.length > 0 ? [`Truncated to fit the request budget: ${input.notes.join("; ")}.`] : []), ]; return [...header, ...lines, ...footer].join("\n"); } /** * Render a refused local image set for the agent: the same abstention block an unanswered request * gets, the rule that refused the set, and exactly what did not run. Content-free: no path, image * byte, credential, or provider text can appear here. */ function renderAskImageRefusal(input: { questions: readonly AskSaiQuestion[]; model: string; refusal: AskImageRefusal; diagnostic: SaiFailureDiagnostic; }): string { return [ renderAskAnswers({ questions: input.questions, answers: {}, rejected: {}, failure: input.refusal.failure, model: input.model, elapsedMs: 0, refused: [], notes: [], diagnostic: input.diagnostic, }), ...(input.refusal.note ? [input.refusal.note] : []), input.refusal.read ? "No command was run and no request was sent." : "Nothing was read, run, or sent.", ].join("\n"); } // --------------------------------------------------------------------------- // sai_find: ripgrep gathers files; Sai narrows directories, then files, then source units // --------------------------------------------------------------------------- /** The file finder's registered name. */ export const SAI_FIND_TOOL = "sai_find"; /** ponytail: 16 nouls per request (the SaiPDF batch size); raise once Sai is measured on larger blocks. */ export const FIND_BATCH = 16; /** ponytail: a set this small (three parallel batches) is judged file by file, without directory descent. */ export const FIND_DIRECT_FILES = 48; /** ponytail: at most 128 files judged per call (eight batches), best word/path score first. */ export const FIND_MAX_JUDGED_FILES = 128; /** ponytail: hard cap of 12 Sai requests per call, retries included; raise once latency and cost are measured. */ export const FIND_MAX_REQUESTS = 12; /** ponytail: directory descent spends at most 4 of those requests (64 directory questions over all levels). */ export const FIND_DIR_REQUESTS = 4; /** ponytail: 2 requests are held back for the source-unit stage (32 units). */ export const FIND_UNIT_REQUESTS = 2; /** ponytail: about 16 KB of verbatim source per call; the rest stays reachable through the reading leads. */ export const FIND_MAX_SOURCE_BYTES = 16 * 1024; /** ponytail: descend at most 4 directory levels below the search root. */ export const FIND_MAX_DEPTH = 4; /** ponytail: keep a directory at 0.35 or above: pruning drops every file under it, so it errs toward keeping. */ export const FIND_DIR_KEEP = 0.35; /** ponytail: at most 6 candidate units per relevant file, each shown as at most 60 lines. */ export const FIND_UNITS_PER_FILE = 6; export const FIND_UNIT_MAX_LINES = 60; const FIND_DIR_FILE_NAMES = 15; const FIND_DIR_HIT_LINES = 3; const FIND_MATCH_LINES = 6; const FIND_DEFAULT_LIMIT = 8; const FIND_MIN_RELEVANCE = 0.5; /** Diagnostics sample paths, never excerpts; they have their own budget, not the source budget. */ export const FIND_MAX_DIAGNOSTIC_BYTES = 4 * 1024; const FIND_DIAGNOSTIC_SAMPLES = 3; /** Counts every outcome but retains only a few bounded examples per category. */ class FindCoverage { readonly counts: Record = {}; private readonly samples = new Map(); add(category: string, path: string, reason: string): void { this.counts[category] = (this.counts[category] ?? 0) + 1; const rows = this.samples.get(category) ?? []; if (rows.length < FIND_DIAGNOSTIC_SAMPLES) { // Quoting prevents unusual filenames from injecting extra diagnostic lines. rows.push(` ${JSON.stringify(clip(path, 160).text)}: ${clip(reason, 120).text}`); } this.samples.set(category, rows); } render(): string[] { const lines = ["Search diagnostics (sampled; scores judge excerpts, not whole files):"]; for (const [category, rows] of this.samples) { lines.push(`- ${category}: ${this.counts[category]}`, ...rows); const omitted = this.counts[category] - rows.length; if (omitted > 0) lines.push(` … ${omitted} more outcome(s) not listed.`); } const text = lines.join("\n"); const bounded = clip(text, FIND_MAX_DIAGNOSTIC_BYTES - 64); return [bounded.text, ...(bounded.clipped ? ["… diagnostic output clipped to 4 KiB."] : [])]; } } /** Files that still get keyword-window source when Sai could not rank them. */ const FIND_FALLBACK_SOURCE_FILES = 3; /** The asker's question is clipped to this before it is repeated in every request. */ const FIND_REQUEST_BYTES = 2 * 1024; /** Room each file batch keeps for the questions and JSON scaffolding when sizing snippets. */ const FIND_QUESTION_RESERVE = 6 * 1024; /** Lines of a long unit shown above its best keyword line. */ const FIND_FOCUS_LEAD = 10; /** One file ripgrep offered. */ export interface FindFile { path: string; /** Matched line numbers (pattern mode). */ lines: number[]; /** Matched lines as `L12: text` (pattern mode). */ hits: string[]; /** Question words in the path, plus matched lines (pattern mode) or question words in the content. */ score: number; } export interface FindCandidate { path: string; /** Matched line numbers, or the best keyword lines, best first. */ lines: number[]; /** What Sai reads: the matched lines, or the file's best keyword lines, or its head. */ snippet: string; } /** Sends one decisions request. The tool binds it to `askSaiAnswers`; tests pass a fake. */ export type SaiFindAsk = (payload: Record) => Promise; /** Run ripgrep with an argument list (no shell). Exit 1 is "no matches"; an overfull buffer keeps what arrived. */ function runRg(bin: string, args: string[], cwd: string, signal: AbortSignal | undefined, onTruncated?: () => void): Promise { return new Promise((done, fail) => { execFile( bin, args, { cwd, signal, maxBuffer: 8 * 1024 * 1024, timeout: ASK_COMMAND_TIMEOUT_SECONDS * 1000 }, (error, stdout) => { const code = error ? (error as { code?: unknown }).code : 0; if (code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") onTruncated?.(); if (!error || code === 1 || code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") done(String(stdout)); else fail(error); }, ); }); } /** Filler words that would match nearly every file. */ const FIND_STOP_WORDS = new Set( "the and for with where what which when who how does did this that from into its are was can should would there file files code find implement implemented implements".split(" "), ); /** Question words worth searching for: 3+ letters, not filler, at most eight. */ function questionWords(question: string): string[] { const words = question.toLowerCase().match(/[a-z0-9]{3,}/g) ?? []; return [...new Set(words)].filter((word) => !FIND_STOP_WORDS.has(word)).slice(0, 8); } /** How much of each listed file is scanned for question words. */ const FIND_SCAN_BYTES = 256 * 1024; const FIND_EXCERPT_LINES = 12; /** * What Sai reads of a listed file: the lines holding the most distinct question words, in file * order and numbered, or the head of the file when none match. A file's head is usually its * doc comment, which rarely shows where the asked-about behavior lives. */ export function excerpt(text: string, words: readonly string[], maxBytes: number): { lines: number[]; snippet: string } { const rows = text.split("\n"); const best = rows .map((row, index) => { const lower = row.toLowerCase(); return { index, count: words.filter((word) => lower.includes(word)).length }; }) .filter((hit) => hit.count > 0) .sort((a, b) => b.count - a.count || a.index - b.index) .slice(0, FIND_EXCERPT_LINES); if (best.length === 0) return { lines: [], snippet: clip(text, maxBytes).text }; const inOrder = [...best].sort((a, b) => a.index - b.index); const snippet = inOrder.map((hit) => `L${hit.index + 1}: ${rows[hit.index].trim().slice(0, 240)}`).join("\n"); // Leads start at the best-matching lines. return { lines: best.map((hit) => hit.index + 1), snippet: clip(snippet, maxBytes).text }; } /** Path parts (3+ letters) sharing a stem with a question word; orders a file listing before it is capped. */ function pathOverlap(path: string, words: readonly string[]): number { const parts = path.toLowerCase().match(/[a-z0-9]{3,}/g) ?? []; return parts.filter((part) => words.some((word) => word.includes(part) || part.includes(word))).length; } const byScore = (a: FindFile, b: FindFile) => b.score - a.score || a.path.localeCompare(b.path); export interface FindGatherOptions { rg: string; cwd: string; /** Search root relative to `cwd`; "." for the working directory. */ root: string; question: string; pattern?: string; glob?: string; ignoreCase?: boolean; /** Local discovery exclusions; no file contents are passed to this callback. */ onSkipped?: (path: string, reason: string) => void; } /** * Every file under `root` worth judging, most promising first. With `pattern`, the files ripgrep * matches with their matched lines; without it, every file ripgrep lists, scored by question words * in the path and (for a set too large to judge file by file) in the content, one fixed-string * ripgrep per word, in parallel. Respects .gitignore, and never offers a file `askPath` would refuse. */ export async function gatherFindFiles(options: FindGatherOptions, signal: AbortSignal | undefined): Promise { // Always name the root: with no path and a piped stdin, rg searches stdin and hangs. // Paths come back as `./src/x.ts`, so they are normalized below. const root = ["--", options.root]; const filters = [...(options.glob ? ["--glob", options.glob] : []), ...(options.ignoreCase ? ["-i"] : [])]; const allowed = (path: string) => { const checked = askPath(path, options.cwd); if ("full" in checked) return true; options.onSkipped?.(path, checked.refused); return false; }; const truncated = () => options.onSkipped?.(options.root, "ripgrep output buffer limit; discovery incomplete"); const words = questionWords(options.question); if (options.pattern) { const out = await runRg( options.rg, ["--null", "--line-number", "--no-heading", "--color=never", "--max-count", String(FIND_MATCH_LINES), "--max-columns", "240", ...filters, "-e", options.pattern, ...root], options.cwd, signal, truncated, ); const byFile = new Map(); for (const row of out.split("\n")) { const nul = row.indexOf("\0"); if (nul < 0) continue; const path = normalize(row.slice(0, nul)); const match = /^(\d+):(.*)$/.exec(row.slice(nul + 1)); if (!match) continue; const entry = byFile.get(path) ?? { lines: [], text: [] }; entry.lines.push(Number(match[1])); entry.text.push(`L${match[1]}: ${match[2].trim()}`); byFile.set(path, entry); } return [...byFile] .filter(([path]) => allowed(path)) .map(([path, entry]) => ({ path, lines: entry.lines, hits: entry.text, score: entry.lines.length + pathOverlap(path, words) })) .sort(byScore); } const out = await runRg(options.rg, ["--files", "--color=never", ...filters, ...root], options.cwd, signal, truncated); const listed = out .split("\n") .filter((path) => path !== "") .map((path) => normalize(path)) .filter(allowed); const score = new Map(listed.map((path) => [path, pathOverlap(path, words)])); if (listed.length > FIND_DIRECT_FILES && words.length > 0) { const hits = await Promise.all( words.map((word) => runRg(options.rg, ["-l", "-i", "-F", "--color=never", ...filters, "-e", word, ...root], options.cwd, signal, truncated), ), ); for (const listing of hits) { for (const raw of listing.split("\n")) { const path = raw === "" ? "" : normalize(raw); const current = score.get(path); if (current !== undefined) score.set(path, current + 1); } } } return listed.map((path) => ({ path, lines: [], hits: [], score: score.get(path) ?? 0 })).sort(byScore); } // Judging ------------------------------------------------------------------ /** One noul: a keyed piece of state and the question asked about it. */ export interface JudgeItem { key: string; text: string; question: string; } export interface JudgeStage { request: string; /** Where the items sit in `state` (`directories`, `files`, `units`). */ stateKey: string; criteria: { true: string; false: string }; } /** One request: the items at `items` (indexes into the stage's list), one noul `q` each. */ export function buildJudgePayload(stage: JudgeStage, items: readonly JudgeItem[]): Record { return buildAskPayload( items.map((item, index) => ({ name: `q${index}`, type: "noul" as const, instructions: item.question, criteria: stage.criteria })), { request: stage.request, [stage.stateKey]: Object.fromEntries(items.map((item) => [item.key, item.text])) }, ); } /** * The stage's items as requests that each serialize within `maxBytes`: FIND_BATCH items per * request, their texts shrunk together until the request fits (code grows under JSON escaping, so a * byte budget per text alone does not guarantee a fit). When even empty texts do not fit — the * questions, paths, and scaffolding alone are too large — the batch is halved, and an item that * cannot fit alone is dropped. No request this returns can come back as `overflow`. */ export function fitJudgePayloads( stage: JudgeStage, items: readonly JudgeItem[], maxBytes: number, ): { payloads: Array<{ payload: Record; items: number[] }>; dropped: number[] } { const payloads: Array<{ payload: Record; items: number[] }> = []; const dropped: number[] = []; const place = (indexes: number[]): void => { let limit = Math.max(0, ...indexes.map((index) => Buffer.byteLength(items[index].text, "utf-8"))); for (;;) { const payload = buildJudgePayload( stage, indexes.map((index) => { const text = clip(items[index].text, limit); return { ...items[index], text: text.clipped && limit > 0 ? `${text.text}${TRUNCATION_MARKER}` : text.text }; }), ); if (fitsClassifierRequest(payload, maxBytes)) { payloads.push({ payload, items: indexes }); return; } if (limit > 0) { limit = limit < 64 ? 0 : Math.floor(limit * 0.75); continue; } if (indexes.length === 1) { dropped.push(indexes[0]); return; } const half = Math.ceil(indexes.length / 2); place(indexes.slice(0, half)); place(indexes.slice(half)); return; } }; for (let start = 0; start < items.length; start += FIND_BATCH) { place(Array.from({ length: Math.min(FIND_BATCH, items.length - start) }, (_, offset) => start + offset)); } return { payloads, dropped }; } /** * The call's Sai requests: every stage sends its batches in parallel (one round trip), retries a * `transport` failure once, and never exceeds FIND_MAX_REQUESTS in total, retries included. */ export class FindRequests { sent = 0; retried = 0; readonly diagnostics: SaiFailureDiagnostic[] = []; private readonly ask: SaiFindAsk; readonly max: number; constructor(ask: SaiFindAsk, max = FIND_MAX_REQUESTS) { this.ask = ask; this.max = max; } get left(): number { return this.max - this.sent; } /** * Send up to `sendCap` payloads in parallel, then retry the transport failures within `retryCap` * total requests for this stage. A payload past the caps comes back undefined (not sent). */ async sendAll( payloads: readonly Record[], sendCap: number, retryCap = sendCap, ): Promise> { const count = Math.max(0, Math.min(payloads.length, sendCap, this.left)); this.sent += count; const results: Array = await Promise.all( payloads.slice(0, count).map((payload) => this.ask(payload)), ); const retry = results .map((result, index) => ({ result, index })) .filter(({ result }) => result?.failure === "transport") .slice(0, Math.max(0, Math.min(retryCap - count, this.left))); this.sent += retry.length; this.retried += retry.length; const again = await Promise.all(retry.map(({ index }) => this.ask(payloads[index]))); retry.forEach(({ index }, k) => { results[index] = again[k]; }); for (const result of results) { const diagnostic = result?.diagnostic; if (diagnostic && !this.diagnostics.some((item) => item.kind === diagnostic.kind && item.httpStatus === diagnostic.httpStatus)) { this.diagnostics.push(diagnostic); } } return [...results, ...Array.from({ length: payloads.length - count }, () => undefined)]; } } interface JudgeOutcome { /** Sai's noul per item; undefined when it was not answered. */ scores: Array; /** Why unanswered items went unanswered: a request failure, `request cap`, or `too large`. */ reasons: string[]; /** One reason per unanswered item, so mixed batches are not mislabeled. */ itemReasons: Array; } /** Ask one noul per item, batched and fitted, within the stage's request caps. */ export async function judgeNouls( requests: FindRequests, stage: JudgeStage, items: readonly JudgeItem[], maxBytes: number, caps: { send: number; retry: number }, ): Promise { const scores: Array = items.map(() => undefined); const reasons: string[] = []; const itemReasons: Array = items.map(() => "missing"); if (items.length === 0) return { scores, reasons, itemReasons }; const fitted = fitJudgePayloads(stage, items, maxBytes); if (fitted.dropped.length > 0) reasons.push("too large"); fitted.dropped.forEach((index) => { itemReasons[index] = "too large"; }); const results = await requests.sendAll( fitted.payloads.map((entry) => entry.payload), caps.send, caps.retry, ); fitted.payloads.forEach((entry, p) => { const result = results[p]; entry.items.forEach((index) => { itemReasons[index] = result?.failure ?? (result ? "missing" : "request cap"); }); if (!result) { reasons.push("request cap"); return; } if (result.failure) reasons.push(result.failure); const answers = result.answers ?? {}; entry.items.forEach((index, k) => { const answer = answers[`q${k}`]; if (isRecord(answer) && typeof answer.noul === "number") { scores[index] = answer.noul; itemReasons[index] = undefined; } }); }); return { scores, reasons: [...new Set(reasons)], itemReasons }; } // Directory descent -------------------------------------------------------- const DIRECTORY_CRITERIA = { true: "Its name, file names, or keyword hits suggest it holds code that implements, defines, or configures what the request asks about.", false: "It clearly holds something unrelated to the request.", }; /** The directory one level below `dir` that holds `path`, or undefined when `path` sits directly in `dir`. */ export function childDirectory(path: string, dir: string): string | undefined { const rest = dir === "." ? path : path.slice(dir.length + 1); const cut = rest.indexOf(sep); if (cut < 0) return undefined; return dir === "." ? rest.slice(0, cut) : `${dir}${sep}${rest.slice(0, cut)}`; } /** What Sai reads of a directory: its size, its most promising file names, and a few keyword hits. */ function describeDirectory(dir: string, files: readonly FindFile[], hitLines: (file: FindFile) => string[]): string { const ordered = [...files].sort(byScore); const local = (path: string) => path.slice(dir.length + 1); const names = ordered.slice(0, FIND_DIR_FILE_NAMES).map((file) => local(file.path)); const more = files.length - names.length; const hits: string[] = []; for (const file of ordered.filter((candidate) => candidate.score > 0).slice(0, FIND_DIR_HIT_LINES)) { for (const line of hitLines(file).slice(0, 2)) { if (hits.length < FIND_DIR_HIT_LINES) hits.push(`${local(file.path)} ${line.slice(0, 160)}`); } } return [ `${files.length} file(s): ${names.join(", ")}${more > 0 ? `, … (+${more} more)` : ""}`, ...(hits.length > 0 ? ["Keyword hits:", ...hits] : []), ].join("\n"); } export interface DescentResult { /** Files still worth judging: those in kept directories, and those sitting directly in a descended one. */ kept: FindFile[]; prunedDirs: number; prunedFiles: number; /** Directories kept without an answer, and why. */ unjudgedDirs: number; reasons: string[]; } /** * Narrow a large file set directory by directory: one noul per next-level directory ("could this * hold the answer?"), all of a level in one parallel round trip. A directory below FIND_DIR_KEEP is * dropped with every file under it; an unanswered one is kept (unknown is not rejected). Kept * directories still larger than FIND_DIRECT_FILES are descended again, up to FIND_MAX_DEPTH * levels; a lone subdirectory is entered without a question. */ export async function narrowByDirectory( files: readonly FindFile[], root: string, options: { request: string; maxBytes: number; requests: FindRequests; hitLines: (file: FindFile) => string[]; coverage?: FindCoverage }, ): Promise { const result: DescentResult = { kept: [], prunedDirs: 0, prunedFiles: 0, unjudgedDirs: 0, reasons: [] }; if (files.length <= FIND_DIRECT_FILES) return { ...result, kept: [...files] }; let pending: Array<{ dir: string; files: FindFile[]; depth: number }> = [{ dir: root, files: [...files], depth: 0 }]; let dirRequests = 0; while (pending.length > 0) { const children: Array<{ dir: string; files: FindFile[]; depth: number; free: boolean }> = []; for (const group of pending) { const byChild = new Map(); let direct = 0; for (const file of group.files) { const child = childDirectory(file.path, group.dir); if (child === undefined) { result.kept.push(file); direct += 1; } else { byChild.set(child, [...(byChild.get(child) ?? []), file]); } } const free = byChild.size === 1 && direct === 0; for (const [dir, under] of byChild) children.push({ dir, files: under, depth: group.depth + 1, free }); } const sendCap = Math.min(FIND_DIR_REQUESTS - dirRequests, options.requests.left - FIND_UNIT_REQUESTS - 1); const questionable = children.filter((child) => !child.free && child.depth <= FIND_MAX_DEPTH); const asked = sendCap > 0 ? questionable.sort( (a, b) => b.files.reduce((sum, f) => sum + f.score, 0) - a.files.reduce((sum, f) => sum + f.score, 0), ) : []; if (children.some((child) => !child.free && child.depth > FIND_MAX_DEPTH)) result.reasons.push("depth limit"); if (questionable.length > 0 && asked.length === 0) result.reasons.push("request cap"); const verdicts = new Map(); const itemReasons = new Map(); if (asked.length > 0) { const before = options.requests.sent; const outcome = await judgeNouls( options.requests, { request: options.request, stateKey: "directories", criteria: DIRECTORY_CRITERIA }, asked.map((child) => ({ key: `${child.dir}${sep}`, text: describeDirectory(child.dir, child.files, options.hitLines), question: `Could the directory "${child.dir}${sep}" contain code that answers the request, judged by its description in state.directories?`, })), options.maxBytes, { send: sendCap, retry: sendCap }, ); dirRequests += options.requests.sent - before; asked.forEach((child, index) => { verdicts.set(child.dir, outcome.scores[index]); itemReasons.set(child.dir, outcome.itemReasons[index]); }); result.reasons.push(...outcome.reasons); } const next: typeof pending = []; for (const child of children) { const wasAsked = verdicts.has(child.dir); const verdict = verdicts.get(child.dir); if (!child.free) { options.coverage?.add( verdict === undefined ? "directories unjudged" : verdict < FIND_DIR_KEEP ? "directories rejected" : "directories kept", child.dir, verdict === undefined ? itemReasons.get(child.dir) ?? (child.depth > FIND_MAX_DEPTH ? "depth limit" : "request cap") : `noul=${verdict.toFixed(2)}; keep threshold=${FIND_DIR_KEEP}; ${child.files.length} file(s)`, ); } if (verdict !== undefined && verdict < FIND_DIR_KEEP) { result.prunedDirs += 1; result.prunedFiles += child.files.length; continue; } if (verdict === undefined && !child.free) result.unjudgedDirs += 1; const canDescend = child.depth < FIND_MAX_DEPTH && (child.free || wasAsked); if (child.files.length > FIND_DIRECT_FILES && canDescend) { next.push({ dir: child.dir, files: child.files, depth: child.depth }); } else result.kept.push(...child.files); } pending = next; } result.reasons = [...new Set(result.reasons)]; return result; } // Source units ------------------------------------------------------------- const FILE_CRITERIA = { true: "The excerpt shows this file implements, defines, configures, or directly answers the request.", false: "The file only mentions related words, or is about something else.", }; const UNIT_CRITERIA = { true: "This unit implements, defines, configures, or directly answers what the request asks.", false: "The unit only mentions related words, or does something else.", }; /** A unit's candidate score: matched/keyword lines inside it weigh most, then distinct question words. */ export function pickUnits( units: readonly SourceUnit[], rows: readonly string[], hits: readonly number[], words: readonly string[], ): SourceUnit[] { const bodies = units.filter((unit) => unit.name !== "imports"); const scored = bodies.map((unit) => { const body = rows.slice(unit.start - 1, unit.end).join("\n").toLowerCase(); const hitCount = hits.filter((line) => line >= unit.start && line <= unit.end).length; return { unit, score: hitCount * 10 + words.filter((word) => body.includes(word)).length }; }); const picks = scored .filter((entry) => entry.score > 0) .sort((a, b) => b.score - a.score || a.unit.start - b.unit.start) .slice(0, FIND_UNITS_PER_FILE) .map((entry) => entry.unit); // Nothing matched a word: let Sai judge the file's first declarations. return (picks.length > 0 ? picks : bodies.slice(0, 3)).sort((a, b) => a.start - b.start); } /** * The line numbers a unit is shown as (0 marks elided lines): the header line of a member, then the * unit whole, or — when it is longer than `maxLines` — its first line and a window from just above * its best keyword line (`focus`), so a long function shows where the asked-about behavior sits. */ export function unitView(unit: SourceUnit, focus: number | undefined, maxLines = FIND_UNIT_MAX_LINES): number[] { const view: number[] = unit.header !== undefined && unit.header < unit.start ? [unit.header, 0] : []; const range = (from: number, to: number) => Array.from({ length: Math.max(0, to - from + 1) }, (_, i) => from + i); if (unit.end - unit.start + 1 <= maxLines) return [...view, ...range(unit.start, unit.end)]; if (focus === undefined || focus < unit.start + maxLines - FIND_FOCUS_LEAD) { return [...view, ...range(unit.start, unit.start + maxLines - 1), 0]; } const from = Math.max(unit.start + 1, focus - FIND_FOCUS_LEAD); const to = Math.min(unit.end, from + maxLines - 2); return [...view, unit.start, ...(from > unit.start + 1 ? [0] : []), ...range(from, to), ...(to < unit.end ? [0] : [])]; } /** The first `hits` line inside the unit, in `hits` order (best first). */ function focusOf(unit: SourceUnit, hits: readonly number[]): number | undefined { return hits.find((line) => line >= unit.start && line <= unit.end); } /** A file's rows without carriage returns (and without the empty row after a final newline), so numbered lines print as they read. */ function fileRows(text: string): string[] { const rows = text.split("\n").map((row) => row.replace(/\r$/, "")); if (rows.length > 1 && rows.at(-1) === "") rows.pop(); return rows; } interface SourceBlock { path: string; unit: SourceUnit; view: number[]; rows: readonly string[]; } /** * `Source block "path" lines a-b:` and the numbered verbatim lines of each block, in order, within * `budgetBytes` of source; a block cut by the budget ends in `…`, and one that cannot show three * lines is left out. */ export function renderSourceBlocks( blocks: readonly SourceBlock[], budgetBytes = FIND_MAX_SOURCE_BYTES, ): { lines: string[]; bytes: number; omitted: number } { const lines: string[] = []; let bytes = 0; let omitted = 0; for (const block of blocks) { const numbered: string[] = []; let used = 0; let cut = false; for (const line of block.view) { const text = line === 0 ? "…" : `${line}: ${block.rows[line - 1] ?? ""}`; const size = Buffer.byteLength(text, "utf-8") + 1; if (bytes + used + size > budgetBytes) { cut = true; break; } numbered.push(text); used += size; } if (numbered.filter((text) => text !== "…").length < Math.min(3, block.view.filter((line) => line !== 0).length)) { omitted += 1; continue; } if (cut && numbered.at(-1) !== "…") numbered.push("…"); lines.push("", `Source block "${block.path}" lines ${block.unit.start}-${block.unit.end}:`, ...numbered); bytes += used; } return { lines, bytes, omitted }; } /** Keyword lines for a directory description or a fallback window: rg's matches, or the file's best question-word lines. */ function keywordLines(text: string | undefined, words: readonly string[]): { lines: number[]; snippet: string[] } { if (text === undefined || words.length === 0) return { lines: [], snippet: [] }; const found = excerpt(text, words, FIND_SCAN_BYTES); return found.lines.length === 0 ? { lines: [], snippet: [] } : { lines: found.lines, snippet: found.snippet.split("\n") }; } function fallbackSourceUnits( path: string, text: string, ts: Awaited>, rows: readonly string[], hits: readonly number[], words: readonly string[], ): SourceUnit[] { if (hits.length === 0) return []; return pickUnits(splitSourceUnits(path, text, ts), rows, hits, words).filter((unit) => hits.some((line) => (line >= unit.start && line <= unit.end) || line === unit.header), ); } function formatSaiDiagnostic(diagnostic: SaiFailureDiagnostic): string { if (diagnostic.kind === "timeout") return "Sai request timed out."; if (diagnostic.kind === "malformed-response") return "Sai returned a malformed response."; if (diagnostic.kind === "image-unsupported") return "The configured Sai classifier does not accept images."; if (diagnostic.kind === "image-invalid") { return diagnostic.imageRejection ? `The image set was refused: ${saiImageRejectionMessage(diagnostic.imageRejection)}.` : "The image set was refused."; } return `Sai provider request failed${diagnostic.httpStatus ? ` (HTTP ${diagnostic.httpStatus})` : ""}.`; } // The whole call ----------------------------------------------------------- export interface SaiFindOptions extends FindGatherOptions { limit?: number; /** The route's request budget; no request is sent larger than this. */ maxBytes: number; model: string; /** Sends one request; absent when Sai is out of reach (`unreachable` says why). */ ask?: SaiFindAsk; unreachable?: SaiAbstainReason; /** The TypeScript loader; tests pass one resolving undefined to force chunking. */ loadTypeScript?: typeof loadTypeScript; signal?: AbortSignal; } export interface SaiFindResult { text: string; /** Shape only, like ask_sai: details persist in the session. */ details: Record; judged: number; topRelevance?: number; failure?: string; elapsedMs: number; } /** * The whole sai_find call: gather files with ripgrep, narrow large sets by directory, judge files, * then judge the source units of the relevant ones and return them verbatim. Without a reachable * Sai (or when no file could be judged) the ripgrep-ranked list still comes back, with keyword * windows of the top files, so the call is never wasted. Throws only when ripgrep fails. */ export async function runSaiFind(options: SaiFindOptions): Promise { const coverage = new FindCoverage(); const files = await gatherFindFiles({ ...options, onSkipped: (path, reason) => { coverage.add("discovery skipped", path, reason); options.onSkipped?.(path, reason); }, }, options.signal); const scopeValue = (value: string | undefined) => JSON.stringify(value === undefined ? null : clip(value, 240).text); const scope = [ `Discovery scope: path=${scopeValue(options.root)}, pattern=${scopeValue(options.pattern)}, glob=${scopeValue(options.glob)}.`, "Ignored, hidden, and nonmatching files are not candidates; scope values above are clipped to 240 bytes.", ].join(" "); if (files.length === 0) { return { text: ["sai_find: no candidate files. Loosen `pattern`, `glob`, or `path`.", scope, ...coverage.render(), "No candidates is a discovery result, not evidence that the behavior does not exist."].join("\n"), details: { total: 0, judged: 0, coverage: coverage.counts }, judged: 0, elapsedMs: 0, }; } const words = questionWords(options.question); const limit = Math.max(1, Math.floor(options.limit ?? FIND_DEFAULT_LIMIT)); const texts = new Map(); const readText = (path: string): string | undefined => { if (!texts.has(path)) { const file = readBounded(resolve(options.cwd, path), FIND_SCAN_BYTES); texts.set(path, "error" in file ? undefined : file.text); } return texts.get(path); }; const hitsOf = (file: FindFile): { lines: number[]; snippet: string[] } => file.lines.length > 0 ? { lines: file.lines, snippet: file.hits } : keywordLines(readText(file.path), words); /** The ripgrep-ranked list and source units of the top files: what comes back when Sai did not rank. */ const fallback = async ( reason: string, notes: string[], elapsedMs: number, requests?: FindRequests, ): Promise => { const shown = files.slice(0, limit); const sourceFiles = shown.slice(0, FIND_FALLBACK_SOURCE_FILES); const ts = sourceFiles.some((file) => isScriptPath(file.path)) ? await (options.loadTypeScript ?? loadTypeScript)() : undefined; const blocks: SourceBlock[] = []; const leads = new Map(); for (const file of sourceFiles) { const text = readText(file.path); if (text === undefined) continue; const rows = fileRows(text); const hits = hitsOf(file).lines; const units = fallbackSourceUnits(file.path, text, ts, rows, hits, words); const selected = units.length > 0 ? units : keywordWindows(rows.length, hits); leads.set(file.path, selected.map((unit) => `${unit.name} lines ${unit.start}-${unit.end}`).join(", ")); for (const unit of selected) blocks.push({ path: file.path, unit, view: unitView(unit, focusOf(unit, hits)), rows }); } const source = renderSourceBlocks(blocks); const diagnostic = requests?.diagnostics[0]; const lines = [ `sai_find: Sai did not judge (${reason}); ${files.length} candidate file(s), ranked by ripgrep:`, ...notes, ...(diagnostic ? [`Diagnostic: ${formatSaiDiagnostic(diagnostic)}`] : []), ...shown.map((file) => `- ${file.path}${leads.has(file.path) ? ` — ${leads.get(file.path)}` : ""}`), ...(files.length > shown.length ? [`${files.length - shown.length} more candidate file(s) not listed.`] : []), ...source.lines, scope, ...coverage.render(), "", "End context.", ]; return { text: lines.join("\n"), details: { total: files.length, coverage: coverage.counts, judged: 0, requests: requests?.sent ?? 0, sourceBytes: source.bytes, elapsedMs, failure: reason, ...(diagnostic ? { diagnostic } : {}), }, judged: 0, failure: reason, ...(diagnostic ? { diagnostic } : {}), elapsedMs, }; }; if (!options.ask) { for (const file of files) coverage.add("files unjudged", file.path, options.unreachable ?? "missing"); return fallback(options.unreachable ?? "missing", [], 0); } const started = Date.now(); const requests = new FindRequests(options.ask); const request = clip(options.question, FIND_REQUEST_BYTES).text; const notes: string[] = []; // 1. Directories. const descent = await narrowByDirectory(files, options.root, { request, maxBytes: options.maxBytes, requests, hitLines: (file) => hitsOf(file).snippet, coverage, }); if (descent.prunedDirs > 0) { notes.push(`Sai ruled out ${descent.prunedDirs} director${descent.prunedDirs === 1 ? "y" : "ies"} holding ${descent.prunedFiles} file(s).`); } if (descent.unjudgedDirs > 0) { notes.push( `${descent.unjudgedDirs} director${descent.unjudgedDirs === 1 ? "y was" : "ies were"} kept unjudged (${descent.reasons.join(", ") || "missing"}).`, ); } // 2. Files, best score first, within the judging cap and the requests left after the unit reserve. const fileSendCap = requests.left - FIND_UNIT_REQUESTS; const judgeCap = Math.min(FIND_MAX_JUDGED_FILES, Math.max(0, fileSendCap) * FIND_BATCH); const ordered = [...descent.kept].sort(byScore); const pastCap = Math.max(0, ordered.length - judgeCap); const snippetBytes = Math.max(256, Math.floor((options.maxBytes - FIND_QUESTION_RESERVE) / FIND_BATCH)); const candidates: FindCandidate[] = []; for (const file of ordered.slice(judgeCap)) coverage.add("files skipped", file.path, "file judging cap"); for (const file of ordered.slice(0, judgeCap)) { if (file.lines.length > 0) { candidates.push({ path: file.path, lines: file.lines, snippet: clip(file.hits.join("\n"), snippetBytes).text }); continue; } const text = readText(file.path); if (text !== undefined) candidates.push({ path: file.path, ...excerpt(text, words, snippetBytes) }); else coverage.add("files skipped", file.path, "unreadable or not a file"); } const filesOutcome = await judgeNouls( requests, { request, stateKey: "files", criteria: FILE_CRITERIA }, candidates.map((candidate) => ({ key: candidate.path, text: candidate.snippet, question: `Is the file "${candidate.path}" relevant to the request, judged by its excerpt in state.files?`, })), options.maxBytes, // A retry may borrow one request from the unit reserve: without file verdicts there is nothing to excerpt. { send: fileSendCap, retry: requests.left - 1 }, ); const scored = candidates.map((candidate, index) => ({ ...candidate, relevance: filesOutcome.scores[index] })); scored.forEach((candidate, index) => coverage.add( candidate.relevance === undefined ? "files unjudged" : candidate.relevance < FIND_MIN_RELEVANCE ? "files rejected" : "files accepted", candidate.path, candidate.relevance === undefined ? filesOutcome.itemReasons[index] ?? "missing" : `noul=${candidate.relevance.toFixed(2)}; accept threshold=${FIND_MIN_RELEVANCE}`, )); const judged = scored.filter((candidate) => candidate.relevance !== undefined).length; const unjudgedFiles = candidates.length - judged; if (pastCap > 0) { notes.push(`${pastCap} file(s) past the ${judgeCap}-file judging cap were not judged; narrow with \`pattern\`, \`glob\`, or \`path\`.`); } if (judged === 0) { return fallback(filesOutcome.reasons[0] ?? "missing", notes, Date.now() - started, requests); } if (unjudgedFiles > 0) notes.push(`${unjudgedFiles} file(s) were not judged (${filesOutcome.reasons.join(", ") || "missing"}).`); const ranked = scored .filter((candidate): candidate is FindCandidate & { relevance: number } => candidate.relevance !== undefined) .sort((a, b) => b.relevance - a.relevance); const relevant = ranked.filter((candidate) => candidate.relevance >= FIND_MIN_RELEVANCE); const shown = relevant.slice(0, limit); // 3. Source units of the relevant files, in relevance order, within the requests left. const ts = shown.some((candidate) => isScriptPath(candidate.path)) ? await (options.loadTypeScript ?? loadTypeScript)() : undefined; const perFile = shown.map((candidate) => { const text = readText(candidate.path) ?? ""; const rows = fileRows(text); const hits = [...new Set([...candidate.lines, ...keywordLines(text, words).lines])]; const picks = text === "" ? [] : pickUnits(splitSourceUnits(candidate.path, text, ts), rows, hits, words); return { candidate, rows, hits, picks }; }); const unitItems: JudgeItem[] = []; const owners: Array<{ file: number; unit: SourceUnit }> = []; const unitCapacity = requests.left * FIND_BATCH; perFile.forEach((entry, file) => { for (const unit of entry.picks) { if (unitItems.length >= unitCapacity) { coverage.add("units skipped", `${entry.candidate.path}:${unit.start}-${unit.end}`, "request cap"); continue; } const key = `${entry.candidate.path} lines ${unit.start}-${unit.end} (${unit.name})`; unitItems.push({ key, text: unitView(unit, focusOf(unit, entry.hits)) .map((line) => (line === 0 ? "…" : (entry.rows[line - 1] ?? ""))) .join("\n"), question: `Does the source unit "${key}" in state.units implement, define, configure, or directly answer the request?`, }); owners.push({ file, unit }); } }); const unitsOutcome = await judgeNouls( requests, { request, stateKey: "units", criteria: UNIT_CRITERIA }, unitItems, options.maxBytes, { send: requests.left, retry: requests.left }, ); owners.forEach((owner, index) => { const score = unitsOutcome.scores[index]; coverage.add( score === undefined ? "units unjudged" : score < FIND_MIN_RELEVANCE ? "units rejected" : "units accepted", `${perFile[owner.file].candidate.path}:${owner.unit.start}-${owner.unit.end}`, score === undefined ? unitsOutcome.itemReasons[index] ?? "missing" : `noul=${score.toFixed(2)}; accept threshold=${FIND_MIN_RELEVANCE}`, ); }); const elapsedMs = Date.now() - started; const blocks: SourceBlock[] = []; const listing: string[] = []; let windowed = 0; perFile.forEach((entry, file) => { const judgedUnits = owners .map((owner, index) => ({ ...owner, score: unitsOutcome.scores[index] })) .filter((owner) => owner.file === file); const answered = judgedUnits.some((owner) => owner.score !== undefined); const fallbackUnits = fallbackSourceUnits( entry.candidate.path, readText(entry.candidate.path) ?? "", ts, entry.rows, entry.hits, words, ); const units = answered || entry.rows.length === 0 || entry.picks.length === 0 ? judgedUnits.filter((owner) => (owner.score ?? 0) >= FIND_MIN_RELEVANCE).map((owner) => owner.unit) : fallbackUnits.length > 0 ? fallbackUnits : keywordWindows(entry.rows.length, entry.hits); if (!answered && units.length > 0) windowed += 1; for (const unit of units) { blocks.push({ path: entry.candidate.path, unit, view: unitView(unit, focusOf(unit, entry.hits)), rows: entry.rows }); } const leads = units.map((unit) => `${unit.name} lines ${unit.start}-${unit.end}`).join(", "); listing.push(`- ${entry.candidate.path} (${entry.candidate.relevance.toFixed(2)})${leads ? ` — reading leads: ${leads}` : ""}`); }); if (windowed > 0) { notes.push( `Sai did not judge the source units of ${windowed} file(s) (${unitsOutcome.reasons.join(", ") || "missing"}); matching source units or keyword windows are shown instead.`, ); } if (relevant.length > shown.length) { const rest = relevant.slice(shown.length); notes.push( `${rest.length} more relevant file(s) past the limit: ${rest .slice(0, 5) .map((candidate) => `${candidate.path} (${candidate.relevance.toFixed(2)})`) .join(", ")}${rest.length > 5 ? ", …" : ""}.`, ); } const source = renderSourceBlocks(blocks); const diagnostic = requests.diagnostics[0]; if (diagnostic) notes.push(`Diagnostic: ${formatSaiDiagnostic(diagnostic)}`); if (source.omitted > 0) { notes.push(`${source.omitted} source block(s) left out to stay within ${FIND_MAX_SOURCE_BYTES / 1024} KB; read them from the leads.`); } const lines = [ `sai_find: ${relevant.length} relevant file(s) (judged ${judged} of ${files.length} candidates via ${options.model} in ${elapsedMs}ms)`, ...notes, ...(shown.length > 0 ? listing : [ "Sai rejected every judged file excerpt; this does not prove the behavior is absent. Try broader discovery or inspect these candidates:", ...ranked.slice(0, 3).map((candidate) => `- ${candidate.path} (${candidate.relevance.toFixed(2)})`), ]), ...source.lines, scope, ...coverage.render(), "Diagnostics are sampled, not exhaustive. If results are weak, broaden the scope or use grep/read; do not infer absence from Sai rejection.", "", "End context.", ]; const failure = filesOutcome.reasons.find((reason) => reason !== "request cap" && reason !== "too large"); return { text: lines.join("\n"), details: { total: files.length, coverage: coverage.counts, judged, relevant: relevant.length, requests: requests.sent, retried: requests.retried, sourceBytes: source.bytes, elapsedMs, ...(failure ? { failure } : {}), ...(diagnostic ? { diagnostic } : {}), }, judged, topRelevance: ranked[0]?.relevance, ...(failure ? { failure } : {}), ...(diagnostic ? { diagnostic } : {}), elapsedMs, }; } const SaiFindParams = Type.Object({ question: Type.String({ description: "What you want to understand, in plain words (e.g. 'how is the retry backoff configured').", }), pattern: Type.Optional( Type.String({ description: "ripgrep regex to narrow candidates to files that match; omit to consider every listed file." }), ), glob: Type.Optional(Type.String({ description: "File glob filter, e.g. '*.ts' or 'src/**/*.md'." })), path: Type.Optional(Type.String({ description: "Directory to search, inside the working directory (default: '.')." })), ignoreCase: Type.Optional(Type.Boolean({ description: "Case-insensitive pattern." })), limit: Type.Optional( Type.Number({ description: `Most relevant files to return, with their source (default ${FIND_DEFAULT_LIMIT}).` }), ), }); // --------------------------------------------------------------------------- // Tool // --------------------------------------------------------------------------- const QuestionSpec = Type.Object({ type: StringEnum(ASK_SAI_QUESTION_TYPES, { description: "choice: pick one option. score: place the state on a rubric. noul: probability a statement holds.", }), instructions: Type.String({ description: "The question, stated in one sentence." }), criteria: Type.Optional( Type.Unknown({ description: 'choice: {"option": "what the option means"} (at least two). score: an ordered array of level labels. noul: {"true": "...", "false": "..."}.', }), ), focus: Type.Optional(Type.String({ description: "Extra judging guidance for this question." })), }); const AskSaiParams = Type.Object({ state: Type.Optional(Type.String({ description: "Your own prose: the situation and what you are deciding." })), paths: Type.Optional( Type.Array(Type.String(), { description: `Code to include as state, read-only (max ${MAX_ASK_PATHS}). Relative paths resolve against the working directory; paths outside it are refused.`, }), ), imagePaths: Type.Optional( Type.Array(Type.String(), { maxItems: MAX_CLASSIFIER_IMAGES, description: `Local images to judge with the state, read-only (max ${MAX_CLASSIFIER_IMAGES}): PNG, JPEG, or WebP files inside the working directory, up to ${MAX_IMAGE_BYTES / 1024 / 1024} MiB of encoded data in total.`, }), ), command: Type.Optional(Type.String({ description: "One shell command whose output becomes state." })), questions: Type.Record(Type.String(), QuestionSpec, { description: "Question name -> spec." }), }); export default function saiAskToolExtension(pi: ExtensionAPI): void { pi.registerTool({ name: ASK_SAI_TOOL, label: "Ask Sai", description: [ "Ask Sai (a typed decisions model, not a chat model) a block of questions about a state you assemble here:", "your own prose in `state`, code read from `paths`, and the output of one shell `command`. Code makes one", "bounded call and returns answers only — the file contents and command output are never sent back to you.", "Anything you put in the state is sent to Sai, so pass only code you are willing to share.", `\`imagePaths\` optionally attaches up to ${MAX_CLASSIFIER_IMAGES} local PNG, JPEG, or WebP files from inside`, "the working directory; they are read whole and sent as images beside the same state.", "An unsafe, unsupported, or oversized image set refuses the whole call: the command does not run and", "nothing is sent as a text-only request.", '`questions` is an object keyed by question name; each value is {"type": "choice" | "score" | "noul",', '"instructions": , "criteria": , "focus"?: }.', 'choice criteria: {"option": "what the option means"}; score criteria: an ordered array of level labels;', 'noul criteria: {"true": "...", "false": "..."} (optional for noul).', "Each answer returns the chosen value, score, or probability plus a confidence; raw probability", "distributions are omitted.", "Use it for a bounded decision when you can supply the evidence: e.g. classify a failure from its output", "and relevant code, or flag a specific risk in a diff. Ask one snap judgment per question; batch independent questions.", "Describe concrete situations in criteria, not vague degrees. Add an other/insufficient_evidence choice when options may be incomplete.", "Keep numbers, exit-code checks, and counting in code. Sai does not investigate missing evidence or replace a code review.", "Confidence describes the supplied evidence, not proof of correctness; incomplete or clipped evidence needs inspection.", `Bounds: ${MAX_ASK_QUESTIONS} questions, ${MAX_ASK_PATHS} paths per call; each file is clipped to about ${MAX_ASK_FILE_BYTES / 1024} KB and marked when clipped; up to ${MAX_CLASSIFIER_IMAGES} images within a ${MAX_IMAGE_BYTES / 1024 / 1024} MiB encoded aggregate.`, ].join(" "), promptSnippet: "Ask Sai a typed question about state you assemble from prose, files, or one command", promptGuidelines: [ "Use ask_sai selectively for bounded judgments over evidence you can supply, not as a mandatory step before investigation, planning, or edits. Inspect facts directly and ask the user about decisions only they can make.", "For ask_sai, ask one specific judgment per question, batch independent questions, provide concrete criteria and an other/insufficient_evidence exit for incomplete choices. Example: given failure output and relevant code, classify code_bug/test_bug/environment/insufficient_evidence; do not ask it to diagnose unseen code.", "Use ask_sai when a verdict is enough; use read/grep when you need source, explanations, or exact evidence. Treat low confidence, missing material, and truncation as reasons to inspect more evidence, not to trust a forced answer. Keep counting and exit-code checks in code.", ], exposure: "direct", parameters: AskSaiParams, async execute(_toolCallId, params, signal, _onUpdate, ctx: ExtensionContext) { const text = (value: string) => ({ content: [{ type: "text" as const, text: value }], details: {} }); const config = readSaiAdvisoryConfig(getSettingsPath()); const route = config.routes.ask; if (!route.enabled) { return text( `ask_sai is disabled (saiAdvisory.routes.ask.enabled is false in ${getSettingsPath()}). Remove that setting or set it to true.`, ); } const parsed = parseAskQuestions(params.questions as Record | undefined); if (parsed.error !== undefined) { return text(`ask_sai needs a usable question block: ${parsed.error}`); } const questions = parsed.questions; // The image block is schema-checked here; the files are not touched until the credential // and the classifier's image input are known good. Only `imagePaths` is ever read: prior // attachments, session images, and history are not harvested. const requestedImages = parseAskImagePaths(params.imagePaths); if ("error" in requestedImages) { return text(`ask_sai refused the image inputs: ${requestedImages.error} Nothing was read, run, or sent.`); } /** An abstention that happens before any file read or command; no content travels. */ const abstain = (failure: SaiAbstainReason, diagnostic?: SaiFailureDiagnostic) => ({ content: [ { type: "text" as const, text: `${renderAskAnswers({ questions, answers: {}, rejected: {}, failure, model: config.model, elapsedMs: 0, refused: [], notes: [], ...(diagnostic ? { diagnostic } : {}), })}\nNothing was read, run, or sent.`, }, ], details: { answered: 0, questions: questions.length, model: config.model, elapsedMs: 0, failure, ...(diagnostic ? { diagnostic } : {}), }, }); /** A refused local image set refuses the whole call: no command, no classification, no text-only downgrade. */ const refuseImages = (refusal: AskImageRefusal) => { const diagnostic = askImageRefusalDiagnostic(refusal); emitSaiTelemetry(pi.events, "decision", { route: "ask", outcome: "fallback", candidates: questions.length, confidence: confidenceBucket(undefined), elapsedMs: 0, reason: refusal.failure, }); return { content: [ { type: "text" as const, text: renderAskImageRefusal({ questions, model: config.model, refusal, diagnostic }), }, ], details: { answered: 0, questions: questions.length, model: config.model, elapsedMs: 0, failure: refusal.failure, diagnostic, }, }; }; // A caller that has already cancelled costs no credential lookup and no file read. if (signal?.aborted) return abstain("timeout", { kind: "timeout" }); // Reading files and running the command exist only to feed Sai: without a reachable // Sai, do neither (an `npm test` would otherwise run in full and be thrown away). const connection = saiConnection(config, route); const unreachable = await saiUnavailable(ctx, connection); if (unreachable) { if (ctx.hasUI) warnSaiUnavailableOnce(ctx.ui, config.provider); emitSaiTelemetry(pi.events, "decision", { route: "ask", outcome: "fallback", candidates: questions.length, confidence: confidenceBucket(undefined), elapsedMs: 0, reason: unreachable, }); return { content: [ { type: "text" as const, text: `${renderAskAnswers({ questions, answers: {}, rejected: {}, failure: unreachable, model: config.model, elapsedMs: 0, refused: [], notes: [], })}\nNothing was read, run, or sent. Decide without Sai: read the files or run the command yourself.`, }, ], details: { answered: 0, questions: questions.length, model: config.model, elapsedMs: 0, failure: unreachable }, }; } // Credential resolution is awaited: a cancellation during it must still abstain before // any image or text file is read or any command runs. if (signal?.aborted) return abstain("timeout", { kind: "timeout" }); // Images are gated only once the credential and the configured classifier are known: a // model without image input refuses the set rather than dropping it or asking text-only. let images: ImageContent[] = []; if (requestedImages.paths.length > 0) { const classifier = ctx.modelRegistry.findOfType("classifier", connection.provider, connection.model); if (!classifier?.input.includes("image")) return refuseImages({ failure: "images-unsupported", read: false }); const loaded = readAskImages(requestedImages.paths, ctx.cwd); if ("refusal" in loaded) return refuseImages(loaded.refusal); images = loaded.images; } const questionBytes = Buffer.byteLength( JSON.stringify(buildAskPayload(questions, {}).questions), "utf-8", ); const requested = (params.paths ?? []).slice(0, MAX_ASK_PATHS); const budget = askPartBudget(route.payloadBytes, requested.length, questionBytes); const refused: string[] = []; const notes: string[] = []; const prose = clip((params.state ?? "").trim(), budget.stateChars); if (prose.clipped) notes.push("state"); const files: Record = {}; for (const raw of requested) { const path = askPath(raw, ctx.cwd); if ("refused" in path) { refused.push(`${raw} (${path.refused})`); continue; } const file = readBounded(path.full, Math.max(0, budget.fileBytes - Buffer.byteLength(TRUNCATION_MARKER, "utf-8"))); if ("error" in file) { refused.push(`${raw} (${file.error})`); continue; } if (file.clipped) notes.push(raw); files[relative(ctx.cwd, path.full)] = file.clipped ? `${file.text}${TRUNCATION_MARKER}` : file.text; } let command: Record | undefined; if (params.command?.trim()) { const result = await runAskCommand(params.command.trim(), ctx.cwd, signal); const output = clip(result.output, budget.commandBytes); if (output.clipped || result.clipped) notes.push("command output"); command = { command: params.command.trim(), output: output.text, exitCode: result.exitCode, ...(result.failed ? { failed: true } : {}), }; } const payload = buildAskPayload(questions, { ...(prose.text ? { request: prose.text } : {}), ...(Object.keys(files).length > 0 ? { files } : {}), ...(command ? { command } : {}), }); if (!fitsClassifierRequest(payload, route.payloadBytes)) { return text("ask_sai could not fit this request in its budget; pass fewer paths or a shorter command."); } const result = await askSaiAnswers(ctx, connection, { payload, maxBytes: route.payloadBytes, ...(images.length > 0 ? { images } : {}), ...(signal ? { signal } : {}), }); const answers = result.answers ?? {}; const rejected: Record = {}; let answered = 0; let topConfidence: number | undefined; for (const question of questions) { const answer = answers[question.name]; if (!isRecord(answer)) { rejected[question.name] = result.failure ?? "missing"; continue; } answered += 1; if (typeof answer.confidence === "number") { topConfidence = topConfidence === undefined ? answer.confidence : Math.max(topConfidence, answer.confidence); } } // Content-free: no state, answers, or criteria travel, only shape and outcome. emitSaiTelemetry(pi.events, "decision", { route: "ask", outcome: answered > 0 ? "sai" : "fallback", candidates: questions.length, confidence: confidenceBucket(topConfidence), elapsedMs: result.elapsedMs, ...(answered > 0 ? {} : { reason: result.failure ?? "missing" }), }); return { content: [ { type: "text" as const, text: renderAskAnswers({ questions, answers, rejected, failure: result.failure, model: config.model, elapsedMs: result.elapsedMs, refused, notes, pathsRequested: requested.length, cwd: ctx.cwd, ...(result.diagnostic ? { diagnostic: result.diagnostic } : {}), }), }, ], // Deliberately shape only: tool details are persisted in the session. details: { answered, questions: questions.length, model: config.model, elapsedMs: result.elapsedMs, ...(result.failure ? { failure: result.failure } : {}), ...(result.diagnostic ? { diagnostic: result.diagnostic } : {}), ...(result.usage ? { usage: result.usage } : {}), ...(result.reportedCost !== undefined ? { reportedCost: result.reportedCost } : {}), ...(result.unpricedUsage ? { unpricedUsage: result.unpricedUsage } : {}), }, }; }, }); pi.registerTool({ name: SAI_FIND_TOOL, label: "Sai Find", description: [ "Find where behavior lives and read it in one call. ripgrep gathers candidate files (those matching `pattern`,", "or every file under `path` filtered by `glob`); Sai narrows a large tree directory by directory, judges each", "remaining file's excerpt against `question`, then judges the functions, classes, and sections of the relevant", "files. Returns the relevant files with reading leads AND the accepted source verbatim with original line", `numbers (about ${FIND_MAX_SOURCE_BYTES / 1024} KB of source, at most ${FIND_MAX_REQUESTS} Sai requests per call).`, "Includes discovery scope and bounded, sampled directory/file/unit verdicts and skip reasons, so you can distinguish discovery gaps from Sai rejection.", "A weak or empty result is not proof of absence: broaden discovery or use grep/read. Respects .gitignore; secret-named files are never sent.", ].join(" "), promptSnippet: "Find where behavior lives: Sai-judged relevant files plus their verbatim source with line numbers", promptGuidelines: [ "Use sai_find optionally for locating behavior by meaning. Prefer grep/read for exact strings, known symbols, or known filenames; sai_find is not a prerequisite for ordinary search or reading.", "For sai_find, ask a focused question and scope candidates only when justified. Its pattern is a hard discovery filter, not a hint. Read returned source and diagnostics; weak or empty results call for broader discovery or grep/read, not repeated blind Sai calls or a claim of absence.", "When delegating discovery to a subagent, share known identifiers and scope; sai_find is optional. Verify source before editing, and read past clipped blocks when needed.", ], exposure: "direct", parameters: SaiFindParams, async execute(_toolCallId, params, signal, _onUpdate, ctx: ExtensionContext) { const text = (value: string, details: Record = {}) => ({ content: [{ type: "text" as const, text: value }], details, }); const config = readSaiAdvisoryConfig(getSettingsPath()); const route = config.routes.ask; if (!route.enabled) { return text(`sai_find is disabled (saiAdvisory.routes.ask.enabled is false in ${getSettingsPath()}).`); } const question = params.question?.trim() ?? ""; if (question === "") return text("sai_find needs a non-empty `question`."); const rootArg = params.path?.trim() || "."; const rootFull = resolve(ctx.cwd, rootArg); const inside = relative(resolve(ctx.cwd), rootFull); if (inside.startsWith("..") || isAbsolute(inside)) { return text(`sai_find: ${rootArg} is outside the working directory.`); } const rg = await ensureTool("rg"); if (!rg) return text("sai_find needs ripgrep (rg), which is not available; use grep instead."); // Without Sai the ripgrep-ranked files and their keyword lines still come back: the call is never wasted. const connection = saiConnection(config, route); const unreachable = await saiUnavailable(ctx, connection); let result: SaiFindResult; try { result = await runSaiFind({ rg, cwd: ctx.cwd, root: inside === "" ? "." : inside, question, pattern: params.pattern || undefined, glob: params.glob || undefined, ignoreCase: params.ignoreCase, limit: params.limit, maxBytes: route.payloadBytes, model: config.model, ...(unreachable ? { unreachable } : { ask: (payload) => askSaiAnswers(ctx, connection, { payload, maxBytes: route.payloadBytes }) }), signal, }); } catch (error) { return text(`sai_find: ripgrep failed (${error instanceof Error ? error.message.split("\n")[0] : String(error)}).`); } if (result.details.total !== 0) { emitSaiTelemetry(pi.events, "decision", { route: "find", outcome: result.judged > 0 ? "sai" : "fallback", candidates: result.details.total, confidence: confidenceBucket(result.topRelevance), elapsedMs: result.elapsedMs, ...(result.judged > 0 ? {} : { reason: result.failure ?? "missing" }), }); } return text(result.text, result.details); }, }); // Keep registered tools/loadouts stable when the service or credential is unavailable. // execute() already returns a bounded actionable error; removing tools clears pending loadouts. }