UNPKG

pi-lens

Version:

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

133 lines (132 loc) • 5.62 kB
/** * Turn-summary collector (#484). * * Opt-in, transcript-persistent record of what pi-lens did: diagnostics * found, autofixes applied, autoformats applied. Accumulated per-file across * the RUN's write/edit pipeline runs + the agent_end deferred-format pass, * then emitted as ONE `pi.sendMessage` custom entry at the `agent_settled` * quiet window (never per-file, and per-RUN not per-turn — see the #484 * discussion + PR #500 review). The quiet-window emit point is load-bearing, * not cosmetic (verified in the installed pi 0.80.6 SDK): * * 1. `sendCustomMessage` STEERS the live model conversation when the * session `isStreaming` (dist/core/agent-session.js), and turn_end can * fire mid-stream — a passive summary must never steer a working agent. * At settle the session is idle, so sendMessage takes the safe append * branch (persisted + rendered immediately, no steer). * 2. A CustomMessageEntry DOES participate in LLM context: `display` only * controls TUI rendering, and `buildSessionContext` converts every such * entry into a `role: "user"` message on later context builds * (dist/core/session-manager.js). Only `content` reaches the model — * `details` never does — so the entry `content` must stay ONE short * line; the model's exposure is that ~80-char collapsed line, an * accepted residue (largely redundant with the #493 agent nudge). * * Collapsed rendering is tool-grouped; expanded rendering (from `details`, * human-only) is file-major. * * All map keys go through `normalizeMapKey` — never a hand-rolled path * comparison (see the two red CI rounds on PR #491 the raw-key trap cost). */ import { normalizeMapKey } from "./path-utils.js"; /** * Per-RUN accumulator. One instance lives on the runtime coordinator; it * survives turn boundaries (NOT cleared in beginTurn) and is cleared only by * `consume()` at the quiet-window emit and by `resetForSession()`. */ export class TurnSummaryCollector { filesByKey = new Map(); getOrCreate(filePath) { const key = normalizeMapKey(filePath); let entry = this.filesByKey.get(key); if (!entry) { // displayPath is resolved at consume() time (cwd known there); the // accumulation phase only has the absolute path. entry = { filePath, displayPath: filePath, events: [] }; this.filesByKey.set(key, entry); } return entry; } record(filePath, event) { this.getOrCreate(filePath).events.push(event); } recordDiagnostic(filePath, args) { this.record(filePath, { kind: "diagnostic", ...args }); } recordAutofix(filePath, args) { this.record(filePath, { kind: "autofix", ...args }); } recordFormat(filePath, args) { this.record(filePath, { kind: "format", ...args }); } isEmpty() { return this.filesByKey.size === 0; } /** Snapshot without clearing — used for tests/observability. */ peek() { return [...this.filesByKey.values()]; } clear() { this.filesByKey.clear(); } /** Consume (snapshot + clear) the run's collection, building the details payload. */ consume(turnIndex, toDisplayPath) { const files = [...this.filesByKey.values()].map((entry) => ({ ...entry, displayPath: toDisplayPath ? toDisplayPath(entry.filePath) : entry.filePath, })); this.filesByKey.clear(); const counts = { diagnostics: 0, autofixes: 0, formats: 0, byTool: { diagnostic: {}, autofix: {}, format: {} }, }; for (const file of files) { for (const event of file.events) { if (event.kind === "diagnostic") counts.diagnostics++; else if (event.kind === "autofix") counts.autofixes++; else counts.formats++; const bucket = counts.byTool[event.kind]; bucket[event.tool] = (bucket[event.tool] ?? 0) + 1; } } return { version: 1, turnIndex, files, counts }; } } function formatToolCounts(byTool) { return Object.entries(byTool) .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) .map(([tool, count]) => `${tool} ${count}`) .join(", "); } /** * Build the collapsed, tool-grouped one-line fallback text (also used as the * plain-text `content` for hosts without the custom renderer). * Example: "pi-lens: 3 diagnostics (eslint 2, tsserver 1) · 2 autofixed (ruff) · 1 reformatted (prettier)" */ export function formatTurnSummaryLine(details) { const parts = []; const { counts } = details; if (counts.diagnostics > 0) { const byTool = formatToolCounts(counts.byTool.diagnostic); parts.push(`${counts.diagnostics} diagnostic${counts.diagnostics === 1 ? "" : "s"}${byTool ? ` (${byTool})` : ""}`); } if (counts.autofixes > 0) { const byTool = formatToolCounts(counts.byTool.autofix); parts.push(`${counts.autofixes} autofixed${byTool ? ` (${byTool})` : ""}`); } if (counts.formats > 0) { const byTool = formatToolCounts(counts.byTool.format); parts.push(`${counts.formats} reformatted${byTool ? ` (${byTool})` : ""}`); } if (parts.length === 0) return "pi-lens: turn summary (empty)"; return `pi-lens: ${parts.join(" · ")}`; } export const TURN_SUMMARY_CUSTOM_TYPE = "pilens:turn-summary";