import { createHash, randomUUID } from "node:crypto"; import { rawKeyHint, type ExtensionAPI, type ExtensionContext, } from "@earendil-works/pi-coding-agent"; import { Text } from "@earendil-works/pi-tui"; import { MAX_FILE_PATCH_BYTES, MAX_FILE_PATH_BYTES, MAX_PATCH_LINES, MAX_SUMMARY_FILES, MAX_SUMMARY_METADATA_BYTES, MAX_TASK_PATCH_BYTES, compareSnapshots, createSnapshot, type FileChange, type GitSnapshot, type PatchOmissionReason, type TaskSummary, } from "./git.ts"; import { EXPLANATION_PROMPT_VERSION, MAX_EXPLANATION_BYTES, MAX_RATIONALE_BYTES, MAX_TASK_REQUEST_BYTES, RATIONALE_PROMPT_VERSION, generateFileExplanation, generateFileRationale, normalizeTaskRequest, } from "./explain.ts"; import { formatSummary } from "./format.ts"; import { SummaryOverlay, type OverlayTask } from "./overlay.ts"; import { DEFAULT_SETTINGS, loadTaskDeltaSettings, resolveExplanationSelection, saveTaskDeltaSettings, showTaskDeltaSettings, type EffectiveEffort, type TaskDeltaSettings, } from "./settings.ts"; const ENTRY_TYPE = "task-git-summary"; const CONTEXT_ENTRY_TYPE = "task-git-context"; const CONTEXT_ENTRY_VERSION = 2; const EXPLANATION_ENTRY_TYPE = "task-git-explanation"; const EXPLANATION_ENTRY_VERSION = 2; const RATIONALE_ENTRY_TYPE = "task-git-rationale"; const RATIONALE_ENTRY_VERSION = 1; const MAX_TASK_HISTORY = 200; const MAX_RESTORED_ANALYSES = 4_096; const MAX_RESTORED_ANALYSIS_BYTES = 32 * 1024 * 1024; interface StoredTaskContext { version: 1 | typeof CONTEXT_ENTRY_VERSION; taskId: string; summaryHash: string; summaryEntryId?: string; taskRequest?: string; createdAt: number; } interface StoredFileExplanation { version: typeof EXPLANATION_ENTRY_VERSION; promptVersion: typeof EXPLANATION_PROMPT_VERSION; summaryHash: string; file: string; text: string; modelProvider: string; modelId: string; modelName: string; effort: EffectiveEffort; createdAt: number; } interface StoredFileRationale { version: typeof RATIONALE_ENTRY_VERSION; promptVersion: typeof RATIONALE_PROMPT_VERSION; taskId: string; summaryHash: string; file: string; text: string; modelProvider: string; modelId: string; modelName: string; effort: EffectiveEffort; createdAt: number; } const EXPLANATION_EFFORTS = new Set([ "off", "minimal", "low", "medium", "high", "xhigh", "max", ]); const PATCH_OMISSION_REASONS = new Set([ "file-too-large", "task-budget", "file-limit", "time-limit", "error", ]); function isNonNegativeInteger(value: unknown): value is number { return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; } function exceedsPatchLineLimit(patch: string): boolean { let lines = 1; for (const character of patch) { if (character !== "\n") continue; lines += 1; if (lines > MAX_PATCH_LINES) return true; } return false; } function isFileChange(value: unknown): value is FileChange { if (!value || typeof value !== "object") return false; const file = value as Partial; if ( typeof file.file !== "string" || Buffer.byteLength(file.file, "utf8") > MAX_FILE_PATH_BYTES || (file.status !== "A" && file.status !== "M" && file.status !== "D") || !isNonNegativeInteger(file.insertions) || !isNonNegativeInteger(file.deletions) || typeof file.binary !== "boolean" ) { return false; } if (file.patch !== undefined) { if (typeof file.patch !== "string") return false; if (Buffer.byteLength(file.patch, "utf8") > MAX_FILE_PATCH_BYTES) return false; if (exceedsPatchLineLimit(file.patch)) return false; } if ( file.patchOmitted !== undefined && !PATCH_OMISSION_REASONS.has(file.patchOmitted) ) { return false; } if (file.patch !== undefined && file.patchOmitted !== undefined) return false; if (file.binary && file.patch !== undefined) return false; return true; } function isTaskSummary(value: unknown): value is TaskSummary { if (!value || typeof value !== "object") return false; const summary = value as Partial; if ( !Array.isArray(summary.files) || summary.files.length === 0 || summary.files.length > MAX_SUMMARY_FILES || !isNonNegativeInteger(summary.totalInsertions) || !isNonNegativeInteger(summary.totalDeletions) ) { return false; } let patchBytes = 0; let metadataBytes = 0; let totalInsertions = 0; let totalDeletions = 0; for (const file of summary.files) { if (!isFileChange(file)) return false; totalInsertions += file.insertions; totalDeletions += file.deletions; if (!Number.isSafeInteger(totalInsertions) || !Number.isSafeInteger(totalDeletions)) { return false; } metadataBytes += Buffer.byteLength(JSON.stringify({ file: file.file, status: file.status, insertions: file.insertions, deletions: file.deletions, binary: file.binary, ...(file.patchOmitted === undefined ? {} : { patchOmitted: file.patchOmitted }), }), "utf8"); if (metadataBytes > MAX_SUMMARY_METADATA_BYTES) return false; if (file.patch !== undefined) { const bytes = Buffer.byteLength(JSON.stringify(file.patch), "utf8"); if (bytes > MAX_FILE_PATCH_BYTES) return false; patchBytes += bytes; if (patchBytes > MAX_TASK_PATCH_BYTES) return false; } } return ( totalInsertions === summary.totalInsertions && totalDeletions === summary.totalDeletions ); } function hasStoredAnalysisFields( analysis: Partial, maxTextBytes: number, ): boolean { return ( typeof analysis.summaryHash === "string" && /^[0-9a-f]{64}$/.test(analysis.summaryHash) && typeof analysis.file === "string" && Buffer.byteLength(analysis.file, "utf8") <= MAX_FILE_PATH_BYTES && typeof analysis.text === "string" && analysis.text.length > 0 && Buffer.byteLength(analysis.text, "utf8") <= maxTextBytes && typeof analysis.modelProvider === "string" && analysis.modelProvider.length > 0 && Buffer.byteLength(analysis.modelProvider, "utf8") <= 512 && typeof analysis.modelId === "string" && analysis.modelId.length > 0 && Buffer.byteLength(analysis.modelId, "utf8") <= 512 && typeof analysis.modelName === "string" && analysis.modelName.length > 0 && Buffer.byteLength(analysis.modelName, "utf8") <= 1_024 && analysis.effort !== undefined && EXPLANATION_EFFORTS.has(analysis.effort) && isNonNegativeInteger(analysis.createdAt) ); } function isStoredTaskContext(value: unknown): value is StoredTaskContext { if (!value || typeof value !== "object") return false; const context = value as Partial; return ( (context.version === 1 || context.version === CONTEXT_ENTRY_VERSION) && typeof context.taskId === "string" && context.taskId.length > 0 && Buffer.byteLength(context.taskId, "utf8") <= 128 && typeof context.summaryHash === "string" && /^[0-9a-f]{64}$/.test(context.summaryHash) && ( context.summaryEntryId === undefined || ( typeof context.summaryEntryId === "string" && context.summaryEntryId.length > 0 && Buffer.byteLength(context.summaryEntryId, "utf8") <= 128 ) ) && ( context.taskRequest === undefined || ( typeof context.taskRequest === "string" && context.taskRequest.length > 0 && Buffer.byteLength(context.taskRequest, "utf8") <= MAX_TASK_REQUEST_BYTES ) ) && isNonNegativeInteger(context.createdAt) ); } function isStoredFileExplanation(value: unknown): value is StoredFileExplanation { if (!value || typeof value !== "object") return false; const explanation = value as Partial; return ( explanation.version === EXPLANATION_ENTRY_VERSION && explanation.promptVersion === EXPLANATION_PROMPT_VERSION && hasStoredAnalysisFields(explanation, MAX_EXPLANATION_BYTES) ); } function isStoredFileRationale(value: unknown): value is StoredFileRationale { if (!value || typeof value !== "object") return false; const rationale = value as Partial; return ( rationale.version === RATIONALE_ENTRY_VERSION && rationale.promptVersion === RATIONALE_PROMPT_VERSION && typeof rationale.taskId === "string" && rationale.taskId.length > 0 && Buffer.byteLength(rationale.taskId, "utf8") <= 128 && hasStoredAnalysisFields(rationale, MAX_RATIONALE_BYTES) ); } function summaryHash(summary: TaskSummary): string { return createHash("sha256").update(JSON.stringify(summary)).digest("hex"); } function latestExplanationKey(hash: string, file: string): string { return JSON.stringify([EXPLANATION_PROMPT_VERSION, hash, file]); } function latestRationaleKey(taskId: string, hash: string, file: string): string { return JSON.stringify([RATIONALE_PROMPT_VERSION, taskId, hash, file]); } function explanationKey( hash: string, file: string, modelProvider: string, modelId: string, effort: EffectiveEffort, ): string { return JSON.stringify([ EXPLANATION_PROMPT_VERSION, hash, file, modelProvider, modelId, effort, ]); } function rationaleKey( taskId: string, hash: string, file: string, modelProvider: string, modelId: string, effort: EffectiveEffort, ): string { return JSON.stringify([ RATIONALE_PROMPT_VERSION, taskId, hash, file, modelProvider, modelId, effort, ]); } interface TaskState { baseline: GitSnapshot | undefined; taskId: string; taskRequest: string | undefined; generation: number; settling: boolean; } export default function taskDelta(pi: ExtensionAPI): void { let activeTask: TaskState | undefined; let pendingTaskRequest: string | undefined; let generation = 0; let taskHistory: OverlayTask[] = []; let settings: TaskDeltaSettings = { version: DEFAULT_SETTINGS.version, model: { ...DEFAULT_SETTINGS.model }, effort: DEFAULT_SETTINGS.effort, }; let settingsLoadError: string | undefined; const settingsReady = loadTaskDeltaSettings().then( (loaded) => { settings = loaded; }, (error: unknown) => { settingsLoadError = error instanceof Error ? error.message : String(error); console.error("[pi-task-delta] settings load failed:", error); }, ); const explanations = new Map(); const latestExplanations = new Map(); const rationales = new Map(); const latestRationales = new Map(); let analysisCacheBytes = 0; let explanationSessionController = new AbortController(); let activeOverlay: SummaryOverlay | undefined; let snapshotQueue: Promise = Promise.resolve(); let settlementQueue: Promise = Promise.resolve(); function queuedSnapshot(cwd: string): Promise { const snapshot = snapshotQueue.then(() => createSnapshot(cwd)); snapshotQueue = snapshot.then( () => undefined, () => undefined, ); return snapshot; } function restoreSessionBranch(ctx: ExtensionContext): void { taskHistory = []; explanations.clear(); latestExplanations.clear(); rationales.clear(); latestRationales.clear(); analysisCacheBytes = 0; const branch = ctx.sessionManager.getBranch(); const reversedBranch = [...branch].reverse(); const restoredTasks: OverlayTask[] = []; const tasksByEntryId = new Map(); let nearestTask: OverlayTask | undefined; for (const entry of branch) { if (entry.type !== "custom") continue; if (entry.customType === ENTRY_TYPE) { if (!isTaskSummary(entry.data)) { nearestTask = undefined; continue; } const parsedTimestamp = Date.parse(entry.timestamp); nearestTask = { summary: entry.data, hash: summaryHash(entry.data), taskId: randomUUID(), taskRequest: undefined, timestamp: Number.isFinite(parsedTimestamp) ? parsedTimestamp : undefined, summaryEntryId: typeof entry.id === "string" ? entry.id : undefined, contextPersisted: false, }; restoredTasks.push(nearestTask); if (nearestTask.summaryEntryId) { tasksByEntryId.set(nearestTask.summaryEntryId, nearestTask); } continue; } if ( entry.customType !== CONTEXT_ENTRY_TYPE || !isStoredTaskContext(entry.data) ) { continue; } const context = entry.data; const task = context.summaryEntryId ? tasksByEntryId.get(context.summaryEntryId) : nearestTask; if ( !task || task.contextPersisted || task.hash !== context.summaryHash ) { continue; } task.taskId = context.taskId; task.taskRequest = context.taskRequest; task.contextPersisted = true; } taskHistory = restoredTasks.slice(-MAX_TASK_HISTORY).reverse(); let restoredBytes = 0; for (const entry of reversedBranch) { if (entry.type !== "custom") continue; if ( entry.customType === EXPLANATION_ENTRY_TYPE && isStoredFileExplanation(entry.data) ) { const key = explanationKey( entry.data.summaryHash, entry.data.file, entry.data.modelProvider, entry.data.modelId, entry.data.effort, ); if ( explanations.has(key) || explanations.size + rationales.size >= MAX_RESTORED_ANALYSES ) { continue; } const entryBytes = Buffer.byteLength(JSON.stringify(entry.data), "utf8"); if (restoredBytes + entryBytes > MAX_RESTORED_ANALYSIS_BYTES) continue; explanations.set(key, entry.data); const latestKey = latestExplanationKey(entry.data.summaryHash, entry.data.file); if (!latestExplanations.has(latestKey)) { latestExplanations.set(latestKey, entry.data); } restoredBytes += entryBytes; analysisCacheBytes += entryBytes; } else if ( entry.customType === RATIONALE_ENTRY_TYPE && isStoredFileRationale(entry.data) ) { const key = rationaleKey( entry.data.taskId, entry.data.summaryHash, entry.data.file, entry.data.modelProvider, entry.data.modelId, entry.data.effort, ); if ( rationales.has(key) || explanations.size + rationales.size >= MAX_RESTORED_ANALYSES ) { continue; } const entryBytes = Buffer.byteLength(JSON.stringify(entry.data), "utf8"); if (restoredBytes + entryBytes > MAX_RESTORED_ANALYSIS_BYTES) continue; rationales.set(key, entry.data); const latestKey = latestRationaleKey( entry.data.taskId, entry.data.summaryHash, entry.data.file, ); if (!latestRationales.has(latestKey)) { latestRationales.set(latestKey, entry.data); } restoredBytes += entryBytes; analysisCacheBytes += entryBytes; } } } function resetExplanationRuntime(): void { activeOverlay?.dismiss(); activeOverlay = undefined; explanationSessionController.abort(); explanationSessionController = new AbortController(); } async function showTaskHistory(ctx: ExtensionContext): Promise { if (taskHistory.length === 0) { ctx.ui.notify("No Task Delta history is available.", "info"); return; } if (ctx.mode !== "tui") return; await settingsReady; const tasks = taskHistory.map((task) => ({ ...task })); const selection = resolveExplanationSelection(settings, ctx); const overlayGeneration = generation; const sessionSignal = explanationSessionController.signal; let shownOverlay: SummaryOverlay | undefined; activeOverlay?.dismiss(); try { await ctx.ui.custom( (tui, theme, _keybindings, done) => { const overlay = new SummaryOverlay( tasks, theme, () => done(undefined), () => tui.requestRender(), () => { const availableRows = Math.max(1, tui.terminal.rows - 2); const overlayRows = Math.min( availableRows, Math.floor(tui.terminal.rows * 0.9), ); return Math.max(1, overlayRows - 6); }, (task, file, view) => ( view === "explanation" ? latestExplanations.get(latestExplanationKey(task.hash, file.file))?.text : latestRationales.get( latestRationaleKey(task.taskId, task.hash, file.file), )?.text ), (task, file, view) => { const stored = view === "explanation" ? latestExplanations.get(latestExplanationKey(task.hash, file.file)) : latestRationales.get( latestRationaleKey(task.taskId, task.hash, file.file), ); return stored ? `${stored.modelName} • ${stored.effort}` : undefined; }, selection.attribution, async (task, file, view, signal, onProgress) => { if (generation !== overlayGeneration) { throw new DOMException("Analysis cancelled", "AbortError"); } if (!ctx.isIdle()) { throw new Error("Wait for the current agent task to finish before requesting model analysis."); } const model = selection.model; if (!model) { throw new Error( settings.model.mode === "fixed" ? "The configured analysis model is unavailable. Run /task-delta-settings to choose another model." : "No model is currently selected in Pi.", ); } if (view === "rationale" && !task.contextPersisted) { const context: StoredTaskContext = { version: CONTEXT_ENTRY_VERSION, taskId: task.taskId, summaryHash: task.hash, ...(task.summaryEntryId === undefined ? {} : { summaryEntryId: task.summaryEntryId }), ...(task.taskRequest === undefined ? {} : { taskRequest: task.taskRequest }), createdAt: task.timestamp ?? Date.now(), }; pi.appendEntry(CONTEXT_ENTRY_TYPE, context); task.contextPersisted = true; const liveTask = taskHistory.find((candidate) => ( candidate.taskId === task.taskId && candidate.hash === task.hash )); if (liveTask) liveTask.contextPersisted = true; } const requestSignal = AbortSignal.any([signal, sessionSignal]); const text = view === "explanation" ? await generateFileExplanation( file, model, ctx.modelRegistry, selection.effort, requestSignal, onProgress, ) : await generateFileRationale( file, task.summary, task.taskRequest, model, ctx.modelRegistry, selection.effort, requestSignal, onProgress, ); if (requestSignal.aborted || generation !== overlayGeneration) { throw new DOMException("Analysis cancelled", "AbortError"); } const common = { summaryHash: task.hash, file: file.file, text, modelProvider: model.provider, modelId: model.id, modelName: model.name || model.id, effort: selection.effort, createdAt: Date.now(), }; if (view === "explanation") { const stored: StoredFileExplanation = { version: EXPLANATION_ENTRY_VERSION, promptVersion: EXPLANATION_PROMPT_VERSION, ...common, }; const key = explanationKey( task.hash, file.file, selection.provider, selection.modelId, selection.effort, ); const entryBytes = Buffer.byteLength(JSON.stringify(stored), "utf8"); if ( ( explanations.has(key) || explanations.size + rationales.size < MAX_RESTORED_ANALYSES ) && analysisCacheBytes + entryBytes <= MAX_RESTORED_ANALYSIS_BYTES ) { explanations.set(key, stored); analysisCacheBytes += entryBytes; pi.appendEntry(EXPLANATION_ENTRY_TYPE, stored); } latestExplanations.set( latestExplanationKey(task.hash, file.file), stored, ); } else { const stored: StoredFileRationale = { version: RATIONALE_ENTRY_VERSION, promptVersion: RATIONALE_PROMPT_VERSION, taskId: task.taskId, ...common, }; const key = rationaleKey( task.taskId, task.hash, file.file, selection.provider, selection.modelId, selection.effort, ); const entryBytes = Buffer.byteLength(JSON.stringify(stored), "utf8"); if ( ( rationales.has(key) || explanations.size + rationales.size < MAX_RESTORED_ANALYSES ) && analysisCacheBytes + entryBytes <= MAX_RESTORED_ANALYSIS_BYTES ) { rationales.set(key, stored); analysisCacheBytes += entryBytes; pi.appendEntry(RATIONALE_ENTRY_TYPE, stored); } latestRationales.set( latestRationaleKey(task.taskId, task.hash, file.file), stored, ); } return text; }, ); shownOverlay = overlay; activeOverlay = overlay; return overlay; }, { overlay: true, overlayOptions: { anchor: "center", width: "90%", minWidth: 72, maxHeight: "90%", margin: 1, }, }, ); } finally { if (activeOverlay === shownOverlay) activeOverlay = undefined; } } pi.registerEntryRenderer(ENTRY_TYPE, (entry, _options, theme) => { if (!isTaskSummary(entry.data)) return undefined; const hint = rawKeyHint("f6", "for details"); const text = formatSummary(entry.data, false, hint); return new Text(theme.fg("muted", text), 1, 0); }); pi.registerEntryRenderer(CONTEXT_ENTRY_TYPE, () => undefined); pi.registerEntryRenderer(EXPLANATION_ENTRY_TYPE, () => undefined); pi.registerEntryRenderer(RATIONALE_ENTRY_TYPE, () => undefined); pi.registerShortcut("f6", { description: "Browse Task Delta history", handler: showTaskHistory, }); pi.registerCommand("git-summary", { description: "Browse Task Delta history", handler: async (_args, ctx) => showTaskHistory(ctx), }); pi.registerCommand("task-delta-settings", { description: "Configure the Task Delta analysis model and effort", handler: async (_args, ctx) => { if (ctx.mode !== "tui") { ctx.ui.notify("Task Delta settings require interactive mode.", "error"); return; } await settingsReady; if (settingsLoadError) { ctx.ui.notify(`Using default settings: ${settingsLoadError}`, "warning"); settingsLoadError = undefined; } const updated = await showTaskDeltaSettings(ctx, settings); if (!updated) return; try { await saveTaskDeltaSettings(updated); settings = updated; ctx.ui.notify("Task Delta settings saved.", "info"); } catch (error) { const message = error instanceof Error ? error.message : String(error); ctx.ui.notify(`Could not save Task Delta settings: ${message}`, "error"); } }, }); pi.on("session_start", (_event, ctx) => { pendingTaskRequest = undefined; resetExplanationRuntime(); restoreSessionBranch(ctx); }); pi.on("session_tree", (_event, ctx) => { generation += 1; activeTask = undefined; pendingTaskRequest = undefined; resetExplanationRuntime(); restoreSessionBranch(ctx); }); pi.on("before_agent_start", (event) => { if (!activeTask || activeTask.settling) { pendingTaskRequest = normalizeTaskRequest(event.prompt); } }); pi.on("agent_start", async (_event, ctx) => { if (activeTask && !activeTask.settling) return; const task: TaskState = { baseline: undefined, taskId: randomUUID(), taskRequest: pendingTaskRequest, generation, settling: false, }; pendingTaskRequest = undefined; activeTask = task; try { const baseline = await queuedSnapshot(ctx.cwd); if (activeTask === task && task.generation === generation) { task.baseline = baseline; } } catch (error) { console.error("[pi-task-delta] baseline snapshot failed:", error); } }); pi.on("agent_settled", async () => { const task = activeTask; if (!task || task.settling) return; task.settling = true; const currentSnapshot = ( task.baseline ? queuedSnapshot(task.baseline.root) : Promise.resolve(undefined) ).then( (current) => ({ current }), (error: unknown) => ({ error }), ); const settlement = settlementQueue.then(async () => { try { const baseline = task.baseline; if (!baseline || task.generation !== generation) return; const snapshotResult = await currentSnapshot; if ("error" in snapshotResult) throw snapshotResult.error; const current = snapshotResult.current; if (!current || task.generation !== generation) return; const summary = await compareSnapshots(baseline, current); if (summary && isTaskSummary(summary) && task.generation === generation) { const hash = summaryHash(summary); const createdAt = Date.now(); pi.appendEntry(ENTRY_TYPE, summary); const context: StoredTaskContext = { version: CONTEXT_ENTRY_VERSION, taskId: task.taskId, summaryHash: hash, ...(task.taskRequest === undefined ? {} : { taskRequest: task.taskRequest }), createdAt, }; pi.appendEntry(CONTEXT_ENTRY_TYPE, context); taskHistory.unshift({ summary, hash, taskId: task.taskId, taskRequest: task.taskRequest, timestamp: createdAt, summaryEntryId: undefined, contextPersisted: true, }); if (taskHistory.length > MAX_TASK_HISTORY) { taskHistory.length = MAX_TASK_HISTORY; } } } catch (error) { console.error("[pi-task-delta] final snapshot failed:", error); } finally { if (activeTask === task) activeTask = undefined; } }); settlementQueue = settlement.then( () => undefined, () => undefined, ); await settlement; }); pi.on("session_shutdown", () => { generation += 1; activeTask = undefined; pendingTaskRequest = undefined; activeOverlay?.dismiss(); activeOverlay = undefined; explanationSessionController.abort(); taskHistory = []; explanations.clear(); latestExplanations.clear(); rationales.clear(); latestRationales.clear(); analysisCacheBytes = 0; }); }