UNPKG

@phren/cli

Version:

Knowledge layer for AI agents - CLI, MCP server, and data layer

305 lines (304 loc) • 12 kB
import * as crypto from "crypto"; import * as fs from "fs"; import { impactLogFile } from "../shared.js"; import { rotateJsonlIfLarge, runtimeFile } from "../phren-paths.js"; import { withFileLock } from "../shared/governance.js"; import { normalizeFindingText } from "../content/metadata.js"; import { logger } from "../logger.js"; import { errorMessage } from "../utils.js"; /** * Impact aggregation, and why it is not a plain memoised read. * * `getHighImpactFindings()` runs inside `applyTrustFilter()` on the prompt hot * path, and `logImpact()` appends to the same file later in the *same* prompt. * The old mtime+size cache was therefore invalidated by the very writer it was * meant to protect: every prompt re-parsed the whole log, line by line, and at * the log's steady-state ceiling of 2MB (13,773 lines) that cost ~9ms per * prompt. Worse, `hook-prompt` is a fresh `node` process per prompt, so an * in-process cache could never hit on the path that actually needed it. * * So the aggregate is (a) derived once, (b) persisted to a sidecar keyed by the * log's exact [mtimeMs, size], and (c) folded forward on append rather than * discarded — appending a record only ever increments one counter. A reader in * the next process validates the sidecar's marker against the log and falls * back to a full derive on any mismatch, so being wrong is impossible; the * worst case is the work we used to do every time. */ const IMPACT_SUMMARY_VERSION = 1; let summaryCache = null; function nowIso() { return new Date().toISOString(); } function statMarker(file) { try { const stat = fs.statSync(file); return { mtimeMs: stat.mtimeMs, size: stat.size }; } catch { return null; } } function markersMatch(a, b) { if (!a || !b) return false; return a.mtimeMs === b.mtimeMs && a.size === b.size; } function impactSummaryFile(phrenPath) { return runtimeFile(phrenPath, "impact-summary.json"); } function emptySummary() { return { surfaceCountByFinding: new Map(), completedByFinding: new Set() }; } function readSidecarSummary(phrenPath, marker) { try { const parsed = JSON.parse(fs.readFileSync(impactSummaryFile(phrenPath), "utf8")); if (parsed?.version !== IMPACT_SUMMARY_VERSION) return null; if (!parsed.source || !markersMatch(parsed.source, marker)) return null; if (!parsed.counts || typeof parsed.counts !== "object" || !Array.isArray(parsed.completed)) return null; const surfaceCountByFinding = new Map(); for (const [findingId, count] of Object.entries(parsed.counts)) { if (typeof count === "number" && Number.isFinite(count)) surfaceCountByFinding.set(findingId, count); } return { surfaceCountByFinding, completedByFinding: new Set(parsed.completed.filter((id) => typeof id === "string")), }; } catch { return null; } } function writeSidecarSummary(phrenPath, marker, summary) { const target = impactSummaryFile(phrenPath); const payload = { version: IMPACT_SUMMARY_VERSION, source: marker, counts: Object.fromEntries(summary.surfaceCountByFinding), completed: [...summary.completedByFinding], }; // Atomic replace, no lock: readers validate the marker, so a lost race costs // one re-derive and can never surface a torn or mismatched aggregate. const tmp = `${target}.tmp-${process.pid}-${crypto.randomUUID()}`; try { fs.writeFileSync(tmp, JSON.stringify(payload)); fs.renameSync(tmp, target); } catch (err) { logger.debug("writeSidecarSummary", errorMessage(err)); try { fs.unlinkSync(tmp); } catch { /* nothing to clean up */ } } } function invalidateImpactSummary(phrenPath) { summaryCache = null; try { fs.unlinkSync(impactSummaryFile(phrenPath)); } catch { /* already absent */ } } /** The aggregate for the log's current revision, without ever re-parsing it. Null when unavailable. */ function peekSummary(phrenPath, file, marker) { if (summaryCache && summaryCache.file === file && markersMatch(summaryCache.marker, marker)) { return summaryCache.summary; } return readSidecarSummary(phrenPath, marker); } function foldEntriesIntoSummary(summary, entries) { for (const entry of entries) { summary.surfaceCountByFinding.set(entry.findingId, (summary.surfaceCountByFinding.get(entry.findingId) ?? 0) + 1); if (entry.taskCompleted) summary.completedByFinding.add(entry.findingId); } } export function findingIdFromLine(line) { const fid = line.match(/<!--\s*fid:([a-z0-9]{8})\s*-->/i); if (fid?.[1]) return `fid:${fid[1].toLowerCase()}`; const normalized = normalizeFindingText(line); if (!normalized) return "hash:empty"; const hash = crypto.createHash("sha1").update(normalized).digest("hex").slice(0, 12); return `hash:${hash}`; } export function extractFindingIdsFromSnippet(snippet) { const lines = snippet .split("\n") .map((line) => line.trim()) .filter((line) => line.length > 0); const bulletLines = lines.filter((line) => line.startsWith("- ")); const candidates = bulletLines.length > 0 ? bulletLines : (lines[0] ? [lines[0]] : []); const ids = new Set(); for (const line of candidates) { ids.add(findingIdFromLine(line)); } return [...ids]; } function parseImpactLine(line) { const trimmed = line.trim(); if (!trimmed) return null; try { const parsed = JSON.parse(trimmed); if (!parsed || typeof parsed.findingId !== "string" || typeof parsed.project !== "string" || typeof parsed.timestamp !== "string" || typeof parsed.sessionId !== "string" || typeof parsed.taskCompleted !== "boolean") { return null; } return { findingId: parsed.findingId, project: parsed.project, timestamp: parsed.timestamp, sessionId: parsed.sessionId, taskCompleted: parsed.taskCompleted, }; } catch { return null; } } /** The expensive path: derive the aggregate by parsing every line of the log. */ function readImpactSummary(phrenPath) { const file = impactLogFile(phrenPath); const summary = emptySummary(); if (!fs.existsSync(file)) return summary; const content = fs.readFileSync(file, "utf8"); for (const line of content.split("\n")) { const entry = parseImpactLine(line); if (!entry) continue; summary.surfaceCountByFinding.set(entry.findingId, (summary.surfaceCountByFinding.get(entry.findingId) ?? 0) + 1); if (entry.taskCompleted) { summary.completedByFinding.add(entry.findingId); } } return summary; } function appendImpact(phrenPath, entries) { if (entries.length === 0) return; const file = impactLogFile(phrenPath); withFileLock(file, () => { // Cap the log. Rotation drops the oldest records, which changes the surface // counts, so the aggregate cannot be folded across it. const sizeBeforeRotate = statMarker(file)?.size ?? 0; rotateJsonlIfLarge(file); const preAppendMarker = statMarker(file); const rotated = (preAppendMarker?.size ?? 0) !== sizeBeforeRotate; // An aggregate that already describes the pre-append log can absorb these // records directly — they are exactly the delta, and appending one only // ever increments one counter. A file that does not exist yet has an empty // aggregate by definition. const known = rotated ? null : preAppendMarker ? peekSummary(phrenPath, file, preAppendMarker) : emptySummary(); const lines = entries.map((entry) => JSON.stringify(entry)); fs.appendFileSync(file, lines.join("\n") + "\n"); const markerAfter = statMarker(file); if (!known || !markerAfter) { // No aggregate at hand. Drop the sidecar and let the next read rebuild // it — deriving it here would put the whole parse on the write path. invalidateImpactSummary(phrenPath); return; } foldEntriesIntoSummary(known, entries); summaryCache = { file, marker: markerAfter, summary: known }; writeSidecarSummary(phrenPath, markerAfter, known); }); } export function logImpact(phrenPath, entries) { if (entries.length === 0) return; const timestamp = nowIso(); appendImpact(phrenPath, entries.map((entry) => ({ findingId: entry.findingId, project: entry.project, sessionId: entry.sessionId, timestamp, taskCompleted: false, }))); } export function getHighImpactFindings(phrenPath, minSurfaceCount = 3) { const file = impactLogFile(phrenPath); const marker = statMarker(file); if (!marker) return new Set(); let summary = peekSummary(phrenPath, file, marker); if (!summary) { summary = readImpactSummary(phrenPath); // Persist it so the *next* prompt's process — hook-prompt is a new process // every prompt — starts from the aggregate instead of the log. writeSidecarSummary(phrenPath, marker, summary); } summaryCache = { file, marker, summary }; // Derived per call: the threshold is a caller's choice, not a property of the // aggregate, so it does not belong in the cache key. const ids = new Set(); for (const [findingId, surfaceCount] of summary.surfaceCountByFinding.entries()) { if (surfaceCount >= minSurfaceCount && summary.completedByFinding.has(findingId)) { ids.add(findingId); } } return ids; } export function markImpactEntriesCompletedForSession(phrenPath, sessionId, project) { if (!sessionId) return 0; const file = impactLogFile(phrenPath); if (!fs.existsSync(file)) return 0; const updated = withFileLock(file, () => { if (!fs.existsSync(file)) return 0; const preMarker = statMarker(file); const known = preMarker ? peekSummary(phrenPath, file, preMarker) : null; const lines = fs.readFileSync(file, "utf8").split("\n"); let updatedCount = 0; const flipped = new Set(); const rewritten = lines .filter((line) => line.trim().length > 0) .map((line) => { const entry = parseImpactLine(line); if (!entry) return line; if (entry.taskCompleted) return line; if (entry.sessionId !== sessionId) return line; if (project && entry.project !== project) return line; updatedCount += 1; flipped.add(entry.findingId); return JSON.stringify({ ...entry, taskCompleted: true }); }); if (updatedCount > 0) { fs.writeFileSync(file, rewritten.join("\n") + "\n"); // This rewrite flips taskCompleted on existing records; it never adds or // removes any, so the surface counts are untouched and the aggregate only // gains completed ids. const postMarker = statMarker(file); if (known && postMarker) { for (const findingId of flipped) known.completedByFinding.add(findingId); summaryCache = { file, marker: postMarker, summary: known }; writeSidecarSummary(phrenPath, postMarker, known); } else { invalidateImpactSummary(phrenPath); } } return updatedCount; }); return updated; }