UNPKG

@phren/cli

Version:

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

85 lines (84 loc) • 3.42 kB
/** * Structured fact extraction from findings (PHREN_FEATURE_FACT_EXTRACT=1). * Each new finding is passed to an LLM that extracts a preference or fact * ("prefers X", "uses Y", "avoids Z"). Stored in project/preferences.json * and surfaced in session_start. */ import * as fs from "fs"; import * as path from "path"; import { debugLog } from "../shared.js"; import { isFeatureEnabled, errorMessage } from "../utils.js"; import { storeAwareProjectPath } from "../store-routing.js"; import { callLlm } from "../content/dedup.js"; import { withFileLock } from "../shared/governance.js"; import { logger } from "../logger.js"; const FACT_EXTRACT_FLAG = "PHREN_FEATURE_FACT_EXTRACT"; const MAX_FACTS = 50; function preferencesPath(phrenPath, project) { const dir = storeAwareProjectPath(phrenPath, project); return dir ? path.join(dir, "preferences.json") : null; } export function readExtractedFacts(phrenPath, project) { const p = preferencesPath(phrenPath, project); if (!p) return []; try { const data = JSON.parse(fs.readFileSync(p, "utf8")); return Array.isArray(data) ? data : []; } catch (err) { logger.debug("extract-facts", `readExtractedFacts: ${errorMessage(err)}`); return []; } } function writeExtractedFacts(phrenPath, project, facts) { const p = preferencesPath(phrenPath, project); if (!p) return; try { const trimmed = facts.slice(-MAX_FACTS); const tmp = `${p}.tmp-${process.pid}-${Date.now()}`; fs.writeFileSync(tmp, JSON.stringify(trimmed, null, 2)); fs.renameSync(tmp, p); } catch (err) { debugLog(`writeExtractedFacts: ${errorMessage(err)}`); } } /** * Fire-and-forget: extract a structured fact from a new finding using an LLM. * Skips silently if the feature flag is off or no LLM is configured. */ export function extractFactFromFinding(phrenPath, project, finding) { if (!isFeatureEnabled(FACT_EXTRACT_FLAG, false)) return; // no LLM configured, skip if (!(process.env.PHREN_LLM_ENDPOINT) && !process.env.ANTHROPIC_API_KEY && !process.env.OPENAI_API_KEY) return; const prompt = `Extract a single user preference, technology choice, or architectural fact from this finding. ` + `Return ONLY a short statement in the format "prefers X", "uses Y", "avoids Z", ` + `or "decided to X because Y". If no clear preference or fact exists, return "none".\n\nFinding: ${finding.slice(0, 500)}`; callLlm(prompt, undefined, 60) .then(raw => { if (!raw || raw.toLowerCase() === "none") return; // cap and strip newlines before storing const fact = raw.replace(/[\r\n]+/g, " ").trim().slice(0, 200); if (!fact) return; const p = preferencesPath(phrenPath, project); if (!p) return; withFileLock(p, () => { const existing = readExtractedFacts(phrenPath, project); const normalized = fact.toLowerCase(); if (existing.some(f => f.fact.toLowerCase() === normalized)) return; existing.push({ fact, source: finding.slice(0, 120), at: new Date().toISOString() }); writeExtractedFacts(phrenPath, project, existing); }); }) .catch((err) => { debugLog(`extractFactFromFinding: ${errorMessage(err)}`); }); }