@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
215 lines (214 loc) • 8.43 kB
JavaScript
import * as fs from "fs";
import * as path from "path";
import * as crypto from "crypto";
import { runtimeDir, phrenOk, phrenErr, PhrenError, atomicWriteText } from "../shared.js";
import { withFileLock } from "../shared/governance.js";
import { addFindingToFile } from "../shared/content.js";
import { isValidProjectName, errorMessage } from "../utils.js";
import { buildSourceComment } from "../content/citation.js";
import { getCurrentActor } from "../machine-identity.js";
import { FINDINGS_FILENAME } from "../data/access.js";
function journalRoot(phrenPath) {
const dir = path.join(runtimeDir(phrenPath), "finding-journal");
fs.mkdirSync(dir, { recursive: true });
return dir;
}
function sanitizeSessionId(sessionId) {
const raw = (sessionId || `session-${new Date().toISOString().slice(0, 10)}`).trim();
const safe = raw.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, "");
return safe || "session";
}
function journalFileFor(phrenPath, project, sessionId) {
const projectDir = path.join(journalRoot(phrenPath), project);
fs.mkdirSync(projectDir, { recursive: true });
return path.join(projectDir, `${sanitizeSessionId(sessionId)}.jsonl`);
}
function listJournalFiles(phrenPath, project) {
const root = journalRoot(phrenPath);
const projects = project ? [project] : fs.readdirSync(root).filter((entry) => fs.statSync(path.join(root, entry)).isDirectory());
const files = [];
for (const projectName of projects) {
const projectDir = path.join(root, projectName);
if (!fs.existsSync(projectDir))
continue;
for (const entry of fs.readdirSync(projectDir)) {
if (!entry.endsWith(".jsonl"))
continue;
files.push(path.join(projectDir, entry));
}
}
return files.sort();
}
export function appendFindingJournal(phrenPath, project, text, opts = {}) {
if (!isValidProjectName(project))
return phrenErr(`Invalid project name: "${project}".`, PhrenError.INVALID_PROJECT_NAME);
const filePath = journalFileFor(phrenPath, project, opts.sessionId);
const entry = {
at: new Date().toISOString(),
project,
text,
...(opts.source ? { source: opts.source } : {}),
...(opts.sessionId ? { sessionId: opts.sessionId } : {}),
...(opts.repo ? { repo: opts.repo } : {}),
...(opts.commit ? { commit: opts.commit } : {}),
...(opts.file ? { file: opts.file } : {}),
};
try {
withFileLock(filePath, () => {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.appendFileSync(filePath, JSON.stringify(entry) + "\n");
});
return phrenOk(filePath);
}
catch (err) {
return phrenErr(`Failed to append finding journal: ${errorMessage(err)}`, PhrenError.PERMISSION_DENIED);
}
}
export function compactFindingJournals(phrenPath, project) {
const result = {
filesProcessed: 0,
entriesProcessed: 0,
added: 0,
skipped: 0,
failed: 0,
};
for (const filePath of listJournalFiles(phrenPath, project)) {
const claimed = `${filePath}.${crypto.randomUUID()}.claim`;
try {
fs.renameSync(filePath, claimed);
}
catch {
continue;
}
result.filesProcessed += 1;
try {
const entries = fs.readFileSync(claimed, "utf8")
.split("\n")
.filter(Boolean)
.map((line) => {
try {
return JSON.parse(line);
}
catch {
return null;
}
})
.filter((entry) => Boolean(entry && entry.project && entry.text));
for (const entry of entries) {
result.entriesProcessed += 1;
const write = addFindingToFile(phrenPath, entry.project, entry.text, {
...(entry.repo ? { repo: entry.repo } : {}),
...(entry.commit ? { commit: entry.commit } : {}),
...(entry.file ? { file: entry.file } : {}),
}, {
sessionId: entry.sessionId,
});
if (!write.ok) {
result.failed += 1;
continue;
}
if (write.data.status === "skipped")
result.skipped += 1;
else
result.added += 1;
}
}
finally {
try {
fs.unlinkSync(claimed);
}
catch { }
}
}
return result;
}
// ── Team store journal (append-only markdown, committed to git) ──────────────
const TEAM_JOURNAL_DIR = "journal";
/**
* Append a finding to a team store's journal.
* Each actor gets one file per day — no merge conflicts possible.
* These are markdown files committed to git (not runtime JSONL).
*/
export function appendTeamJournal(storePath, project, finding, actor, machine) {
const resolvedActor = actor || getCurrentActor();
const date = new Date().toISOString().slice(0, 10);
const journalDir = path.join(storePath, project, TEAM_JOURNAL_DIR);
const journalFile = `${date}-${resolvedActor}.md`;
const journalPath = path.join(journalDir, journalFile);
try {
fs.mkdirSync(journalDir, { recursive: true });
const sourceComment = buildSourceComment({ source: "human", machine, actor: resolvedActor });
const entry = `- ${finding}${sourceComment ? ` ${sourceComment}` : ""}\n`;
if (fs.existsSync(journalPath)) {
fs.appendFileSync(journalPath, entry);
}
else {
fs.writeFileSync(journalPath, `## ${date} (${resolvedActor})\n\n${entry}`);
}
return phrenOk(journalFile);
}
catch (err) {
return phrenErr(`Team journal append failed: ${errorMessage(err)}`, PhrenError.PERMISSION_DENIED);
}
}
/**
* Read all team journal entries for a project, newest first.
*/
export function readTeamJournalEntries(storePath, project) {
const journalDir = path.join(storePath, project, TEAM_JOURNAL_DIR);
if (!fs.existsSync(journalDir))
return [];
return fs.readdirSync(journalDir)
.filter((f) => f.endsWith(".md"))
.sort()
.reverse()
.map((file) => {
const match = file.match(/^(\d{4}-\d{2}-\d{2})-(.+)\.md$/);
const date = match?.[1] ?? "unknown";
const actor = match?.[2] ?? "unknown";
const content = fs.readFileSync(path.join(journalDir, file), "utf8");
const entries = content.split("\n")
.filter((line) => line.startsWith("- "))
.map((line) => line.slice(2).trim());
return { file, date, actor, entries };
});
}
/**
* Materialize FINDINGS.md from team journal entries.
* Groups by date, includes actor attribution.
*/
export function materializeTeamFindings(storePath, project) {
const journalEntries = readTeamJournalEntries(storePath, project);
if (journalEntries.length === 0) {
return phrenErr("No journal entries found", PhrenError.FILE_NOT_FOUND);
}
// Group by date, chronological order
const byDate = new Map();
for (const entry of [...journalEntries].reverse()) {
if (!byDate.has(entry.date))
byDate.set(entry.date, []);
byDate.get(entry.date).push({ actor: entry.actor, entries: entry.entries });
}
const lines = [`# ${project} findings\n`];
let count = 0;
for (const [date, actors] of byDate) {
lines.push(`## ${date}`);
for (const { actor, entries } of actors) {
for (const entry of entries) {
const hasSource = /<!--\s*source:.*?-->/.test(entry);
const fallback = hasSource ? "" : ` ${buildSourceComment({ source: "human", actor })}`;
lines.push(`- ${entry}${fallback}`);
count++;
}
}
lines.push("");
}
const findingsPath = path.join(storePath, project, FINDINGS_FILENAME);
try {
atomicWriteText(findingsPath, lines.join("\n"));
return phrenOk({ entryCount: count });
}
catch (err) {
return phrenErr(`Materialize failed: ${errorMessage(err)}`, PhrenError.PERMISSION_DENIED);
}
}