@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
450 lines (449 loc) • 19.5 kB
JavaScript
import { decodeStringRow } from "./index.js";
import * as fs from "fs";
import * as path from "path";
import { runtimeFile } from "../shared.js";
import { logger } from "../logger.js";
import { UNIVERSAL_TECH_TERMS_RE } from "../phren-core.js";
import { errorMessage } from "../utils.js";
// NOTE on naming: "entity" was renamed to "fragment" everywhere user-facing
// (CHANGELOG 0.0.5) — tool names, titles, and descriptions in tools/graph.ts
// all say "fragment" now. The underlying SQLite tables (`entities`,
// `entity_links`, `global_entities`, created in shared/index.ts) and the
// on-disk `.runtime/manual-links.json` format (`{entity, entityType, ...}`)
// were never renamed to match. Both are effectively rebuilt/local-runtime
// state rather than synced source-of-truth files, so a rename would not need
// a store migration in the git-history sense, but it does require touching
// shared/index.ts (which creates the tables and reads manual-links.json) —
// out of scope for a change confined to this file.
/** @internal Exported for tests. */
export function escapeRegex(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
/** Escape SQL LIKE wildcard characters so user input is treated literally.
* @internal Exported for tests. */
export function escapeLike(s) { return s.replace(/[%_\\]/g, '\\$&'); }
/**
* Log fragment resolution misses to .runtime/fragment-misses.jsonl.
*
* Judgment criteria — what's worth capturing vs noise:
* - Worth capturing: repeated lookups for the same fragment name (indicates a gap
* in the fragment graph that the user keeps hitting), fragment names that look like
* real library/tool names (not random query fragments).
* - Noise: single one-off lookups for short generic terms, lookups that fail
* because the query was malformed. We filter these by requiring name.length > 2.
*
* Gated by PHREN_DEBUG (or PHREN_DEBUG for compat) to avoid disk writes for
* regular users. The miss log is append-only JSONL so downstream tooling can
* detect repeated patterns (e.g. "fragment X was looked up 5 times but never
* found" -> suggest adding it).
*/
export function logFragmentMiss(phrenPath, name, context, project) {
if (!process.env.PHREN_DEBUG)
return;
if (!name || name.length <= 2)
return;
try {
const entry = JSON.stringify({
fragment: name,
context,
ts: Date.now(),
project: project ?? null,
});
const missFile = runtimeFile(phrenPath, "fragment-misses.jsonl");
fs.appendFileSync(missFile, entry + "\n");
}
catch {
// Best-effort logging; don't let miss tracking break the caller.
}
}
// Use the shared universal starter set. Framework/tool specifics are learned
// dynamically per project via extractDynamicFragments() in content-dedup.ts.
const PROSE_FRAGMENT_PATTERN = UNIVERSAL_TECH_TERMS_RE;
const FRAGMENT_PATTERNS = [
// import/require patterns: import X from 'pkg' or require('pkg')
/(?:import\s+.*?\s+from\s+['"])(@?[\w\-/]+)(?:['"])/g,
/(?:require\s*\(\s*['"])(@?[\w\-/]+)(?:['"]\s*\))/g,
// @scope/package patterns in text
/@[\w-]+\/[\w-]+/g,
// Known library/tool names mentioned in prose (case-insensitive word boundaries)
PROSE_FRAGMENT_PATTERN,
// Backtick-quoted identifiers: `word` or `word-with-dashes`
/`([\w][\w\-\.\/]{1,48}[\w])`/g,
// Double-quoted short identifiers (tool/package names, not full sentences)
/"([\w][\w\-]{1,30}[\w])"/g,
];
function isAllowedFragmentName(name) {
const trimmed = name.trim();
if (!trimmed || trimmed.length <= 1 || trimmed.length >= 100)
return false;
// Skip version strings (e.g. "1.2.3", "v2.0.0-beta")
if (/^v?\d+\.\d+/.test(trimmed))
return false;
// Skip file paths and file-like names (e.g. "src/utils.ts", "./config.json")
if (/^\.?\//.test(trimmed) ||
/\.(ts|js|json|md|yaml|yml|py|go|rs|java|tsx|jsx|css|html|txt|sh|toml|cfg|ini|env|lock)$/i.test(trimmed)) {
return false;
}
if (!/\s/.test(trimmed)) {
const normalized = trimmed.replace(/^[@#]/, "").toLowerCase();
if (COMMON_SINGLE_WORD_FRAGMENTS.has(normalized))
return false;
}
return true;
}
/**
* Lightweight synchronous fragment extraction from text — regex only, no DB writes.
* Used by add_finding to surface detected fragments in the MCP response without
* requiring a DB reference in the write path. Full DB linking happens on the next
* index rebuild, which is triggered automatically after every add_finding call via
* updateFileInIndex -> extractAndLinkFragments.
*/
export function extractFragmentNames(content) {
const found = new Set();
for (const pattern of FRAGMENT_PATTERNS) {
const regex = new RegExp(pattern.source, pattern.flags);
let match;
while ((match = regex.exec(content)) !== null) {
const name = match[1] || match[0];
if (!isAllowedFragmentName(name))
continue;
found.add(name.toLowerCase());
}
}
// Extract explicit fragment annotations: <!-- fragment: Foo,Bar --> (also supports legacy <!-- entity: ... -->)
const annotationRe = /<!--\s*(?:fragment|entity):\s*([^-]+)-->/gi;
let m;
while ((m = annotationRe.exec(content)) !== null) {
for (const part of m[1].split(",")) {
const name = part.trim();
if (isAllowedFragmentName(name)) {
found.add(name.toLowerCase());
}
}
}
return [...found];
}
function getOrCreateFragment(db, name, type) {
try {
db.run("INSERT OR IGNORE INTO entities (name, type, first_seen_at) VALUES (?, ?, ?)", [name, type, new Date().toISOString().slice(0, 10)]);
}
catch (err) {
logger.debug("fragmentInsert", errorMessage(err));
}
const result = db.exec("SELECT id FROM entities WHERE name = ? AND type = ?", [name, type]);
if (result?.length && result[0]?.values?.length) {
return Number(result[0].values[0][0]);
}
return -1;
}
/**
* Ensure the global_entities cross-project index table exists.
* Called during buildIndex to enable cross-project fragment queries.
*/
export function ensureGlobalEntitiesTable(db) {
try {
db.run(`CREATE TABLE IF NOT EXISTS global_entities (
entity TEXT NOT NULL,
project TEXT NOT NULL,
doc_key TEXT NOT NULL,
PRIMARY KEY (entity, project, doc_key)
)`);
}
catch (err) {
logger.debug("ensureGlobalEntitiesTable", errorMessage(err));
}
}
/**
* Parse user-defined fragment names from AGENTS.md frontmatter.
* Looks for: <!-- phren:fragments: Redis,MyService,InternalAPI -->
* Also supports legacy: <!-- phren:entities: ... -->
*
* Results are cached per project+mtime to avoid repeated sync readFileSync calls
* during a single index build that processes many docs for the same project.
*/
const _userFragmentCache = new Map();
const _buildUserFragmentCache = new Map();
let _activeBuildCacheKeyPrefix = null;
function readUserDefinedFragmentsFromDisk(claudeMdPath) {
if (!fs.existsSync(claudeMdPath))
return null;
const stat = fs.statSync(claudeMdPath);
const content = fs.readFileSync(claudeMdPath, "utf-8");
// Support both new phren:fragments and legacy phren:entities annotations
const match = content.match(/<!--\s*(?:phren:fragments|phren:entities):\s*(.+?)\s*-->/);
const fragments = match
? match[1].split(",").map(s => s.trim()).filter(s => s.length > 0)
: [];
return { mtime: stat.mtimeMs, fragments };
}
/**
* Prime AGENTS.md fragments per project for a single build pass.
* During an active build, extractAndLinkFragments resolves user fragments from this
* in-memory map and avoids per-file sync stat/read calls.
*/
export function beginUserFragmentBuildCache(phrenPath, projects) {
_activeBuildCacheKeyPrefix = `${phrenPath}/`;
for (const project of projects) {
// The cache key stays a slash-joined string — it is only ever compared
// against other keys built the same way. The file path is a real path, so
// it goes through path.join: concatenating with "/" reads fine on Windows
// but produces a mixed-separator string that no other code path matches.
const cacheKey = `${phrenPath}/${project}`;
const claudeMdPath = path.join(phrenPath, project, "AGENTS.md");
try {
const loaded = readUserDefinedFragmentsFromDisk(claudeMdPath);
if (!loaded) {
_buildUserFragmentCache.set(cacheKey, []);
continue;
}
_userFragmentCache.set(cacheKey, loaded);
_buildUserFragmentCache.set(cacheKey, loaded.fragments);
}
catch (err) {
logger.debug("beginUserFragmentBuildCache", errorMessage(err));
_buildUserFragmentCache.set(cacheKey, []);
}
}
}
/** End a build-scoped cache created by beginUserFragmentBuildCache(). */
export function endUserFragmentBuildCache(phrenPath) {
const prefix = `${phrenPath}/`;
for (const key of [..._buildUserFragmentCache.keys()]) {
if (key.startsWith(prefix))
_buildUserFragmentCache.delete(key);
}
if (_activeBuildCacheKeyPrefix === prefix)
_activeBuildCacheKeyPrefix = null;
}
function parseUserDefinedFragments(phrenPath, project) {
const claudeMdPath = path.join(phrenPath, project, "AGENTS.md");
const cacheKey = `${phrenPath}/${project}`;
try {
// Active build path: no sync I/O in per-file extraction.
if (_activeBuildCacheKeyPrefix === `${phrenPath}/`) {
if (_buildUserFragmentCache.has(cacheKey))
return _buildUserFragmentCache.get(cacheKey) ?? [];
_buildUserFragmentCache.set(cacheKey, []);
return [];
}
const cached = _userFragmentCache.get(cacheKey);
if (cached) {
try {
if (fs.existsSync(claudeMdPath) && fs.statSync(claudeMdPath).mtimeMs === cached.mtime) {
return cached.fragments;
}
}
catch (err) {
logger.debug("parseUserDefinedFragments statCheck", errorMessage(err));
}
}
const loaded = readUserDefinedFragmentsFromDisk(claudeMdPath);
if (!loaded)
return [];
_userFragmentCache.set(cacheKey, loaded);
return loaded.fragments;
}
catch (err) {
logger.debug("parseUserDefinedFragments", errorMessage(err));
return [];
}
}
/** Clear the user fragment cache (call between index builds). */
function _clearUserFragmentCache() {
_userFragmentCache.clear();
_buildUserFragmentCache.clear();
_activeBuildCacheKeyPrefix = null;
}
// Words that commonly start sentences or appear in titles — not fragment names
const SENTENCE_START_WORDS = new Set([
"the", "this", "that", "these", "those", "when", "where", "which", "while",
"what", "with", "will", "would", "should", "could", "have", "has", "had",
"been", "being", "before", "after", "about", "above", "below", "between",
"only", "also", "even", "just", "like", "make", "made", "many", "more",
"most", "much", "must", "need", "never", "note", "once", "other", "over",
"same", "some", "such", "sure", "take", "than", "them", "then", "they",
"each", "every", "both", "either", "neither", "here", "there", "first",
"second", "third", "next", "last", "new", "old", "good", "bad", "best",
"however", "therefore", "because", "although", "since", "unless", "until",
"instead", "rather", "already", "always", "never", "sometimes", "often",
]);
const COMMON_SINGLE_WORD_FRAGMENTS = new Set([
...SENTENCE_START_WORDS,
"agent", "analysis", "app", "approach", "artifact", "branch", "build", "cache",
"change", "changes", "check", "cli", "code", "command", "config", "context",
"data", "debug", "detail", "doc", "docs", "document", "entity", "error",
"example", "extract", "feature", "file", "files", "fix", "flow", "hook", "idea",
"index", "info", "issue", "item", "key", "log", "memory", "message", "model",
"note", "output", "path", "pattern", "policy", "process", "profile", "project",
"query", "repo", "result", "rule", "search", "session", "setting", "state",
"step", "summary", "system", "task", "tasks", "test", "tool", "tools", "type",
"update", "user", "value", "version", "workflow", "write",
]);
// Patterns that look like version strings, file paths, or dates — not fragments
const FALSE_POSITIVE_PATTERNS = [
/^v?\d+\.\d+/, // version strings: 1.2.3, v2.0
/^[A-Z]:\\/, // Windows paths: C:\
/^\//, // Unix paths: /usr/bin
/^\d{4}-\d{2}/, // ISO dates: 2026-03
/^[A-Z]{2,6}$/, // All-caps abbreviations shorter than 7 chars (OK, API, etc.)
/^(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\b/i, // Month names
];
/**
* Extract capitalized noun phrases (2+ words starting with uppercase) as candidate fragments.
* e.g. "Auth Service", "Data Pipeline", "Internal API"
*
* Filters out common false positives: sentence-start capitalization, version strings,
* file paths, and single-word abbreviations.
*/
function extractCapitalizedPhrases(content) {
const found = new Set();
const pattern = /\b([A-Z][a-zA-Z]+(?:\s+[A-Z][a-zA-Z]+)+)\b/g;
let match;
while ((match = pattern.exec(content)) !== null) {
const phrase = match[1];
if (phrase.length < 4 || phrase.length >= 60)
continue;
// Check if first word is a common sentence-start word
const firstWord = phrase.split(/\s+/)[0].toLowerCase();
if (SENTENCE_START_WORDS.has(firstWord))
continue;
// Check if it looks like a false positive pattern
if (FALSE_POSITIVE_PATTERNS.some(p => p.test(phrase)))
continue;
found.add(phrase.toLowerCase());
}
return [...found];
}
export function extractAndLinkFragments(db, content, sourceDoc, phrenPath) {
const fragmentNames = extractFragmentNames(content);
// Extract capitalized noun phrases as candidate fragments
const capitalizedPhrases = extractCapitalizedPhrases(content);
for (const phrase of capitalizedPhrases) {
fragmentNames.push(phrase);
}
// Add user-defined fragments from AGENTS.md frontmatter
if (phrenPath) {
const projectMatch = sourceDoc.match(/^([^/]+)\//);
if (projectMatch) {
const project = projectMatch[1];
const userFragments = parseUserDefinedFragments(phrenPath, project);
for (const uf of userFragments) {
const lower = uf.toLowerCase();
// Check if user-defined fragment appears in content (use escaped regex for safe matching)
const safePattern = new RegExp(`\\b${escapeRegex(lower)}\\b`, "i");
if (safePattern.test(content)) {
fragmentNames.push(lower);
}
}
}
}
// Deduplicate
const uniqueNames = [...new Set(fragmentNames)];
if (uniqueNames.length === 0)
return;
const docFragmentId = getOrCreateFragment(db, sourceDoc, "document");
if (docFragmentId === -1)
return;
// Ensure global_entities table exists
ensureGlobalEntitiesTable(db);
// Extract project from sourceDoc for global_entities
const projectMatch = sourceDoc.match(/^([^/]+)\//);
const project = projectMatch ? projectMatch[1] : null;
for (const name of uniqueNames) {
const fragmentType = name.includes(" ") ? "concept" : "library";
const fragmentId = getOrCreateFragment(db, name, fragmentType);
if (fragmentId === -1)
continue;
try {
db.run("INSERT OR IGNORE INTO entity_links (source_id, target_id, rel_type, source_doc) VALUES (?, ?, ?, ?)", [docFragmentId, fragmentId, "mentions", sourceDoc]);
}
catch (err) {
logger.debug("fragmentLinksInsert", errorMessage(err));
}
// Write to global_entities for cross-project queries
if (project) {
try {
db.run("INSERT OR IGNORE INTO global_entities (entity, project, doc_key) VALUES (?, ?, ?)", [name, project, sourceDoc]);
}
catch (err) {
logger.debug("globalFragmentsInsert", errorMessage(err));
}
}
}
}
/**
* Query related fragments for a given name.
*/
export function queryFragmentLinks(db, name) {
const related = [];
try {
// Find the fragment
const fragmentResult = db.exec("SELECT id FROM entities WHERE name = ?", [name.toLowerCase()]);
if (!fragmentResult?.length || !fragmentResult[0]?.values?.length)
return { related };
const fragmentId = Number(fragmentResult[0].values[0][0]);
// Find related fragments through links (both directions)
const links = db.exec(`SELECT DISTINCT e.name FROM entity_links el JOIN entities e ON (el.target_id = e.id OR el.source_id = e.id)
WHERE (el.source_id = ? OR el.target_id = ?) AND e.id != ?`, [fragmentId, fragmentId, fragmentId]);
if (links?.length && links[0]?.values?.length) {
for (const row of links[0].values) {
related.push(decodeStringRow(row, 1, "queryFragmentLinks")[0]);
}
}
}
catch (err) {
logger.debug("queryFragmentLinks", errorMessage(err));
}
return { related };
}
/**
* Query cross-project fragment relationships.
* Returns projects and docs that share fragments with the given query.
*/
export function queryCrossProjectFragments(db, fragmentName, excludeProject) {
const results = [];
try {
ensureGlobalEntitiesTable(db);
const pattern = `%${escapeLike(fragmentName.toLowerCase())}%`;
let sql = "SELECT entity, project, doc_key FROM global_entities WHERE entity LIKE ? ESCAPE '\\'";
const params = [pattern];
if (excludeProject) {
sql += " AND project != ?";
params.push(excludeProject);
}
sql += " ORDER BY entity LIMIT 50";
const rows = db.exec(sql, params);
if (rows?.length && rows[0]?.values?.length) {
for (const row of rows[0].values) {
const [fragment, project, docKey] = decodeStringRow(row, 3, "queryCrossProjectFragments");
results.push({
fragment,
project,
docKey,
});
}
}
}
catch (err) {
logger.debug("queryCrossProjectFragments", errorMessage(err));
}
return results;
}
export function getFragmentBoostDocs(db, query) {
const normalizedQuery = query.toLowerCase();
try {
const rows = db.exec(`SELECT DISTINCT el.source_doc
FROM entity_links el
JOIN entities e ON el.target_id = e.id
WHERE length(e.name) > 2
AND ? LIKE '%' || lower(e.name) || '%'`, [normalizedQuery])[0]?.values ?? [];
const boostDocs = new Set();
for (const [doc] of rows) {
if (typeof doc === "string")
boostDocs.add(doc);
}
return boostDocs;
}
catch (err) {
logger.debug("getFragmentBoostDocs", errorMessage(err));
return new Set();
}
}