@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
277 lines (276 loc) • 12.5 kB
JavaScript
import * as fs from "fs";
import * as path from "path";
import * as crypto from "crypto";
import { debugLog, runtimeFile, phrenOk, phrenErr, PhrenError, appendAuditLog, tryUnlink } from "../shared.js";
import { isValidProjectName, errorMessage } from "../utils.js";
import { storeAwareProjectPath } from "../store-routing.js";
import { withFileLock } from "../governance/locks.js";
import { walkDirectory } from "../shared/data-utils.js";
import { appendArchivedEntriesToTopicDoc, classifyTopicForText, readProjectTopics, topicReferencePath } from "../project-topics.js";
import { isCitationLine, isArchiveStart, isArchiveEnd, stripComments } from "./metadata.js";
import { logger } from "../logger.js";
import { FINDINGS_FILENAME } from "../filenames.js";
/**
* Count active (non-archived) finding entries in FINDINGS.md content.
* Entries inside archive blocks are considered archived.
* Supports structured archive markers and HTML details blocks.
*/
export function countActiveFindings(content) {
let inArchive = false;
let count = 0;
for (const line of content.split("\n")) {
if (isArchiveStart(line)) {
inArchive = true;
continue;
}
if (isArchiveEnd(line)) {
inArchive = false;
continue;
}
if (!inArchive && line.startsWith("- "))
count++;
}
return count;
}
/**
* Parse active (non-archived) entries from FINDINGS.md, oldest first.
*/
function parseActiveEntries(content) {
const lines = content.split("\n");
const entries = [];
let currentDate = "";
let inArchive = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (isArchiveStart(line)) {
inArchive = true;
continue;
}
if (isArchiveEnd(line)) {
inArchive = false;
continue;
}
if (inArchive)
continue;
const heading = line.match(/^## (\d{4}-\d{2}-\d{2})$/);
if (heading) {
currentDate = heading[1];
continue;
}
if (line.startsWith("- ") && currentDate) {
const next = lines[i + 1] || "";
const hasCitation = isCitationLine(next);
entries.push({
date: currentDate,
bullet: line,
citation: hasCitation ? next : undefined,
lineIndex: i,
});
if (hasCitation)
i++;
}
}
// Sort oldest first: earliest date first; within the same date, higher
// lineIndex = earlier in the file (newest findings are prepended at top).
// Q25: use descending lineIndex within the same day so that when we slice
// `toArchive = entries.slice(0, N)` we archive the truly oldest entries
// (lowest in the file = largest lineIndex for that date).
entries.sort((a, b) => a.date.localeCompare(b.date) || b.lineIndex - a.lineIndex);
return entries;
}
/**
* Check whether a bullet already exists in a reference file (already archived).
*/
/** Build a Set of normalized bullet strings from all .md files in referenceDir. */
function buildArchivedBulletSet(referenceDir) {
const bulletSet = new Set();
try {
for (const filePath of walkDirectory(referenceDir)) {
const content = fs.readFileSync(filePath, "utf8");
for (const line of content.split("\n")) {
if (!line.startsWith("- "))
continue;
const normalizedLine = stripComments(line).replace(/^-\s+/, "").trim().toLowerCase();
if (normalizedLine)
bulletSet.add(normalizedLine);
}
}
}
catch (err) {
logger.debug("archive", `buildArchivedBulletSet: ${errorMessage(err)}`);
}
return bulletSet;
}
function isAlreadyArchived(archivedSet, bullet) {
const normalizedBullet = stripComments(bullet).replace(/^-\s+/, "").trim().toLowerCase();
if (!normalizedBullet)
return false;
return archivedSet.has(normalizedBullet);
}
/**
* Archive the oldest entries from FINDINGS.md into reference/{topic}.md files.
* Keeps `keepCount` most recent entries, archives the rest grouped by topic.
* Returns the number of entries archived.
*/
export function autoArchiveToReference(phrenPath, project, keepCount) {
if (!isValidProjectName(project))
return phrenErr(`Invalid project name: "${project}".`, PhrenError.INVALID_PROJECT_NAME);
const resolvedDir = storeAwareProjectPath(phrenPath, project);
if (!resolvedDir || !fs.existsSync(resolvedDir))
return phrenErr(`Project "${project}" not found in phren.`, PhrenError.PROJECT_NOT_FOUND);
const learningsPath = path.join(resolvedDir, FINDINGS_FILENAME);
if (!fs.existsSync(learningsPath))
return phrenOk(0);
// Consolidation lock to prevent concurrent runs for the same project (atomic create via wx flag).
// Use a project-specific lock so consolidating multiple projects in parallel is allowed.
const STALE_LOCK_MS = 600_000; // 10 min
const lockFile = runtimeFile(phrenPath, `consolidation-${project}.lock`);
try {
fs.writeFileSync(lockFile, String(Date.now()), { flag: "wx" });
}
catch (e) {
if (e.code === "EEXIST") {
try {
const stat = fs.statSync(lockFile);
if (Date.now() - stat.mtimeMs < STALE_LOCK_MS) {
return phrenErr("Consolidation already running", PhrenError.LOCK_TIMEOUT);
}
// Stale lock: delete then re-create atomically with wx.
tryUnlink(lockFile);
// Re-attempt atomic create. If EEXIST, another thread won the race.
try {
fs.writeFileSync(lockFile, String(Date.now()), { flag: "wx" });
}
catch (wxErr) {
if (wxErr.code === "EEXIST")
return phrenErr("Consolidation already running", PhrenError.LOCK_TIMEOUT);
throw wxErr;
}
}
catch (innerErr) {
if (innerErr.code === "EEXIST" || innerErr.code === "ENOENT") {
return phrenErr("Consolidation already running", PhrenError.LOCK_TIMEOUT);
}
throw innerErr;
}
}
else {
throw e;
}
}
// Q11: Hold the per-file lock on FINDINGS.md for the entire read-modify-write
// cycle so finding writers and the archive pass see a consistent file.
try {
return withFileLock(learningsPath, () => {
const content = fs.readFileSync(learningsPath, "utf8");
const entries = parseActiveEntries(content);
if (entries.length <= keepCount)
return phrenOk(0);
const toArchive = entries.slice(0, entries.length - keepCount);
// Guard: skip entries already present in reference tier (prevent double-archive)
const referenceDir = path.join(resolvedDir, "reference");
const { topics } = readProjectTopics(phrenPath, project);
const today = new Date().toISOString().slice(0, 10);
const archivedSet = buildArchivedBulletSet(referenceDir);
const actuallyArchived = [];
for (const entry of toArchive) {
if (isAlreadyArchived(archivedSet, entry.bullet)) {
debugLog(`auto_archive: skipping already-archived entry: "${entry.bullet.slice(0, 60)}"`);
continue;
}
actuallyArchived.push(entry);
}
// Group archived entries by topic
const byTopic = new Map();
for (const entry of actuallyArchived) {
const topic = classifyTopicForText(entry.bullet, topics);
const bucket = byTopic.get(topic.slug) ?? [];
bucket.push({ date: today, bullet: entry.bullet, citation: entry.citation });
byTopic.set(topic.slug, bucket);
}
// Write to reference/topics/{topic}.md (atomic rename per file)
fs.mkdirSync(referenceDir, { recursive: true });
const successfulTopics = new Set();
for (const [topicSlug, topicEntries] of byTopic) {
const filePath = topicReferencePath(phrenPath, project, topicSlug);
const topic = topics.find((item) => item.slug === topicSlug) ?? topics.find((item) => item.slug === "general");
if (!filePath || !topic)
continue;
try {
appendArchivedEntriesToTopicDoc(filePath, project, topic, topicEntries);
successfulTopics.add(topicSlug);
}
catch (err) {
debugLog(`auto_archive: failed to write reference file for topic "${topicSlug}": ${errorMessage(err)}`);
}
}
// Only remove entries whose topics were successfully written to reference files,
// plus entries already present in reference (safe to remove since they're already archived).
const alreadyArchivedEntries = toArchive.filter(entry => !actuallyArchived.includes(entry));
const successfullyArchived = actuallyArchived.filter(entry => successfulTopics.has(classifyTopicForText(entry.bullet, topics).slug));
const safeToRemove = [...successfullyArchived, ...alreadyArchivedEntries];
// Remove archived entries from FINDINGS.md
const lines = content.split("\n");
const archiveLineSet = new Set();
for (const entry of safeToRemove) {
archiveLineSet.add(entry.lineIndex);
if (entry.citation)
archiveLineSet.add(entry.lineIndex + 1);
}
const filtered = lines.filter((_, i) => !archiveLineSet.has(i));
// Clean up empty date sections
const cleaned = [];
for (let i = 0; i < filtered.length; i++) {
const line = filtered[i];
const isDateHeading = /^## \d{4}-\d{2}-\d{2}$/.test(line);
if (isDateHeading) {
// Check if next non-empty lines have any bullets
let hasBullets = false;
for (let j = i + 1; j < filtered.length; j++) {
const next = filtered[j].trim();
if (!next)
continue;
if (next.startsWith("## ") || next.startsWith("# "))
break;
if (next.startsWith("- ")) {
hasBullets = true;
break;
}
break;
}
if (!hasBullets)
continue;
}
cleaned.push(line);
}
// Write consolidation marker
const marker = `<!-- consolidated: ${today} -->`;
const markerIdx = cleaned.findIndex(l => l.includes("consolidated:"));
if (markerIdx >= 0) {
cleaned[markerIdx] = marker;
}
else {
// Insert after title
const titleIdx = cleaned.findIndex(l => l.startsWith("# "));
if (titleIdx >= 0) {
cleaned.splice(titleIdx + 1, 0, "", marker);
}
}
const tmpPath = learningsPath + `.tmp-${crypto.randomUUID()}`;
fs.writeFileSync(tmpPath, cleaned.join("\n"));
fs.renameSync(tmpPath, learningsPath);
const skippedCount = alreadyArchivedEntries.length;
const failedTopics = [...byTopic.keys()].filter(t => !successfulTopics.has(t));
appendAuditLog(phrenPath, "auto_archive_reference", `project=${project} archived=${successfullyArchived.length} skipped_duplicates=${skippedCount}${failedTopics.length ? ` failed_topics=${failedTopics.join(",")}` : ""} topics=${[...successfulTopics].join(",")}`);
return phrenOk(safeToRemove.length);
});
}
finally {
try {
fs.unlinkSync(lockFile);
}
catch (err) {
logger.debug("archive", `autoArchiveToReference unlockFile: ${errorMessage(err)}`);
}
}
}