@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
374 lines (373 loc) • 17.6 kB
JavaScript
import { createHash } from "crypto";
import { debugLog } from "../shared.js";
import { logger } from "../logger.js";
import { STOP_WORDS, errorMessage } from "../utils.js";
import { porterStem } from "./stemmer.js";
import { classifyFile, normalizeIndexedContent, rowToDocWithRowid } from "./index.js";
import { embedText, cosineSimilarity, getEmbeddingModel, getOllamaUrl, getCloudEmbeddingUrl, getVectorQueryTimeoutMs, isEmbeddingBackendReachable, } from "./ollama.js";
import { getEmbeddingCache } from "./embedding-cache.js";
import { getPersistentVectorIndex } from "./vector-index.js";
import * as fs from "fs";
import * as path from "path";
const HYBRID_SEARCH_FLAG = "PHREN_FEATURE_HYBRID_SEARCH";
const COSINE_SIMILARITY_MIN = 0.15;
const COSINE_MAX_CORPUS = 10000;
const COSINE_CANDIDATE_CAP = 500; // max docs loaded into memory for cosine scoring
const COSINE_WINDOW_COUNT = 4;
function splitPathSegments(filePath) {
return filePath.split(/[\\/]+/).filter(Boolean);
}
/** @internal Exported for tests. */
export function deriveVectorDocIdentity(phrenPath, fullPath) {
const normalizedPhrenPath = phrenPath.replace(/[\\/]+/g, "/").replace(/\/+$/, "");
const normalizedFullPath = fullPath.replace(/[\\/]+/g, "/");
let rel = fullPath;
if (normalizedFullPath === normalizedPhrenPath) {
rel = "";
}
else if (normalizedFullPath.startsWith(`${normalizedPhrenPath}/`)) {
rel = normalizedFullPath.slice(normalizedPhrenPath.length + 1);
}
else {
const relative = path.relative(phrenPath, fullPath);
if (!relative.startsWith("..") && !path.isAbsolute(relative)) {
rel = relative;
}
}
const relParts = splitPathSegments(rel);
return {
project: relParts[0] ?? "",
filename: splitPathSegments(fullPath).at(-1) ?? "",
relFile: relParts.slice(1).join("/"),
};
}
// Module-level cache for TF-IDF document frequencies.
// Keyed by a fingerprint of the candidate doc IDs so that different candidate subsets and
// incremental index mutations produce distinct cache entries rather than reusing stale counts.
// Intentionally not locked: single-threaded JS event loop, cache is eventually consistent,
// worst case is a redundant recompute. No data loss is possible since this is a pure computation cache.
// Max 100 entries to bound memory (LRU-style: oldest key evicted on overflow).
const MAX_DF_CACHE_SIZE = 100;
const dfCache = new Map();
/** Invalidate the DF cache. Call after a full index rebuild. */
export function invalidateDfCache() {
dfCache.clear();
tokenCache.clear();
}
// Module-level cache for tokenized document content.
// Keyed by a short content hash so the same document content is only tokenized once per server lifetime.
// Cleared on full rebuild (same lifecycle as dfCache). Max 2000 entries to bound memory.
// Intentionally not locked: single-threaded JS event loop, cache is eventually consistent,
// worst case is a redundant recompute. No data loss is possible since this is a pure computation cache.
const MAX_TOKEN_CACHE = 2000;
const tokenCache = new Map();
function cachedTokenize(text) {
const key = createHash("sha256").update(text).digest("hex").slice(0, 16);
const hit = tokenCache.get(key);
if (hit)
return hit;
const tokens = stemmedTerms(text);
if (tokenCache.size >= MAX_TOKEN_CACHE) {
// Evict oldest entry
tokenCache.delete(tokenCache.keys().next().value ?? "");
}
tokenCache.set(key, tokens);
return tokens;
}
export function deterministicSeed(text) {
let hash = 2166136261;
for (let i = 0; i < text.length; i++) {
hash ^= text.charCodeAt(i);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
}
function loadCosineFallbackWindow(db, startRowid, limit, wrapBefore) {
const where = wrapBefore === undefined ? "rowid >= ?" : "rowid < ?";
const params = [wrapBefore ?? startRowid, limit];
const rows = db.exec(`SELECT rowid, project, filename, type, content, path FROM docs WHERE ${where} ORDER BY rowid LIMIT ?`, params);
return rows?.[0]?.values ?? [];
}
/** Punctuation-free, stop-word-free, Porter-stemmed terms for TF-IDF scoring. */
function stemmedTerms(text) {
return text
.toLowerCase()
.replace(/[^\w\s]/g, " ")
.split(/\s+/)
.filter(w => w.length > 1 && !STOP_WORDS.has(w))
.map(w => porterStem(w));
}
/**
* Compute TF-IDF cosine similarity scores for a query against a corpus of documents.
* Returns an array of similarity scores in the same order as docs.
* @param corpusN - Total number of documents in the full corpus (for IDF denominator).
* Defaults to docs.length, which is correct when docs IS the full corpus.
* Pass the real total when docs is a pre-filtered subset so IDF scores are not inflated.
*/
function tfidfCosine(docs, query, corpusN) {
const queryTokens = stemmedTerms(query);
if (queryTokens.length === 0)
return docs.map(() => 0);
// Collect all unique terms from query + all docs (use cached tokenization for repeated content)
const allTokens = new Set(queryTokens);
const docTokenLists = docs.map(d => {
const tokens = cachedTokenize(d);
for (const t of tokens)
allTokens.add(t);
return tokens;
});
// Build a Set per document for O(1) term lookups
const docTokenSets = docTokenLists.map(tokens => new Set(tokens));
const terms = [...allTokens];
// Use the full corpus N for IDF so scores are comparable even when docs is a subset.
const N = corpusN ?? docs.length;
// Compute document frequency for each term, keyed by a fingerprint of the candidate doc set
// so that different subsets and incremental index mutations get distinct cache entries.
const corpusSize = docTokenLists.length;
const corpusLengthSum = docTokenLists.reduce((sum, tl) => sum + tl.length, 0);
const candidateFingerprint = docTokenLists.map(tl => tl.slice(0, 4).join(",")).join("|").slice(0, 128);
const cacheKey = `fp:${corpusSize}:${corpusLengthSum}:${candidateFingerprint}`;
const cachedDf = dfCache.get(cacheKey);
const df = cachedDf ?? new Map();
// Compute DF for any terms not yet in cache
for (const term of terms) {
if (!df.has(term)) {
let count = 0;
for (const docSet of docTokenSets) {
if (docSet.has(term))
count++;
}
df.set(term, count);
}
}
if (!cachedDf) {
if (dfCache.size >= MAX_DF_CACHE_SIZE)
dfCache.delete(dfCache.keys().next().value ?? "");
dfCache.set(cacheKey, df);
}
function buildVector(tokens) {
const tf = new Map();
for (const t of tokens)
tf.set(t, (tf.get(t) ?? 0) + 1);
return terms.map(term => {
const termTf = (tf.get(term) ?? 0) / (tokens.length || 1);
const idf = Math.log((N + 1) / ((df.get(term) ?? 0) + 1)) + 1;
return termTf * idf;
});
}
const queryVec = buildVector(queryTokens);
return docTokenLists.map(docTokens => cosineSimilarity(queryVec, buildVector(docTokens)));
}
/**
* Cosine fallback search: when FTS5 returns fewer than COSINE_FALLBACK_THRESHOLD results,
* load all docs and rank by TF-IDF cosine similarity.
* Only activated when PHREN_FEATURE_HYBRID_SEARCH=1 and corpus size <= COSINE_MAX_CORPUS.
* Returns DocRow[] ranked by similarity (threshold > COSINE_SIMILARITY_MIN), excluding already-found rowids.
*/
export function cosineFallback(db, query, excludeRowids, limit) {
// Feature flag guard — default ON; set PHREN_FEATURE_HYBRID_SEARCH=0 to disable
const flagVal = process.env[HYBRID_SEARCH_FLAG];
if (flagVal !== undefined && ["0", "false", "off", "no"].includes(flagVal.trim().toLowerCase())) {
return [];
}
// Count total docs to guard against large corpora
let totalDocs = 0;
let minRowid = 0;
let maxRowid = 0;
try {
const statsResult = db.exec("SELECT MIN(rowid), MAX(rowid), COUNT(*) FROM docs");
if (statsResult?.length && statsResult[0]?.values?.length) {
minRowid = Number(statsResult[0].values[0][0] ?? 0);
maxRowid = Number(statsResult[0].values[0][1] ?? 0);
totalDocs = Number(statsResult[0].values[0][2] ?? 0);
}
}
catch (err) {
logger.debug("cosineFallback count", errorMessage(err));
return [];
}
if (totalDocs > COSINE_MAX_CORPUS) {
debugLog(`cosineFallback: corpus size ${totalDocs} exceeds ${COSINE_MAX_CORPUS}, skipping`);
return [];
}
// Load docs with candidate capping to bound memory usage.
// If corpus fits in cap, load all; otherwise use FTS5 keyword pre-filter to get relevant candidates.
let allRows = null;
try {
if (totalDocs <= COSINE_CANDIDATE_CAP) {
const results = db.exec("SELECT rowid, project, filename, type, content, path FROM docs");
if (!Array.isArray(results) || !results.length || !results[0]?.values?.length)
return [];
allRows = results[0].values;
}
else {
// Pre-filter: use FTS5 to get top candidates, then fill to cap with deterministic rowid windows
const safeQ = query.replace(/[^\w\s]/g, " ").trim().split(/\s+/).filter(w => w.length > 2).slice(0, 5).join(" OR ");
const ftsRows = [];
if (safeQ) {
try {
const ftsRes = db.exec(`SELECT rowid, project, filename, type, content, path FROM docs WHERE docs MATCH ? ORDER BY rank LIMIT ${COSINE_CANDIDATE_CAP}`, [safeQ]);
if (ftsRes?.length && ftsRes[0]?.values?.length)
ftsRows.push(...ftsRes[0].values);
}
catch (err) {
logger.debug("cosineFallback FTS pre-filter", errorMessage(err));
}
}
// If FTS gave fewer than cap, supplement with deterministic rowid windows.
if (ftsRows.length < COSINE_CANDIDATE_CAP && totalDocs > 0 && maxRowid >= minRowid) {
const ftsRowIds = new Set(ftsRows.map(r => Number(r[0])));
const remaining = COSINE_CANDIDATE_CAP - ftsRows.length;
const span = Math.max(1, maxRowid - minRowid + 1);
const windowCount = Math.min(COSINE_WINDOW_COUNT, remaining);
const perWindow = Math.max(1, Math.ceil(remaining / Math.max(1, windowCount)));
const stride = Math.max(1, Math.floor(span / Math.max(1, windowCount)));
const seed = deterministicSeed(query);
const pushRows = (rows) => {
for (const row of rows) {
const rowid = Number(row[0]);
if (ftsRowIds.has(rowid))
continue;
ftsRowIds.add(rowid);
ftsRows.push(row);
if (ftsRows.length >= COSINE_CANDIDATE_CAP)
break;
}
};
try {
for (let i = 0; i < windowCount && ftsRows.length < COSINE_CANDIDATE_CAP; i++) {
const offset = (seed + i * stride) % span;
const startRowid = minRowid + offset;
pushRows(loadCosineFallbackWindow(db, startRowid, perWindow));
if (ftsRows.length >= COSINE_CANDIDATE_CAP)
break;
pushRows(loadCosineFallbackWindow(db, startRowid, perWindow, startRowid));
}
if (ftsRows.length < COSINE_CANDIDATE_CAP) {
pushRows(loadCosineFallbackWindow(db, minRowid, COSINE_CANDIDATE_CAP - ftsRows.length));
}
}
catch (err) {
logger.debug("cosineFallback deterministicSample", errorMessage(err));
}
}
if (ftsRows.length === 0)
return [];
allRows = ftsRows;
debugLog(`cosineFallback: pre-filtered ${totalDocs} docs to ${allRows.length} candidates`);
}
}
catch (err) {
logger.debug("cosineFallback loadDocs", errorMessage(err));
return [];
}
// Separate rowids, DocRows, and content strings for scoring
const docContents = [];
const docMeta = [];
for (const row of allRows ?? []) {
const { rowid, doc } = rowToDocWithRowid(row);
if (excludeRowids.has(rowid))
continue;
docContents.push(doc.content);
docMeta.push(doc);
}
if (docContents.length === 0)
return [];
// Pass totalDocs so IDF denominators reflect the full corpus, not just the candidate subset.
const scores = tfidfCosine(docContents, query, totalDocs);
// Collect scored results above threshold
const scored = [];
for (let i = 0; i < scores.length; i++) {
if (scores[i] > COSINE_SIMILARITY_MIN) {
scored.push({ score: scores[i], doc: docMeta[i] });
}
}
// Sort descending by score and return top-limit
scored.sort((a, b) => b.score - a.score);
return scored.slice(0, limit).map(s => s.doc);
}
/**
* Vector-based semantic search fallback using pre-computed Ollama embeddings.
* Only runs when Ollama is configured (PHREN_OLLAMA_URL is set or defaults).
* Returns DocRow[] sorted by cosine similarity, above 0.5 threshold.
*/
export async function vectorFallback(phrenPath, query, excludePaths, limit, project) {
// Run when either Ollama or a cloud embedding endpoint is available
if (!getOllamaUrl() && !getCloudEmbeddingUrl())
return [];
// Reachability first, and before the embedding cache load. Without a backend
// this function can only return [], so parsing a multi-megabyte
// embeddings.json ahead of that check is work whose result is already known
// — 42ms of a hook's budget on an 8.5MB cache, every prompt, for nothing.
if (!await isEmbeddingBackendReachable(phrenPath)) {
debugLog("vectorFallback: embedding backend unreachable, skipping vector search");
return [];
}
const cache = getEmbeddingCache(phrenPath);
// Ensure the cache is loaded from disk — in hook subprocesses the singleton
// starts empty because load() is only called in the MCP server / CLI entry.
if (cache.size() === 0) {
try {
await cache.load();
}
catch (err) {
logger.debug("vectorFallback cacheLoad", errorMessage(err));
}
}
if (cache.size() === 0)
return [];
// A reachable backend can still be slow (a cold model load takes seconds).
// Cap the one embedding a prompt waits on so the hook always finishes.
const queryVec = await embedText(query, undefined, undefined, getVectorQueryTimeoutMs());
if (!queryVec || queryVec.length === 0)
return [];
const model = getEmbeddingModel();
// Apply project scoping: when a project is detected, restrict vector results to that
// project and the global project to prevent cross-project memory injection.
const entries = cache.getAllEntries().filter(e => {
if (e.model !== model)
return false;
if (excludePaths.has(e.path))
return false;
if (project) {
// Allow global docs and docs from the active project
const entryProject = deriveVectorDocIdentity(phrenPath, e.path).project;
if (entryProject !== project && entryProject !== "global")
return false;
}
return true;
});
if (entries.length === 0)
return [];
const eligiblePaths = new Set(entries.map((entry) => entry.path));
const vectorIndex = getPersistentVectorIndex(phrenPath);
// Stamp the index with the revision these entries came from, not with a fresh
// stat: a long-lived process's cache can lag the file on disk.
vectorIndex.ensure(cache.getAllEntries(), cache.sourceMarker());
const indexedPaths = vectorIndex.query(model, queryVec, limit, eligiblePaths);
const candidatePaths = indexedPaths.length > 0 ? new Set(indexedPaths) : eligiblePaths;
const scored = entries
.filter((entry) => candidatePaths.has(entry.path))
.map(e => ({ path: e.path, score: cosineSimilarity(queryVec, e.vec) }))
.filter(e => e.score > 0.50)
.sort((a, b) => b.score - a.score)
.slice(0, limit);
return scored.map(e => {
const { project: entryProject, filename, relFile } = deriveVectorDocIdentity(phrenPath, e.path);
// Use the same path-aware classifyFile logic as the indexer so reference/skills/etc.
// get their correct type instead of always falling back to "other".
const type = classifyFile(filename, relFile);
// Hydrate and normalize content from disk with the same pipeline as the indexer.
let content = "";
try {
if (e.path && fs.existsSync(e.path)) {
const raw = fs.readFileSync(e.path, "utf-8");
content = normalizeIndexedContent(raw, type, phrenPath, 10000);
}
}
catch (err) {
logger.debug("vectorFallback fileRead", errorMessage(err));
}
return { project: entryProject, filename, type, content, path: e.path };
});
}