UNPKG

@phren/cli

Version:

Knowledge layer for AI agents - CLI, MCP server, and data layer

431 lines (430 loc) • 16.4 kB
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"; import { snippetBudget } from "../response-budget.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 = []; // Same per-result budget as search_knowledge: store lines can run to many KB. const perResultChars = snippetBudget(rows.length); for (const row of rows) { const snippet = extractSnippet(row.content, opts.query, 7, perResultChars); lines.push(`[${row.project}/${row.filename}] (${row.type})`); lines.push(snippet); if (row.path && snippet !== extractSnippet(row.content, opts.query, 7)) lines.push(` (shortened; full text: ${row.path})`); 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 }; }