@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
349 lines (348 loc) • 13.6 kB
JavaScript
import * as crypto from "crypto";
import * as fs from "fs";
import * as path from "path";
import { appendAuditLog, debugLog, isRecord, memoryScoresFile, memoryUsageLogFile, runtimeFile } from "../shared.js";
import { withFileLock, isFiniteNumber, hasValidSchemaVersion } from "../shared/governance.js";
import { errorMessage } from "../utils.js";
import { logger } from "../logger.js";
const MAX_LOG_LINES = 1000;
const GOVERNANCE_SCHEMA_VERSION = 1;
const DEFAULT_MEMORY_SCORES_FILE = {
schemaVersion: GOVERNANCE_SCHEMA_VERSION,
entries: {},
};
function usageLogFile(phrenPath) {
return memoryUsageLogFile(phrenPath);
}
function scoresJournalFile(phrenPath) {
return runtimeFile(phrenPath, "scores.jsonl");
}
function isEntryScore(value) {
if (!isRecord(value))
return false;
return isFiniteNumber(value.impressions)
&& isFiniteNumber(value.helpful)
&& isFiniteNumber(value.repromptPenalty)
&& isFiniteNumber(value.regressionPenalty)
&& typeof value.lastUsedAt === "string";
}
function isVersionedEntries(data) {
return "entries" in data || "schemaVersion" in data;
}
function entriesObject(data) {
if (isRecord(data.entries))
return data.entries;
return data;
}
function validateScoresData(data) {
if (!isRecord(data))
return false;
if (isVersionedEntries(data) && !hasValidSchemaVersion(data))
return false;
if (isVersionedEntries(data) && !isRecord(data.entries))
return false;
return Object.values(entriesObject(data)).every((entry) => isEntryScore(entry));
}
function normalizeVersionedEntries(data, guard) {
const out = {};
for (const [key, value] of Object.entries(entriesObject(data))) {
if (guard(value))
out[key] = value;
}
return {
schemaVersion: GOVERNANCE_SCHEMA_VERSION,
entries: out,
};
}
function readScoresFile(phrenPath) {
const file = memoryScoresFile(phrenPath);
try {
if (!fs.existsSync(file))
return { ...DEFAULT_MEMORY_SCORES_FILE.entries };
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
if (!validateScoresData(parsed)) {
debugLog(`readScoresFile: ${file} failed validation, using defaults`);
return { ...DEFAULT_MEMORY_SCORES_FILE.entries };
}
return normalizeVersionedEntries(parsed, isEntryScore).entries;
}
catch (err) {
debugLog(`readScoresFile failed for ${file}: ${errorMessage(err)}`);
return { ...DEFAULT_MEMORY_SCORES_FILE.entries };
}
}
function writeScoresFile(phrenPath, scores) {
const file = memoryScoresFile(phrenPath);
withFileLock(file, () => {
fs.mkdirSync(path.dirname(file), { recursive: true });
const tmpPath = path.join(path.dirname(file), `.tmp-${crypto.randomUUID()}`);
fs.writeFileSync(tmpPath, JSON.stringify({
schemaVersion: GOVERNANCE_SCHEMA_VERSION,
entries: scores,
}, null, 2) + "\n");
fs.renameSync(tmpPath, file);
});
}
let scoresCache = null;
let scoresCachePath = null;
let scoresDirty = false;
function appendScoreJournal(phrenPath, key, delta) {
const file = scoresJournalFile(phrenPath);
const entry = { key, delta, at: new Date().toISOString() };
withFileLock(file, () => {
fs.appendFileSync(file, JSON.stringify(entry) + "\n");
});
}
function parseScoreJournal(raw, context) {
return raw
.split("\n")
.filter(Boolean)
.map((line) => {
try {
return JSON.parse(line);
}
catch (err) {
logger.debug("scores", `${context} parseLine: ${errorMessage(err)}`);
return null;
}
})
.filter((entry) => entry !== null && typeof entry.key === "string" && typeof entry.delta === "object" && entry.delta !== null);
}
function readScoreJournal(phrenPath) {
const file = scoresJournalFile(phrenPath);
if (!fs.existsSync(file))
return [];
try {
return parseScoreJournal(fs.readFileSync(file, "utf8"), "readScoreJournal");
}
catch (err) {
debugLog(`readScoreJournal failed: ${errorMessage(err)}`);
return [];
}
}
function claimScoreJournal(phrenPath) {
const file = scoresJournalFile(phrenPath);
let claimedFile = null;
withFileLock(file, () => {
if (!fs.existsSync(file))
return;
claimedFile = `${file}.${crypto.randomUUID()}.claim`;
fs.renameSync(file, claimedFile);
fs.writeFileSync(file, "");
});
if (!claimedFile)
return [];
try {
return parseScoreJournal(fs.readFileSync(claimedFile, "utf8"), "claimScoreJournal");
}
catch (err) {
debugLog(`claimScoreJournal failed: ${errorMessage(err)}`);
return [];
}
finally {
try {
fs.unlinkSync(claimedFile);
}
catch (err) {
logger.debug("scores", `claimScoreJournal unlinkClaim: ${errorMessage(err)}`);
}
}
}
function aggregateJournalScores(entries) {
const aggregated = {};
for (const entry of entries) {
if (!aggregated[entry.key]) {
aggregated[entry.key] = { impressions: 0, helpful: 0, repromptPenalty: 0, regressionPenalty: 0, lastUsedAt: "" };
}
const current = aggregated[entry.key];
if (entry.delta.impressions)
current.impressions += entry.delta.impressions;
if (entry.delta.helpful)
current.helpful += entry.delta.helpful;
if (entry.delta.repromptPenalty)
current.repromptPenalty += entry.delta.repromptPenalty;
if (entry.delta.regressionPenalty)
current.regressionPenalty += entry.delta.regressionPenalty;
// Q24: carry the max journal timestamp so lastUsedAt is persisted correctly during flush
if (entry.at && entry.at > current.lastUsedAt)
current.lastUsedAt = entry.at;
}
return aggregated;
}
function ensureScoreEntry(scores, key) {
if (!scores[key]) {
scores[key] = {
impressions: 0,
helpful: 0,
repromptPenalty: 0,
regressionPenalty: 0,
lastUsedAt: new Date(0).toISOString(),
};
}
return scores[key];
}
function loadEntryScores(phrenPath) {
const file = memoryScoresFile(phrenPath);
if (scoresCache && scoresCachePath === file)
return scoresCache;
scoresCache = readScoresFile(phrenPath);
scoresCachePath = file;
scoresDirty = false;
return scoresCache;
}
function saveEntryScores(phrenPath, scores) {
scoresCache = scores;
scoresCachePath = memoryScoresFile(phrenPath);
scoresDirty = true;
}
export function flushEntryScores(phrenPath) {
// Invalidate journal cache since claimScoreJournal will clear the file
journalCache = null;
journalCachePath = null;
const journalEntries = claimScoreJournal(phrenPath);
if (journalEntries.length > 0) {
const scores = loadEntryScores(phrenPath);
const aggregated = aggregateJournalScores(journalEntries);
for (const [key, deltas] of Object.entries(aggregated)) {
const entry = ensureScoreEntry(scores, key);
entry.impressions += deltas.impressions;
entry.helpful += deltas.helpful;
entry.repromptPenalty += deltas.repromptPenalty;
entry.regressionPenalty += deltas.regressionPenalty;
// Q24: persist the max journal timestamp into lastUsedAt so recency boost advances correctly
if (deltas.lastUsedAt && deltas.lastUsedAt > entry.lastUsedAt) {
entry.lastUsedAt = deltas.lastUsedAt;
}
}
saveEntryScores(phrenPath, scores);
}
if (scoresDirty && scoresCache && scoresCachePath === memoryScoresFile(phrenPath)) {
writeScoresFile(phrenPath, scoresCache);
scoresDirty = false;
}
}
export function entryScoreKey(project, filename, snippet) {
const short = snippet.replace(/\s+/g, " ").slice(0, 200);
const digest = crypto.createHash("sha1").update(`${project}:${filename}:${short}`).digest("hex").slice(0, 12);
return `${project}/${filename}:${digest}`;
}
export function recordInjection(phrenPath, key, sessionId) {
appendScoreJournal(phrenPath, key, { impressions: 1 });
const session = sessionId || "none";
const logFile = usageLogFile(phrenPath);
fs.mkdirSync(path.dirname(logFile), { recursive: true });
fs.appendFileSync(logFile, `${new Date().toISOString()}\tinject\t${session}\t${key}\n`);
try {
const stat = fs.statSync(logFile);
if (stat.size > 1_000_000) {
const content = fs.readFileSync(logFile, "utf8");
const lines = content.split("\n");
fs.writeFileSync(logFile, lines.slice(-MAX_LOG_LINES).join("\n"));
}
}
catch (err) {
debugLog(`Usage log rotation failed: ${errorMessage(err)}`);
}
}
/**
* Injected snippet headers label the score key as `fb:<key>` so an agent can
* see it. Callers are told to pass it without that prefix, but a model that
* copies the token verbatim would otherwise score a key no `entryScoreKey`
* can ever produce — an `ok: true` no-op, which is exactly the silent failure
* printing the key was meant to end. Accept both spellings.
*/
export function normalizeFeedbackKey(key) {
return key.startsWith("fb:") ? key.slice(3) : key;
}
export function recordFeedback(phrenPath, rawKey, feedback, sessionId) {
const key = normalizeFeedbackKey(rawKey);
const delta = {};
if (feedback === "helpful")
delta.helpful = 1;
if (feedback === "reprompt")
delta.repromptPenalty = 1;
if (feedback === "regression")
delta.regressionPenalty = 1;
appendScoreJournal(phrenPath, key, delta);
appendAuditLog(phrenPath, "memory_feedback", `key=${key} feedback=${feedback}`);
// When feedback is "helpful", mark correlated query entries for future boost
if (feedback === "helpful" && sessionId) {
import("../query-correlation.js").then(({ markCorrelationsHelpful: markHelpful }) => {
const colonIdx = key.indexOf(":");
const docKey = colonIdx >= 0 ? key.slice(0, colonIdx) : key;
markHelpful(phrenPath, sessionId, docKey);
}).catch(() => { });
}
}
// Module-level cache for the journal aggregation used by getQualityMultiplier.
// Invalidated whenever flushEntryScores runs (at which point the journal is cleared).
let journalCache = null;
let journalCachePath = null;
function getJournalCache(phrenPath) {
const file = scoresJournalFile(phrenPath);
if (journalCache && journalCachePath === file)
return journalCache;
// Build the cache by reading the journal once and aggregating by key
const entries = readScoreJournal(phrenPath);
const cache = new Map();
for (const entry of entries) {
const cur = cache.get(entry.key) ?? { helpful: 0, repromptPenalty: 0, regressionPenalty: 0, impressions: 0, lastUsedAt: "" };
if (entry.delta.helpful)
cur.helpful += entry.delta.helpful;
if (entry.delta.repromptPenalty)
cur.repromptPenalty += entry.delta.repromptPenalty;
if (entry.delta.regressionPenalty)
cur.regressionPenalty += entry.delta.regressionPenalty;
if (entry.delta.impressions)
cur.impressions += entry.delta.impressions;
if (entry.at && entry.at > cur.lastUsedAt)
cur.lastUsedAt = entry.at;
cache.set(entry.key, cur);
}
journalCache = cache;
journalCachePath = file;
return cache;
}
export function getQualityMultiplier(phrenPath, key) {
const scores = loadEntryScores(phrenPath);
const entry = scores[key];
let helpful = entry ? entry.helpful : 0;
let repromptPenalty = entry ? entry.repromptPenalty : 0;
let regressionPenalty = entry ? entry.regressionPenalty : 0;
let impressions = entry ? entry.impressions : 0;
let lastUsedAt = entry ? entry.lastUsedAt : "";
// Use the cached journal aggregation to avoid O(n×m) reads during ranking
const journalAgg = getJournalCache(phrenPath).get(key);
const hasJournalData = journalAgg !== undefined;
if (journalAgg) {
helpful += journalAgg.helpful;
repromptPenalty += journalAgg.repromptPenalty;
regressionPenalty += journalAgg.regressionPenalty;
impressions += journalAgg.impressions;
if (journalAgg.lastUsedAt && journalAgg.lastUsedAt > lastUsedAt)
lastUsedAt = journalAgg.lastUsedAt;
}
if (!entry && !hasJournalData)
return 1;
let recencyBoost = 0;
if (lastUsedAt) {
const lastUsedMs = new Date(lastUsedAt).getTime();
if (!Number.isNaN(lastUsedMs)) {
const daysSinceUse = Math.max(0, (Date.now() - lastUsedMs) / 86_400_000);
if (daysSinceUse <= 7) {
recencyBoost = 0.15;
}
else if (daysSinceUse <= 30) {
recencyBoost = 0;
}
else {
recencyBoost = -0.1 * Math.min(3, (daysSinceUse - 30) / 30);
}
}
}
const frequencyBoost = impressions > 0 ? Math.min(0.2, Math.log2(impressions + 1) * 0.05) : 0;
const penalties = repromptPenalty + regressionPenalty * 2;
const feedbackScore = helpful * 0.15 - penalties * 0.2;
const raw = 1 + feedbackScore + recencyBoost + frequencyBoost;
return Math.max(0.2, Math.min(1.5, raw));
}