UNPKG

@phren/cli

Version:

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

272 lines (271 loc) • 13.3 kB
import * as fs from "fs"; import * as path from "path"; import * as yaml from "js-yaml"; import { loadYamlDocument } from "../phren-core.js"; import { isValidProjectName } from "../utils.js"; import { homeDir, homePath } from "../shared.js"; import { resolveTaskFilePath } from "../data/tasks.js"; import { FINDINGS_FILENAME } from "../data/access.js"; import { log } from "../init/shared.js"; function contextFilePath() { return homePath(".phren-context.md"); } export function claudeProjectKey() { return homeDir().replace(/[/\\:]/g, "-").replace(/^-/, ""); } function displayName(slug) { if (!slug) return ""; return slug.split("-").map(w => w[0]?.toUpperCase() + w.slice(1)).join(" "); } function allKnownProjects(phrenPath) { const profilesDir = path.join(phrenPath, "profiles"); if (!fs.existsSync(profilesDir)) return []; const projects = new Set(); for (const f of fs.readdirSync(profilesDir)) { if (!f.endsWith(".yaml")) continue; const data = loadYamlDocument(fs.readFileSync(path.join(profilesDir, f), "utf8"), (text) => yaml.load(text, { schema: yaml.CORE_SCHEMA })); for (const p of (data?.projects ?? [])) projects.add(p); } return [...projects].sort(); } // ── Context file writing ──────────────────────────────────────────────────── function writeContextFile(managedContent) { const contextFile = contextFilePath(); const wrapped = `<!-- phren-managed -->\n${managedContent}\n<!-- phren-managed -->`; if (fs.existsSync(contextFile)) { const existing = fs.readFileSync(contextFile, "utf8"); if (existing.includes("<!-- phren-managed -->")) { const startIdx = existing.indexOf("<!-- phren-managed -->"); const endIdx = existing.indexOf("<!-- phren-managed -->", startIdx + "<!-- phren-managed -->".length); const before = startIdx > 0 ? existing.slice(0, startIdx).trimEnd() : ""; const after = endIdx !== -1 ? existing.slice(endIdx + "<!-- phren-managed -->".length).trimStart() : ""; const parts = [before, wrapped, after].filter(Boolean); fs.writeFileSync(contextFile, parts.join("\n") + "\n"); return; } } fs.writeFileSync(contextFile, wrapped + "\n"); } function formatMcpStatus(status) { if (status === "installed" || status === "already_configured") { return "MCP: active (search_knowledge, get_project_summary, list_projects)"; } if (status === "disabled" || status === "already_disabled") { return "MCP: disabled (hooks-only fallback active)"; } if (status === "not_built") return "MCP: not built. Run: cd mcp && npm install && npm run build"; return ""; } export function writeContextDefault(machine, profile, mcpStatus, projects, phrenPath) { const all = allKnownProjects(phrenPath); const inactive = all.filter(p => !projects.includes(p)); const mcpLine = formatMcpStatus(mcpStatus); const lines = [ "# phren context", `Machine: ${machine}`, `Profile: ${profile}`, `Active projects: ${projects.join(", ")}`, `Not on this machine: ${inactive.length ? inactive.join(", ") : "none"}`, ...(mcpLine ? [mcpLine] : []), `Last synced: ${new Date().toISOString().slice(0, 10)}`, ]; writeContextFile(lines.join("\n")); log(` wrote ${contextFilePath()}`); } export function writeContextDebugging(machine, profile, mcpStatus, projects, phrenPath) { const mcpLine = formatMcpStatus(mcpStatus); let content = [ "# phren context (debugging)", `Machine: ${machine}`, `Profile: ${profile}`, `Last synced: ${new Date().toISOString().slice(0, 10)}`, ...(mcpLine ? [mcpLine] : []), ].join("\n") + "\n\n## Project Findings\n"; const MAX_FILE_BYTES = 50 * 1024; for (const project of projects) { if (project === "global") continue; const findings = path.join(phrenPath, project, FINDINGS_FILENAME); if (fs.existsSync(findings)) { let body = fs.readFileSync(findings, "utf8"); if (body.length > MAX_FILE_BYTES) { body = body.slice(-MAX_FILE_BYTES); const firstNewline = body.indexOf("\n"); if (firstNewline !== -1) body = body.slice(firstNewline + 1); body = `(truncated to most recent entries)\n${body}`; } content += `\n### ${project}\n${body}\n`; } } writeContextFile(content); log(` wrote ${contextFilePath()} (debugging mode)`); } export function writeContextPlanning(machine, profile, mcpStatus, projects, phrenPath) { const mcpLine = formatMcpStatus(mcpStatus); let content = [ "# phren context (planning)", `Machine: ${machine}`, `Profile: ${profile}`, `Last synced: ${new Date().toISOString().slice(0, 10)}`, ...(mcpLine ? [mcpLine] : []), ].join("\n"); const MAX_CONTEXT_BYTES = 100 * 1024; for (const project of projects) { if (project === "global") continue; if (content.length >= MAX_CONTEXT_BYTES) { content += `\n\n(remaining projects truncated, context size limit reached)\n`; break; } const summaryFile = path.join(phrenPath, project, "summary.md"); const taskFile = resolveTaskFilePath(phrenPath, project); if (!fs.existsSync(summaryFile) && !taskFile) continue; content += `\n\n## ${project}\n`; if (fs.existsSync(summaryFile)) content += fs.readFileSync(summaryFile, "utf8") + "\n"; if (taskFile && fs.existsSync(taskFile)) { let task = fs.readFileSync(taskFile, "utf8"); const remaining = MAX_CONTEXT_BYTES - content.length; if (task.length > remaining && remaining > 0) { task = task.slice(0, remaining) + "\n(task truncated)\n"; } content += `\n### Task\n${task}\n`; } } writeContextFile(content); log(` wrote ${contextFilePath()} (planning mode)`); } export function writeContextClean(machine, profile, mcpStatus, projects) { const mcpLine = formatMcpStatus(mcpStatus); let content = `# phren context (clean)\nMachine: ${machine} | Profile: ${profile} | Projects: ${projects.join(", ")}\n`; if (mcpLine) content += mcpLine + "\n"; writeContextFile(content); log(` wrote ${contextFilePath()} (clean mode)`); } // ── Memory management ─────────────────────────────────────────────────────── export function readBackNativeMemory(phrenPath, projects) { const projectKey = claudeProjectKey(); const memoryDir = homePath(".claude", "projects", projectKey, "memory"); if (!fs.existsSync(memoryDir)) return; for (const project of projects) { if (project === "global") continue; if (!isValidProjectName(project)) { log(` skip native memory sync for invalid project name: ${project}`); continue; } const nativeFile = path.join(memoryDir, `MEMORY-${project}.md`); if (!fs.existsSync(nativeFile)) continue; const content = fs.readFileSync(nativeFile, "utf8"); const notesMatch = content.match(/^## Notes\n([\s\S]*)$/m); if (!notesMatch) continue; const notes = notesMatch[1] .replace(/<!-- Session findings, patterns, decisions -->\n?/, "") .trim(); if (!notes) continue; const targetFile = path.join(phrenPath, project, "native-notes.md"); const existing = fs.existsSync(targetFile) ? fs.readFileSync(targetFile, "utf8").trim() : ""; if (existing === notes) continue; fs.mkdirSync(path.join(phrenPath, project), { recursive: true }); fs.writeFileSync(targetFile, notes + "\n"); log(` synced native memory notes for ${project}`); } } export function rebuildMemory(phrenPath, projects) { const projectKey = claudeProjectKey(); const memoryDir = homePath(".claude", "projects", projectKey, "memory"); const memoryFile = path.join(memoryDir, "MEMORY.md"); const hasSummaries = projects.some(p => p !== "global" && fs.existsSync(path.join(phrenPath, p, "summary.md"))); if (!hasSummaries) return; fs.mkdirSync(memoryDir, { recursive: true }); const existing = fs.existsSync(memoryFile) ? fs.readFileSync(memoryFile, "utf8") : ""; const projectsStart = "<!-- phren:projects:start -->"; const projectsEnd = "<!-- phren:projects:end -->"; let managed = "<!-- phren:projects:start -->\n<!-- Auto-generated by phren link. Do not edit below this line. -->\n\n## Active Projects\n\n| Project | What | Memory |\n|---------|------|--------|\n"; for (const project of projects) { if (project === "global") continue; const summaryFile = path.join(phrenPath, project, "summary.md"); if (!fs.existsSync(summaryFile)) continue; const summary = fs.readFileSync(summaryFile, "utf8"); const whatMatch = summary.match(/^\*\*What:\*\*\s*(.+)/m); const what = whatMatch?.[1]?.trim() ?? "(see summary)"; managed += `| ${displayName(project)} | ${what} | MEMORY-${project}.md |\n`; } managed += "\n<!-- phren:projects:end -->"; const freshHeader = "# Root Memory\n\n## Machine Context\nRead `~/.phren-context.md` for profile, active projects, last sync date.\n\n## Cross-Project Notes\n- Read a project's AGENTS.md before making changes.\n- Per-project memory files (MEMORY-{name}.md) have commands, versions, findings.\n\n"; const start = existing.indexOf(projectsStart); const end = existing.indexOf(projectsEnd); let updated = existing; if (start === -1 && end === -1) { // A preset downgrade removes our block but leaves the owner's notes. // An unmarked file is user content, not an empty generated header. const header = existing || freshHeader; const separator = header.endsWith("\n\n") ? "" : header.endsWith("\n") ? "\n" : "\n\n"; updated = header + separator + managed + "\n"; } else if (start !== -1 && end > start) { updated = existing.slice(0, start) + managed + existing.slice(end + projectsEnd.length); } // Incomplete or reversed markers cannot establish ownership: leave them alone. if (updated !== existing) { fs.writeFileSync(memoryFile, updated); log(` rebuilt ${memoryFile} (pointer format)`); } const SUMMARY_START = "<!-- phren:summary:start -->"; const SUMMARY_END = "<!-- phren:summary:end -->"; for (const project of projects) { if (project === "global") continue; const summaryFile = path.join(phrenPath, project, "summary.md"); if (!fs.existsSync(summaryFile)) continue; const summaryContent = fs.readFileSync(summaryFile, "utf8"); const projectMemory = path.join(memoryDir, `MEMORY-${project}.md`); if (!fs.existsSync(projectMemory)) { // Create fresh file with managed summary section and user-editable notes block fs.writeFileSync(projectMemory, `# ${displayName(project)}\n\n${SUMMARY_START}\n${summaryContent.trimEnd()}\n${SUMMARY_END}\n\n## Notes\n<!-- Session findings, patterns, decisions -->\n`); log(` created ${projectMemory}`); } else { // Refresh only the managed summary section; preserve user-editable content outside it const existing = fs.readFileSync(projectMemory, "utf8"); const startIdx = existing.indexOf(SUMMARY_START); const endIdx = existing.indexOf(SUMMARY_END); if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) { const before = existing.slice(0, startIdx); const after = existing.slice(endIdx + SUMMARY_END.length); const updated = `${before}${SUMMARY_START}\n${summaryContent.trimEnd()}\n${SUMMARY_END}${after}`; if (updated !== existing) { fs.writeFileSync(projectMemory, updated); log(` refreshed ${projectMemory} (summary section updated)`); } } else if (startIdx === -1) { // Legacy file without managed section: prepend summary block before the first heading or append const firstHeading = existing.indexOf("\n## "); const insertAt = firstHeading !== -1 ? firstHeading : existing.length; const updated = existing.slice(0, insertAt) + `\n\n${SUMMARY_START}\n${summaryContent.trimEnd()}\n${SUMMARY_END}` + existing.slice(insertAt); fs.writeFileSync(projectMemory, updated); log(` refreshed ${projectMemory} (added managed summary section)`); } } } }