@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
139 lines (138 loc) • 7.48 kB
JavaScript
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;
}