import * as fs from "node:fs/promises"; import * as nodePath from "node:path"; import type { AgentToolResult } from "@f5-sales-demo/pi-agent-core"; import { StringEnum } from "@f5-sales-demo/pi-ai"; import { ChunkAnchorStyle, ChunkEditOp, type ChunkInfo, ChunkReadStatus, type ChunkReadTarget, ChunkRegion, ChunkState, type EditOperation as NativeEditOperation, } from "@f5-sales-demo/pi-natives"; import { $envpos } from "@f5-sales-demo/pi-utils"; import { type Static, Type } from "@sinclair/typebox"; import type { BunFile } from "bun"; import { LRUCache } from "lru-cache"; import type { Settings } from "../../config/settings"; import type { WritethroughCallback, WritethroughDeferredHandle } from "../../lsp"; import { getLanguageFromPath } from "../../modes/theme/theme"; import type { ToolSession } from "../../tools"; import { assertEditableFileContent } from "../../tools/auto-generated-guard"; import { invalidateFsScanAfterWrite } from "../../tools/fs-cache-invalidation"; import { outputMeta } from "../../tools/output-meta"; import { enforcePlanModeWrite, resolvePlanPath } from "../../tools/plan-mode-guard"; import { generateUnifiedDiffString } from "../diff"; import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "../normalize"; import type { EditToolDetails, LspBatchRequest } from "../renderer"; export type { ChunkReadTarget }; export type ChunkEditOperation = | { op: "put"; sel?: string; content: string } | { op: "replace"; sel?: string; content: string; find: string } | { op: "delete"; sel?: string } | { op: "before"; sel?: string; content: string } | { op: "after"; sel?: string; content: string } | { op: "prepend"; sel?: string; content: string } | { op: "append"; sel?: string; content: string }; type ChunkEditResult = { diffSourceBefore: string; diffSourceAfter: string; responseText: string; changed: boolean; parseValid: boolean; touchedPaths: string[]; warnings: string[]; }; export type ParsedChunkReadPath = { filePath: string; selector?: string; }; type ChunkCacheEntry = { mtimeMs: number; size: number; source: string; state: ChunkState; }; const validAnchorStyles: Record = { full: ChunkAnchorStyle.Full, kind: ChunkAnchorStyle.Kind, bare: ChunkAnchorStyle.Bare, }; export function resolveChunkAutoIndent(rawValue = Bun.env.PI_CHUNK_AUTOINDENT): boolean { if (!rawValue) return true; const normalized = rawValue.trim().toLowerCase(); switch (normalized) { case "1": case "true": case "yes": case "on": return true; case "0": case "false": case "no": case "off": return false; default: throw new Error(`Invalid PI_CHUNK_AUTOINDENT: ${rawValue}`); } } function getChunkRenderIndentOptions(): { normalizeIndent: boolean; tabReplacement: string; } { return resolveChunkAutoIndent() ? { normalizeIndent: true, tabReplacement: " " } : { normalizeIndent: false, tabReplacement: "\t" }; } export function resolveAnchorStyle(settings?: Settings): ChunkAnchorStyle { const envStyle = Bun.env.PI_ANCHOR_STYLE; return ( (envStyle && validAnchorStyles[envStyle]) || (settings?.get("read.anchorstyle") as ChunkAnchorStyle | undefined) || ChunkAnchorStyle.Full ); } const chunkStateCache = new LRUCache({ max: $envpos("PI_CHUNK_CACHE_MAX_ENTRIES", 200), }); export function invalidateChunkCache(filePath: string): void { chunkStateCache.delete(filePath); } type ChunkSourceContext = { resolvedPath: string; sourceFile: BunFile; sourceExists: boolean; rawContent: string; chunkLanguage: string | undefined; }; function normalizeLanguage(language: string | undefined): string { return language?.trim().toLowerCase() || ""; } function normalizeChunkSource(text: string): string { return normalizeToLF(stripBom(text).text); } function displayPathForFile(filePath: string, cwd: string): string { const relative = nodePath.relative(cwd, filePath).replace(/\\/g, "/"); return relative && !relative.startsWith("..") ? relative : filePath.replace(/\\/g, "/"); } function fileLanguageTag(filePath: string, language?: string): string | undefined { const normalizedLanguage = normalizeLanguage(language); if (normalizedLanguage.length > 0) return normalizedLanguage; const ext = nodePath.extname(filePath).replace(/^\./, "").toLowerCase(); return ext.length > 0 ? ext : undefined; } async function resolveChunkSourceContext(session: ToolSession, path: string): Promise { const resolvedPath = resolvePlanPath(session, path); const sourceFile = Bun.file(resolvedPath); const sourceExists = await sourceFile.exists(); enforcePlanModeWrite(session, path, { op: sourceExists ? "update" : "create" }); let rawContent = ""; if (sourceExists) { rawContent = await sourceFile.text(); assertEditableFileContent(rawContent, path); } return { resolvedPath, sourceFile, sourceExists, rawContent, chunkLanguage: getLanguageFromPath(resolvedPath), }; } function normalizeChunkRegionSyntax(text: string): string { return text.replaceAll("@body", "~").replaceAll("@head", "^"); } function buildChunkEditResult(result: { diffBefore: string; diffAfter: string; responseText: string; changed: boolean; parseValid: boolean; touchedPaths: string[]; warnings: string[]; }): ChunkEditResult { return { diffSourceBefore: result.diffBefore, diffSourceAfter: result.diffAfter, responseText: result.responseText, changed: result.changed, parseValid: result.parseValid, touchedPaths: result.touchedPaths, warnings: result.warnings.map(normalizeChunkRegionSyntax), }; } function chunkReadPathSeparatorIndex(readPath: string): number { if (/^[a-zA-Z]:[/\\]/.test(readPath)) { return readPath.indexOf(":", 2); } return readPath.indexOf(":"); } export function parseChunkSelector(selector: string | undefined): { selector?: string } { if (!selector || selector.length === 0) { return {}; } return { selector }; } /** Split a combined `file:selector` path into file path and chunk selector. */ export function parseChunkEditPath(editPath: string | undefined): { filePath: string; selector?: string } { if (!editPath) return { filePath: "" }; const colonIndex = chunkReadPathSeparatorIndex(editPath); if (colonIndex === -1) { return { filePath: editPath }; } const sel = editPath.slice(colonIndex + 1) || undefined; return { filePath: editPath.slice(0, colonIndex), selector: sel }; } export function parseChunkReadPath(readPath: string): ParsedChunkReadPath { const colonIndex = chunkReadPathSeparatorIndex(readPath); if (colonIndex === -1) { return { filePath: readPath }; } const parsedSelector = parseChunkSelector(readPath.slice(colonIndex + 1) || undefined); return { filePath: readPath.slice(0, colonIndex), selector: parsedSelector.selector, }; } export function isChunkReadablePath(readPath: string): boolean { return parseChunkReadPath(readPath).selector !== undefined; } export async function loadChunkStateForFile(filePath: string, language: string | undefined): Promise { const file = Bun.file(filePath); const stat = await file.stat(); const cached = chunkStateCache.get(filePath); if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) { return cached; } const source = normalizeChunkSource(await file.text()); const state = ChunkState.parse(source, normalizeLanguage(language)); const entry = { mtimeMs: stat.mtimeMs, size: stat.size, source, state }; chunkStateCache.set(filePath, entry); return entry; } export async function formatChunkedRead(params: { filePath: string; readPath: string; cwd: string; language?: string; omitChecksum?: boolean; anchorStyle?: ChunkAnchorStyle; absoluteLineRange?: { startLine: number; endLine?: number }; }): Promise<{ text: string; resolvedPath?: string; chunk?: ChunkReadTarget }> { const { filePath, readPath, cwd, language, omitChecksum = false, anchorStyle, absoluteLineRange } = params; const normalizedLanguage = normalizeLanguage(language); const { state } = await loadChunkStateForFile(filePath, normalizedLanguage); const displayPath = displayPathForFile(filePath, cwd); const renderIndentOptions = getChunkRenderIndentOptions(); const result = state.renderRead({ readPath, displayPath, languageTag: fileLanguageTag(filePath, normalizedLanguage), omitChecksum, anchorStyle, absoluteLineRange: absoluteLineRange ? { startLine: absoluteLineRange.startLine, endLine: absoluteLineRange.endLine ?? absoluteLineRange.startLine } : undefined, tabReplacement: renderIndentOptions.tabReplacement, normalizeIndent: renderIndentOptions.normalizeIndent, }); return { text: result.text, resolvedPath: filePath, chunk: result.chunk }; } export type ChunkedGrepMatch = { displayPath: string; fileLineCount: number; chunkPath?: string; chunkChecksum?: string; lineNumber: number; line: string; }; export async function describeChunkedGrepMatch(params: { filePath: string; lineNumber: number; line: string; cwd: string; language?: string; }): Promise { const { filePath, lineNumber, line, cwd, language } = params; const { state } = await loadChunkStateForFile(filePath, language); const chunkPath = state.lineToContainingChunkPath(lineNumber) || undefined; const chunkInfo = chunkPath ? state.chunk(chunkPath) : null; return { displayPath: displayPathForFile(filePath, cwd), fileLineCount: state.lineCount, chunkPath, chunkChecksum: chunkInfo?.checksum, lineNumber, line, }; } const CHUNK_CHECKSUM_ALPHABET = "ZPMQVRWSNKTXJBYH"; type NativeChunkRegion = "head" | "body"; function isChunkChecksumToken(value: string): boolean { return value.length === 4 && Array.from(value).every(ch => CHUNK_CHECKSUM_ALPHABET.includes(ch.toUpperCase())); } function parseChunkEditSelector(selector: string | undefined): { selector?: string; crc?: string; region?: NativeChunkRegion; } { if (!selector) { return {}; } let trimmed = selector.trim(); if (trimmed.length === 0) { return {}; } let region: NativeChunkRegion | undefined; const suffix = trimmed.at(-1); if (suffix === "~" || suffix === "^") { region = suffix === "~" ? "body" : "head"; trimmed = trimmed.slice(0, -1).trimEnd(); } let selectorPart = trimmed; let crc: string | undefined; const hashIndex = selectorPart.lastIndexOf("#"); if (hashIndex >= 0) { const suffix = selectorPart.slice(hashIndex + 1).trim(); if (isChunkChecksumToken(suffix)) { crc = suffix.toUpperCase(); selectorPart = selectorPart.slice(0, hashIndex).trimEnd(); } } else if (isChunkChecksumToken(selectorPart)) { crc = selectorPart.toUpperCase(); selectorPart = ""; } return { selector: selectorPart || undefined, crc, region }; } type NativeChunkRegionEncoding = "named" | "symbolic"; function toNativeEditRegion( region: NativeChunkRegion | undefined, encoding: NativeChunkRegionEncoding, ): NativeEditOperation["region"] | undefined { if (!region) { return undefined; } if (encoding === "symbolic") { return region === "body" ? ChunkRegion.Body : ChunkRegion.Head; } return region as unknown as NativeEditOperation["region"] | undefined; } function toNativeEditOperation( operation: ChunkEditOperation, defaultRegion: NativeChunkRegion | undefined, encoding: NativeChunkRegionEncoding, ): NativeEditOperation { const { selector, crc, region } = parseChunkEditSelector(operation.sel); const nativeRegion = toNativeEditRegion(operation.sel === undefined ? (region ?? defaultRegion) : region, encoding); switch (operation.op) { case "put": return { op: ChunkEditOp.Put, sel: selector, crc, region: nativeRegion, content: operation.content, }; case "replace": return { op: ChunkEditOp.Replace, sel: selector, crc, region: nativeRegion, find: operation.find, content: operation.content, }; case "before": return { op: ChunkEditOp.Before, sel: selector, crc, region: nativeRegion, content: operation.content }; case "after": return { op: ChunkEditOp.After, sel: selector, crc, region: nativeRegion, content: operation.content }; case "prepend": return { op: ChunkEditOp.Prepend, sel: selector, crc, region: nativeRegion, content: operation.content }; case "append": return { op: ChunkEditOp.Append, sel: selector, crc, region: nativeRegion, content: operation.content }; case "delete": return { op: ChunkEditOp.Delete, sel: selector, crc, region: nativeRegion }; default: { const exhaustive: never = operation; return exhaustive; } } } function buildNativeChunkEditRequest( params: { defaultSelector?: string; defaultCrc?: string; operations: ChunkEditOperation[] }, encoding: NativeChunkRegionEncoding, ): Pick[0], "operations" | "defaultSelector" | "defaultCrc"> { const parsedDefaultSelector = parseChunkEditSelector(params.defaultSelector); const operations = params.operations.map(operation => toNativeEditOperation(operation, parsedDefaultSelector.region, encoding), ); return { operations, defaultSelector: parsedDefaultSelector.selector, defaultCrc: params.defaultCrc ?? parsedDefaultSelector.crc, }; } function isChunkRegionEncodingError(error: unknown): error is Error { return ( error instanceof Error && /value `"(body|head|~|\^)"` does not match any variant of enum `ChunkRegion`/.test(error.message) ); } export function applyChunkEdits(params: { source: string; language?: string; cwd: string; filePath: string; operations: ChunkEditOperation[]; defaultSelector?: string; defaultCrc?: string; anchorStyle?: ChunkAnchorStyle; }): ChunkEditResult { const normalizedSource = normalizeChunkSource(params.source); const applyNativeEdits = (encoding: NativeChunkRegionEncoding): ChunkEditResult => { const request = buildNativeChunkEditRequest(params, encoding); const state = ChunkState.parse(normalizedSource, normalizeLanguage(params.language)); return buildChunkEditResult( state.applyEdits({ operations: request.operations, normalizeIndent: resolveChunkAutoIndent(), defaultSelector: request.defaultSelector, defaultCrc: request.defaultCrc, anchorStyle: params.anchorStyle, cwd: params.cwd, filePath: params.filePath, }), ); }; try { return applyNativeEdits("named"); } catch (error) { if (isChunkRegionEncodingError(error)) { try { return applyNativeEdits("symbolic"); } catch (fallbackError) { if (fallbackError instanceof Error) { throw new Error(normalizeChunkRegionSyntax(fallbackError.message)); } throw fallbackError; } } if (error instanceof Error) { throw new Error(normalizeChunkRegionSyntax(error.message)); } throw error; } } export async function getChunkInfoForFile( filePath: string, language: string | undefined, chunkPath: string, ): Promise { const { state } = await loadChunkStateForFile(filePath, language); return state.chunk(chunkPath) ?? undefined; } export function missingChunkReadTarget(selector: string): ChunkReadTarget { return { status: ChunkReadStatus.NotFound, selector }; } export const chunkToolEditSchema = Type.Object( { path: Type.String({ description: "File path with chunk selector. Examples: 'src/app.ts:fn_foo#ABCD~', 'src/app.ts:class_Bar'.", }), write: Type.Optional( Type.Union([Type.String(), Type.Null()], { description: "Write complete new content to the targeted region. Use null to delete the chunk.", }), ), replace: Type.Optional( Type.Object( { old: Type.String({ description: "Literal substring to find. Must match exactly once." }), new: Type.String({ description: "Replacement text." }), }, { description: "Find and replace a substring within the chunk." }, ), ), insert: Type.Optional( Type.Object( { loc: StringEnum(["append", "prepend"] as const), body: Type.String({ description: "Content to insert." }), }, { description: "Insert content relative to the chunk." }, ), ), }, { additionalProperties: false }, ); export const chunkEditParamsSchema = Type.Object( { edits: Type.Array(chunkToolEditSchema, { description: "Chunk edits", minItems: 1, }), }, { additionalProperties: false }, ); export type ChunkToolEdit = Static; export type ChunkParams = Static; export interface ExecuteChunkSingleOptions { session: ToolSession; path: string; edits: ChunkToolEdit[]; signal?: AbortSignal; batchRequest?: LspBatchRequest; writethrough: WritethroughCallback; beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle; } export function isChunkParams(params: unknown): params is ChunkParams { if ( typeof params !== "object" || params === null || !("edits" in params) || !Array.isArray(params.edits) || params.edits.length === 0 ) { return false; } const first = params.edits[0]; if (typeof first !== "object" || first === null || !("path" in first)) return false; return "write" in first || "replace" in first || "insert" in first; } /** Auto-correct indentation for content targeting a body region (`~`) when autoIndent is on. * Handles two patterns: * 1. Tab-based over-indentation: models include the function's base \t indent. * 2. Space-based indentation: models use literal spaces instead of \t. * Returns the corrected content and any warnings. */ function autoCorrectBodyIndent(content: string, index: number): { content: string; warnings: string[] } { const warnings: string[] = []; if (!content || !resolveChunkAutoIndent()) return { content, warnings }; const lines = content.split("\n"); const nonEmpty = lines.filter(l => l.length > 0); if (nonEmpty.length <= 1) return { content, warnings }; // 1. Tab-based over-indentation: strip common leading tabs. const minTabs = Math.min(...nonEmpty.map(l => l.match(/^\t*/)?.[0].length ?? 0)); if (minTabs >= 1) { const fixed = lines.map(l => (l.length === 0 ? l : l.slice(minTabs))).join("\n"); warnings.push( `Edit ${index + 1}: auto-corrected body indentation \u2014 stripped ${minTabs} leading tab(s). When writing to \`~\`, write at column 0; the tool adds the function's base indent.`, ); return { content: fixed, warnings }; } // 2. Space-based indentation: strip common leading spaces and convert to tabs. const spaceIndents = nonEmpty.map(l => l.match(/^ */)?.[0].length ?? 0); const minSpaces = Math.min(...spaceIndents); if (minSpaces >= 2) { const indentDiffs = spaceIndents.map(s => s - minSpaces).filter(d => d > 0); const indentUnit = indentDiffs.length > 0 ? Math.min(...indentDiffs) : 4; const unit = indentUnit >= 2 && indentUnit <= 8 ? indentUnit : 4; const fixed = lines .map(line => { if (line.length === 0) return line; const stripped = line.slice(minSpaces); const leadingSpaces = stripped.match(/^ */)?.[0].length ?? 0; const tabs = Math.floor(leadingSpaces / unit); const rem = leadingSpaces % unit; return "\t".repeat(tabs) + " ".repeat(rem) + stripped.slice(leadingSpaces); }) .join("\n"); warnings.push( `Edit ${index + 1}: auto-converted space indentation to tabs \u2014 stripped ${minSpaces} common leading spaces and converted ${unit}-space indent to tabs. When auto-indent is on, use \\t for indentation.`, ); return { content: fixed, warnings }; } return { content, warnings }; } function normalizeChunkEditOperations(edits: ChunkToolEdit[]): { operations: ChunkEditOperation[]; warnings: string[]; } { const warnings: string[] = []; const operations = edits.map((edit, index): ChunkEditOperation => { const { selector } = parseChunkEditPath(edit.path); // When multiple ops are present (model confusion), prefer write (total replacement) as the // safest default, then replace (surgical), then insert (additive), then delete. const hasInsert = edit.insert != null && typeof edit.insert.body === "string" && edit.insert.body.length > 0; const hasReplace = edit.replace != null && ((typeof edit.replace.old === "string" && edit.replace.old.length > 0) || (typeof edit.replace.new === "string" && edit.replace.new.length > 0)); const hasWrite = typeof edit.write === "string" && edit.write.length > 0; const opCount = [hasInsert, hasReplace, hasWrite].filter(Boolean).length; if (opCount > 1) { const chosen = hasWrite ? "write" : hasReplace ? "replace" : "insert"; const present = [hasWrite && "write", hasReplace && "replace", hasInsert && "insert"] .filter(Boolean) .join(", "); warnings.push( `Edit ${index + 1}: multiple operation fields set (${present}). Each edit entry must have exactly ONE of write/replace/insert — not multiple. Used "${chosen}", ignored the rest.`, ); } if (hasWrite) { let writeContent = edit.write!; if (selector?.endsWith("~")) { const corrected = autoCorrectBodyIndent(writeContent, index); writeContent = corrected.content; warnings.push(...corrected.warnings); } return { op: "put", sel: selector, content: writeContent }; } if (typeof edit.write === "string" && !hasInsert && !hasReplace) { return { op: "put", sel: selector, content: edit.write }; } if (hasReplace) { return { op: "replace", sel: selector, content: edit.replace!.new, find: edit.replace!.old }; } if (hasInsert) { const op = edit.insert!.loc === "prepend" ? "before" : "after"; let insertContent = edit.insert!.body; if (selector?.endsWith("~")) { const corrected = autoCorrectBodyIndent(insertContent, index); insertContent = corrected.content; warnings.push(...corrected.warnings); } return { op, sel: selector, content: insertContent }; } // write: null or no op specified → delete return { op: "delete", sel: selector }; }); return { operations, warnings }; } async function writeChunkResult(params: { result: ChunkEditResult; resolvedPath: string; sourceFile: BunFile; sourceText: string; sourceExists: boolean; signal?: AbortSignal; batchRequest?: LspBatchRequest; writethrough: WritethroughCallback; beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle; }): Promise> { const { result, resolvedPath, sourceFile, sourceText, sourceExists, signal, batchRequest, writethrough, beginDeferredDiagnosticsForPath, } = params; const { bom, text } = stripBom(sourceText); const originalEnding = detectLineEnding(text); const finalContent = bom + restoreLineEndings(result.diffSourceAfter, originalEnding); const diagnostics = await writethrough(resolvedPath, finalContent, signal, sourceFile, batchRequest, dst => dst === resolvedPath ? beginDeferredDiagnosticsForPath(resolvedPath) : undefined, ); invalidateFsScanAfterWrite(resolvedPath); const diffResult = generateUnifiedDiffString(result.diffSourceBefore, result.diffSourceAfter); const warningsBlock = result.warnings.length > 0 ? `\n\nWarnings:\n${result.warnings.join("\n")}` : ""; const meta = outputMeta() .diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? []) .get(); return { content: [{ type: "text", text: `${result.responseText}${warningsBlock}` }], details: { diff: diffResult.diff, firstChangedLine: diffResult.firstChangedLine, diagnostics, op: sourceExists ? "update" : "create", meta, }, }; } export async function executeChunkSingle( options: ExecuteChunkSingleOptions, ): Promise> { const { session, path, edits, signal, batchRequest, writethrough, beginDeferredDiagnosticsForPath } = options; const { resolvedPath, sourceFile, sourceExists, rawContent, chunkLanguage } = await resolveChunkSourceContext( session, path, ); const parentDir = nodePath.dirname(resolvedPath); if (parentDir && parentDir !== ".") { await fs.mkdir(parentDir, { recursive: true }); } const { operations: normalizedOperations, warnings: normWarnings } = normalizeChunkEditOperations(edits); if (!sourceExists && normalizedOperations.some(op => op.sel)) { throw new Error( `File does not exist: ${path}. Cannot resolve chunk selectors on a non-existent file. Use the write tool to create a new file, or check the path for typos.`, ); } const chunkResult = applyChunkEdits({ source: rawContent, language: chunkLanguage, cwd: session.cwd, filePath: resolvedPath, operations: normalizedOperations, anchorStyle: resolveAnchorStyle(session.settings), }); chunkResult.warnings.push(...normWarnings); if (!chunkResult.changed) { const warningsBlock = chunkResult.warnings.length > 0 ? `\n\nWarnings:\n${chunkResult.warnings.join("\n")}` : ""; return { content: [{ type: "text", text: `[No changes needed — content already matches.]${warningsBlock}` }], details: { diff: "", op: sourceExists ? "update" : "create", meta: outputMeta().get(), }, }; } return writeChunkResult({ result: chunkResult, resolvedPath, sourceFile, sourceText: rawContent, sourceExists, signal, batchRequest, writethrough, beginDeferredDiagnosticsForPath, }); }