UNPKG

@phren/cli

Version:

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

820 lines (819 loc) • 42.3 kB
import { nonInteractiveGitEnv } from "../utils-helpers.js"; import { projectMemoryCounts } from "../content/summarize.js"; import { moduleEnabled } from "../modules/runtime.js"; import { mcpResponse, resolveStoreForProject } from "./types.js"; import { z } from "zod"; import * as fs from "fs"; import * as path from "path"; import { createHash } from "crypto"; import { execFileSync } from "child_process"; import { isValidProjectName, errorMessage } from "../utils.js"; import { resolveAllStores } from "../store-registry.js"; import { readFindings, FINDINGS_FILENAME } from "../data/access.js"; import { debugLog, runtimeFile, DOC_TYPES, FINDING_TAGS, isMemoryScopeVisible, normalizeMemoryScope, } from "../shared.js"; import { FINDING_LIFECYCLE_STATUSES, parseFindingLifecycle, } from "../shared/content.js"; import { decodeStringRow, queryRows, queryDocRows, queryFragmentLinks, logFragmentMiss, extractSnippet, queryDocBySourceKey, normalizeMemoryId, } from "../shared/index.js"; import { runCustomHooks } from "../hooks.js"; import { entryScoreKey, getQualityMultiplier, getRetentionPolicy, recordLookupEvents } from "../shared/governance.js"; import { bestFindingNodeId } from "../finding-graph-id.js"; import { callLlm } from "../content/dedup.js"; import { rankResults, searchKnowledgeRows, applyTrustFilter, searchFederatedStores } from "../shared/retrieval.js"; import { formatActorAttribution, parseScopeComment, parseSourceComment, collectSymbolCitations } from "../content/citation.js"; import { resolveActiveSessionScope } from "./session.js"; import { logger } from "../logger.js"; /** * Q30: Log zero-result queries to .runtime/search-misses.jsonl. * Strips PII-like tokens (emails, UUIDs, numbers) and keeps only query terms. */ export function logSearchMiss(phrenPath, query, project) { try { const sanitized = query .replace(/\S+@\S+\.\S+/g, "<email>") // strip emails .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "<uuid>") // strip UUIDs .replace(/\b\d{3,}\b/g, "<num>") // strip long numbers .trim(); if (!sanitized) return; const entry = JSON.stringify({ query: sanitized, ts: Date.now(), project: project ?? null, }); const missFile = runtimeFile(phrenPath, "search-misses.jsonl"); fs.appendFileSync(missFile, entry + "\n"); } catch (err) { logger.debug("search", `logSearchMiss: ${errorMessage(err)}`); } } const HISTORY_FINDING_STATUSES = new Set(["superseded", "retracted"]); const DEGRADED_FINDING_STATUSES = new Set(["contradicted", "stale", "invalid_citation"]); function findingRowKey(row) { return row.path || `${row.project}/${row.filename}`; } function summarizeFindingStatuses(content, includeHistory, filterStatus) { const statuses = content .split("\n") .filter(line => line.startsWith("- ")) .map(line => parseFindingLifecycle(line).status); if (!statuses.length) return null; const deduped = [...new Set(statuses)]; const visible = deduped.filter(status => includeHistory || !HISTORY_FINDING_STATUSES.has(status)); if (!visible.length) return null; const filtered = filterStatus ? visible.filter(status => status === filterStatus) : visible; if (!filtered.length) return null; const primaryStatus = filtered.includes("active") ? "active" : filtered[0]; return { statuses: filtered, primaryStatus }; } function lifecycleSortBucket(summary) { if (!summary) return 1; if (summary.statuses.includes("active")) return 0; if (summary.statuses.some(status => DEGRADED_FINDING_STATUSES.has(status))) return 3; return 2; } function filterFindingsContentByScope(content, activeScope) { const lines = content.split("\n"); const out = []; let keepCitation = false; for (const line of lines) { if (line.startsWith("- ")) { const itemScope = normalizeMemoryScope(parseScopeComment(line) ?? parseSourceComment(line)?.scope); keepCitation = isMemoryScopeVisible(itemScope, activeScope); if (keepCitation) out.push(line); continue; } if (/^\s*<!--\s*phren:cite\s+\{.*\}\s*-->\s*$/.test(line.trim())) { if (keepCitation) out.push(line); continue; } keepCitation = false; out.push(line); } return out.join("\n"); } function taskLineScope(line) { const scopeMatch = line.match(/<!--[^>]*\bscope:([^\s>]+)[^>]*-->/); return normalizeMemoryScope(scopeMatch?.[1]); } function filterTaskContentByScope(content, activeScope) { const lines = content.split("\n"); const out = []; let keepContinuation = false; for (const line of lines) { if (line.startsWith("- ")) { keepContinuation = isMemoryScopeVisible(taskLineScope(line), activeScope); if (keepContinuation) out.push(line); continue; } if (/^\s+/.test(line)) { if (keepContinuation) out.push(line); continue; } keepContinuation = false; out.push(line); } return out.join("\n"); } // ── Handlers ───────────────────────────────────────────────────────────────── async function handleGetMemoryDetail(ctx, { id: rawId }) { const { phrenPath } = ctx; // Normalize ID: decode URL encoding and normalize path separators let id; try { id = normalizeMemoryId(rawId); } catch { return mcpResponse({ ok: false, error: `Invalid memory ID format: "${rawId}" contains malformed URL encoding.` }); } const match = id.match(/^mem:([^/]+)\/(.+)$/); if (!match) { return mcpResponse({ ok: false, error: `Invalid memory ID format "${rawId}". Expected mem:project/path/to/file.md.` }); } const [, project] = match; if (!isValidProjectName(project)) { return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` }); } const db = ctx.db(); const doc = queryDocBySourceKey(db, phrenPath, id.slice(4)); if (!doc) { return mcpResponse({ ok: false, error: `Memory not found: ${id}` }); } // Extract metadata from filesystem and content let updatedAt = null; let createdAt = null; try { const stat = fs.statSync(doc.path); updatedAt = stat.mtime.toISOString(); createdAt = stat.birthtime.toISOString(); } catch (err) { logger.debug("search", `search_knowledge statFile: ${errorMessage(err)}`); } // Extract tags from content (e.g. [decision], [pitfall], [pattern]) const tagMatches = doc.content.match(/\[(decision|pitfall|pattern|tradeoff|architecture|bug)\]/gi); const tags = tagMatches ? [...new Set(tagMatches.map(t => t.slice(1, -1).toLowerCase()))] : []; // Get quality score if available const scoreKey = entryScoreKey(doc.project, doc.filename, doc.content); const qualityMultiplier = getQualityMultiplier(phrenPath, scoreKey); return mcpResponse({ ok: true, message: `[${id.slice(4)}] (${doc.type})\n\n${doc.content}`, data: { id, project: doc.project, filename: doc.filename, type: doc.type, content: doc.content, path: doc.path, created_at: createdAt, updated_at: updatedAt, tags: tags.length > 0 ? tags : undefined, score: qualityMultiplier, rank: undefined, relevance_score: undefined, }, }); } async function handleSearchKnowledge(ctx, { query, limit, project, type, tag, since, status, include_history, synthesize }) { const { phrenPath } = ctx; try { if (query.length > 1000) return mcpResponse({ ok: false, error: "Search query exceeds 1000 character limit." }); const db = ctx.db(); const maxResults = limit ?? 5; const filterType = type === "skills" ? "skill" : type; const filterTag = tag?.toLowerCase(); const filterStatus = status; const includeHistory = include_history ?? false; const filterProject = project?.trim(); if (filterProject && !isValidProjectName(filterProject)) { return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` }); } const fetchLimit = (filterTag || since || filterStatus) ? Math.min(maxResults * 5, 200) : maxResults; const retrieval = await searchKnowledgeRows(db, { query, maxResults, fetchLimit, filterProject, filterType, phrenPath, }); const safeQuery = retrieval.safeQuery; if (!safeQuery) return mcpResponse({ ok: false, error: "Search query is empty after sanitization." }); let rows = retrieval.rows ?? []; const usedFallback = retrieval.usedFallback; // Merge federated store results when PHREN_FEDERATION_PATHS is set and no project filter // (federation is global by nature — per-project filter only makes sense within a single store) if (!filterProject) { try { const federatedRows = await searchFederatedStores(phrenPath, { query, maxResults, fetchLimit, filterType, }); if (federatedRows.length > 0) { // Dedup by path to avoid duplicates if stores share files const localPaths = new Set(rows.map((r) => r.path || `${r.project}/${r.filename}`)); const uniqueFederated = federatedRows.filter((r) => { const key = r.path || `${r.project}/${r.filename}`; return !localPaths.has(key); }); rows = [...rows, ...uniqueFederated]; } } catch (err) { if (process.env.PHREN_DEBUG) { logger.debug("search", `search_knowledge federation: ${errorMessage(err)}`); } } } if (rows.length === 0) { logSearchMiss(phrenPath, query, filterProject); return mcpResponse({ ok: true, message: "No results found.", data: { query, results: [] } }); } const activeScope = resolveActiveSessionScope(phrenPath, filterProject); if (activeScope) { rows = rows .map((row) => { if (row.type === "findings") { const filteredContent = filterFindingsContentByScope(row.content, activeScope); const hasVisible = filteredContent.split("\n").some((line) => line.startsWith("- ")); return hasVisible ? { ...row, content: filteredContent } : null; } if (row.type === "task") { const filteredContent = filterTaskContentByScope(row.content, activeScope); const hasVisible = filteredContent.split("\n").some((line) => line.startsWith("- ")); return hasVisible ? { ...row, content: filteredContent } : null; } return row; }) .filter((row) => Boolean(row)); if (rows.length === 0) { logSearchMiss(phrenPath, query, filterProject); return mcpResponse({ ok: true, message: "No results found.", data: { query, results: [] } }); } } // Filter by observation tag if requested if (filterTag && rows) { const tagPattern = `[${filterTag.toLowerCase()}]`; rows = rows.filter(row => row.content.toLowerCase().includes(tagPattern)); if (rows.length === 0) { logSearchMiss(phrenPath, query, filterProject); return mcpResponse({ ok: true, message: `No results found with tag [${filterTag}].`, data: { query, results: [] } }); } } // Filter by since date if requested if (since && rows) { let sinceDate = null; const daysMatch = since.match(/^(\d+)d$/); if (daysMatch) { sinceDate = new Date(Date.now() - parseInt(daysMatch[1], 10) * 86400000); } else if (/^\d{4}-\d{2}$/.test(since)) { // Validate month is 01-12 const [, mm] = since.split("-"); const month = parseInt(mm, 10); if (month < 1 || month > 12) { return mcpResponse({ ok: false, error: `Invalid since value "${since}": month must be 01-12.` }); } sinceDate = new Date(`${since}-01T00:00:00Z`); } else if (/^\d{4}-\d{2}-\d{2}$/.test(since)) { // Validate month and day strictly (reject impossible dates like 2026-02-31) const [, mm, dd] = since.split("-"); const month = parseInt(mm, 10); const day = parseInt(dd, 10); if (month < 1 || month > 12 || day < 1 || day > 31) { return mcpResponse({ ok: false, error: `Invalid since value "${since}": month or day out of range.` }); } const candidate = new Date(`${since}T00:00:00Z`); // new Date() normalizes impossible dates (e.g. Feb 31 → Mar 3); detect by comparing parsed month/day if (candidate.getUTCMonth() + 1 !== month || candidate.getUTCDate() !== day) { return mcpResponse({ ok: false, error: `Invalid since value "${since}": date does not exist on the calendar.` }); } sinceDate = candidate; } else if (since) { return mcpResponse({ ok: false, error: `Invalid since format "${since}". Use "7d", "YYYY-MM", or "YYYY-MM-DD".` }); } if (sinceDate && !isNaN(sinceDate.getTime())) { const sinceMs = sinceDate.getTime(); rows = rows.filter(row => { const createdDates = [...row.content.matchAll(/<!-- created: (\d{4}-\d{2}-\d{2}) -->/g)]; if (createdDates.length === 0) return true; return createdDates.some(m => new Date(`${m[1]}T00:00:00Z`).getTime() >= sinceMs); }); if (rows.length === 0) { logSearchMiss(phrenPath, query, filterProject); return mcpResponse({ ok: true, message: `No results found since ${since}.`, data: { query, results: [] } }); } } } const lifecycleByRowKey = new Map(); if (rows) { const filteredRows = []; for (const row of rows) { if (row.type !== "findings") { if (filterStatus) continue; filteredRows.push(row); continue; } const summary = summarizeFindingStatuses(row.content, includeHistory, filterStatus); if (!summary) continue; lifecycleByRowKey.set(findingRowKey(row), summary); filteredRows.push(row); } rows = filteredRows; if (rows.length === 0) { logSearchMiss(phrenPath, query, filterProject); return mcpResponse({ ok: true, message: "No results found after lifecycle filters.", data: { query, results: [] } }); } } rows = rankResults(rows, "general", null, filterProject ?? null, phrenPath, db, undefined, query, { skipTaskFilter: true, filterType: filterType ?? null }); // Apply trust filter — same as hook-prompt uses — to strip stale/low-confidence findings try { const policy = getRetentionPolicy(phrenPath); const trustResult = applyTrustFilter(rows, policy.ttlDays, policy.minInjectConfidence, policy.decay, phrenPath); rows = trustResult.rows; } catch (err) { debugLog(`search_knowledge trustFilter: ${errorMessage(err)}`); } rows = rows .map((row, idx) => ({ row, idx })) .sort((a, b) => { const aBucket = a.row.type === "findings" ? lifecycleSortBucket(lifecycleByRowKey.get(findingRowKey(a.row))) : 1; const bBucket = b.row.type === "findings" ? lifecycleSortBucket(lifecycleByRowKey.get(findingRowKey(b.row))) : 1; if (aBucket !== bBucket) return aBucket - bBucket; return a.idx - b.idx; }) .map(entry => entry.row); if (rows.length > maxResults) { rows = rows.slice(0, maxResults); } const results = rows.map((row) => { const snippet = extractSnippet(row.content, query); const lifecycle = row.type === "findings" ? lifecycleByRowKey.get(findingRowKey(row)) : undefined; const federationSource = "federationSource" in row ? row.federationSource : undefined; const symbols = row.type === "findings" ? collectSymbolCitations(row.content) : []; return { project: row.project, filename: row.filename, type: row.type, snippet, path: row.path, status: lifecycle?.primaryStatus, statuses: lifecycle?.statuses, ...(symbols.length > 0 ? { symbol: symbols[0], symbols } : {}), ...(federationSource ? { federation_source: federationSource } : {}), }; }); // Live activity: record each memory this search landed on so the web UI and // VS Code extension can surface lookups in real time. Best-effort only. try { const at = new Date().toISOString(); recordLookupEvents(phrenPath, results.map((r, i) => { // For findings, resolve the specific finding node so phren can walk // to it on the graph; otherwise the project node is used downstream. const nodeId = r.type === "findings" ? bestFindingNodeId(r.project, rows[i]?.content ?? "", query) : null; return { at, query, project: r.project, filename: r.filename, type: r.type, path: r.path, snippet: r.snippet, source: "search", ...(nodeId ? { nodeId } : {}), }; })); } catch (err) { debugLog(`search_knowledge lookup-events: ${errorMessage(err)}`); } let relatedFragments = []; try { const terms = query.toLowerCase().split(/\s+/).filter(t => t.length > 2); for (const term of terms) { const links = queryFragmentLinks(db, term); if (links.related.length > 0) { relatedFragments.push(...links.related); } else { logFragmentMiss(phrenPath, term, "search_knowledge", filterProject); } } relatedFragments = [...new Set(relatedFragments)].slice(0, 10); } catch (err) { logger.debug("search", `fragment query: ${errorMessage(err)}`); } const formatted = results.map((r) => { const fedNote = r.federation_source ? ` [from: ${r.federation_source}]` : ""; return `### ${r.project}/${r.filename} (${r.type})${fedNote}\n${r.snippet}\n\n\`${r.path}\``; }); // Memory synthesis: generate a concise paragraph from top results when requested let synthesis; if (synthesize && results.length > 0) { try { const synthKey = createHash("sha256").update([query, filterProject ?? "", filterType ?? "", filterTag ?? "", since ?? ""].join("|")).digest("hex").slice(0, 16); const synthCachePath = runtimeFile(phrenPath, "synth-cache.json"); let synthCache = {}; try { synthCache = JSON.parse(fs.readFileSync(synthCachePath, "utf8")); } catch (err) { logger.debug("search", `search_knowledge synthCacheRead: ${errorMessage(err)}`); } const cached = synthCache[synthKey]; const SYNTH_CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes if (cached && Date.now() - cached.ts < SYNTH_CACHE_TTL_MS) { synthesis = cached.result; } else { const snippets = results.slice(0, 5).map((r, i) => `[${i + 1}] ${r.snippet}`).join("\n"); const synthPrompt = `Summarize these search results for "${query}" in 2-3 sentences. No headers, no lists. Plain paragraph only.\n\n${snippets}`; synthesis = await callLlm(synthPrompt, undefined, 300); if (synthesis) { synthCache[synthKey] = { result: synthesis, ts: Date.now() }; // Trim cache to 100 entries const cacheKeys = Object.keys(synthCache); if (cacheKeys.length > 100) { const oldest = cacheKeys.sort((a, b) => synthCache[a].ts - synthCache[b].ts).slice(0, cacheKeys.length - 100); for (const k of oldest) delete synthCache[k]; } try { fs.writeFileSync(synthCachePath, JSON.stringify(synthCache)); } catch (err) { logger.debug("search", `synthCache write: ${errorMessage(err)}`); } } } } catch (err) { debugLog(`search synthesis failed: ${errorMessage(err)}`); } } const fallbackNote = usedFallback ? " (keyword fallback)" : ""; const fragmentNote = relatedFragments.length > 0 ? `\n\nRelated fragments: ${relatedFragments.join(", ")}` : ""; const synthesisBlock = synthesis ? `\n\n${synthesis}\n\n---\n\n` : "\n\n"; runCustomHooks(phrenPath, "post-search", { PHREN_QUERY: query, PHREN_RESULT_COUNT: String(results.length) }); return mcpResponse({ ok: true, message: `Found ${results.length} result(s) for "${query}"${fallbackNote}:${synthesisBlock}${formatted.join("\n\n---\n\n")}${fragmentNote}`, data: { query, count: results.length, results, fallback: usedFallback, relatedFragments: relatedFragments.length > 0 ? relatedFragments : undefined, ...(synthesis ? { synthesis } : {}) }, }); } catch (err) { return mcpResponse({ ok: false, error: `Search error: ${errorMessage(err)}`, errorCode: "INTERNAL_ERROR" }); } } async function handleGetProjectSummary(ctx, { name }) { // Support store-qualified names (e.g., "team/arc") const { parseStoreQualified } = await import("../store-routing.js"); const { storeName, projectName } = parseStoreQualified(name); const lookupName = projectName; const db = ctx.db(); let docs = queryDocRows(db, "SELECT project, filename, type, content, path FROM docs WHERE project = ?", [lookupName]); // If not in primary index and store-qualified, try reading from the store's filesystem if (!docs && storeName) { const store = resolveAllStores(ctx.phrenPath).find((s) => s.name === storeName); if (store && fs.existsSync(path.join(store.path, lookupName))) { const projDir = path.join(store.path, lookupName); const fsDocs = []; for (const [file, type] of [["summary.md", "summary"], ["AGENTS.md", "claude"], [FINDINGS_FILENAME, "findings"], ["tasks.md", "task"], ["truths.md", "canonical"]]) { if (type === "task" && !moduleEnabled(store.path, "tasks", ctx.profile)) continue; const filePath = path.join(projDir, file); if (fs.existsSync(filePath)) { fsDocs.push({ project: lookupName, filename: file, type, content: fs.readFileSync(filePath, "utf8").slice(0, 8000), path: filePath }); } } if (fsDocs.length > 0) docs = fsDocs; } } if (!docs) { const projectRows = queryRows(db, "SELECT DISTINCT project FROM docs ORDER BY project", []); const names = projectRows ? projectRows.map(row => decodeStringRow(row, 1, "get_project_summary.projects")[0]) : []; return mcpResponse({ ok: false, error: `Project "${name}" not found.`, data: { available: names } }); } const summaryDoc = docs.find(doc => doc.type === "summary"); const claudeDoc = docs.find(doc => doc.type === "claude"); const canonicalDoc = docs.find(doc => doc.type === "canonical"); const indexedFiles = docs.map(doc => ({ filename: doc.filename, type: doc.type, path: doc.path })); const parts = [`# ${name}`]; if (summaryDoc) { parts.push(`\n## Summary\n${summaryDoc.content}`); } else { parts.push("\n*No summary.md found for this project.*"); } if (claudeDoc) { parts.push(`\n## AGENTS.md path\n\`${claudeDoc.path}\``); } // Show truths if they exist if (canonicalDoc) { const truthLines = canonicalDoc.content.split("\n").filter((l) => l.startsWith("- ")); if (truthLines.length > 0) { parts.push(`\n## Truths (${truthLines.length})\n${truthLines.join("\n")}`); } } const fileList = indexedFiles.map((f) => `- ${f.filename} (${f.type})`).join("\n"); parts.push(`\n## Indexed files\n${fileList}`); return mcpResponse({ ok: true, message: parts.join("\n"), data: { name, summary: summaryDoc?.content ?? null, // What the project holds, counted the way every graph counts it. counts: projectMemoryCounts(storeName ? (resolveAllStores(ctx.phrenPath).find((s) => s.name === storeName)?.path ?? ctx.phrenPath) : ctx.phrenPath, lookupName), agentsMdPath: claudeDoc?.path ?? null, claudeMdPath: claudeDoc?.path ?? null, truthsPath: canonicalDoc?.path ?? null, files: indexedFiles, }, }); } async function handleListProjects(ctx, { page, page_size }) { const { phrenPath, profile } = ctx; const db = ctx.db(); // Gather projects from primary store index const projectRows = queryRows(db, "SELECT DISTINCT project FROM docs ORDER BY project", []); const primaryProjects = projectRows ? projectRows.map(row => decodeStringRow(row, 1, "list_projects.projects")[0]) : []; // Gather projects from non-primary stores const { getNonPrimaryStores, getStoreProjectDirs } = await import("../store-registry.js"); const nonPrimaryStores = getNonPrimaryStores(phrenPath); const storeProjects = []; for (const store of nonPrimaryStores) { if (!fs.existsSync(store.path)) continue; const dirs = getStoreProjectDirs(store); for (const dir of dirs) { const projName = path.basename(dir); storeProjects.push({ name: projName, store: store.name }); } } // Filter primary projects: exclude any that are claimed by a non-primary store // (they'll appear under their store instead), and hide native: auto-indexed entries const storeClaimedNames = new Set(storeProjects.map((sp) => sp.name)); const filteredPrimary = primaryProjects.filter((p) => !storeClaimedNames.has(p) && !p.startsWith("native:")); // Combine: primary-only projects + non-primary store projects const allProjects = [ ...filteredPrimary.map((p) => ({ name: p, store: undefined })), ...storeProjects, ]; if (allProjects.length === 0) return mcpResponse({ ok: true, message: "No projects indexed.", data: { projects: [], total: 0 } }); const pageSize = page_size ?? 20; const pageNum = page ?? 1; const start = Math.max(0, (pageNum - 1) * pageSize); const end = start + pageSize; const pageProjects = allProjects.slice(start, end); const totalPages = Math.max(1, Math.ceil(allProjects.length / pageSize)); if (pageNum > totalPages) { return mcpResponse({ ok: false, error: `Page ${pageNum} out of range. Total pages: ${totalPages}.` }); } const badgeTypes = ["claude", "findings", "summary", "task"]; const badgeLabels = { claude: "AGENTS.md", findings: "FINDINGS", summary: "summary", task: "task" }; const projectList = pageProjects.map((entry) => { // Primary store projects: query the DB for badge info if (!entry.store) { const rows = queryDocRows(db, "SELECT project, filename, type, content, path FROM docs WHERE project = ?", [entry.name]) ?? []; const types = rows.map(row => row.type); const summaryRow = rows.find(row => row.type === "summary"); const claudeRow = rows.find(row => row.type === "claude"); const source = summaryRow?.content ?? claudeRow?.content; let brief = ""; if (source) { const firstLine = source.split("\n").find(l => l.trim() && !l.startsWith("#")); brief = firstLine?.trim() || ""; } const badges = badgeTypes.filter(t => types.includes(t)).map(t => badgeLabels[t]); return { name: entry.name, store: undefined, brief, badges, fileCount: rows.length }; } // Non-primary store projects: basic info (no DB query, just check file existence) const store = nonPrimaryStores.find((s) => s.name === entry.store); const projDir = store ? path.join(store.path, entry.name) : ""; const badges = []; if (projDir) { if (fs.existsSync(path.join(projDir, "AGENTS.md"))) badges.push("AGENTS.md"); if (fs.existsSync(path.join(projDir, FINDINGS_FILENAME))) badges.push("FINDINGS"); if (fs.existsSync(path.join(projDir, "summary.md"))) badges.push("summary"); if (store && moduleEnabled(store.path, "tasks", ctx.profile) && fs.existsSync(path.join(projDir, "tasks.md"))) badges.push("task"); } return { name: entry.name, store: entry.store, brief: "", badges, fileCount: badges.length }; }); const lines = [`# Phren Projects (${allProjects.length})`]; if (profile) lines.push(`Profile: ${profile}`); lines.push(`Page: ${pageNum}/${totalPages} (page_size=${pageSize})`); lines.push(`Path: ${phrenPath}\n`); for (const p of projectList) { const storeTag = p.store ? ` (${p.store})` : ""; lines.push(`## ${p.name}${storeTag}`); if (p.brief) lines.push(p.brief); lines.push(`[${p.badges.join(" | ")}] - ${p.fileCount} file(s)\n`); } return mcpResponse({ ok: true, message: lines.join("\n"), data: { projects: projectList, total: allProjects.length, page: pageNum, totalPages, pageSize }, }); } async function handleGetFindings(ctx, { project, limit, include_superseded, include_history, status }) { if (!isValidProjectName(project)) return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` }); const resolved = resolveStoreForProject(ctx, project, "read"); const { phrenPath } = resolved; const includeHistory = include_history ?? include_superseded ?? false; // Always read with archive so we can compute historyCount without a second read const result = readFindings(phrenPath, project, { includeArchived: true }); if (!result.ok) return mcpResponse({ ok: false, error: result.error }); const allItems = result.data; const historyCount = allItems.filter(f => f.tier === "archived" || HISTORY_FINDING_STATUSES.has(f.status)).length; const visibleItems = includeHistory ? allItems : allItems.filter(f => f.tier !== "archived" && !HISTORY_FINDING_STATUSES.has(f.status)); // Apply scope filter: only show findings visible to the active scope const activeScope = resolveActiveSessionScope(phrenPath, project); const scopedItems = activeScope ? visibleItems.filter(f => { const itemScope = normalizeMemoryScope(f.scope); return isMemoryScopeVisible(itemScope, activeScope); }) : visibleItems; const filteredItems = status ? scopedItems.filter(f => f.status === status) : scopedItems; if (!filteredItems.length) { const msg = historyCount > 0 && !includeHistory ? `No findings found for "${project}" with current filters. ${historyCount} historical finding(s) hidden. Pass include_history=true to show history.` : `No findings found for "${project}".`; return mcpResponse({ ok: true, message: msg, data: { project, findings: [], total: 0, status: status ?? null, include_history: includeHistory, historyCount } }); } const capped = filteredItems.slice(0, limit ?? 50).map(entry => ({ ...entry, symbol: entry.citationData?.symbol, lifecycle: { status: entry.status, status_updated: entry.status_updated, status_reason: entry.status_reason, status_ref: entry.status_ref, }, })); const lines = capped.map((entry) => { const metadata = []; metadata.push(`status=${entry.status}`); if (entry.taskItem) metadata.push(`task=${entry.taskItem}`); if (entry.scope) metadata.push(`scope=${entry.scope}`); if (entry.symbol) metadata.push(`name=${entry.symbol}`); if (entry.supersedes) metadata.push(`supersedes="${entry.supersedes.slice(0, 30)}"`); if (entry.supersededBy) metadata.push(`superseded_by="${entry.supersededBy.slice(0, 30)}"`); if (entry.contradicts?.length) metadata.push(`contradicts=${entry.contradicts.length}`); if (entry.tier === "archived") metadata.push("tier=archived"); const idLabel = entry.stableId ? `${entry.id}|fid:${entry.stableId}` : entry.id; const attribution = formatActorAttribution(entry.actor, entry.machine); return `- [${idLabel}] ${entry.date}: ${entry.text}${attribution ? ` ${attribution}` : ""}${entry.confidence !== undefined ? ` [confidence ${entry.confidence.toFixed(2)}]` : ""}${metadata.length > 0 ? ` [${metadata.join(" ")}]` : ""}${entry.citation ? ` (${entry.citation})` : ""}`; }); const hiddenHistoryCount = includeHistory ? 0 : historyCount; const historyNote = hiddenHistoryCount > 0 ? ` (${hiddenHistoryCount} historical hidden)` : ""; return mcpResponse({ ok: true, message: `Findings for ${project} (${capped.length}/${filteredItems.length})${historyNote}:\n` + lines.join("\n"), data: { project, findings: capped, total: filteredItems.length, status: status ?? null, include_history: includeHistory, historyCount: hiddenHistoryCount }, }); } async function handleStoreList(ctx) { const { phrenPath } = ctx; const stores = resolveAllStores(phrenPath); const storeData = stores.map((store) => { // Get last sync time from git log let lastSync = null; try { const result = execFileSync("git", ["log", "-1", "--format=%ci"], { env: nonInteractiveGitEnv(), cwd: store.path, encoding: "utf8", timeout: 3000 }).trim(); lastSync = result || null; } catch { /* non-critical */ } return { id: store.id, name: store.name, path: store.path, role: store.role, sync: store.sync, remote: store.remote ?? null, exists: fs.existsSync(store.path), projects: store.projects ?? null, lastSync, }; }); const lines = storeData.map((s) => { const status = s.exists ? "ok" : "MISSING"; return `- ${s.name} (${s.role}) [${status}] ${s.remote ?? "(local)"}`; }); return mcpResponse({ ok: true, message: `${storeData.length} store(s):\n${lines.join("\n")}`, data: { stores: storeData }, }); } // ── Registration ───────────────────────────────────────────────────────────── export function register(server, ctx) { server.registerTool("get_memory_detail", { title: "◆ phren · memory detail", description: "Fetch the full content of a specific memory entry by its ID. Use this after receiving a compact " + "memory index from the hook-prompt (when PHREN_FEATURE_PROGRESSIVE_DISCLOSURE is enabled). " + "The id format is `mem:project/path/to/file.md` as shown in the memory index.", inputSchema: z.object({ id: z.string().describe("Memory ID in the format `mem:project/path/to/file.md` (e.g. `mem:my-app/reference/api/auth.md`). " + "Returned by the hook-prompt compact index when PHREN_FEATURE_PROGRESSIVE_DISCLOSURE=1."), }), }, (params) => handleGetMemoryDetail(ctx, params)); server.registerTool("search_knowledge", { title: "◆ phren · search", description: "Search the user's phren. Call this at the start of any session to get project context, and any time the user asks about their codebase, stack, architecture, past decisions, commands, conventions, or findings. Prefer this over asking the user to re-explain things they've already told phren.", inputSchema: z.object({ query: z.string().describe("Search query (supports FTS5 syntax: AND, OR, NOT, phrase matching with quotes)"), limit: z.number().min(1).max(20).optional().describe("Max results to return (1-20, default 5)"), project: z.string().optional().describe("Filter by project name."), type: z.enum(DOC_TYPES) .optional() .describe("Filter by document type: claude, findings, reference, summary, task, skill"), tag: z.preprocess(value => typeof value === "string" ? value.toLowerCase() : value, z.enum(FINDING_TAGS)) .optional() .describe("Filter findings by type tag: decision, pitfall, pattern, bug, workaround, context."), since: z.string().optional().describe('Filter findings by creation date. Formats: "7d" (last 7 days), "30d" (last 30 days), "YYYY-MM" (since start of month), "YYYY-MM-DD" (since date).'), status: z.enum(FINDING_LIFECYCLE_STATUSES).optional().describe("Filter findings by lifecycle status: active, superseded, contradicted, stale, invalid_citation, or retracted."), include_history: z.boolean().optional().describe("When true, include historical findings (superseded/retracted). Default false."), synthesize: z.boolean().optional().describe("When true, generate a short synthesis paragraph from the top results using an LLM. Requires PHREN_LLM_ENDPOINT, ANTHROPIC_API_KEY, or OPENAI_API_KEY."), }), }, (params) => handleSearchKnowledge(ctx, params)); server.registerTool("get_project_summary", { title: "◆ phren · project", description: "Get a project's summary card and available docs. Call this when starting work on a specific project to orient yourself: what it is, the stack, current status, and how to run it.", inputSchema: z.object({ name: z.string().describe("Project name (e.g. 'my-app', 'backend', 'frontend')"), }), }, (params) => handleGetProjectSummary(ctx, params)); server.registerTool("list_projects", { title: "◆ phren · projects", description: "List all projects in the active phren profile with a brief summary of each. " + "Shows which documentation files exist per project.", inputSchema: z.object({ page: z.number().int().min(1).optional().describe("1-based page number (default 1)."), page_size: z.number().int().min(1).max(50).optional().describe("Page size (default 20, max 50)."), }), }, (params) => handleListProjects(ctx, params)); server.registerTool("get_findings", { title: "◆ phren · findings", description: "List recent findings for a project without requiring a search query.", inputSchema: z.object({ project: z.string().describe("Project name."), limit: z.number().int().min(1).max(200).optional().describe("Max rows to return (default 50)."), include_superseded: z.boolean().optional().describe("If true, include findings that have been superseded by a newer finding (hidden by default)."), include_history: z.boolean().optional().describe("When true, include historical findings (superseded/retracted). Default false."), status: z.enum(FINDING_LIFECYCLE_STATUSES).optional().describe("Filter findings by lifecycle status."), }), }, (params) => handleGetFindings(ctx, params)); server.registerTool("store_list", { title: "◆ phren · stores", description: "List all registered phren stores and their sync status. " + "Shows the primary store plus any team or readonly stores from the store registry.", inputSchema: z.object({}), }, () => handleStoreList(ctx)); }