UNPKG

pi-lens

Version:

Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo

564 lines (563 loc) • 24.2 kB
import * as nodeCrypto from "node:crypto"; import * as nodeFs from "node:fs"; import * as path from "node:path"; import { extractGrepSearchReadsFromOutput, extractWrittenPathsFromCommand, } from "./bash-file-access.js"; import { registerSearchReads, } from "./search-read-registration.js"; import { createFileTime } from "./file-time.js"; import { isPathIgnoredByProject } from "./file-utils.js"; import { getFormatService } from "./format-service.js"; import { isExternalOrVendorFile } from "./path-utils.js"; import { resolveLanguageRootForFile } from "./language-profile.js"; import { logLatency } from "./latency-logger.js"; import { runPipeline } from "./pipeline.js"; import { appendProjectChange, } from "./project-changes.js"; import { scheduleWordIndexPersist } from "./word-index.js"; function parseDiffRanges(diff) { const changedLines = []; for (const line of diff.split("\n")) { const match = line.match(/^[+-]\s*(\d+)\s/); if (match) { changedLines.push(Number.parseInt(match[1], 10)); } } if (changedLines.length === 0) return []; const sorted = [...new Set(changedLines)].sort((a, b) => a - b); const ranges = []; let rangeStart = sorted[0]; let rangeEnd = sorted[0]; for (const line of sorted.slice(1)) { if (line <= rangeEnd + 1) { rangeEnd = line; } else { ranges.push({ start: rangeStart, end: rangeEnd }); rangeStart = line; rangeEnd = line; } } ranges.push({ start: rangeStart, end: rangeEnd }); return ranges; } // Deduplicates tool_result calls for the same post-write file state. // The pi framework can emit one tool_result per edit hunk; those events often // observe the same final file content. Deduping by file alone is unsafe because // a later same-turn edit to the same file must still run the pipeline. const inFlightPipelines = new Map(); const lastAnalyzedStateByFile = new Map(); // Called at turn_start — entries from the previous turn can never match the new // turnIndex so they're dead weight. Clearing here keeps the map bounded to the // files touched in the current turn only (typically < 20). export function clearLastAnalyzedStateCache() { lastAnalyzedStateByFile.clear(); } const debouncedPipelines = new Map(); const DEFAULT_DEBOUNCE_MS = 0; const MAX_DEBOUNCE_MS = 1000; function getDebounceMs() { const raw = Number(process.env.PI_LENS_TOOL_RESULT_DEBOUNCE_MS); if (!Number.isFinite(raw) || raw < 0) return DEFAULT_DEBOUNCE_MS; // Cap at 1s so turn_end and agent_end don't block on the timer for // pathologically long windows. flushDebouncedToolResults below also // short-circuits at boundary events. return Math.min(raw, MAX_DEBOUNCE_MS); } /** * Drain any pending debounced tool_result pipelines immediately, awaiting their * completion. Call from turn_end / agent_end before reading anything that depends * on the pipeline's bookkeeping (project change log, modified ranges, etc.). * * Passing a filePath flushes only that entry; omitting it flushes all. */ export async function flushDebouncedToolResults(filePath) { const entries = filePath ? debouncedPipelines.has(filePath) ? [ [ filePath, debouncedPipelines.get(filePath), ], ] : [] : [...debouncedPipelines.entries()]; for (const [key, entry] of entries) { clearTimeout(entry.timer); debouncedPipelines.delete(key); // Re-enter the pipeline synchronously via the bypass flag so the // timer body's resolve/reject still fires through the shared promise. handleToolResult({ ...entry.latestDeps, _bypassDebounce: true }).then(entry.resolve, entry.reject); } if (entries.length > 0) { // Allow microtasks to settle so awaiting callers see the latest state. await Promise.all(entries.map(([, entry]) => entry.promise.catch(() => undefined))); } } function scheduleDebounced(filePath, debounceMs, deps) { const existing = debouncedPipelines.get(filePath); if (existing) { clearTimeout(existing.timer); existing.latestDeps = deps; existing.coalescedCount += 1; existing.timer = setTimeout(() => { debouncedPipelines.delete(filePath); deps.dbg(`tool_result: debounce fired after ${existing.coalescedCount} coalesced calls for ${filePath}`); handleToolResult({ ...existing.latestDeps, _bypassDebounce: true }).then(existing.resolve, existing.reject); }, debounceMs); deps.dbg(`tool_result: coalesced into pending debounce for ${filePath} (count=${existing.coalescedCount})`); return existing.promise; } let resolveFn; let rejectFn; const promise = new Promise((res, rej) => { resolveFn = res; rejectFn = rej; }); const entry = { timer: setTimeout(() => { debouncedPipelines.delete(filePath); handleToolResult({ ...entry.latestDeps, _bypassDebounce: true }).then(entry.resolve, entry.reject); }, debounceMs), promise, resolve: resolveFn, reject: rejectFn, latestDeps: deps, scheduledAt: Date.now(), coalescedCount: 1, }; debouncedPipelines.set(filePath, entry); return promise; } function getFileStateHash(filePath) { try { const content = nodeFs.readFileSync(filePath); return nodeCrypto.createHash("sha256").update(content).digest("hex"); } catch (err) { const code = err.code ?? "unknown"; return `unreadable:${code}`; } } function sourceForToolName(toolName, details) { if (details ?.piLensPartialApply) { return "partial-apply"; } return toolName === "write" ? "agent-write" : "agent-edit"; } function singleRange(ranges) { return ranges?.length === 1 ? ranges[0] : undefined; } function recordProjectChange(args) { const bump = args.runtime.bumpFileSeq; if (!bump) return; const { projectSeq, fileSeq } = bump.call(args.runtime, args.filePath); try { appendProjectChange(args.cwd, { seq: projectSeq, timestamp: new Date().toISOString(), sessionId: args.runtime.telemetrySessionId, turnIndex: args.runtime.turnIndex, source: args.source, filePath: path.resolve(args.filePath), fileSeq, changedRange: args.changedRange, }); } catch (err) { args.dbg(`project change log append failed for ${args.filePath}: ${err}`); } } export async function handleToolResult(deps) { const { event, getFlag, dbg, runtime, cacheManager, biomeClient, ruffClient, metricsClient, resetLSPService, agentBehaviorRecord, formatBehaviorWarnings, } = deps; const rawFilePath = event.input.path; const workspaceRoot = runtime.projectRoot || process.cwd(); const filePath = rawFilePath ? path.isAbsolute(rawFilePath) ? rawFilePath : path.resolve(workspaceRoot, rawFilePath) : rawFilePath; const behaviorWarnings = agentBehaviorRecord(event.toolName, filePath); // Bash writes (redirects, tee, sed -i, cp/mv, touch, git checkout/restore) — // these change file content but never go through the edit tool, so bash // early-returns before the dispatch pipeline below. For each in-project file // the command wrote/restored we therefore: (1) mark it authored-by-agent for // the read-guard (like the Write tool), and (2) re-run the pipeline via a // synthetic `write` event so its diagnostics, fileSeq, and change-log refresh. // Without (2) a `git checkout -- f` restore keeps serving the pre-restore // (e.g. broken-state) warnings on every later lens_diagnostics call. if (event.toolName === "bash" && typeof event.input.command === "string") { const command = event.input.command; const written = extractWrittenPathsFromCommand(command, workspaceRoot).filter((wp) => !isExternalOrVendorFile(wp, workspaceRoot) && !isPathIgnoredByProject(wp, workspaceRoot, false)); for (const wp of written) { if (!getFlag("no-read-guard")) deps.readGuard?.recordWritten(wp); await handleToolResult({ ...deps, event: { ...event, toolName: "write", input: { path: wp } }, _bypassDebounce: true, }); } } // Search tools reveal specific lines (file:line) the agent then edits — register // those shown lines (± context) as reads so the follow-up edit isn't blocked (#169). // Our tools attach locations as `details.searchReads`; bash grep is parsed from // `grep -n` output. Only shown lines are registered, never the whole file. if (deps.readGuard && !getFlag("no-read-guard")) { const searchReads = []; const detailSearchReads = event.details?.searchReads; if (Array.isArray(detailSearchReads)) searchReads.push(...detailSearchReads); if (event.toolName === "bash" && typeof event.input.command === "string") { const command = event.input.command; const output = event.content .map((part) => (typeof part.text === "string" ? part.text : "")) .join("\n"); searchReads.push(...extractGrepSearchReadsFromOutput(command, workspaceRoot, output)); } if (searchReads.length > 0) { registerSearchReads(deps.readGuard, searchReads, { projectRoot: workspaceRoot, turnIndex: runtime.turnIndex, writeIndex: runtime.peekWriteIndex(), }); } } if (event.toolName !== "write" && event.toolName !== "edit") { dbg(`tool_result: skipped turn tracking - toolName="${event.toolName}" (not write/edit)`); return; } if (!filePath) { dbg(`tool_result: skipped turn tracking - no filePath for toolName="${event.toolName}"`); return; } if (isExternalOrVendorFile(filePath, workspaceRoot)) { dbg(`tool_result: skipped pipeline - file outside project root or in node_modules: ${filePath}`); return; } // Coalesce sequential edits to the same file into one pipeline run against // the final state. Only the debounce-fired call (with _bypassDebounce=true) // proceeds to the pipeline body; in-window callers share its promise. if (!deps._bypassDebounce) { const debounceMs = getDebounceMs(); if (debounceMs > 0) { return scheduleDebounced(filePath, debounceMs, deps); } } // Refresh the read-guard's FileTime stamp so that the model's own write // doesn't trigger a spurious "file_modified" block on the next edit. deps.readGuard?.recordWritten(filePath); // Keep cachedExports in sync after each write/edit so the pre-write STOP // check doesn't fire on names that were removed from this file this session. if (runtime.cachedExports.size > 0 && nodeFs.existsSync(filePath)) { const exportRe = /export\s+(?:async\s+)?(?:function|class|const|let|type|interface)\s+(\w+)/g; for (const [name, file] of runtime.cachedExports) { if (path.resolve(file) === path.resolve(filePath)) { runtime.cachedExports.delete(name); } } try { const freshContent = nodeFs.readFileSync(filePath, "utf-8"); for (const match of freshContent.matchAll(exportRe)) { const name = match[1]; if (!runtime.cachedExports.has(name)) { runtime.cachedExports.set(name, filePath); } } } catch { // Non-fatal — stale entry is worse than a missing one } } const initialStateHash = getFileStateHash(filePath); const pipelineDedupeKey = `${filePath}:${initialStateHash}`; // Deduplicate concurrent calls for the same final file state (pi can fire one // tool_result per edit hunk). Do not dedupe by file alone: a distinct later // same-turn edit to this file must still be analyzed. if (inFlightPipelines.has(pipelineDedupeKey)) { dbg(`tool_result: skipping duplicate concurrent state for ${filePath}`); await inFlightPipelines.get(pipelineDedupeKey); return; } // Deduplicate sequential duplicate events for the same post-write state in the // same turn while allowing later same-file edits whose content changed. const lastAnalyzed = lastAnalyzedStateByFile.get(filePath); if (lastAnalyzed?.turnIndex === runtime.turnIndex && lastAnalyzed.stateHash === initialStateHash) { dbg(`tool_result: skipping already-analyzed file state this turn for ${filePath}`); return; } const sessionFileTime = createFileTime("default"); // tool_result is emitted after write/edit has already been applied. // Asserting pre-write stamps here produces false positives on rapid edits. sessionFileTime.read(filePath); if (!getFlag("no-read-guard")) { const readGuard = runtime.readGuard; readGuard?.recordWritten?.(filePath); } const toolResultStart = Date.now(); dbg(`tool_result: tracking turn state for ${event.toolName} on ${filePath}`); if (isPathIgnoredByProject(filePath, workspaceRoot, false)) { dbg(`tool_result: skipping gitignored file ${filePath}`); return; } const dispatchCwd = resolveLanguageRootForFile(filePath, workspaceRoot); const turnStateCwd = path.resolve(workspaceRoot); dbg(`tool_result: resolved dispatch cwd ${dispatchCwd} for ${filePath} (turnState cwd ${turnStateCwd})`); if (event.model || event.provider || event.sessionId || event.session?.id) { runtime.setTelemetryIdentity({ model: event.model, provider: event.provider, sessionId: event.sessionId ?? event.session?.id, }); } const writeIndex = runtime.nextWriteIndex(); let modifiedRanges; try { const details = event.details; dbg(`tool_result: details.diff=${details?.diff ? "present" : "missing"}, details keys: ${Object.keys(event.details || {}).join(", ")}`); if (event.toolName === "edit" && details?.diff) { const diff = details.diff; dbg(`tool_result: diff content (first 500 chars): ${diff.substring(0, 500)}`); const ranges = parseDiffRanges(diff); modifiedRanges = ranges; const importsChanged = /import\s/.test(diff) || /from\s+['"]/.test(diff); dbg(`tool_result: parsed ${ranges.length} ranges, importsChanged=${importsChanged}`); for (const range of ranges) { dbg(`tool_result: adding range ${range.start}-${range.end} for ${filePath}`); cacheManager.addModifiedRange(filePath, range, importsChanged, turnStateCwd, runtime.telemetrySessionId); } dbg(`tool_result: turn state after add: ${JSON.stringify(cacheManager.readTurnState(turnStateCwd))}`); } else if (event.toolName === "write" && nodeFs.existsSync(filePath)) { const content = nodeFs.readFileSync(filePath, "utf-8"); const lineCount = content.split("\n").length; const hasImports = /^import\s/m.test(content); modifiedRanges = [{ start: 1, end: lineCount }]; cacheManager.addModifiedRange(filePath, { start: 1, end: lineCount }, hasImports, turnStateCwd, runtime.telemetrySessionId); } } catch (err) { dbg(`turn state tracking error: ${err}`); dbg(`turn state tracking error stack: ${err.stack}`); } recordProjectChange({ runtime, cwd: turnStateCwd, filePath, source: sourceForToolName(event.toolName, event.details), changedRange: singleRange(modifiedRanges), dbg, }); const turnStateMs = Date.now() - toolResultStart; logLatency({ type: "phase", toolName: event.toolName, filePath, phase: "turn_state_tracking", durationMs: turnStateMs, }); dbg(`tool_result fired for: ${filePath} (turn_state: ${turnStateMs}ms)`); let result; const pipelinePromise = runPipeline({ filePath, cwd: dispatchCwd, toolName: event.toolName, modifiedRanges, telemetry: { model: runtime.telemetryModel, sessionId: runtime.telemetrySessionId, turnIndex: runtime.turnIndex, writeIndex, }, getFlag, dbg, // #451: hand the deferred cascade live sequence accessors so the // review-graph builder can skip its per-build O(project) sweep when // only pi-observed edits happened. projectSeq is a function because the // cascade runs after this returns (#450) — read current, not captured. seqState: { projectSeq: () => runtime.projectSeq, getFilesChangedSince: (seq) => runtime.getFilesChangedSince(seq), }, // #348 phase 2: live reference so the deferred cascade can update the // warm word index in place at the same seam as the graph rebuild. // `runtime.wordIndex` is read fresh (not captured) via this closure-free // property access being re-evaluated at object-literal construction // time here — that's fine because runPipeline reads `ctx.wordIndex` // synchronously into computeCascadeForFile's options before returning // (the deferred part is the cascade's OWN execution, not this handoff). wordIndex: runtime.wordIndex, onWordIndexUpdated: (index) => { scheduleWordIndexPersist(dispatchCwd, index, dbg); }, }, { biomeClient, ruffClient, metricsClient, getFormatService, fixedThisTurn: runtime.fixedThisTurn, }); inFlightPipelines.set(pipelineDedupeKey, pipelinePromise); try { result = await pipelinePromise; } catch (pipelineErr) { dbg(`runPipeline crashed: ${pipelineErr}`); dbg(`runPipeline crash stack: ${pipelineErr.stack}`); if (!getFlag("no-lsp")) { resetLSPService({ fast: true }); } logLatency({ type: "tool_result", toolName: event.toolName, filePath, durationMs: Date.now() - toolResultStart, result: "pipeline_crash", }); const notice = runtime.formatPipelineCrashNotice(filePath, pipelineErr); if (!notice) return; return { content: [...event.content, { type: "text", text: notice }], }; } finally { inFlightPipelines.delete(pipelineDedupeKey); } lastAnalyzedStateByFile.set(filePath, { turnIndex: runtime.turnIndex, stateHash: getFileStateHash(filePath), }); // The model's write/edit and pi-lens' own immediate format/autofix are now // reflected on disk. Refresh read-guard staleness stamps so a follow-up edit // is judged by read-range coverage, not by our own previous write. if (!getFlag("no-read-guard")) { const changedForReadGuard = new Set([ path.resolve(filePath), ...(result.changedFiles ?? []).map((changedFile) => path.resolve(changedFile)), ]); for (const changedFile of changedForReadGuard) { if (nodeFs.existsSync(changedFile)) { deps.readGuard?.recordWritten(changedFile); } } } if (!result.isError && !getFlag("no-autoformat") && !getFlag("immediate-format") && nodeFs.existsSync(filePath)) { runtime.deferFormat(filePath, dispatchCwd, event.toolName, turnStateCwd); dbg(`tool_result: queued deferred format for ${filePath}`); logLatency({ type: "phase", toolName: event.toolName, filePath, phase: "deferred_format_queued", durationMs: 0, metadata: { cwd: dispatchCwd }, }); } for (const changedFile of result.changedFiles ?? []) { const resolvedChanged = path.resolve(changedFile); if (!nodeFs.existsSync(resolvedChanged)) continue; recordProjectChange({ runtime, cwd: turnStateCwd, filePath: resolvedChanged, source: "autofix", dbg, }); if (resolvedChanged === path.resolve(filePath)) continue; try { const content = nodeFs.readFileSync(resolvedChanged, "utf-8"); const lineCount = content.split("\n").length; const hasImports = /^import\s/m.test(content); cacheManager.addModifiedRange(resolvedChanged, { start: 1, end: lineCount }, hasImports, turnStateCwd); dbg(`tool_result: tracking pi-lens side-effect change for ${resolvedChanged}`); } catch (err) { dbg(`tool_result: side-effect tracking failed for ${resolvedChanged}: ${err}`); } } if (result.cascadePromise) { runtime.appendCascadePromise(result.cascadePromise); } if (result.actionableWarnings?.length) { runtime.recordActionableWarnings(result.actionableWarnings); } if (result.codeQualityWarnings?.length) { runtime.recordCodeQualityWarnings(result.codeQualityWarnings); } // #484: opt-in per-turn summary collection. Same signals the pipeline // already computed above (diagnostics, autofix count/tools, formatters // used) — no new collection plumbing, just fed into the collector when // the feature is on. if (getFlag("lens-turn-summary")) { if (result.diagnostics?.length) { for (const d of result.diagnostics) { runtime.turnSummary.recordDiagnostic(d.filePath || filePath, { tool: d.tool, ruleId: d.rule ?? d.code, severity: d.severity, line: d.line, description: d.message, }); } } if (result.fixedCount && result.fixedCount > 0) { for (const label of result.autofixTools ?? []) { const [tool, countStr] = label.split(":"); const count = Number.parseInt(countStr ?? "", 10); runtime.turnSummary.recordAutofix(filePath, { tool: tool || label, description: Number.isFinite(count) && count > 0 ? `${count} issue(s) fixed` : undefined, }); } } if (result.formattersUsed?.length) { for (const tool of result.formattersUsed) { runtime.turnSummary.recordFormat(filePath, { tool }); } } } if (result.inlineBlockerSummary) { runtime.recordInlineBlockers(filePath, result.inlineBlockerSummary); } else { runtime.clearInlineBlockers(filePath); } if (result.isError) { return { content: [...event.content, { type: "text", text: result.output }], isError: true, }; } let output = result.output; runtime.updateGitGuardStatus(result.hasBlockers, result.output); if (behaviorWarnings.length > 0 && !result.hasBlockers) { output += `\n\n${formatBehaviorWarnings(behaviorWarnings)}`; } const totalMs = Date.now() - toolResultStart; logLatency({ type: "tool_result", toolName: event.toolName, filePath, durationMs: totalMs, result: output ? "completed" : "no_output", }); runtime.reportedThisTurn.add(filePath); if (!output) return; return { content: [...event.content, { type: "text", text: output }], }; }