UNPKG

@phren/cli

Version:

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

823 lines (822 loc) • 36.9 kB
import * as fs from "fs"; import * as path from "path"; import { createHash } from "crypto"; import { getProjectDirs, runtimeDir, runtimeHealthFile, memoryUsageLogFile, homePath, } from "../shared.js"; import { getNonPrimaryStores, getStoreProjectDirs } from "../store-registry.js"; import { errorMessage } from "../utils.js"; import { readInstallPreferences } from "../init/preferences.js"; import { readCustomHooks } from "../hooks.js"; import { hookConfigPaths, hookConfigRoots } from "../provider-adapters.js"; import { readProjectConfig, isProjectHookEnabled, PROJECT_HOOK_EVENTS } from "../project-config.js"; import { getAllSkills } from "../skill/registry.js"; import { readTasks, TASKS_FILENAME } from "../data/tasks.js"; import { FINDINGS_FILENAME, readFindings } from "../data/access.js"; import { buildIndex, queryDocBySourceKey, queryRows } from "../shared/index.js"; import { readProjectTopics, classifyTopicForText } from "../project-topics.js"; import { entryScoreKey } from "../governance/scores.js"; import { findingStableId } from "../finding-graph-id.js"; import { readRecentLookups } from "../governance/activity.js"; import { logger } from "../logger.js"; import { projectMemoryCounts } from "../content/summarize.js"; /** `[tag] text` → `text`, lower-cased, for matching lifecycle snippets against node labels. */ function lifecycleKey(text) { return text.replace(/^\[[a-z_-]+\]\s+/i, "").trim().toLowerCase(); } /** * Resolve a 60-char lifecycle snippet to a finding node of the same project. * Either side may be the prefix of the other: the snippet is truncated, and * the node label may itself be shorter than 60 chars. */ function resolveLifecycleRef(snippet, candidates) { const key = lifecycleKey(snippet); if (!key) return undefined; for (const candidate of candidates) { if (candidate.key.startsWith(key) || key.startsWith(candidate.key)) return candidate.nodeId; } return undefined; } function extractGithubUrl(content) { const match = content.match(/https?:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+/); return match ? match[0] : undefined; } function stableId(scope, ...parts) { const hash = createHash("sha1"); for (const part of parts) hash.update(part); return `${scope}:${hash.digest("hex").slice(0, 12)}`; } function exactProjectMentions(text, projectSet, currentProject) { const tokenMatches = text.toLowerCase().match(/[a-z0-9_-]+/g) ?? []; const tokens = new Set(tokenMatches); const matches = []; for (const project of projectSet) { if (project === currentProject) continue; if (tokens.has(project.toLowerCase())) matches.push(project); } return matches; } function projectFromSourceDoc(sourceDoc) { const slash = sourceDoc.indexOf("/"); return slash > 0 ? sourceDoc.slice(0, slash) : ""; } export function readSyncSnapshot(phrenPath) { try { const runtimeHealth = runtimeHealthFile(phrenPath); if (!fs.existsSync(runtimeHealth)) return {}; const parsed = JSON.parse(fs.readFileSync(runtimeHealth, "utf8")); return { autoSaveStatus: parsed.lastAutoSave?.status || "", autoSaveDetail: parsed.lastAutoSave?.detail || "", lastPullAt: parsed.lastSync?.lastPullAt || "", lastPullStatus: parsed.lastSync?.lastPullStatus || "", lastPushAt: parsed.lastSync?.lastPushAt || "", lastPushStatus: parsed.lastSync?.lastPushStatus || "", unsyncedCommits: parsed.lastSync?.unsyncedCommits || 0, lastPushDetail: parsed.lastSync?.lastPushDetail || "", }; } catch { return {}; } } export function isAllowedFilePath(filePath, phrenPath) { const resolved = path.resolve(filePath); const allowedRoots = hookConfigRoots(phrenPath); if (!allowedRoots.some((root) => resolved === root || resolved.startsWith(root + path.sep))) { return false; } let existingAncestor = resolved; const pendingSegments = []; while (!fs.existsSync(existingAncestor)) { const parent = path.dirname(existingAncestor); if (parent === existingAncestor) break; pendingSegments.unshift(path.basename(existingAncestor)); existingAncestor = parent; } let realResolved; try { const realAncestor = fs.realpathSync(existingAncestor); realResolved = pendingSegments.length ? path.resolve(realAncestor, ...pendingSegments) : realAncestor; } catch { return false; } const allowedRealRoots = allowedRoots.map((root) => { try { return fs.realpathSync(root); } catch { return root; } }); return allowedRealRoots.some((root) => realResolved === root || realResolved.startsWith(root + path.sep)); } /** * Stricter path check for skill endpoints — only allows files under skills/ directories, * not the entire phren store. */ export function isAllowedSkillPath(filePath, phrenPath) { if (!isAllowedFilePath(filePath, phrenPath)) return false; const resolved = path.resolve(filePath); // Must be under a "skills" directory segment const segments = resolved.split(path.sep); return segments.some((seg) => seg === "skills"); } /** Allow only the four exact lifecycle-hook config files surfaced by the UI. */ export function isAllowedHookConfigPath(filePath, phrenPath) { if (!isAllowedFilePath(filePath, phrenPath)) return false; const resolved = path.resolve(filePath); return Object.values(hookConfigPaths(phrenPath)).some((configPath) => path.resolve(configPath) === resolved); } export function collectSkillsForUI(phrenPath, profile = "") { return getAllSkills(phrenPath, profile).map((skill) => ({ name: skill.name, source: skill.source, path: skill.path, enabled: skill.enabled, })); } export function getHooksData(phrenPath, profile) { const prefs = readInstallPreferences(phrenPath); const globalEnabled = prefs.hooksEnabled !== false; const toolPrefs = (prefs.hookTools && typeof prefs.hookTools === "object") ? prefs.hookTools : {}; const paths = hookConfigPaths(phrenPath); const tools = ["claude", "copilot", "cursor", "codex"].map((tool) => ({ tool, enabled: globalEnabled && toolPrefs[tool] !== false, configPath: paths[tool], exists: fs.existsSync(paths[tool]), })); // Collect per-project hook overrides const projectOverrides = []; const projects = getProjectDirs(phrenPath, profile) .map((dir) => path.basename(dir)) .filter((p) => p !== "global"); for (const project of projects) { const config = readProjectConfig(phrenPath, project); const hasOverrides = typeof config.hooks?.enabled === "boolean" || PROJECT_HOOK_EVENTS.some((ev) => typeof config.hooks?.[ev] === "boolean"); if (!hasOverrides) continue; projectOverrides.push({ project, baseEnabled: typeof config.hooks?.enabled === "boolean" ? config.hooks.enabled : null, events: PROJECT_HOOK_EVENTS.map((event) => ({ event, configured: typeof config.hooks?.[event] === "boolean" ? config.hooks[event] : null, enabled: isProjectHookEnabled(phrenPath, project, event, config), })), }); } return { globalEnabled, tools, customHooks: readCustomHooks(phrenPath), projectOverrides }; } export async function buildGraph(phrenPath, profile, focusProject, existingDb, opts = {}) { // Collect projects from primary store and all readable non-primary stores const storeProjects = []; const primaryProjects = getProjectDirs(phrenPath, profile) .map((projectDir) => path.basename(projectDir)) .filter((project) => project !== "global"); for (const project of primaryProjects) storeProjects.push({ storePath: phrenPath, project }); // Map store paths to store names const storePathToName = new Map(); storePathToName.set(phrenPath, "primary"); for (const store of getNonPrimaryStores(phrenPath)) { if (!fs.existsSync(store.path)) continue; storePathToName.set(store.path, store.name); try { const storeProjectDirs = getStoreProjectDirs(store) .map((projectDir) => path.basename(projectDir)) .filter((project) => project !== "global"); for (const project of storeProjectDirs) storeProjects.push({ storePath: store.path, project }); } catch { /* store not accessible — skip */ } } const projects = storeProjects.map((sp) => sp.project); const nodes = []; const links = []; const projectSet = new Set(projects); // Collect all unique topics across projects for the UI const topicMetaMap = new Map(); // Track which project nodes have already been pushed (same name may appear in multiple stores) const addedProjectNodeIds = new Set(); // Project nodes by name so per-project finding/task totals can be stamped after the scans const projectNodeByName = new Map(); // Finding ids are content hashes, so duplicate-text bullets collide — every // id-keyed map downstream (renderer, selection, removal) then conflates the // twins. Salt repeat occurrences deterministically (file order); the FIRST // occurrence keeps the canonical id so findingNodeIdForLine / lookup-event // targeting stays in lockstep. const usedFindingIds = new Map(); const uniqueFindingId = (id) => { const seen = usedFindingIds.get(id) || 0; usedFindingIds.set(id, seen + 1); return seen === 0 ? id : `${id}-${seen + 1}`; }; // Finding text → node id per project, for resolving lifecycle snippets. const findingKeysByProject = new Map(); const rememberFinding = (project, text, nodeId) => { if (!opts.includeLifecycleEdges) return; const list = findingKeysByProject.get(project) ?? []; list.push({ key: lifecycleKey(text), nodeId }); findingKeysByProject.set(project, list); }; for (const { storePath, project } of storeProjects) { // Load dynamic topics for this project const { topics: projectTopics } = readProjectTopics(storePath, project); for (const topic of projectTopics) { if (!topicMetaMap.has(topic.slug)) { topicMetaMap.set(topic.slug, { slug: topic.slug, label: topic.label }); } } const findingsPath = path.join(storePath, project, FINDINGS_FILENAME); const storeName = storePathToName.get(storePath) || "unknown"; if (!fs.existsSync(findingsPath)) { if (!addedProjectNodeIds.has(project)) { addedProjectNodeIds.add(project); const projectNode = { id: project, label: project, fullLabel: project, group: "project", refCount: 0, project, store: storeName, tagged: false, findingCount: 0, taskCount: 0, }; nodes.push(projectNode); projectNodeByName.set(project, projectNode); } continue; } if (!addedProjectNodeIds.has(project)) { addedProjectNodeIds.add(project); const projectNode = { id: project, label: project, fullLabel: project, group: "project", refCount: 1, project, store: storeName, tagged: false, findingCount: 0, taskCount: 0, }; nodes.push(projectNode); projectNodeByName.set(project, projectNode); } const content = fs.readFileSync(findingsPath, "utf8"); const lines = content.split("\n"); // No cap for focused project; high caps otherwise const isFocused = focusProject && project === focusProject; const MAX_TAGGED = isFocused ? Infinity : 200; const MAX_UNTAGGED = isFocused ? Infinity : 100; let taggedCount = 0; let untaggedAdded = 0; // Support heading-based findings: ## topic / ### title / paragraph let currentHeadingTag; let _currentHeadingTitle; // FINDINGS.md groups entries under `## YYYY-MM-DD` date headings let currentDate; for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) { const line = lines[lineIdx]; const dateMatch = line.match(/^##\s+(\d{4}-\d{2}-\d{2})\s*$/); if (dateMatch) { currentDate = dateMatch[1]; continue; } // Track heading context for heading-based findings const h2Match = line.match(/^##\s+([a-z_-]+)\s*$/i); if (h2Match) { currentHeadingTag = h2Match[1].toLowerCase(); _currentHeadingTitle = undefined; continue; } const h3Match = line.match(/^###\s+(.+)$/); if (h3Match && currentHeadingTag) { // Read the next non-empty line as the body let body = ""; for (let j = lineIdx + 1; j < lines.length; j++) { const next = lines[j].trim(); if (!next) continue; if (next.startsWith("#")) break; body = next; break; } const title = h3Match[1].trim(); const text = body ? `${title} — ${body}` : title; if (text.length >= 10) { if (taggedCount >= MAX_TAGGED) continue; const topic = classifyTopicForText(`[${currentHeadingTag}] ${text}`, projectTopics); const scoreKey = entryScoreKey(project, FINDINGS_FILENAME, `[${currentHeadingTag}] ${text}`); const nodeId = uniqueFindingId(findingStableId(scoreKey)); taggedCount++; nodes.push({ id: nodeId, label: text.length > 55 ? `${text.slice(0, 52)}...` : text, fullLabel: text, group: `topic:${topic.slug}`, refCount: taggedCount, project, store: storeName, tagged: true, scoreKey, scoreKeys: [scoreKey], refDocs: [{ doc: `${project}/FINDINGS.md`, project, scoreKey }], topicSlug: topic.slug, topicLabel: topic.label, date: currentDate, }); links.push({ source: project, target: nodeId }); rememberFinding(project, text, nodeId); for (const other of exactProjectMentions(text, projectSet, project)) { links.push({ source: project, target: other }); } } continue; } // Standard bullet-based findings: - [tag] text const tagMatch = line.match(/^-\s+\[([a-z_-]+)\]\s+(.+?)(?:\s*<!--.*-->)?$/); if (tagMatch) { if (taggedCount >= MAX_TAGGED) continue; const tag = tagMatch[1]; const text = tagMatch[2].trim(); const label = text.length > 55 ? `${text.slice(0, 52)}...` : text; // Classify the finding using the project's topic system const topic = classifyTopicForText(`[${tag}] ${text}`, projectTopics); const scoreKey = entryScoreKey(project, FINDINGS_FILENAME, `[${tag}] ${text}`); const nodeId = uniqueFindingId(findingStableId(scoreKey)); taggedCount++; nodes.push({ id: nodeId, label, fullLabel: text, group: `topic:${topic.slug}`, refCount: taggedCount, project, store: storeName, tagged: true, scoreKey, scoreKeys: [scoreKey], refDocs: [{ doc: `${project}/FINDINGS.md`, project, scoreKey }], topicSlug: topic.slug, topicLabel: topic.label, date: currentDate, }); links.push({ source: project, target: nodeId }); rememberFinding(project, text, nodeId); for (const other of exactProjectMentions(text, projectSet, project)) { links.push({ source: project, target: other }); } continue; } if (untaggedAdded >= MAX_UNTAGGED) continue; const plainMatch = line.match(/^-\s+(.+?)(?:\s*<!--.*-->)?$/); if (!plainMatch) continue; const text = plainMatch[1].trim(); if (text.length < 10) continue; const label = text.length > 55 ? `${text.slice(0, 52)}...` : text; // Classify using dynamic topics const topic = classifyTopicForText(text, projectTopics); const scoreKey = entryScoreKey(project, FINDINGS_FILENAME, text); const nodeId = uniqueFindingId(findingStableId(scoreKey)); untaggedAdded++; nodes.push({ id: nodeId, label, fullLabel: text, group: `topic:${topic.slug}`, refCount: untaggedAdded, project, store: storeName, tagged: false, scoreKey, scoreKeys: [scoreKey], refDocs: [{ doc: `${project}/FINDINGS.md`, project, scoreKey }], topicSlug: topic.slug, topicLabel: topic.label, date: currentDate, }); links.push({ source: project, target: nodeId }); rememberFinding(project, text, nodeId); } const projectNode = projectNodeByName.get(project); if (projectNode) { projectNode.findingCount = (projectNode.findingCount || 0) + taggedCount + untaggedAdded; } } // ── Lifecycle edges (opt-in) ───────────────────────────────────────── if (opts.includeLifecycleEdges) { for (const { storePath, project } of storeProjects) { const candidates = findingKeysByProject.get(project); if (!candidates?.length) continue; const result = readFindings(storePath, project); if (!result.ok) continue; for (const item of result.data) { if (!item.supersedes && !item.supersededBy && !item.contradicts?.length) continue; const self = resolveLifecycleRef(item.text, candidates); if (!self) continue; const edge = (snippet, kind, newerFirst) => { const other = resolveLifecycleRef(snippet, candidates); if (!other || other === self) return; links.push(newerFirst ? { source: self, target: other, kind } : { source: other, target: self, kind }); }; if (item.supersedes) edge(item.supersedes, "supersedes", true); if (item.supersededBy) edge(item.supersededBy, "supersedes", false); for (const snippet of item.contradicts ?? []) edge(snippet, "contradicts", true); } } } // ── Tasks ────────────────────────────────────────────────────────── try { for (const { storePath, project } of storeProjects) { const taskResult = readTasks(storePath, project); if (!taskResult.ok) continue; const taskStoreName = storePathToName.get(storePath) || "unknown"; const doc = taskResult.data; let taskCount = 0; const MAX_TASKS = 50; for (const section of ["Active", "Queue"]) { const group = section === "Active" ? "task-active" : "task-queue"; for (const item of doc.items[section]) { if (taskCount >= MAX_TASKS) break; const nodeId = `${project}:task:${item.id}`; const label = item.line.length > 55 ? `${item.line.slice(0, 52)}...` : item.line; const scoreKey = entryScoreKey(project, TASKS_FILENAME, item.line); nodes.push({ id: nodeId, label, fullLabel: item.line, group, project, store: taskStoreName, tagged: false, scoreKey, scoreKeys: [scoreKey], refDocs: [{ doc: `${project}/${TASKS_FILENAME}`, project, scoreKey }], refCount: 0, priority: item.priority, section: item.section, }); links.push({ source: project, target: nodeId }); taskCount++; } } const projectNode = projectNodeByName.get(project); if (projectNode) { projectNode.taskCount = (projectNode.taskCount || 0) + taskCount; } } } catch { // task loading failed — continue with other data sources } // ── Fragments (fragment graph) ────────────────────────────────────── const ownDb = !existingDb; let db = existingDb ?? null; try { if (!db) db = await buildIndex(phrenPath, profile); const rows = queryRows(db, `SELECT e.id, e.name, e.type, COUNT(DISTINCT el.source_doc) as ref_count FROM entities e JOIN entity_links el ON el.target_id = e.id WHERE e.type != 'document' GROUP BY e.id, e.name, e.type ORDER BY ref_count DESC LIMIT 5000`, []); const refRows = queryRows(db, `SELECT e.id, el.source_doc FROM entities e JOIN entity_links el ON el.target_id = e.id WHERE e.type != 'document'`, []); const refsByEntity = new Map(); const seenEntityDoc = new Set(); if (refRows) { for (const row of refRows) { const entityId = typeof row[0] === "number" ? row[0] : -1; if (entityId < 0) continue; const doc = String(row[1] ?? ""); if (!doc) continue; const entityDocKey = `${entityId}::${doc}`; if (seenEntityDoc.has(entityDocKey)) continue; seenEntityDoc.add(entityDocKey); const project = projectFromSourceDoc(doc); const docRow = queryDocBySourceKey(db, phrenPath, doc); const content = docRow?.content ?? ""; const filename = docRow?.filename ?? ""; const scoreKey = project && filename && content ? entryScoreKey(project, filename, content) : undefined; const refs = refsByEntity.get(entityId) ?? []; refs.push({ doc, project, scoreKey }); refsByEntity.set(entityId, refs); } } const entityNodeIds = new Map(); if (rows) { for (const row of rows) { const entityId = typeof row[0] === "number" ? row[0] : -1; if (entityId < 0) continue; const name = String(row[1] ?? ""); const type = String(row[2] ?? ""); const refCount = typeof row[3] === "number" ? row[3] : 0; const refs = (refsByEntity.get(entityId) ?? []).slice().sort((a, b) => a.doc.localeCompare(b.doc)); const scoreKeys = refs .map((ref) => ref.scoreKey) .filter((key) => Boolean(key)) .sort(); // Only include entities that link to at least one visible project const linkedProjects = new Set(); for (const ref of refs) { if (ref.project && projectSet.has(ref.project)) linkedProjects.add(ref.project); } if (linkedProjects.size === 0) continue; // skip orphan entities from other profiles/stores const nodeId = `entity:${stableId("entity", type, name)}`; nodes.push({ id: nodeId, label: name.length > 55 ? `${name.slice(0, 52)}...` : name, fullLabel: name, group: "entity", project: "", tagged: false, refCount, scoreKey: scoreKeys[0], scoreKeys, entityType: type, refDocs: refs, }); for (const proj of linkedProjects) { links.push({ source: nodeId, target: proj }); } entityNodeIds.set(entityId, nodeId); } } // ── Fragment co-occurrence edges (opt-in) ────────────────────────── // Two fragments mentioned by the same document are related; the more // documents they share, the stronger the tie. Derived from refsByEntity, // so no extra SQL. if (opts.includeFragmentEdges && entityNodeIds.size > 1) { const MAX_ENTITIES_PER_DOC = 40; const MAX_FRAGMENT_EDGES = 400; const entitiesByDoc = new Map(); for (const [entityId, nodeId] of entityNodeIds) { for (const ref of refsByEntity.get(entityId) ?? []) { const list = entitiesByDoc.get(ref.doc) ?? []; if (list.length < MAX_ENTITIES_PER_DOC && !list.includes(nodeId)) list.push(nodeId); entitiesByDoc.set(ref.doc, list); } } const pairCounts = new Map(); for (const members of entitiesByDoc.values()) { const sorted = members.slice().sort(); for (let i = 0; i < sorted.length; i++) { for (let j = i + 1; j < sorted.length; j++) { const key = `${sorted[i]}||${sorted[j]}`; const entry = pairCounts.get(key) ?? { a: sorted[i], b: sorted[j], count: 0 }; entry.count++; pairCounts.set(key, entry); } } } const ranked = Array.from(pairCounts.values()).sort((x, y) => y.count - x.count || x.a.localeCompare(y.a) || x.b.localeCompare(y.b)); // Prefer pairs that co-occur in several docs; on a small store fall back // to single co-mentions so the fragment layer still has structure. const strong = ranked.filter((pair) => pair.count >= 2); const chosen = (strong.length >= 20 ? strong : ranked).slice(0, MAX_FRAGMENT_EDGES); for (const pair of chosen) links.push({ source: pair.a, target: pair.b, kind: "fragment" }); } } catch { // fragment loading failed — continue with other data sources } finally { if (ownDb && db) { try { db.close(); } catch { /* already closed or failed — ignore */ } } } // ── Reference docs ──────────────────────────────────────────────── try { for (const { storePath, project } of storeProjects) { const refStoreName = storePathToName.get(storePath) || "unknown"; const refDir = path.join(storePath, project, "reference"); if (!fs.existsSync(refDir) || !fs.statSync(refDir).isDirectory()) continue; const files = fs.readdirSync(refDir); const MAX_REFS = 20; let refCount = 0; for (const file of files) { if (refCount >= MAX_REFS) break; const nodeId = `${project}:ref:${file}`; const docRef = `${project}/reference/${file}`; nodes.push({ id: nodeId, label: file.length > 55 ? `${file.slice(0, 52)}...` : file, fullLabel: file, group: "reference", project, store: refStoreName, tagged: false, scoreKeys: [], refDocs: [{ doc: docRef, project }], refCount: 0, }); links.push({ source: project, target: nodeId }); refCount++; } } } catch { // reference doc loading failed — continue } // ── Memory scores ──────────────────────────────────────────────── let scores = {}; try { const scoresPath = path.join(phrenPath, ".runtime", "memory-scores.json"); if (fs.existsSync(scoresPath)) { const parsed = JSON.parse(fs.readFileSync(scoresPath, "utf8")); if (parsed && typeof parsed.entries === "object") { scores = parsed.entries; } } } catch { // scores loading failed — return empty } const seen = new Set(); const dedupedLinks = links.filter((link) => { const key = `${[link.source, link.target].sort().join("||")}::${link.kind ?? ""}`; if (seen.has(key)) return false; seen.add(key); return true; }); // Remove orphan project nodes (0 edges) to avoid scattered floaters const connectedIds = new Set(); for (const link of dedupedLinks) { connectedIds.add(link.source); connectedIds.add(link.target); } const filteredNodes = nodes.filter((n) => n.group !== "project" || connectedIds.has(n.id)); // The counts beside a project are what it holds, not the slice drawn here: // live findings, journal findings, the whole archive, and every open task. for (const [project, projectNode] of projectNodeByName) { const counts = projectMemoryCounts(phrenPath, project); projectNode.findingCount = counts.findings; projectNode.taskCount = counts.openTasks; } const total = filteredNodes.length; const topics = Array.from(topicMetaMap.values()); return { nodes: filteredNodes, links: dedupedLinks, total, scores, topics }; } export function recentUsage(phrenPath) { const usage = memoryUsageLogFile(phrenPath); if (!fs.existsSync(usage)) return []; const lines = fs.readFileSync(usage, "utf8").trim().split("\n").filter(Boolean); return lines.slice(-40).reverse(); } export function recentLookups(phrenPath, limit = 40) { return readRecentLookups(phrenPath, limit); } export function recentAccepted(phrenPath) { const audit = path.join(runtimeDir(phrenPath), "audit.log"); if (!fs.existsSync(audit)) return []; const lines = fs.readFileSync(audit, "utf8").split("\n").filter((line) => line.includes("approve_memory")); return lines.slice(-40).reverse(); } function buildProjectInfo(basePath, project, store) { const dir = path.join(basePath, project); const findingsPath = path.join(dir, FINDINGS_FILENAME); const claudeMdPath = path.join(dir, "AGENTS.md"); const summaryPath = path.join(dir, "summary.md"); const refPath = path.join(dir, "reference"); const counts = projectMemoryCounts(basePath, project); const findingCount = counts.findings; const sparkline = new Array(8).fill(0); if (fs.existsSync(findingsPath)) { const now = Date.now(); const weekMs = 7 * 24 * 60 * 60 * 1000; const sparkContent = fs.readFileSync(findingsPath, "utf8"); const dateRe = /(?:created[_:]?\s*"?|created_at[":]+\s*)(\d{4}-\d{2}-\d{2})/g; let match; while ((match = dateRe.exec(sparkContent)) !== null) { const age = now - new Date(match[1]).getTime(); const weekIdx = Math.floor(age / weekMs); if (weekIdx >= 0 && weekIdx < 8) sparkline[7 - weekIdx]++; } } // Every open task (Active and Queue), the same number the graph shows. const taskCount = counts.openTasks; let summaryText = ""; if (fs.existsSync(summaryPath)) { summaryText = fs.readFileSync(summaryPath, "utf8").trim(); if (summaryText.length > 300) summaryText = `${summaryText.slice(0, 300)}...`; } let githubUrl; if (fs.existsSync(claudeMdPath)) { githubUrl = extractGithubUrl(fs.readFileSync(claudeMdPath, "utf8")); } if (!githubUrl && fs.existsSync(summaryPath)) { githubUrl = extractGithubUrl(fs.readFileSync(summaryPath, "utf8")); } return { name: project, storePath: basePath, store, findingCount, taskCount, hasClaudeMd: fs.existsSync(claudeMdPath), hasSummary: fs.existsSync(summaryPath), hasReference: fs.existsSync(refPath) && fs.statSync(refPath).isDirectory(), summaryText, githubUrl, sparkline, }; } export function collectProjectsForUI(phrenPath, profile) { const projects = getProjectDirs(phrenPath, profile).map((projectDir) => path.basename(projectDir)).filter((project) => project !== "global"); let allowedProjects = null; try { const contextPath = homePath(".phren-context.md"); if (fs.existsSync(contextPath)) { const contextContent = fs.readFileSync(contextPath, "utf8"); const activeMatch = contextContent.match(/Active projects?:\s*(.+)/i); if (activeMatch) { const names = activeMatch[1].split(/[,;]/).map((name) => name.trim().toLowerCase()).filter(Boolean); if (names.length) allowedProjects = new Set(names); } } } catch (err) { logger.debug("memory-ui", `memory-ui filterByProfile: ${errorMessage(err)}`); } const results = []; const seen = new Set(); for (const project of projects) { if (allowedProjects && !allowedProjects.has(project.toLowerCase())) continue; seen.add(project); results.push(buildProjectInfo(phrenPath, project)); } // Include projects from non-primary stores try { const teamStores = getNonPrimaryStores(phrenPath); for (const store of teamStores) { if (!fs.existsSync(store.path)) continue; const teamProjects = getStoreProjectDirs(store).map((d) => path.basename(d)).filter((p) => p !== "global"); for (const project of teamProjects) { if (seen.has(project)) continue; // skip if same name exists in primary seen.add(project); results.push(buildProjectInfo(store.path, project, store.name)); } } } catch (err) { logger.debug("memory-ui", `collectProjectsForUI team stores: ${errorMessage(err)}`); } return results.sort((a, b) => (b.findingCount + b.taskCount) - (a.findingCount + a.taskCount)); }