@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
406 lines (405 loc) • 22.1 kB
JavaScript
import { moduleEnabled } from "../modules/runtime.js";
// cli-hooks.ts — Thin orchestrator. Delegates to focused modules:
// shared-retrieval.ts — shared search, scoring, ranking, snippet selection
// cli-hooks-citations.ts — citation parsing and validation
// cli-hooks-session.ts — session lifecycle hooks, metrics, background maintenance
// cli-hooks-output.ts — hook output formatting
// cli-hooks-globs.ts — project glob matching
import { debugLog, sessionMarker, sessionsDir, getPhrenPath, } from "../shared.js";
import { mergeConfig, recordLookupEvents } from "../shared/governance.js";
import { loadIndexForHook, detectProject, } from "../shared/index.js";
import { isProjectHookEnabled } from "../project-config.js";
import { checkConsolidationNeeded, } from "../shared/content.js";
import { buildRobustFtsQuery, extractKeywordEntries, isFeatureEnabled, clampInt, clampFloat, errorMessage, } from "../utils.js";
import { getHooksEnabledPreference } from "../init/init.js";
import { logger } from "../logger.js";
import { isToolHookEnabled, runPrePromptHooks } from "../hooks.js";
import { handleExtractMemories } from "./extract.js";
import { appendAuditLog } from "../shared.js";
import { updateRuntimeHealth } from "../shared/governance.js";
import { getProactivityLevelForTask, getProactivityLevelForFindings } from "../proactivity.js";
import { FINDING_SENSITIVITY_CONFIG } from "./config.js";
import * as fs from "fs";
// ── Re-exports from focused modules ─────────────────────────────────────────
// Citations
export { parseCitations, validateCitation, annotateStale, clearCitationValidCache, } from "./hooks-citations.js";
// Globs
export { getProjectGlobBoost, clearProjectGlobCache, } from "./hooks-globs.js";
// Retrieval
export { detectTaskIntent, filterTaskByPriority, searchDocuments, applyTrustFilter, rankResults, applyRelevanceFloor, promptRarity, DEFAULT_MIN_QUERY_RELEVANCE, selectSnippets, } from "../shared/retrieval.js";
// Output
export { buildHookOutput, } from "./hooks-output.js";
// Session
export { handleHookSessionStart, handleHookStop, handleBackgroundSync, handleHookContext, handleHookTool, trackSessionMetrics, filterConversationInsightsForProactivity, extractToolFindings, filterToolFindingsForProactivity, resolveSubprocessArgs, } from "./hooks-session.js";
// ── Imports for the orchestrator ─────────────────────────────────────────────
import { searchDocumentsAsync, applyTrustFilter, rankResults, applyRelevanceFloor, promptRarity, DEFAULT_MIN_QUERY_RELEVANCE, selectSnippets, detectTaskIntent, wantsTasks } from "../shared/retrieval.js";
import { buildHookOutput } from "./hooks-output.js";
import { getGitContext, trackSessionMetrics, } from "./hooks-session.js";
import { approximateTokens, SNIPPET_OVERHEAD_TOKENS } from "../shared/retrieval.js";
import { resolveRuntimeProfile } from "../runtime-profile.js";
import { handleTaskPromptLifecycle } from "../task/lifecycle.js";
import { bestFindingNodeId } from "../finding-graph-id.js";
import { readKnowsBlock } from "../content/summarize.js";
// Auto-learn from prompts was removed — it learned conversational noise ("bro", "idk", typos)
// as synonyms for high-frequency terms. Manual `phren config synonyms add` still works.
// Future: finding-based co-occurrence mining (phren maintain command, not live hook).
async function readStdin() {
return new Promise((resolve, reject) => {
const chunks = [];
process.stdin.on("data", (chunk) => chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk));
process.stdin.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
process.stdin.on("error", reject);
});
}
export function parseHookInput(raw) {
try {
const data = JSON.parse(raw);
const prompt = data.prompt || "";
if (!prompt.trim())
return null;
return { prompt, cwd: data.cwd, sessionId: data.session_id };
}
catch (err) {
debugLog(`parseHookInput: failed to parse hook JSON: ${errorMessage(err)}`);
return null;
}
}
// ── handleHookPrompt: orchestrator using extracted stages ────────────────────
export async function handleHookPrompt() {
const profile = resolveRuntimeProfile(getPhrenPath());
const stage = { indexMs: 0, searchMs: 0, trustMs: 0, rankMs: 0, selectMs: 0 };
let raw = "";
try {
raw = await readStdin();
}
catch (err) {
if (process.env.PHREN_DEBUG)
logger.debug("cli-hooks", `hookPrompt stdinRead: ${errorMessage(err)}`);
process.exit(0);
}
const input = parseHookInput(raw);
if (!input)
process.exit(0);
const { prompt, cwd, sessionId } = input;
if (!getHooksEnabledPreference(getPhrenPath())) {
appendAuditLog(getPhrenPath(), "hook_prompt", "status=disabled");
process.exit(0);
}
// Check per-tool hook preference (PHREN_HOOK_TOOL is set by session wrappers;
// Claude hooks always run as "claude" from settings.json)
const hookTool = process.env.PHREN_HOOK_TOOL || "claude";
if (!isToolHookEnabled(getPhrenPath(), hookTool)) {
appendAuditLog(getPhrenPath(), "hook_prompt", `status=tool_disabled tool=${hookTool}`);
process.exit(0);
}
updateRuntimeHealth(getPhrenPath(), { lastPromptAt: new Date().toISOString() });
const keywordEntries = extractKeywordEntries(prompt);
const keywords = keywordEntries.join(" ");
if (!keywords)
process.exit(0);
debugLog(`hook-prompt keywords: "${keywords}"`);
// Session momentum: track topic frequencies within the session
let hotTopics = [];
if (sessionId) {
const topicFile = sessionMarker(getPhrenPath(), `topics-${sessionId}.json`);
let sessionTopics = {};
try {
if (fs.existsSync(topicFile)) {
sessionTopics = JSON.parse(fs.readFileSync(topicFile, 'utf8'));
}
}
catch { /* ignore parse errors */ }
for (const kw of keywordEntries) {
sessionTopics[kw] = (sessionTopics[kw] ?? 0) + 1;
}
const topicTmp = `${topicFile}.tmp-${process.pid}`;
fs.writeFileSync(topicTmp, JSON.stringify(sessionTopics));
fs.renameSync(topicTmp, topicFile);
// Find hot topics (3+ mentions this session)
hotTopics = Object.entries(sessionTopics)
.filter(([, count]) => count >= 3)
.map(([topic]) => topic);
}
const tIndex0 = Date.now();
// Never block the prompt on a rebuild: serve the current/stale snapshot and
// refresh in a detached process (see loadIndexForHook).
const db = await loadIndexForHook(getPhrenPath(), profile);
stage.indexMs = Date.now() - tIndex0;
const intent = detectTaskIntent(prompt);
const detectedProject = cwd ? detectProject(getPhrenPath(), cwd, profile) : null;
if (detectedProject)
debugLog(`Detected project: ${detectedProject}`);
if (!isProjectHookEnabled(getPhrenPath(), detectedProject, "UserPromptSubmit")) {
appendAuditLog(getPhrenPath(), "hook_prompt", `status=project_disabled project=${detectedProject}`);
process.exit(0);
}
// Resolve git context AFTER the enabled check — it spawns 3 git processes and is
// wasted work when the project has hooks disabled.
const gitCtx = moduleEnabled(getPhrenPath(), "git") ? getGitContext(cwd) : null;
const resolvedConfig = mergeConfig(getPhrenPath(), detectedProject ?? undefined);
const safeQuery = buildRobustFtsQuery(keywords, detectedProject, getPhrenPath());
if (!safeQuery)
process.exit(0);
try {
const tSearch0 = Date.now();
let rows = await searchDocumentsAsync(db, safeQuery, prompt, keywords, detectedProject, false, getPhrenPath());
stage.searchMs = Date.now() - tSearch0;
if (!rows || !rows.length)
process.exit(0);
const tTrust0 = Date.now();
const policy = resolvedConfig.retentionPolicy;
const memoryTtlDays = Number.parseInt(process.env.PHREN_MEMORY_TTL_DAYS || String(policy.ttlDays), 10);
const trustResult = applyTrustFilter(rows, Number.isNaN(memoryTtlDays) ? policy.ttlDays : memoryTtlDays, policy.minInjectConfidence, policy.decay, getPhrenPath());
rows = trustResult.rows;
// Notes are intentionally available to explicit search, but are personal scratch
// context and should never be injected into an agent prompt automatically.
rows = rows.filter((row) => row.type !== "notes");
// Tasks are a backlog, not memory: only when the prompt is about the work.
if (!wantsTasks(prompt, intent))
rows = rows.filter((row) => row.type !== "task");
stage.trustMs = Date.now() - tTrust0;
if (!rows.length)
process.exit(0);
const findingsProactivity = resolvedConfig.proactivity.findings
?? resolvedConfig.proactivity.base
?? getProactivityLevelForFindings(getPhrenPath());
if (moduleEnabled(getPhrenPath(), "git") && isFeatureEnabled("PHREN_FEATURE_AUTO_EXTRACT", true) && findingsProactivity !== "low" && sessionId && detectedProject && cwd) {
const marker = sessionMarker(getPhrenPath(), `extracted-${sessionId}-${detectedProject}`);
if (!fs.existsSync(marker)) {
try {
await handleExtractMemories(detectedProject, cwd, true, sessionId, "hook");
fs.writeFileSync(marker, "");
}
catch (err) {
debugLog(`auto-extract failed for ${detectedProject}: ${errorMessage(err)}`);
}
}
}
const tRank0 = Date.now();
rows = rankResults(rows, intent, gitCtx, detectedProject, getPhrenPath(), db, cwd, keywords);
stage.rankMs = Date.now() - tRank0;
if (!rows.length)
process.exit(0);
// Relevance floor: inject signal or nothing. Ranking scores on priors too,
// so a doc can rank well with no real tie to this prompt — drop those rather
// than pad to a quota with noise (env PHREN_MIN_QUERY_RELEVANCE=0 disables).
const relevanceFloor = clampFloat(process.env.PHREN_MIN_QUERY_RELEVANCE, DEFAULT_MIN_QUERY_RELEVANCE, 0, 1);
const preFloorCount = rows.length;
debugLog(`relevance-floor candidates: ${rows.slice(0, 12).map((row) => `${row.project}/${row.filename}`).join(", ")}`);
rows = applyRelevanceFloor(rows, keywords, gitCtx, detectedProject, relevanceFloor, promptRarity(db, keywords));
if (rows.length !== preFloorCount) {
debugLog(`relevance-floor: ${preFloorCount} -> ${rows.length} rows (floor=${relevanceFloor})`);
}
if (!rows.length)
process.exit(0);
let safeTokenBudget = clampInt(process.env.PHREN_CONTEXT_TOKEN_BUDGET, 550, 180, 10000);
const safeLineBudget = clampInt(process.env.PHREN_CONTEXT_SNIPPET_LINES, 6, 2, 100);
const safeCharBudget = clampInt(process.env.PHREN_CONTEXT_SNIPPET_CHARS, 520, 120, 10000);
// Session momentum: boost token budget for hot topics
if (hotTopics.length > 0) {
safeTokenBudget = Math.min(Math.floor(safeTokenBudget * 1.3), parseInt(process.env.PHREN_MAX_INJECT_TOKENS ?? '2000', 10));
}
const tSelect0 = Date.now();
let { selected, usedTokens } = selectSnippets(rows, keywords, safeTokenBudget, safeLineBudget, safeCharBudget);
stage.selectMs = Date.now() - tSelect0;
if (!selected.length)
process.exit(0);
// Once per session per project: the "What phren knows" paragraph from
// summary.md, so the agent gets the shape of a project's archive before
// individual bullets. Written by `phren maintain summarize`; absent until then.
if (detectedProject && sessionId) {
const knowsMarker = sessionMarker(getPhrenPath(), `knows-${sessionId}-${detectedProject}`);
if (!fs.existsSync(knowsMarker)) {
const knows = readKnowsBlock(getPhrenPath(), detectedProject);
if (knows) {
const text = knows.text.length > 520 ? `${knows.text.slice(0, 519)}…` : knows.text;
selected = [{ doc: { project: detectedProject, filename: "summary.md", type: "summary", content: knows.text, path: knows.path }, snippet: text, key: `knows:${detectedProject}` }, ...selected.filter((s) => s.key !== `knows:${detectedProject}`)];
usedTokens += approximateTokens(text) + SNIPPET_OVERHEAD_TOKENS;
try {
fs.writeFileSync(knowsMarker, "");
}
catch (err) {
debugLog(`knows marker: ${errorMessage(err)}`);
}
}
}
}
// Injection budget: cap total injected tokens across all content
const maxInjectTokens = clampInt(process.env.PHREN_MAX_INJECT_TOKENS, 2000, 200, 20000);
let budgetSelected = selected;
let budgetUsedTokens = usedTokens;
if (budgetUsedTokens > maxInjectTokens) {
const priorityOrder = (s) => {
if (s.doc.type === "findings")
return 0;
if (s.doc.type === "canonical")
return 1;
if (s.doc.type === "summary" || s.doc.type === "claude")
return 2;
if (s.doc.type === "reference")
return 4;
return 3;
};
const sorted = [...budgetSelected].sort((a, b) => priorityOrder(a) - priorityOrder(b));
const kept = [];
let runningTokens = 36;
for (const s of sorted) {
const est = approximateTokens(s.snippet) + SNIPPET_OVERHEAD_TOKENS;
if (runningTokens + est <= maxInjectTokens) {
kept.push(s);
runningTokens += est;
}
}
budgetSelected = kept;
budgetUsedTokens = runningTokens;
debugLog(`injection-budget: trimmed ${selected.length} -> ${kept.length} snippets to fit ${maxInjectTokens} token budget`);
}
// Live activity: what a prompt pulled in is a recall as much as a search
// hit is, and it is by far the most common kind. Recording it here is what
// lets the terminal graph's watch mode, and anything else tailing the log,
// light up while an agent works. Best-effort: logging must never break
// the hook.
try {
const at = new Date().toISOString();
// The prompt, not the expanded keyword string: the feed shows this to a
// person, and "retry retry backoff backoff jitter" is not what they asked.
const asked = prompt.replace(/\s+/g, " ").trim().slice(0, 160);
recordLookupEvents(getPhrenPath(), budgetSelected.map((s) => {
const nodeId = s.doc.type === "findings" ? bestFindingNodeId(s.doc.project, s.doc.content, keywords) : null;
return {
at,
query: asked,
project: s.doc.project,
filename: s.doc.filename,
type: s.doc.type,
path: s.doc.path,
snippet: s.snippet,
source: "hook",
...(nodeId ? { nodeId } : {}),
...(sessionId ? { session: sessionId } : {}),
};
}));
}
catch (err) {
debugLog(`hook-prompt lookup-events: ${errorMessage(err)}`);
}
const parts = buildHookOutput(budgetSelected, budgetUsedTokens, intent, gitCtx, detectedProject, stage, safeTokenBudget, getPhrenPath(), sessionId, keywords);
const taskLevel = resolvedConfig.proactivity.tasks
?? resolvedConfig.proactivity.base
?? getProactivityLevelForTask(getPhrenPath());
const taskLifecycle = moduleEnabled(getPhrenPath(), "tasks") ? handleTaskPromptLifecycle({
phrenPath: getPhrenPath(),
prompt,
project: detectedProject,
sessionId,
intent,
taskLevel,
}) : { noticeLines: [] };
if (taskLifecycle.noticeLines.length > 0) {
parts.push("");
parts.push(...taskLifecycle.noticeLines);
}
// Inject finding sensitivity agent instruction
try {
const sensitivity = resolvedConfig.findingSensitivity ?? "balanced";
const sensitivityConfig = FINDING_SENSITIVITY_CONFIG[sensitivity];
if (sensitivityConfig) {
parts.push("");
parts.push(`[phren finding-sensitivity=${sensitivity}] ${sensitivityConfig.agentInstruction}`);
}
}
catch {
// ignore — non-fatal
}
// Add budget info to trace
if (parts.length > 0) {
const traceIdx = parts.findIndex(p => p.includes("trace:"));
if (traceIdx !== -1) {
parts[traceIdx] = parts[traceIdx].replace(/tokens/, `budget=${budgetUsedTokens}/${maxInjectTokens};tokens`);
}
}
if (sessionId) {
trackSessionMetrics(getPhrenPath(), sessionId, budgetSelected);
}
// Reads stay side-effect free: trust filter output informs ranking/snippets now,
// while queue/audit mutation is deferred to explicit governance maintenance.
if (trustResult.queueItems.length > 0 || trustResult.auditEntries.length > 0) {
debugLog(`hook-prompt deferred trust governance items=${trustResult.queueItems.length} audit=${trustResult.auditEntries.length}`);
}
const noticeFile = sessionId ? sessionMarker(getPhrenPath(), `noticed-${sessionId}`) : null;
const alreadyNoticed = noticeFile ? fs.existsSync(noticeFile) : false;
if (!alreadyNoticed) {
// Defer stale session marker cleanup to after output — it's I/O-heavy and not needed for results
setImmediate(() => {
try {
const cutoff = Date.now() - 86400000;
const sessDir = sessionsDir(getPhrenPath());
if (fs.existsSync(sessDir)) {
for (const f of fs.readdirSync(sessDir)) {
if (!f.startsWith("noticed-") && !f.startsWith("extracted-"))
continue;
const fp = `${sessDir}/${f}`;
try {
if (fs.statSync(fp).mtimeMs < cutoff)
fs.unlinkSync(fp);
}
catch { /* ignore per-file errors */ }
}
}
// Also clean stale markers from the phren root
for (const f of fs.readdirSync(getPhrenPath())) {
if (!f.startsWith(".noticed-") && !f.startsWith(".extracted-"))
continue;
const fp = `${getPhrenPath()}/${f}`;
try {
fs.unlinkSync(fp);
}
catch { /* ignore */ }
}
}
catch (err) {
debugLog(`stale notice cleanup failed: ${errorMessage(err)}`);
}
});
const needed = checkConsolidationNeeded(getPhrenPath(), profile);
if (needed.length > 0) {
const notices = needed.map((n) => {
const since = n.lastConsolidated ? ` since ${n.lastConsolidated}` : "";
return ` ${n.project}: ${n.entriesSince} new findings${since}`;
});
parts.push(`\u25c8 phren \u00b7 consolidation ready`);
parts.push(`<phren-notice>`);
parts.push(`Findings ready for consolidation:`);
parts.push(notices.join("\n"));
parts.push(`Run phren-consolidate when ready.`);
parts.push(`</phren-notice>`);
}
if (noticeFile) {
try {
fs.writeFileSync(noticeFile, "");
}
catch (err) {
if (process.env.PHREN_DEBUG)
logger.debug("cli-hooks", `hookPrompt noticeFileWrite: ${errorMessage(err)}`);
}
}
}
const totalMs = stage.indexMs + stage.searchMs + stage.trustMs + stage.rankMs + stage.selectMs;
const slowThreshold = Number.parseInt(process.env.PHREN_SLOW_FS_WARN_MS || "3000", 10) || 3000;
if (totalMs > slowThreshold) {
debugLog(`slow-fs: hook-prompt took ${totalMs}ms (index=${stage.indexMs} search=${stage.searchMs} trust=${stage.trustMs} rank=${stage.rankMs} select=${stage.selectMs})`);
process.stderr.write(`phren: hook-prompt took ${totalMs}ms, check if ~/.phren is on a slow or network filesystem\n`);
}
// Run pre-prompt custom hooks and prepend their output
const prePromptOutput = runPrePromptHooks(getPhrenPath(), raw);
if (prePromptOutput)
parts.unshift(prePromptOutput);
console.log(parts.join("\n"));
}
catch (err) {
const msg = errorMessage(err);
process.stdout.write(`\n<phren-error>phren hook failed: ${msg}. Check ~/.phren/.runtime/debug.log for details.</phren-error>\n`);
debugLog(`hook-prompt error: ${msg}`);
process.exit(0);
}
}