/** * Pi Loop Extension - Evidence Collection * * Observes Pi events (tool calls, results, agent turns) and builds * structured evidence for gates and steering decisions. */ import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import type { EvidenceSummary, ToolCallRecord } from "./types.ts"; /** * Create a fresh evidence summary. */ export function createEvidenceSummary(): EvidenceSummary { return { toolCalls: [], commandsRun: [], filesRead: [], filesChanged: [], errorCount: 0, repeatedCommands: {}, warnings: [], timestamps: { startedAt: Date.now(), lastActivity: Date.now(), }, }; } /** * Record a tool call in the evidence summary. */ export function recordToolCall( evidence: EvidenceSummary, toolName: string, input: unknown, isError?: boolean, ): void { evidence.toolCalls.push({ toolName, input, timestamp: Date.now(), isError, }); evidence.timestamps.lastActivity = Date.now(); if (isError) { evidence.errorCount++; } // Track bash commands specifically if (toolName === "bash" && typeof input === "object" && input !== null) { const cmd = (input as { command?: string }).command ?? ""; evidence.commandsRun.push({ command: cmd, timestamp: Date.now(), exitCode: isError ? 1 : 0, }); // Track repeated commands const normalizedCmd = cmd.trim().replace(/\s+/g, " "); evidence.repeatedCommands[normalizedCmd] = (evidence.repeatedCommands[normalizedCmd] ?? 0) + 1; } // Track file reads if (toolName === "read" && typeof input === "object" && input !== null) { const path = (input as { path?: string }).path; if (path && !evidence.filesRead.includes(path)) { evidence.filesRead.push(path); } } // Track file writes/edits if ((toolName === "edit" || toolName === "write") && typeof input === "object" && input !== null) { const path = (input as { path?: string }).path; if (path && !evidence.filesChanged.includes(path)) { evidence.filesChanged.push(path); } } } /** * Check for repeated command failures (steering trigger). */ export function checkRepeatedCommandFailure( evidence: EvidenceSummary, maxRepeats: number = 3, ): { triggered: boolean; command?: string; count?: number } { for (const [cmd, count] of Object.entries(evidence.repeatedCommands)) { if (count >= maxRepeats) { return { triggered: true, command: cmd, count }; } } return { triggered: false }; } /** * Check if agent has been idle (no tool use) for too many turns. */ export function checkStall( evidence: EvidenceSummary, maxIdleMs: number = 60_000, ): { triggered: boolean; idleMs?: number } { const idleMs = Date.now() - evidence.timestamps.lastActivity; if (idleMs >= maxIdleMs && evidence.toolCalls.length > 0) { return { triggered: true, idleMs }; } return { triggered: false }; } /** * Check if there were many edits but no validation command. */ export function checkEditsWithoutValidation( evidence: EvidenceSummary, maxEdits: number = 5, ): { triggered: boolean } { const editCount = evidence.toolCalls.filter((t) => t.toolName === "edit" || t.toolName === "write").length; const bashCount = evidence.toolCalls.filter((t) => t.toolName === "bash").length; return { triggered: editCount >= maxEdits && bashCount === 0 }; } /** * Add a warning to evidence. */ export function addEvidenceWarning(evidence: EvidenceSummary, warning: string): void { evidence.warnings.push(warning); evidence.timestamps.lastActivity = Date.now(); } /** * Merge two evidence summaries. */ export function mergeEvidence(target: EvidenceSummary, source: Partial): void { if (source.toolCalls) target.toolCalls.push(...source.toolCalls); if (source.commandsRun) target.commandsRun.push(...source.commandsRun); if (source.filesRead) { for (const f of source.filesRead) { if (!target.filesRead.includes(f)) target.filesRead.push(f); } } if (source.filesChanged) { for (const f of source.filesChanged) { if (!target.filesChanged.includes(f)) target.filesChanged.push(f); } } if (source.errorCount) target.errorCount += source.errorCount; if (source.warnings) target.warnings.push(...source.warnings); if (source.timestamps) { target.timestamps.lastActivity = Math.max(target.timestamps.lastActivity, source.timestamps.lastActivity); } // Merge repeated commands if (source.repeatedCommands) { for (const [cmd, count] of Object.entries(source.repeatedCommands)) { target.repeatedCommands[cmd] = (target.repeatedCommands[cmd] ?? 0) + count; } } }