@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
164 lines (163 loc) • 9.76 kB
JavaScript
import * as path from "node:path";
import { cachedCitationFreshness, citationFreshnessAnnotation } from "../content/citation-freshness.js";
import { parseCitationComment } from "../content/citation.js";
import { getProjectRemote, readProjectConfig } from "../project-config.js";
import { recordInjection, recordRetrieval, } from "../shared/governance.js";
import { getDocSourceKey, } from "../shared/index.js";
import { logImpact, extractFindingIdsFromSnippet, } from "../finding/impact.js";
import { errorMessage } from "../utils.js";
import { HOOK_TITLE_CHARS, clankerEnabled, docEntries, queryTerms, rowKeywords, rowTitle, termScore } from "../clanker.js";
import { annotateStale } from "./hooks-citations.js";
import { approximateTokens, fileRelevanceBoost, branchMatchBoost, SNIPPET_OVERHEAD_TOKENS } from "../shared/retrieval.js";
import { logger } from "../logger.js";
// ── Clanker mode: compact index ──────────────────────────────────────────────
function buildCompactIndex(selected, phrenPathLocal, terms) {
const lines = [];
const options = new Map();
for (const { doc, snippet, key } of selected) {
// A snippet that is one finding or task gets that entry's own id, so
// expanding it returns the entry rather than the whole file.
// Snippets are cut at a character budget, which often drops the id
// comment at the end of a long bullet, so match the bullet in the doc.
const textLines = snippet.split("\n").filter((l) => l.trim() && !l.trim().startsWith("<!--"));
// The line that matched most of the prompt, skipping headings and table rules.
const candidates = textLines.filter((l) => !/^\s*(#|\|?[\s:|-]+\|?\s*$)/.test(l));
const firstLine = candidates.reduce((best, l) => (best === undefined || termScore(l, terms) > termScore(best, terms) ? l : best), undefined) ?? textLines[0] ?? "";
const bullets = snippet.split("\n").filter((l) => /^-\s/.test(l));
const prefix = bullets.length === 1 ? bullets[0].replace(/ \[may be out of date:[\s\S]*$/, "").replace(/…$/, "").slice(0, 120) : "";
const entry = prefix ? docEntries(doc.content).find((e) => e.line.startsWith(prefix)) : undefined;
const id = entry ? entry.id : `mem:${getDocSourceKey(doc, phrenPathLocal)}`;
const text = (entry ? entry.line : firstLine).replace(/ \[may be out of date:[\s\S]*$/, "");
const citation = entry ? entry.text.split("\n").map(parseCitationComment).find(cite => cite !== null)
: parseCitationComment(snippet.split("\n")[snippet.split("\n").indexOf(firstLine) + 1] ?? "");
let annotation = "";
if (citation) {
const root = doc.path ? path.dirname(path.dirname(doc.path)) : phrenPathLocal;
const optionsKey = JSON.stringify([root, doc.project]);
let opts = options.get(optionsKey);
if (!opts) {
opts = { phrenPath: root, projectRemote: getProjectRemote(root, doc.project, readProjectConfig(root, doc.project)) };
options.set(optionsKey, opts);
}
annotation = citationFreshnessAnnotation(citation, cachedCitationFreshness(citation, opts));
}
const keywords = rowKeywords(text, terms);
// 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 clanker mode is on.
lines.push(`${id} fb:${key} ${rowTitle(text, HOOK_TITLE_CHARS)}${annotation}${keywords.length ? ` [${keywords.join(", ")}]` : ""}`);
}
return lines;
}
// ── Hook output formatting ───────────────────────────────────────────────────
export function buildHookOutput(selected, usedTokens, intent, gitCtx, detectedProject, stage, tokenBudget, phrenPathLocal, sessionId, keywords) {
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 clanker = clankerEnabled(phrenPathLocal);
if (clanker) {
const indexEntries = selected.slice(0, 8);
const indexLines = buildCompactIndex(indexEntries, phrenPathLocal, queryTerms(keywords ?? ""));
parts.push("Index (get_memory_detail id=<id> for full text):");
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>");
// The trace is for people reading the hook log; clanker mode spends no tokens on it.
if (clanker)
return parts;
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;
}