@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
426 lines (425 loc) • 16.1 kB
JavaScript
import * as fs from "fs";
import * as path from "path";
import { runtimeFile } from "../shared.js";
import { buildIndex, extractSnippet, queryDocRows, queryRows, queryFragmentLinks, queryDocBySourceKey, logFragmentMiss } from "../shared/index.js";
import { buildFtsQueryVariants, errorMessage, isValidProjectName } from "../utils.js";
import { logger } from "../logger.js";
import { keywordFallbackSearch } from "../core/search.js";
import { recordLookupEvents } from "../shared/governance.js";
import { bestFindingNodeId } from "../finding-graph-id.js";
const MAX_HISTORY = 20;
const SEARCH_TYPE_ALIASES = {
skills: "skill",
};
const SEARCH_TYPES = new Set([
"claude",
"summary",
"findings",
"reference",
"task",
"changelog",
"canonical",
"review-queue",
"skill",
"other",
]);
function historyFile(phrenPath) {
return runtimeFile(phrenPath, "search-history.jsonl");
}
function readSearchHistory(phrenPath) {
const file = historyFile(phrenPath);
if (!fs.existsSync(file))
return [];
try {
return fs.readFileSync(file, "utf8")
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line));
}
catch (err) {
if ((process.env.PHREN_DEBUG))
logger.debug("cli-search", `readSearchHistory: ${errorMessage(err)}`);
return [];
}
}
function printSearchUsage() {
console.error("Usage:");
console.error(" phren search <query> [--project <name>] [--type <type>] [--limit <n>] [--all]");
console.error(" phren search --project <name> [--type <type>] [--limit <n>] [--all]");
console.error(" phren search --history Show recent searches");
console.error(" phren search --from-history <n> Re-run search #n from history");
console.error(" type: claude|summary|findings|reference|task|changelog|canonical|review-queue|skill|other");
}
function validateAndNormalizeSearchOptions(phrenPath, queryParts, project, type, limit, showHistory, fromHistory, searchAll) {
if (showHistory) {
return { query: "", limit, showHistory: true };
}
if (fromHistory !== undefined) {
const history = readSearchHistory(phrenPath);
if (fromHistory > history.length || fromHistory < 1) {
console.error(`No search at position ${fromHistory}. History has ${history.length} entries.`);
process.exit(1);
}
const entry = history[fromHistory - 1];
return {
query: entry.query,
limit,
project: entry.project,
type: entry.type,
};
}
if (project && !isValidProjectName(project)) {
console.error(`Invalid project name: "${project}"`);
process.exit(1);
}
let normalizedType;
if (type) {
normalizedType = SEARCH_TYPE_ALIASES[type.toLowerCase()] || type.toLowerCase();
if (!SEARCH_TYPES.has(normalizedType)) {
console.error(`Invalid --type value: "${type}"`);
printSearchUsage();
process.exit(1);
}
}
const query = queryParts.join(" ").trim();
if (!query && !project) {
console.error("Provide a query, or pass --project to browse a project's indexed docs.");
printSearchUsage();
process.exit(1);
}
return {
query,
limit,
project,
type: normalizedType,
searchAll,
};
}
export function parseSearchArgs(phrenPath, args) {
const queryParts = [];
let project;
let type;
let limit = 10;
let showHistory = false;
let fromHistory;
let searchAll = false;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--history") {
showHistory = true;
continue;
}
if (arg === "--all") {
limit = 100;
searchAll = true;
continue;
}
const [flag, inlineValue] = arg.startsWith("--") ? arg.split("=", 2) : [arg, undefined];
const readValue = () => {
if (inlineValue !== undefined)
return inlineValue;
const next = args[i + 1];
if (!next || next.startsWith("-")) {
console.error(`Missing value for ${flag}`);
printSearchUsage();
process.exit(1);
}
i++;
return next;
};
if (flag === "--project") {
project = readValue();
continue;
}
if (flag === "--type") {
type = readValue();
continue;
}
if (flag === "--limit") {
const parsed = Number.parseInt(readValue(), 10);
if (Number.isNaN(parsed) || parsed < 1 || parsed > 200) {
console.error("Invalid --limit value. Use an integer between 1 and 200.");
process.exit(1);
}
limit = parsed;
continue;
}
if (flag === "--from-history") {
const parsed = Number.parseInt(readValue(), 10);
if (Number.isNaN(parsed) || parsed < 1) {
console.error("Invalid --from-history value. Use a positive integer.");
process.exit(1);
}
fromHistory = parsed;
continue;
}
if (arg.startsWith("-")) {
console.error(`Unknown search flag: ${arg}`);
printSearchUsage();
process.exit(1);
}
queryParts.push(arg);
}
return validateAndNormalizeSearchOptions(phrenPath, queryParts, project, type, limit, showHistory, fromHistory, searchAll);
}
function recordSearchQuery(phrenPath, opts) {
if (!opts.query)
return;
const file = historyFile(phrenPath);
fs.mkdirSync(path.dirname(file), { recursive: true });
const entry = {
query: opts.query,
...(opts.project && { project: opts.project }),
...(opts.type && { type: opts.type }),
ts: new Date().toISOString(),
};
let entries = readSearchHistory(phrenPath);
entries.push(entry);
if (entries.length > MAX_HISTORY)
entries = entries.slice(-MAX_HISTORY);
fs.writeFileSync(file, entries.map((item) => JSON.stringify(item)).join("\n") + "\n");
}
function formatSearchHistoryLines(phrenPath) {
const entries = readSearchHistory(phrenPath);
if (!entries.length)
return ["No search history."];
const lines = ["Recent searches:", ""];
entries.forEach((entry, index) => {
const scope = [
entry.project ? `--project ${entry.project}` : "",
entry.type ? `--type ${entry.type}` : "",
].filter(Boolean).join(" ");
const ts = entry.ts.slice(0, 16).replace("T", " ");
lines.push(` ${index + 1}. "${entry.query}"${scope ? " " + scope : ""} (${ts})`);
});
return lines;
}
export async function runSearch(opts, phrenPath, profile) {
if (opts.showHistory) {
return { lines: formatSearchHistoryLines(phrenPath), exitCode: 0 };
}
recordSearchQuery(phrenPath, opts);
const db = await buildIndex(phrenPath, profile);
try {
let sql = "SELECT project, filename, type, content, path FROM docs";
const where = [];
const params = [];
let queryVariants = [];
if (opts.query) {
queryVariants = buildFtsQueryVariants(opts.query, opts.project, phrenPath);
const safeQuery = queryVariants[0] ?? "";
if (!safeQuery) {
return { lines: ["Query empty after sanitization."], exitCode: 1 };
}
where.push("docs MATCH ?");
params.push(safeQuery);
}
if (opts.project) {
where.push("project = ?");
params.push(opts.project);
}
if (opts.type) {
where.push("type = ?");
params.push(opts.type);
}
if (where.length > 0) {
sql += ` WHERE ${where.join(" AND ")}`;
}
sql += opts.query ? " ORDER BY rank LIMIT ?" : " ORDER BY project, type, filename LIMIT ?";
params.push(opts.limit);
let rows = queryDocRows(db, sql, params);
if ((!rows || rows.length === 0) && queryVariants.length > 1) {
for (const variant of queryVariants.slice(1)) {
const relaxedParams = [...params];
relaxedParams[0] = variant;
rows = queryDocRows(db, sql, relaxedParams);
if (rows?.length)
break;
}
}
const lines = [];
if (!rows && opts.query) {
const fallbackRows = keywordFallbackSearch(db, opts.query, { project: opts.project, type: opts.type, limit: opts.limit });
if (fallbackRows) {
rows = fallbackRows;
lines.push("(keyword fallback)");
}
}
if (!rows) {
if (opts.query) {
try {
const { logSearchMiss } = await import("../tools/search.js");
logSearchMiss(phrenPath, opts.query, opts.project);
}
catch (err) {
if ((process.env.PHREN_DEBUG))
logger.debug("cli-search", `search logSearchMiss: ${errorMessage(err)}`);
}
}
const scope = [
opts.query ? `query "${opts.query}"` : undefined,
opts.project ? `project "${opts.project}"` : undefined,
opts.type ? `type "${opts.type}"` : undefined,
].filter(Boolean).join(", ");
return { lines: [scope ? `No results found for ${scope}.` : "No results found."], exitCode: 0 };
}
if (opts.project && !opts.query) {
lines.push(`Browsing ${rows.length} document(s) in project "${opts.project}"`);
if (opts.type)
lines.push(`Type filter: ${opts.type}`);
lines.push("");
}
const hits = [];
for (const row of rows) {
const snippet = extractSnippet(row.content, opts.query, 7);
lines.push(`[${row.project}/${row.filename}] (${row.type})`);
lines.push(snippet);
lines.push("");
hits.push({ project: row.project, filename: row.filename, type: row.type, snippet, path: row.path, content: row.content });
}
// Live activity: the CLI records the same lookup events the MCP search
// does, so `phren search` in one terminal lights up a graph watching in
// another. Best-effort — logging must never fail a search.
if (opts.query) {
try {
const at = new Date().toISOString();
recordLookupEvents(phrenPath, hits.map((hit) => {
const nodeId = hit.type === "findings" ? bestFindingNodeId(hit.project, hit.content, opts.query ?? "") : null;
return {
at,
query: opts.query ?? "",
project: hit.project,
filename: hit.filename,
type: hit.type,
path: hit.path,
snippet: hit.snippet,
source: "search",
...(nodeId ? { nodeId } : {}),
};
}));
}
catch (err) {
logger.debug("search", `cli search lookup-events: ${errorMessage(err)}`);
}
}
return { lines, exitCode: 0 };
}
catch (err) {
return { lines: [`Search error: ${errorMessage(err)}`], exitCode: 1 };
}
}
// ── Fragment search (parity with MCP search_fragments) ────────────────────
export async function runFragmentSearch(query, phrenPath, profile, opts) {
if (!query.trim()) {
return { lines: ["Usage: phren search-fragments <query> [--project <name>] [--limit <n>]"], exitCode: 1 };
}
const db = await buildIndex(phrenPath, profile);
const max = opts.limit ?? 10;
const pattern = `%${query.toLowerCase()}%`;
let sql;
let params;
if (opts.project) {
sql = `
SELECT e.name, e.type, COUNT(el.source_id) as ref_count
FROM entities e
LEFT JOIN entity_links el ON el.target_id = e.id
WHERE e.name LIKE ? AND el.source_doc LIKE ?
GROUP BY e.id, e.name, e.type
ORDER BY ref_count DESC
LIMIT ?
`;
params = [pattern, `${opts.project}/%`, max];
}
else {
sql = `
SELECT e.name, e.type, COUNT(el.source_id) as ref_count
FROM entities e
LEFT JOIN entity_links el ON el.target_id = e.id
WHERE e.name LIKE ?
GROUP BY e.id, e.name, e.type
ORDER BY ref_count DESC
LIMIT ?
`;
params = [pattern, max];
}
const rows = queryRows(db, sql, params);
if (!rows || rows.length === 0) {
logFragmentMiss(phrenPath, query, "cli_search_fragments", opts.project);
return { lines: [`No fragments matching "${query}".`], exitCode: 0 };
}
const lines = [`Fragments matching "${query}" (${rows.length} result(s)):\n`];
for (const r of rows) {
const name = String(r[0]);
const type = String(r[1]);
const refCount = Number(r[2]);
lines.push(` ${name} (${type}) — ${refCount} reference(s)`);
}
return { lines, exitCode: 0 };
}
export function parseFragmentSearchArgs(args) {
const queryParts = [];
let project;
let limit;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--project" && args[i + 1]) {
project = args[++i];
continue;
}
if (arg === "--limit" && args[i + 1]) {
limit = Number.parseInt(args[++i], 10);
continue;
}
if (!arg.startsWith("-")) {
queryParts.push(arg);
}
}
return { query: queryParts.join(" "), project, limit };
}
// ── Related docs (parity with MCP get_related_docs) ───────────────────────
export async function runRelatedDocs(entity, phrenPath, profile, opts) {
if (!entity.trim()) {
return { lines: ["Usage: phren related-docs <fragment-name> [--project <name>] [--limit <n>]"], exitCode: 1 };
}
const db = await buildIndex(phrenPath, profile);
const max = opts.limit ?? 10;
const links = queryFragmentLinks(db, entity.toLowerCase());
let relatedDocs = links.related.filter(r => r.includes("/"));
if (opts.project) {
relatedDocs = relatedDocs.filter(d => d.startsWith(`${opts.project}/`));
}
relatedDocs = relatedDocs.slice(0, max);
if (relatedDocs.length === 0) {
logFragmentMiss(phrenPath, entity, "cli_related_docs", opts.project);
return { lines: [`No docs found referencing fragment "${entity}".`], exitCode: 0 };
}
const lines = [`Docs referencing "${entity}" (${relatedDocs.length} result(s)):\n`];
for (const doc of relatedDocs) {
const docRow = queryDocBySourceKey(db, phrenPath, doc);
const snippet = docRow?.content ? docRow.content.slice(0, 120).replace(/\n/g, " ").trim() : "";
lines.push(` [${doc}]`);
if (snippet)
lines.push(` ${snippet}...`);
}
return { lines, exitCode: 0 };
}
export function parseRelatedDocsArgs(args) {
const entityParts = [];
let project;
let limit;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--project" && args[i + 1]) {
project = args[++i];
continue;
}
if (arg === "--limit" && args[i + 1]) {
limit = Number.parseInt(args[++i], 10);
continue;
}
if (!arg.startsWith("-")) {
entityParts.push(arg);
}
}
return { entity: entityParts.join(" "), project, limit };
}