@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
151 lines (150 loc) • 5.85 kB
JavaScript
// query-correlation.ts — Lightweight query-to-finding correlation tracker.
// Tracks which queries led to which documents being selected (and later rated "helpful"),
// then uses that data to pre-warm results for recurring query patterns.
//
// Gated behind PHREN_FEATURE_QUERY_CORRELATION env var (disabled by default).
// Storage: JSONL append to .runtime/query-correlations.jsonl, last-500 window.
import * as fs from "fs";
import { runtimeFile, debugLog } from "./shared.js";
import { isFeatureEnabled, errorMessage } from "./utils.js";
import { withFileLock } from "./shared/governance.js";
const CORRELATION_FILENAME = "query-correlations.jsonl";
const RECENT_WINDOW = 500;
const MIN_TOKEN_OVERLAP = 2;
const MIN_TOKEN_LENGTH = 3;
/**
* Check if query correlation feature is enabled via env var.
*/
export function isQueryCorrelationEnabled() {
return isFeatureEnabled("PHREN_FEATURE_QUERY_CORRELATION", false);
}
/**
* Log query-to-finding correlations after snippet selection.
* Called from handleHookPrompt after selectSnippets.
*/
export function logCorrelations(phrenPath, keywords, selected, sessionId) {
if (!isQueryCorrelationEnabled())
return;
if (!selected.length || !keywords.trim())
return;
try {
const correlationFile = runtimeFile(phrenPath, CORRELATION_FILENAME);
const lines = [];
for (const sel of selected) {
const entry = {
timestamp: new Date().toISOString(),
keywords: keywords.slice(0, 200),
project: sel.doc.project,
filename: sel.doc.filename,
sessionId,
};
lines.push(JSON.stringify(entry));
}
fs.appendFileSync(correlationFile, lines.join("\n") + "\n");
}
catch (err) {
debugLog(`query-correlation log failed: ${errorMessage(err)}`);
}
}
/**
* Mark correlations from a session as "helpful" when positive feedback is received.
* This retroactively stamps entries so that future correlation lookups weight them higher.
*/
export function markCorrelationsHelpful(phrenPath, sessionId, docKey) {
if (!isQueryCorrelationEnabled())
return;
try {
const correlationFile = runtimeFile(phrenPath, CORRELATION_FILENAME);
if (!fs.existsSync(correlationFile))
return;
withFileLock(correlationFile, () => {
const raw = fs.readFileSync(correlationFile, "utf8");
const lines = raw.split("\n").filter(Boolean);
let modified = false;
const updated = lines.map((line) => {
try {
const entry = JSON.parse(line);
if (entry.sessionId === sessionId &&
`${entry.project}/${entry.filename}` === docKey &&
!entry.helpful) {
entry.helpful = true;
modified = true;
return JSON.stringify(entry);
}
}
catch {
// keep original line
}
return line;
});
if (modified) {
fs.writeFileSync(correlationFile, updated.join("\n") + "\n");
}
});
}
catch (err) {
debugLog(`query-correlation mark-helpful failed: ${errorMessage(err)}`);
}
}
/** Lowercased whitespace-separated keywords for overlap comparison. Unlike the
* search fallback it keeps punctuation and does not stem: correlation keys
* are stored keyword strings, compared as written. */
function keywordSet(text) {
return new Set(text
.toLowerCase()
.split(/\s+/)
.filter((w) => w.length >= MIN_TOKEN_LENGTH));
}
/**
* Find documents that historically correlate with the given query keywords.
* Returns doc keys (project/filename) sorted by correlation strength.
*
* Only looks at the last RECENT_WINDOW entries for performance.
* Entries marked "helpful" get a 2x weight boost.
*/
export function getCorrelatedDocs(phrenPath, keywords, limit = 3) {
if (!isQueryCorrelationEnabled())
return [];
try {
const correlationFile = runtimeFile(phrenPath, CORRELATION_FILENAME);
if (!fs.existsSync(correlationFile))
return [];
const raw = fs.readFileSync(correlationFile, "utf8");
const lines = raw.split("\n").filter(Boolean);
// Only look at last RECENT_WINDOW entries to keep it fast
const recent = lines.slice(-RECENT_WINDOW);
const queryTokens = keywordSet(keywords);
if (queryTokens.size === 0)
return [];
const docScores = new Map();
for (const line of recent) {
try {
const entry = JSON.parse(line);
const entryTokens = keywordSet(entry.keywords);
// Calculate overlap between current query and past query
let overlap = 0;
for (const t of queryTokens) {
if (entryTokens.has(t))
overlap++;
}
if (overlap >= MIN_TOKEN_OVERLAP) {
const key = `${entry.project}/${entry.filename}`;
// Helpful entries get a 2x weight boost
const weight = entry.helpful ? overlap * 2 : overlap;
docScores.set(key, (docScores.get(key) ?? 0) + weight);
}
}
catch {
// skip malformed lines
}
}
return [...docScores.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, limit)
.map(([key]) => key);
}
catch (err) {
debugLog(`query-correlation lookup failed: ${errorMessage(err)}`);
return [];
}
}