@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
272 lines (271 loc) • 13.3 kB
JavaScript
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)`);
}
}
}
}