UNPKG

@phren/cli

Version:

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

139 lines (138 loc) • 7.48 kB
import { recordInjection, recordRetrieval, } from "../shared/governance.js"; import { getDocSourceKey, } from "../shared/index.js"; import { logImpact, extractFindingIdsFromSnippet, } from "../finding/impact.js"; import { isFeatureEnabled, errorMessage } from "../utils.js"; import { annotateStale } from "./hooks-citations.js"; import { approximateTokens, fileRelevanceBoost, branchMatchBoost, SNIPPET_OVERHEAD_TOKENS } from "../shared/retrieval.js"; import { logger } from "../logger.js"; // ── Progressive disclosure helpers ──────────────────────────────────────────── function buildOneLiner(snippet) { const lines = snippet.split("\n").map(l => l.trim()).filter(l => l.length > 0); if (!lines.length) return ""; const first = lines[0].replace(/^[-*#>\s]+/, "").trim(); if (first.length <= 80) return first; return first.slice(0, 79) + "\u2026"; } function buildCompactIndex(selected, phrenPathLocal) { const lines = []; for (const { doc, snippet, key } of selected) { const id = `mem:${getDocSourceKey(doc, phrenPathLocal)}`; const summary = buildOneLiner(snippet); // The fb: key rides here too. memory_feedback's description promises it // appears in injected headers without qualification, and this path renders // instead of the full one whenever progressive disclosure is enabled. lines.push(`[${id}] fb:${key} ${doc.type}: ${summary}`); } return lines; } // ── Hook output formatting ─────────────────────────────────────────────────── export function buildHookOutput(selected, usedTokens, intent, gitCtx, detectedProject, stage, tokenBudget, phrenPathLocal, sessionId) { const projectLabel = detectedProject ? ` \u00b7 ${detectedProject}` : ""; const resultLabel = selected.length === 1 ? "1 result" : `${selected.length} results`; const statusLine = `\u25c6 phren${projectLabel} \u00b7 ${resultLabel}`; const parts = [statusLine, "<phren-context>"]; const impactEntries = []; const impactSessionId = sessionId ?? "none"; const useCompactIndex = isFeatureEnabled("PHREN_FEATURE_PROGRESSIVE_DISCLOSURE", false) && selected.length >= 3; if (useCompactIndex) { const indexEntries = selected.slice(0, 8); const indexLines = buildCompactIndex(indexEntries, phrenPathLocal); parts.push("Context index (use get_memory_detail to expand any entry):"); for (const line of indexLines) { parts.push(line); } parts.push(""); for (const injected of indexEntries) { recordInjection(phrenPathLocal, injected.key, sessionId); if (injected.doc.type === "findings") { for (const findingId of extractFindingIdsFromSnippet(injected.snippet)) { impactEntries.push({ findingId, project: injected.doc.project, sessionId: impactSessionId, }); } } try { recordRetrieval(phrenPathLocal, `${injected.doc.project}/${injected.doc.filename}`, injected.doc.type); } catch (err) { logger.debug("hooks-output", `injectContext recordRetrieval: ${errorMessage(err)}`); } } } else { // Position-aware injection: place most relevant at START and END so the // highest-value snippets survive truncation pressure better. // Input `selected` is already ranked by relevance (best first). // Reorder so: [0] stays first, [1] goes last, middle positions get [2..N-1]. let ordered = selected; if (selected.length >= 3) { ordered = [ selected[0], // most relevant → start ...selected.slice(2), // remaining → middle selected[1], // second most → end ]; } // Re-verify token budget after reordering; trim middle items if over budget if (ordered.length > 2) { let totalTokens = 36; // base overhead const keep = ordered.map(() => true); for (let i = 0; i < ordered.length; i++) { // 24 ≈ header line ([source key] (type) fb:key) + blank separator. totalTokens += approximateTokens(ordered[i].snippet) + SNIPPET_OVERHEAD_TOKENS; } // Trim from the middle (indices 1..N-2) if over budget if (totalTokens > tokenBudget) { for (let i = ordered.length - 2; i >= 1; i--) { if (totalTokens <= tokenBudget) break; totalTokens -= approximateTokens(ordered[i].snippet) + SNIPPET_OVERHEAD_TOKENS; keep[i] = false; } ordered = ordered.filter((_, i) => keep[i]); } } for (const injected of ordered) { const { doc, snippet, key } = injected; recordInjection(phrenPathLocal, key, sessionId); if (doc.type === "findings") { for (const findingId of extractFindingIdsFromSnippet(snippet)) { impactEntries.push({ findingId, project: doc.project, sessionId: impactSessionId, }); } } try { recordRetrieval(phrenPathLocal, doc.path ?? doc.filename, doc.type); } catch (err) { logger.debug("hooks-output", `injectContext recordRetrievalOrdered: ${errorMessage(err)}`); } // `fb:` is the memory_feedback handle. The tool scores entryScoreKey // (project/filename:digest) — NOT the mem: source key and NOT an fid — // and this header was the only place the injection pipeline had the // key in hand; before it was printed here, the feedback loop was // built, wired into ranking, and unreachable. parts.push(`[${getDocSourceKey(doc, phrenPathLocal)}] (${doc.type}) fb:${key}`); parts.push(annotateStale(snippet)); parts.push(""); } } logImpact(phrenPathLocal, impactEntries); parts.push("</phren-context>"); const changedCount = gitCtx?.changedFiles.size ?? 0; if (gitCtx) { const fileHits = selected.filter((r) => fileRelevanceBoost(r.doc.path, gitCtx.changedFiles) > 0).length; const branchHits = selected.filter((r) => branchMatchBoost(r.doc.content, gitCtx.branch) > 0).length; parts.push(`\u25c6 phren \u00b7 trace: intent=${intent}; reasons=file:${fileHits},branch:${branchHits}; branch=${gitCtx.branch}; changed_files=${changedCount}; tokens\u2248${usedTokens}/${tokenBudget}; stages=index:${stage.indexMs}ms,search:${stage.searchMs}ms,trust:${stage.trustMs}ms,rank:${stage.rankMs}ms,select:${stage.selectMs}ms`); } else { parts.push(`\u25c6 phren \u00b7 trace: intent=${intent}; reasons=intent-only; tokens\u2248${usedTokens}/${tokenBudget}; stages=index:${stage.indexMs}ms,search:${stage.searchMs}ms,trust:${stage.trustMs}ms,rank:${stage.rankMs}ms,select:${stage.selectMs}ms`); } return parts; }