@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
1,491 lines • 72.7 kB
JavaScript
import { taskView } from "../data/task-contract.js";
import { nonInteractiveGitEnv } from "../utils-helpers.js";
import { moduleSnapshot } from "../modules/runtime.js";
import { disabledHint } from "../modules/registry.js";
import * as http from "http";
import * as crypto from "crypto";
import { timingSafeEqual } from "crypto";
import * as fs from "fs";
import * as path from "path";
import * as querystring from "querystring";
import { spawn, execFileSync } from "child_process";
import { computePhrenLiveStateToken, getProjectDirs, } from "../shared.js";
import { getNonPrimaryStores, subscribeStoreProjects, unsubscribeStoreProjects } from "../store-registry.js";
import { editFinding, editQueueItem, approveQueueItem, readReviewQueue, rejectQueueItem, removeFinding, readFindings, addFinding as addFindingStore, readTasksAcrossProjects, addTask as addTaskStore, completeTask as completeTaskStore, removeTask as removeTaskStore, updateTask as updateTaskStore, TASKS_FILENAME, FINDINGS_FILENAME, } from "../data/access.js";
import { entryScoreKey } from "../governance/scores.js";
import { isValidProjectName, errorMessage, safeProjectPath } from "../utils.js";
import { readInstallPreferences, writeInstallPreferences, writeGovernanceInstallPreferences } from "../init/preferences.js";
import { buildGraph, collectProjectsForUI, collectSkillsForUI, getHooksData, isAllowedHookConfigPath, isAllowedSkillPath, readSyncSnapshot, recentAccepted, recentLookups, recentUsage, } from "./data.js";
import { lookupEventsLogFile } from "../shared.js";
import { LookupTail } from "../shared/lookup-tail.js";
import { CONSOLIDATION_ENTRY_THRESHOLD } from "../content/validate.js";
import { ensureTopicReferenceDoc, getProjectTopicsResponse, listProjectReferenceDocs, pinProjectTopicSuggestion, readReferenceContent, reclassifyLegacyTopicDocs, unpinProjectTopicSuggestion, writeProjectTopics, } from "../project-topics.js";
import { getWorkflowPolicy, updateWorkflowPolicy, updateRetentionPolicy, mergeConfig, getRetentionPolicy, getProjectConfigOverrides, getIndexPolicy, updateIndexPolicy, VALID_TASK_MODES } from "../governance/policy.js";
import { listProfiles, setMachineProfile, getDefaultMachineAlias } from "../profile-store.js";
import { setAccessRoles, ACCESS_ROLE_KEYS, permissionDeniedError } from "../governance/rbac.js";
import { readProjectConfig, updateProjectConfigOverrides } from "../project-config.js";
import { buildConfigView } from "../config/resolve.js";
import { CONFIG_DOMAINS } from "../config/schema.js";
import { findSkill } from "../skill/registry.js";
import { setSkillEnabledAndSync } from "../skill/files.js";
import { repairPreexistingInstall } from "../init/setup.js";
import { logger } from "../logger.js";
import { addNote, editNote, listNotes, removeNote } from "../data/notes.js";
import { promoteNote } from "../core/note.js";
import { FINDING_TYPES } from "../shared.js";
import { storeCommitMessage } from "../machine-identity.js";
const CSRF_TOKEN_TTL_MS = 15 * 60 * 1000;
const MAX_FORM_BODY_BYTES = 1_048_576;
const WEB_UI_READY_ATTEMPTS = 12;
const WEB_UI_READY_DELAY_MS = 75;
const WEB_UI_PORT_RETRY_ATTEMPTS = 3;
export function getWebUiBrowserCommand(url, platform = process.platform) {
if (platform === "darwin")
return { command: "open", args: [url] };
if (platform === "win32")
return { command: process.env.ComSpec || "cmd.exe", args: ["/c", "start", "", url] };
return { command: "xdg-open", args: [url] };
}
async function launchWebUiBrowser(url) {
const { command, args } = getWebUiBrowserCommand(url);
await new Promise((resolve, reject) => {
try {
const child = spawn(command, args, { detached: true, stdio: "ignore" });
child.once("error", reject);
child.once("spawn", () => {
child.removeListener("error", reject);
child.unref();
resolve();
});
}
catch (err) {
reject(err);
}
});
}
export async function waitForWebUiReady(url, attempts = WEB_UI_READY_ATTEMPTS, delayMs = WEB_UI_READY_DELAY_MS) {
for (let attempt = 0; attempt < attempts; attempt++) {
const ready = await new Promise((resolve) => {
const req = http.get(url, (res) => {
res.resume();
resolve(true);
});
req.setTimeout(1000, () => {
req.destroy();
resolve(false);
});
req.on("error", () => resolve(false));
});
if (ready)
return true;
if (attempt < attempts - 1) {
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
return false;
}
function isAddressInUse(err) {
return Boolean(err && typeof err === "object" && "code" in err && err.code === "EADDRINUSE");
}
async function listenOnLoopback(server, port) {
return await new Promise((resolve, reject) => {
const onError = (err) => {
server.off("listening", onListening);
reject(err);
};
const onListening = () => {
server.off("error", onError);
const address = server.address();
if (!address || typeof address === "string") {
reject(new Error("failed to determine web-ui port"));
return;
}
resolve(address.port);
};
server.once("error", onError);
server.once("listening", onListening);
server.listen(port, "127.0.0.1");
});
}
async function bindWebUiPort(server, requestedPort, allowPortFallback) {
const candidates = [requestedPort];
if (allowPortFallback && requestedPort > 0) {
for (let i = 1; i <= WEB_UI_PORT_RETRY_ATTEMPTS; i++) {
candidates.push(requestedPort + i);
}
}
let lastError = null;
for (let i = 0; i < candidates.length; i++) {
const candidate = candidates[i];
try {
if (candidate !== requestedPort) {
logger.info("web-ui", `port ${candidate - 1} is busy, retrying on ${candidate}`);
}
return await listenOnLoopback(server, candidate);
}
catch (err) {
lastError = err;
if (!allowPortFallback || !isAddressInUse(err) || i === candidates.length - 1)
break;
}
}
throw lastError instanceof Error ? lastError : new Error("failed to bind web-ui server");
}
function pruneExpiredCsrfTokens(csrfTokens) {
if (!csrfTokens)
return;
const now = Date.now();
for (const [token, createdAt] of csrfTokens) {
if (now - createdAt > CSRF_TOKEN_TTL_MS)
csrfTokens.delete(token);
}
}
function setCommonHeaders(res, nonce) {
res.setHeader("Referrer-Policy", "no-referrer");
if (nonce) {
// Page responses: allow nonce-gated inline scripts but disallow inline event handlers
res.setHeader("Content-Security-Policy", `default-src 'self'; script-src 'self' 'nonce-${nonce}' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.bunny.net; font-src https://fonts.bunny.net; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'`);
}
else {
// API responses: no inline scripts needed
res.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'");
}
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("X-Frame-Options", "DENY");
}
function getSubmittedAuthToken(req, url, parsedBody) {
const authHeader = req.headers.authorization;
if (typeof authHeader === "string") {
const bearerMatch = authHeader.match(/^Bearer\s+(.+)$/i);
if (bearerMatch)
return bearerMatch[1];
}
const query = url.includes("?") ? querystring.parse(url.slice(url.indexOf("?") + 1)) : {};
const queryAuth = query._auth;
if (typeof queryAuth === "string")
return queryAuth;
const bodyAuth = parsedBody?._auth;
if (typeof bodyAuth === "string")
return bodyAuth;
return "";
}
function authTokensMatch(submitted, authToken) {
if (!authToken || !submitted)
return false;
const submittedBuffer = Buffer.from(submitted);
const authTokenBuffer = Buffer.from(authToken);
if (submittedBuffer.length !== authTokenBuffer.length)
return false;
return timingSafeEqual(submittedBuffer, authTokenBuffer);
}
function rejectUnauthorized(res, json = false) {
if (json) {
res.writeHead(401, { "content-type": "application/json" });
res.end(JSON.stringify({ ok: false, error: "Unauthorized" }));
return;
}
res.writeHead(401, { "content-type": "text/plain; charset=utf-8" });
res.end("Unauthorized");
}
function requireGetAuth(req, res, url, authToken, json = false) {
if (!authToken)
return true;
const submitted = getSubmittedAuthToken(req, url);
if (authTokensMatch(submitted, authToken))
return true;
rejectUnauthorized(res, json);
return false;
}
function readFormBody(req, res) {
const contentLength = parseInt(req.headers["content-length"] || "0", 10);
if (contentLength > MAX_FORM_BODY_BYTES) {
res.writeHead(413, { "content-type": "text/plain" });
res.end("Request body too large");
return Promise.resolve(null);
}
return new Promise((resolve) => {
let body = "";
let received = 0;
req.on("data", (chunk) => {
received += chunk.length;
if (received > MAX_FORM_BODY_BYTES) {
res.writeHead(413, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: "Request body too large" }));
req.destroy();
resolve(null);
return;
}
body += String(chunk);
});
req.on("end", () => resolve(querystring.parse(body)));
req.on("error", () => resolve(null));
req.on("close", () => {
if (received > MAX_FORM_BODY_BYTES)
resolve(null);
});
});
}
function requirePostAuth(req, res, url, parsed, authToken, json = false) {
if (!authToken)
return true;
const submitted = getSubmittedAuthToken(req, url, parsed);
if (authTokensMatch(submitted, authToken))
return true;
rejectUnauthorized(res, json);
return false;
}
function requireCsrf(res, parsed, csrfTokens, json = false) {
if (!csrfTokens)
return true;
pruneExpiredCsrfTokens(csrfTokens);
const submitted = String(parsed._csrf || "");
if (submitted && csrfTokens.delete(submitted))
return true;
if (json) {
res.writeHead(403, { "content-type": "application/json" });
res.end(JSON.stringify({ ok: false, error: "Invalid or missing CSRF token" }));
return false;
}
res.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
res.end("Invalid or missing CSRF token");
return false;
}
function readProjectQueue(phrenPath, profile) {
const projects = getProjectDirs(phrenPath, profile).map((projectDir) => path.basename(projectDir)).filter((project) => project !== "global");
const items = [];
for (const project of projects) {
const queueResult = readReviewQueue(phrenPath, project);
const queueItems = queueResult.ok ? queueResult.data : [];
for (const item of queueItems) {
items.push({
project,
section: item.section,
line: item.line,
text: item.text,
date: item.date,
machine: item.machine || undefined,
model: item.model || undefined,
});
}
}
return items;
}
function parseTopicsPayload(raw) {
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed))
return null;
return parsed.map((topic) => ({
slug: String(topic?.slug || ""),
label: String(topic?.label || ""),
description: String(topic?.description || ""),
keywords: Array.isArray(topic?.keywords) ? topic.keywords.map((keyword) => String(keyword)) : [],
}));
}
catch {
return null;
}
}
function parseTopicPayload(raw) {
try {
const topic = JSON.parse(raw);
if (!topic || typeof topic !== "object")
return null;
return {
slug: String(topic.slug || ""),
label: String(topic.label || ""),
description: String(topic.description || ""),
keywords: Array.isArray(topic.keywords)
? topic.keywords.map((keyword) => String(keyword))
: [],
};
}
catch {
return null;
}
}
function parseQs(url) {
return url.includes("?") ? querystring.parse(url.slice(url.indexOf("?") + 1)) : {};
}
function jsonOk(res, data, status = 200) {
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
res.end(JSON.stringify(data));
}
function jsonErr(res, error, status = 200) {
res.writeHead(status, { "content-type": "application/json" });
res.end(JSON.stringify({ ok: false, error }));
}
function handleRequestError(res, err) {
if (res.destroyed || res.writableEnded)
return;
if (res.headersSent) {
res.destroy();
return;
}
if (err instanceof URIError) {
jsonErr(res, "Invalid URL encoding", 400);
}
else {
logger.debug("web-ui", `request failed: ${errorMessage(err)}`);
jsonErr(res, "Internal server error", 500);
}
}
function withPostBody(req, res, url, ctx, handler) {
void readFormBody(req, res).then((parsed) => {
if (!parsed)
return;
if (!requirePostAuth(req, res, url, parsed, ctx.authToken, true))
return;
if (!requireCsrf(res, parsed, ctx.csrfTokens, true))
return;
handler(parsed);
}).catch((err) => handleRequestError(res, err));
}
// ── GET handlers ──────────────────────────────────────────────────────────────
function handleGetHome(res, ctx) {
const nonce = crypto.randomBytes(16).toString("base64");
setCommonHeaders(res, nonce);
const html = ctx.renderPage(ctx.phrenPath, ctx.authToken, nonce);
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
res.end(html);
}
/** Returns the store base path that contains the given project (primary or team store). */
function resolveProjectBasePath(phrenPath, project) {
const primaryDir = path.join(phrenPath, project);
if (fs.existsSync(primaryDir))
return phrenPath;
try {
for (const store of getNonPrimaryStores(phrenPath)) {
if (fs.existsSync(path.join(store.path, project)))
return store.path;
}
}
catch { /* fall through */ }
return phrenPath;
}
function handleGetProjects(res, ctx) {
jsonOk(res, collectProjectsForUI(ctx.phrenPath, ctx.profile));
}
function handleGetChangeToken(res, ctx) {
jsonOk(res, { token: computePhrenLiveStateToken(ctx.phrenPath) });
}
function handleGetStores(res, ctx) {
try {
const stores = getNonPrimaryStores(ctx.phrenPath);
const storeData = stores.map((store) => {
const availableProjects = getProjectDirs(store.path)
.map((dir) => path.basename(dir))
.filter((p) => p !== "global");
const subscribedProjects = store.projects || [];
return {
name: store.name,
role: store.role,
path: store.path,
availableProjects,
subscribedProjects,
};
});
jsonOk(res, { ok: true, stores: storeData });
}
catch (err) {
jsonErr(res, errorMessage(err), 500);
}
}
function handleGetRuntimeHealth(res, ctx) {
jsonOk(res, readSyncSnapshot(ctx.phrenPath));
}
function handleGetReviewQueue(res, ctx) {
jsonOk(res, readProjectQueue(ctx.phrenPath, ctx.profile));
}
function handleGetReviewActivity(res, ctx) {
jsonOk(res, { accepted: recentAccepted(ctx.phrenPath), usage: recentUsage(ctx.phrenPath) });
}
/** Initial snapshot of recent memory lookups (newest first) for the Activity feed. */
function handleGetLookups(res, ctx) {
jsonOk(res, { ok: true, lookups: recentLookups(ctx.phrenPath, 40) });
}
/**
* Server-Sent Events stream of memory lookups. The server tails
* .runtime/lookup-events.jsonl and pushes each newly appended event to the
* browser, so the Activity tab lights up in real time as phren lands on
* memories. A short poll loop reads only appended bytes (robust across
* platforms vs. fs.watch); delivery to the client is push-based via SSE.
*/
function handleLookupStream(req, res, ctx) {
res.writeHead(200, {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
});
res.write("retry: 3000\n\n");
// Only stream events appended after the client connects; the initial
// snapshot comes from /api/lookups.
const tail = new LookupTail(lookupEventsLogFile(ctx.phrenPath));
const flushNew = () => {
for (const line of tail.pollLines())
res.write(`data: ${line}\n\n`);
};
const pollTimer = setInterval(flushNew, 1000);
const heartbeat = setInterval(() => {
try {
res.write(": ping\n\n");
}
catch { /* connection gone */ }
}, 25000);
const cleanup = () => {
clearInterval(pollTimer);
clearInterval(heartbeat);
try {
res.end();
}
catch { /* already closed */ }
};
req.on("close", cleanup);
req.on("error", cleanup);
}
function handleGetProjectContent(res, url, ctx) {
const qs = parseQs(url);
const project = String(qs.project || "");
const file = String(qs.file || "");
if (!project || !isValidProjectName(project) || !file)
return jsonErr(res, "Invalid project or file", 400);
const allowedFiles = [FINDINGS_FILENAME, TASKS_FILENAME, "AGENTS.md", "summary.md"];
if (!allowedFiles.includes(file))
return jsonErr(res, `File not allowed: ${file}`, 400);
const basePath = resolveProjectBasePath(ctx.phrenPath, project);
const filePath = safeProjectPath(basePath, project, file);
if (!filePath)
return jsonErr(res, "Invalid project or file path", 400);
if (!fs.existsSync(filePath))
return jsonErr(res, `File not found: ${file}`);
jsonOk(res, { ok: true, content: fs.readFileSync(filePath, "utf8") });
}
function handleGetProjectTopics(res, url, ctx) {
const project = String(parseQs(url).project || "");
if (!project || !isValidProjectName(project))
return jsonErr(res, "Invalid project", 400);
const basePath = resolveProjectBasePath(ctx.phrenPath, project);
jsonOk(res, { ok: true, ...getProjectTopicsResponse(basePath, project) });
}
function handleGetProjectReferenceList(res, url, ctx) {
const project = String(parseQs(url).project || "");
if (!project || !isValidProjectName(project))
return jsonErr(res, "Invalid project", 400);
const basePath = resolveProjectBasePath(ctx.phrenPath, project);
jsonOk(res, { ok: true, ...listProjectReferenceDocs(basePath, project) });
}
function handleGetProjectReferenceContent(res, url, ctx) {
const qs = parseQs(url);
const project = String(qs.project || "");
const basePath = resolveProjectBasePath(ctx.phrenPath, project);
const contentResult = readReferenceContent(basePath, project, String(qs.file || ""));
res.writeHead(contentResult.ok ? 200 : 400, { "content-type": "application/json; charset=utf-8" });
res.end(JSON.stringify(contentResult.ok ? { ok: true, content: contentResult.content } : { ok: false, error: contentResult.error }));
}
function handleGetSkills(res, ctx) {
jsonOk(res, collectSkillsForUI(ctx.phrenPath, ctx.profile));
}
function handleGetSkillContent(res, url, ctx) {
const filePath = String(parseQs(url).path || "");
if (!filePath || (!isAllowedSkillPath(filePath, ctx.phrenPath) && !isAllowedHookConfigPath(filePath, ctx.phrenPath))) {
return jsonErr(res, "Invalid path", 400);
}
if (!fs.existsSync(filePath))
return jsonErr(res, "File not found");
jsonOk(res, { ok: true, content: fs.readFileSync(filePath, "utf8") });
}
function handleGetHooks(res, ctx) {
jsonOk(res, getHooksData(ctx.phrenPath));
}
async function handleGetSearch(res, url, ctx) {
const searchParams = new URLSearchParams(url.includes("?") ? url.slice(url.indexOf("?") + 1) : "");
const query = searchParams.get("q") || searchParams.get("query") || "";
const searchProject = searchParams.get("project") || undefined;
const searchType = searchParams.get("type") || undefined;
const searchLimit = parseInt(searchParams.get("limit") || "10", 10) || 10;
if (!query.trim())
return jsonErr(res, "Missing query parameter (q or query).");
try {
const { runSearch } = await import("../cli/search.js");
const result = await runSearch({ query, limit: Math.min(searchLimit, 50), project: searchProject, type: searchType }, ctx.phrenPath, ctx.profile || "");
const fileDates = {};
for (const line of result.lines) {
const srcMatch = line.match(/^\[([^\]]+)\]\s/);
if (srcMatch) {
const sourceKey = srcMatch[1];
if (fileDates[sourceKey])
continue;
const slashIdx = sourceKey.indexOf("/");
if (slashIdx > 0) {
try {
const filePath = path.join(ctx.phrenPath, sourceKey.slice(0, slashIdx), sourceKey.slice(slashIdx + 1));
if (fs.existsSync(filePath))
fileDates[sourceKey] = fs.statSync(filePath).mtime.toISOString();
}
catch { /* skip */ }
}
}
}
jsonOk(res, { ok: true, query, results: result.lines, fileDates });
}
catch (err) {
jsonErr(res, errorMessage(err));
}
}
async function handleGetGraph(res, url, ctx) {
const graphParams = new URLSearchParams(url.includes("?") ? url.slice(url.indexOf("?") + 1) : "");
jsonOk(res, await buildGraph(ctx.phrenPath, ctx.profile, graphParams.get("project") || undefined));
}
function handleGetScores(res, ctx) {
let scores = {};
try {
const raw = fs.readFileSync(path.join(ctx.phrenPath, ".runtime", "memory-scores.json"), "utf-8");
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object")
scores = parsed;
}
catch { /* file missing or unparseable */ }
jsonOk(res, scores);
}
function handleGetTasks(res, ctx) {
try {
const docs = readTasksAcrossProjects(ctx.phrenPath, ctx.profile);
const tasks = [];
for (const doc of docs) {
for (const section of ["Active", "Queue", "Done"]) {
for (const item of doc.items[section]) {
tasks.push({
...taskView(ctx.phrenPath, doc, item),
project: doc.project, section: item.section, line: item.line, priority: item.priority,
pinned: item.pinned, githubIssue: item.githubIssue, githubUrl: item.githubUrl,
context: item.context, checked: item.checked, sessionId: item.sessionId,
});
}
}
}
jsonOk(res, { ok: true, tasks });
}
catch (err) {
jsonOk(res, { ok: false, error: errorMessage(err), tasks: [] });
}
}
function handleGetSettings(res, url, ctx) {
try {
const prefs = readInstallPreferences(ctx.phrenPath);
const workflowPolicy = getWorkflowPolicy(ctx.phrenPath);
const retentionPolicy = getRetentionPolicy(ctx.phrenPath);
const hooksData = getHooksData(ctx.phrenPath);
const proactivityFindings = prefs.proactivityFindings || prefs.proactivity || "high";
const settingsProject = String(parseQs(url).project || "");
const merged = settingsProject && isValidProjectName(settingsProject) ? mergeConfig(ctx.phrenPath, settingsProject) : null;
const overrides = settingsProject && isValidProjectName(settingsProject) ? getProjectConfigOverrides(ctx.phrenPath, settingsProject) : null;
let projectInfo = null;
if (settingsProject && isValidProjectName(settingsProject)) {
const projectDir = path.join(ctx.phrenPath, settingsProject);
const configFile = path.join(projectDir, "phren.project.yaml");
const projConfig = readProjectConfig(ctx.phrenPath, settingsProject);
const findingsPath = path.join(projectDir, FINDINGS_FILENAME);
const taskPath = path.join(projectDir, "tasks.md");
let findingCount = 0;
if (fs.existsSync(findingsPath))
findingCount = (fs.readFileSync(findingsPath, "utf8").match(/^- /gm) || []).length;
let taskCount = 0;
if (fs.existsSync(taskPath)) {
const queueMatch = fs.readFileSync(taskPath, "utf8").match(/## Queue[\s\S]*?(?=## |$)/);
if (queueMatch)
taskCount = (queueMatch[0].match(/^- /gm) || []).length;
}
projectInfo = {
diskPath: projConfig.sourcePath || projectDir, ownership: projConfig.ownership || "default",
configFile, configExists: fs.existsSync(configFile), hasFindings: fs.existsSync(findingsPath),
hasTasks: fs.existsSync(taskPath), hasSummary: fs.existsSync(path.join(projectDir, "summary.md")),
hasClaudeMd: fs.existsSync(path.join(projectDir, "AGENTS.md")), findingCount, taskCount,
};
}
const indexPolicy = getIndexPolicy(ctx.phrenPath);
const view = buildConfigView(ctx.phrenPath, settingsProject && isValidProjectName(settingsProject) ? settingsProject : undefined);
jsonOk(res, {
ok: true, proactivity: prefs.proactivity || "high", proactivityFindings,
proactivityTask: prefs.proactivityTask || prefs.proactivity || "high", taskMode: workflowPolicy.taskMode,
findingSensitivity: workflowPolicy.findingSensitivity || "balanced", autoCaptureEnabled: proactivityFindings !== "low",
consolidationEntryThreshold: CONSOLIDATION_ENTRY_THRESHOLD, hooksEnabled: hooksData.globalEnabled,
mcpEnabled: prefs.mcpEnabled !== false, hookTools: hooksData.tools,
retentionPolicy, workflowPolicy, indexPolicy, merged, overrides, projectInfo, view,
});
}
catch (err) {
jsonErr(res, errorMessage(err));
}
}
function handleGetConfig(res, url, ctx) {
const project = String(parseQs(url).project || "");
if (project && !isValidProjectName(project))
return jsonErr(res, "Invalid project name", 400);
try {
const config = mergeConfig(ctx.phrenPath, project || undefined);
const projects = getProjectDirs(ctx.phrenPath, ctx.profile).map((d) => path.basename(d)).filter((p) => p !== "global");
const view = buildConfigView(ctx.phrenPath, project || undefined);
jsonOk(res, { ok: true, config, projects, view });
}
catch (err) {
jsonErr(res, errorMessage(err));
}
}
/**
* Resolved config view plus the shared schema — everything the settings UI
* needs to render every domain with a consistent source chip, in one call.
*/
function handleGetConfigView(res, url, ctx) {
const project = String(parseQs(url).project || "");
if (project && !isValidProjectName(project))
return jsonErr(res, "Invalid project name", 400);
try {
const view = buildConfigView(ctx.phrenPath, project || undefined);
jsonOk(res, { ok: true, view, schema: CONFIG_DOMAINS });
}
catch (err) {
jsonErr(res, errorMessage(err));
}
}
function handleGetCsrfToken(res, ctx) {
if (!ctx.csrfTokens)
return jsonOk(res, { ok: true, token: null });
pruneExpiredCsrfTokens(ctx.csrfTokens);
const token = crypto.randomUUID();
ctx.csrfTokens.set(token, Date.now());
jsonOk(res, { ok: true, token });
}
function handleGetFindings(res, pathname, ctx) {
const project = decodeURIComponent(pathname.slice("/api/findings/".length));
if (!project || !isValidProjectName(project))
return jsonErr(res, "Invalid project name", 400);
const basePath = resolveProjectBasePath(ctx.phrenPath, project);
const result = readFindings(basePath, project);
jsonOk(res, result.ok ? { ok: true, data: { project, findings: result.data } } : { ok: false, error: result.error });
}
function handleGetNotes(res, url, pathname, ctx) {
const project = decodeURIComponent(pathname.slice("/api/notes/".length));
if (!project || !isValidProjectName(project))
return jsonErr(res, "Invalid project name", 400);
const date = String(parseQs(url).date || "") || undefined;
const basePath = resolveProjectBasePath(ctx.phrenPath, project);
const result = listNotes(basePath, project, { date, limit: 500 });
jsonOk(res, result.ok
? { ok: true, data: { project, notes: result.data } }
: { ok: false, error: result.error, errorCode: result.code });
}
// ── POST handlers ─────────────────────────────────────────────────────────────
function handlePostSync(req, res, url, ctx) {
withPostBody(req, res, url, ctx, (parsed) => {
const message = String(parsed.message || "update phren");
try {
const EXEC_TIMEOUT = 15_000;
const runGit = (args) => execFileSync("git", args, { env: nonInteractiveGitEnv(), cwd: ctx.phrenPath, encoding: "utf8", timeout: EXEC_TIMEOUT }).trim();
const status = runGit(["status", "--porcelain"]);
if (!status)
return jsonOk(res, { ok: true, message: "Nothing to sync — working tree clean." });
// Stage each extension separately: `git add` exits 128 as soon as ONE
// pathspec matches nothing, and a normal store has no tracked .yml,
// .jsonl or .txt (.runtime/*.jsonl is gitignored, and gitignored files do
// not satisfy a pathspec). One combined add therefore failed on every
// non-clean store and the user saw a raw git fatal instead of a sync.
for (const spec of ["*.md", "*.json", "*.yaml", "*.yml", "*.jsonl", "*.txt"]) {
try {
execFileSync("git", ["add", "--", spec], {
env: nonInteractiveGitEnv(),
cwd: ctx.phrenPath,
encoding: "utf8",
timeout: EXEC_TIMEOUT,
// A non-matching pathspec is expected here; don't print git's fatal.
stdio: ["ignore", "ignore", "ignore"],
});
}
catch { /* no file with this extension */ }
}
const stagedFiles = runGit(["diff", "--cached", "--name-only"]);
if (!stagedFiles)
return jsonOk(res, { ok: true, message: "Nothing to sync — no matching files to commit." });
runGit(["commit", "-m", storeCommitMessage(message), "--only", "--", ...stagedFiles.split("\n").filter(Boolean)]);
let pushed = false;
try {
if (runGit(["remote"])) {
runGit(["push"]);
pushed = true;
}
}
catch { /* no remote or push failed */ }
const changedFiles = status.split("\n").filter(Boolean).length;
jsonOk(res, { ok: true, message: `Synced ${changedFiles} file(s).${pushed ? " Pushed to remote." : " No remote, saved locally."}` });
}
catch (err) {
jsonErr(res, errorMessage(err));
}
});
}
function handlePostApprove(req, res, url, ctx) {
withPostBody(req, res, url, ctx, (parsed) => {
const project = String(parsed.project || "");
const line = String(parsed.line || "");
if (!project || !isValidProjectName(project) || !line)
return jsonErr(res, "Missing project or line");
try {
const result = approveQueueItem(ctx.phrenPath, project, line);
jsonOk(res, { ok: result.ok, error: result.ok ? undefined : result.error });
}
catch (err) {
jsonErr(res, errorMessage(err));
}
});
}
function handlePostReject(req, res, url, ctx) {
withPostBody(req, res, url, ctx, (parsed) => {
const project = String(parsed.project || "");
const line = String(parsed.line || "");
if (!project || !isValidProjectName(project) || !line)
return jsonErr(res, "Missing project or line");
try {
const result = rejectQueueItem(ctx.phrenPath, project, line);
jsonOk(res, { ok: result.ok, error: result.ok ? undefined : result.error });
}
catch (err) {
jsonErr(res, errorMessage(err));
}
});
}
function handlePostEdit(req, res, url, ctx) {
withPostBody(req, res, url, ctx, (parsed) => {
const project = String(parsed.project || "");
const line = String(parsed.line || "");
const newText = String(parsed.new_text || "");
if (!project || !isValidProjectName(project) || !line || !newText)
return jsonErr(res, "Missing project, line, or new_text");
try {
const result = editQueueItem(ctx.phrenPath, project, line, newText);
jsonOk(res, { ok: result.ok, error: result.ok ? undefined : result.error });
}
catch (err) {
jsonErr(res, errorMessage(err));
}
});
}
function handlePostSkillSave(req, res, url, ctx) {
withPostBody(req, res, url, ctx, (parsed) => {
const filePath = String(parsed.path || "");
const content = String(parsed.content || "");
if (!filePath || (!isAllowedSkillPath(filePath, ctx.phrenPath) && !isAllowedHookConfigPath(filePath, ctx.phrenPath))) {
return jsonErr(res, "Invalid path");
}
try {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const tmpPath = `${filePath}.tmp-${crypto.randomUUID()}`;
fs.writeFileSync(tmpPath, content);
fs.renameSync(tmpPath, filePath);
jsonOk(res, { ok: true });
}
catch (err) {
jsonErr(res, errorMessage(err));
}
});
}
function handlePostSkillToggle(req, res, url, ctx) {
withPostBody(req, res, url, ctx, (parsed) => {
const project = String(parsed.project || "");
const name = String(parsed.name || "");
const enabled = String(parsed.enabled || "") === "true";
if (!project || !name || (project.toLowerCase() !== "global" && !isValidProjectName(project)))
return jsonErr(res, "Invalid skill toggle request");
const skill = findSkill(ctx.phrenPath, ctx.profile || "", project, name);
if (!skill || "error" in skill)
return jsonErr(res, skill && "error" in skill ? skill.error : "Skill not found");
setSkillEnabledAndSync(ctx.phrenPath, skill.source, skill.name, enabled);
jsonOk(res, { ok: true, enabled });
});
}
function handlePostHookToggle(req, res, url, ctx) {
withPostBody(req, res, url, ctx, (parsed) => {
const tool = String(parsed.tool || "").toLowerCase();
if (!["claude", "copilot", "cursor", "codex"].includes(tool))
return jsonErr(res, "Invalid tool");
const prefs = readInstallPreferences(ctx.phrenPath);
const toolPrefs = (prefs.hookTools && typeof prefs.hookTools === "object") ? prefs.hookTools : {};
const current = toolPrefs[tool] !== false && prefs.hooksEnabled !== false;
writeInstallPreferences(ctx.phrenPath, { hookTools: { ...toolPrefs, [tool]: !current } });
jsonOk(res, { ok: true, enabled: !current });
});
}
function handlePostTopicsSave(req, res, url, ctx) {
withPostBody(req, res, url, ctx, (parsed) => {
const project = String(parsed.project || "");
if (!project || !isValidProjectName(project))
return jsonErr(res, "Invalid project", 400);
const topics = parseTopicsPayload(String(parsed.topics || ""));
if (!topics)
return jsonErr(res, "Invalid topics payload", 400);
const saved = writeProjectTopics(ctx.phrenPath, project, topics);
if (!saved.ok)
return jsonOk(res, saved);
for (const topic of saved.topics) {
const ensured = ensureTopicReferenceDoc(ctx.phrenPath, project, topic);
if (!ensured.ok)
return jsonErr(res, ensured.error);
}
jsonOk(res, { ok: true, ...getProjectTopicsResponse(ctx.phrenPath, project) });
});
}
function handlePostTopicsReclassify(req, res, url, ctx) {
withPostBody(req, res, url, ctx, (parsed) => {
const project = String(parsed.project || "");
if (!project || !isValidProjectName(project))
return jsonErr(res, "Invalid project", 400);
jsonOk(res, { ok: true, ...reclassifyLegacyTopicDocs(ctx.phrenPath, project) });
});
}
function handlePostTopicsPin(req, res, url, ctx) {
withPostBody(req, res, url, ctx, (parsed) => {
const project = String(parsed.project || "");
if (!project || !isValidProjectName(project))
return jsonErr(res, "Invalid project", 400);
const topic = parseTopicPayload(String(parsed.topic || ""));
if (!topic)
return jsonErr(res, "Invalid topic payload", 400);
const pinned = pinProjectTopicSuggestion(ctx.phrenPath, project, topic);
if (!pinned.ok)
return jsonOk(res, pinned);
jsonOk(res, { ok: true, ...getProjectTopicsResponse(ctx.phrenPath, project) });
});
}
function handlePostTopicsUnpin(req, res, url, ctx) {
withPostBody(req, res, url, ctx, (parsed) => {
const project = String(parsed.project || "");
if (!project || !isValidProjectName(project))
return jsonErr(res, "Invalid project", 400);
const unpinned = unpinProjectTopicSuggestion(ctx.phrenPath, project, String(parsed.slug || ""));
if (!unpinned.ok)
return jsonOk(res, unpinned);
jsonOk(res, { ok: true, ...getProjectTopicsResponse(ctx.phrenPath, project) });
});
}
function handlePostTaskAction(req, res, url, ctx, action) {
withPostBody(req, res, url, ctx, (parsed) => {
const project = String(parsed.project || "");
const item = String(parsed.item || "");
if (!project || !item || !isValidProjectName(project))
return jsonErr(res, "Missing or invalid project/item", 400);
if (action === "complete") {
const result = completeTaskStore(ctx.phrenPath, project, item);
jsonOk(res, { ok: result.ok, message: result.ok ? result.data : undefined, error: result.ok ? undefined : result.error });
}
else if (action === "add") {
const result = addTaskStore(ctx.phrenPath, project, item);
jsonOk(res, { ok: result.ok, message: result.ok ? `Task added: ${result.data.line}` : undefined, error: result.ok ? undefined : result.error });
}
else {
const result = removeTaskStore(ctx.phrenPath, project, item);
jsonOk(res, { ok: result.ok, message: result.ok ? result.data : undefined, error: result.ok ? undefined : result.error });
}
});
}
function handlePostTaskUpdate(req, res, url, ctx) {
withPostBody(req, res, url, ctx, (parsed) => {
const project = String(parsed.project || "");
const item = String(parsed.item || "");
if (!project || !item || !isValidProjectName(project))
return jsonErr(res, "Missing or invalid project/item", 400);
const denied = permissionDeniedError(ctx.phrenPath, "update_task", project);
if (denied)
return jsonErr(res, denied, 403);
const updates = {};
if (parsed.responsibility !== undefined) {
if (parsed.responsibility !== "human" && parsed.responsibility !== "agent")
return jsonErr(res, "Responsibility must be human or agent", 400);
updates.responsibility = parsed.responsibility;
}
if (parsed.dependencies !== undefined) {
try {
updates.dependencies = JSON.parse(String(parsed.dependencies));
}
catch {
return jsonErr(res, "Dependencies must be a JSON array", 400);
}
}
if (Object.prototype.hasOwnProperty.call(parsed, "text"))
updates.text = String(parsed.text || "");
if (Object.prototype.hasOwnProperty.call(parsed, "priority"))
updates.priority = String(parsed.priority || "");
if (Object.prototype.hasOwnProperty.call(parsed, "section"))
updates.section = String(parsed.section || "");
const result = updateTaskStore(ctx.phrenPath, project, item, updates);
jsonOk(res, { ok: result.ok, message: result.ok ? result.data : undefined, error: result.ok ? undefined : result.error });
});
}
function handlePostSettingsFindingSensitivity(req, res, url, ctx) {
withPostBody(req, res, url, ctx, (parsed) => {
const value = String(parsed.value || "");
const valid = ["minimal", "conservative", "balanced", "aggressive"];
if (!valid.includes(value))
return jsonErr(res, `Invalid finding sensitivity: "${value}". Must be one of: ${valid.join(", ")}`);
const result = updateWorkflowPolicy(ctx.phrenPath, { findingSensitivity: value });
jsonOk(res, result.ok ? { ok: true, findingSensitivity: result.data.findingSensitivity } : { ok: false, error: result.error });
});
}
function handlePostSettingsTaskMode(req, res, url, ctx) {
withPostBody(req, res, url, ctx, (parsed) => {
const value = String(parsed.value || "").trim().toLowerCase();
const valid = VALID_TASK_MODES;
if (!valid.includes(value))
return jsonErr(res, `Invalid task mode: "${value}". Must be one of: ${valid.join(", ")}`);
const result = updateWorkflowPolicy(ctx.phrenPath, { taskMode: value });
jsonOk(res, result.ok ? { ok: true, taskMode: result.data.taskMode } : { ok: false, error: result.error });
});
}
function handlePostSettingsProactivity(req, res, url, ctx) {
withPostBody(req, res, url, ctx, (parsed) => {
const value = String(parsed.value || "").trim().toLowerCase();
const valid = ["high", "medium", "low"];
if (!valid.includes(value))
return jsonErr(res, `Invalid proactivity: "${value}". Must be one of: ${valid.join(", ")}`);
writeInstallPreferences(ctx.phrenPath, { proactivity: value });
writeGovernanceInstallPreferences(ctx.phrenPath, { proactivity: value });
jsonOk(res, { ok: true, proactivity: value });
});
}
function handlePostSettingsAutoCapture(req, res, url, ctx) {
withPostBody(req, res, url, ctx, (parsed) => {
const enabled = String(parsed.enabled || "").toLowerCase() === "true";
const next = enabled ? "high" : "low";
writeInstallPreferences(ctx.phrenPath, { proactivityFindings: next });
writeGovernanceInstallPreferences(ctx.phrenPath, { proactivityFindings: next });
jsonOk(res, { ok: true, autoCaptureEnabled: enabled, proactivityFindings: next });
});
}
function handlePostSettingsIndexPolicy(req, res, url, ctx) {
withPostBody(req, res, url, ctx, (parsed) => {
const patch = {};
const toGlobList = (v) => (Array.isArray(v) ? v : String(v ?? "").split(","))
.map((s) => String(s).trim())
.filter(Boolean);
if (Object.prototype.hasOwnProperty.call(parsed, "includeGlobs"))
patch.includeGlobs = toGlobList(parsed.includeGlobs);
if (Object.prototype.hasOwnProperty.call(parsed, "excludeGlobs"))
patch.excludeGlobs = toGlobList(parsed.excludeGlobs);
if (Object.prototype.hasOwnProperty.call(parsed, "includeHidden")) {
patch.includeHidden = String(parsed.includeHidden).toLowerCase() === "true";
}
const result = updateIndexPolicy(ctx.phrenPath, patch);
jsonOk(res, result.ok ? { ok: true, indexPolicy: result.data } : { ok: false, error: result.error });
});
}
function handlePostSettingsAccess(req, res, url, ctx) {
withPostBody(req, res, url, ctx, (parsed) => {
const project = String(parsed.project || "").trim();
if (project && !isValidProjectName(project))
return jsonErr(res, "Invalid project name", 400);
const toList = (v) => (Array.isArray(v) ? v : String(v ?? "").split(","))
.map((s) => String(s).trim())
.filter(Boolean);
const patch = {};
for (const role of ACCESS_ROLE_KEYS) {
if (Object.prototype.hasOwnProperty.call(parsed, role))
patch[role] = toList(parsed[role]);
}
if (!Object.keys(patch).length)
return jsonErr(res, "Provide at least one of: admins, contributors, readers");
// Rewriting the ACL is admin-only — otherwise a denied actor could POST
// itself into `admins` here and undo every other check on this server.
const denied = permissionDeniedError(ctx.phrenPath, "manage_config", project || undefined);
if (denied)
return jsonErr(res, denied, 403);
try {
const written = setAccessRoles(ctx.phrenPath, patch, project || undefined);
jsonOk(res, { ok: true, access: written });
}
catch (err) {
jsonErr(res, errorMessage(err));
}
});
}
function handlePostSettingsMcpEnabled(req, res, url, ctx) {
withPostBody(req, res, url, ctx, (parsed) => {
const enabled = String(parsed.enabled || "").toLowerCase() === "true";
writeInstallPreferences(ctx.phrenPath, { mcpEnabled: enabled });
jsonOk(res, { ok: true, mcpEnabled: enabled });
});
}
function handleGetProfiles(res, ctx) {
try {
const profileResult = listProfiles(ctx.phrenPath);
if (!profileResult.ok) {
return jsonOk(res, { ok: false, error: profileResult.error, profiles: [] });
}
const profiles = profileResult.data || [];
jsonOk(res, { ok: true, profiles, activeProfile: ctx.profile || undefined });
}
catch (err) {
jsonOk(res, { ok: false, error: errorMessage(err), profiles: [] });
}
}
function handlePostProfile(req, res, url, ctx) {
withPostBody(req, res, url, ctx, (parsed) => {
const profileName = String(parsed.profile || "").trim();
if (!profileName)
return jsonErr(res, "Profile name required", 400);
try {
const machineAlias = getDefaultMachineAlias();
const result = setMachineProfile(ctx.phrenPath, machineAlias, profileName);
if (!result.ok)
return jsonErr(res, result.error || "Failed to switch profile", 500);
jsonOk(res, { ok: true, message: `Switched to profile: ${profileName}`, profile: profileName });
}
catch (err) {
jsonErr(res, errorMessage(err), 500);
}
});
}
function handlePostStoreSubscribe(req, res, url, ctx) {
withPostBody(req, res, url, ctx, (parsed) => {
const storeName = String(parsed.store || "");
const projects = Array.isArray(parsed.projects) ? parsed.projects : [];
if (!storeName)
return jsonErr(res, "Missing store name", 400);
try {
subscribeStoreProjects(ctx.phrenPath, storeName, projects);
jsonOk(res, { ok: true });
}
catch (err) {
jsonErr(res, errorMessage(err), 500);
}
});
}
function handlePostStoreUnsubscribe(req, res, url, ctx) {
withPostBody(req, res, url, ctx, (parsed) => {
const storeName = String(parsed.store || "");
const projects = Array.isArray(parsed.projects) ? parsed.projects : [];
if (!storeName)
return jsonErr(res, "Missing store name", 400);
try {
unsubscribeStoreProjects(ctx.phrenPath, storeName, projects);
jsonOk(res, { ok: true });
}
catch (err) {
jsonErr(res, errorMessage(err), 500);
}
});
}
function handlePostSettingsProjectOverrides(req, res, url, ctx) {
withPostBody(req, res, url, ctx, (parsed) => {
const globalUpdate = String(parsed.globalUpdate || "") === "true";
const project = String(parsed.project || "");
const field = String(parsed.field || "");
const value = String(parsed.value || "");
const clearField = String(parsed.clear || "") === "true";
const NUMERIC_RETENTION_FIELDS = ["ttlDays", "retentionDays", "autoAcceptThreshold", "minInjectConfidence"];
const NUMERIC_WORKFLOW_FIELDS = ["lowConfidenceThreshold"];
// Handle global retention/workflow policy updates
if (globalUpdate) {
try {
if (NUMERIC_RETENTION_FIELDS.includes(field)) {
let updateData = {};
if (!clearField) {
const num = parseFloat(value);
if (!Number.isFinite(num) || num < 0)
throw new Error("Invalid numeric value for " + field);
updateData[field] = num;
}
const result = updateRetentionPolicy(ctx.phrenPath, updateData);
if (!result.ok)
return jsonErr(res, result.error || "Failed to update retention policy", 500);
return jsonOk(res, { ok: true, retentionPolicy: result.data });
}
else if (field.startsWith("decay.")) {
const point = field.slice("decay.".length);
let updateData = {};
if (!clearField) {
const num = parseFloat(value);
if (!Number.isFinite(num) || num < 0 || num > 1)
throw new Error("Invalid value for " + field + " (must be 0-1)");
updateData = { decay: { [point]: num } };
}
const result = updateRetentionPolicy(ctx.phrenPath, updateData);
if (!result.ok)
return jsonErr(res, result.error || "Failed to update retention policy", 500);
return jsonOk(res, { ok: true, retentionPolicy: result.data });
}
else if (field === "riskySections") {
const sections = (clearField ? [] : value.split(",").map((s) => s.trim()).filter(Boolean));
const result = updateWorkflowPolicy(ctx.phrenPath, { riskySections: sections });
if (!result.ok)
return jsonErr(res, result.error || "Failed to update workflow policy", 500);
return jsonOk(res, { ok: true, workflowPolicy: result.data });
}
else if (NUMERIC_WORKFLOW_FIELDS.includes(field)) {
let updateData = {};
if (!clearField) {
const num = parseFloat(value);
if (!Number.isFinite(num) || num < 0 || num > 1)
throw new Error("Invalid value for " + field + " (must be 0-1)");
updateData[field] = num;
}
const result = updateWorkflowPolicy(ctx.phrenPath, updateData);
if (!result.ok)
return jsonErr(res, result.error || "Failed to update workflow policy", 500);
return jsonOk(res, { ok: true, workflowPolicy: result.data });
}
else {
return jsonErr(res, "Unknown config field: " + field, 400);
}
}
catch (err) {
return jsonErr(res, errorMessage(err));
}
}
if (!project || !isValidProjectName(project))
return jsonErr(res, "Invalid project name", 400);
const registeredProjects = getProjectDirs(ctx.phrenPath, ctx.profile).map((d) => path.basename(d)).filter((p) => p !== "global");
const registrationWarning = registeredProjects.includes(project) ? undefined : `Project '${project}' is not registered in the active profile. Config was saved but it will have no effect until the project is added with 'phren add'.`;
const VALID_FIELDS = {
findingSensitivity: ["minimal", "conservative", "balanced", "aggressive"],
proactivity: ["high", "medium", "low"], proactivityFindings: ["high", "medium", "low"],
proactivityTask: ["high", "medium", "low"], taskMode: ["off", "manual", "suggest", "auto"],
};
try {
updateProjectConfigOverrides(ctx.phrenPath, project, (current) => {
const next = { ...current };
if (clearField) {
if (field in VALID_FIELDS)
delete next[field];
else if (NUMERIC_RETENTION_FIELDS.includes(field)) {
if (next.retentionPolicy)
delete next.retentionPolicy[field];
}
else if (field.startsWith("decay.")) {
if (next.retentionPolicy?.decay)
delete next.retentionPolicy.decay[field.slice("decay.".length)];
}
else if (field === "riskySections") {
if (next.workflowPolicy)
delete next.workflowPolicy.riskySections;
}
else if (NUMERIC_WORKFLOW_FIELDS.includes(field)) {
if (next.workflowPolicy)
delete next.workflowPolicy[field];
}
return next;
}
if (field in VALID_FIELDS) {
if (!VALID_FIELDS[field].includes(value))
throw new Error(`Invalid value "${value}" for ${field}`);
next[field] = value;
}
else if (NUMERIC_RETENTION_FIELDS.includes(field)) {
const num = parseFloat(value);
if (!Number.isFinite(num) || num < 0)
throw new Error(`Invalid numeric value for ${field}`);
next.retentionPolicy = { ...next.retentionPolicy, [field]: num };
}
else if (field.startsWith("decay.")) {
const num = parseFloat(value);
if (!Number.isFinite(num) || num < 0 || num > 1)
throw new Error(`Invalid value for ${field} (must be 0-1)`);
next.retentionPolicy = { ...next.retentionPolicy, decay: { ...next.retentionPolicy?.decay, [field.slice("decay.".length)]: num } };
}
else if (field === "riskySections") {
const sections = value.split(",").map((s) => s.trim()).filter(Boolean);
next.workflowPolicy = { ...next.workflowPolicy, riskySections: sections };
}
else if (NUMERIC_WORKFLOW_FIELDS.includes(field)) {
const num = parseFloat(value);
if (!Number.isFinite(num) || num < 0 || num > 1)
throw new Error(`Invalid value for ${field} (must be 0-1)`);
next.workflowPolicy = { ...next.workflowPolicy, [field]: num };
}
else {
throw new Error(`Unknown config field: ${field}`);
}
return next;
});
jsonOk(res, { ok: true, config: mergeConfig(ctx.phrenPath, project), ...(registrationWarning ? { warning: registrationWarning } : {}) });
}
catch (err) {
jsonErr(res, errorMessage(err));
}
});
}
/**
* Resolve a graph node's score_key back to the exact FINDINGS.md bullet text
* (including its [tag] prefix). Score keys are minted per-bullet by the graph
* builder, so this pins the precise entry even when display texts are
* ambiguous (e.g. two findings differing only by tag). Returns null when the
* key doesn't resolve — callers fall back to text matching.
*/
function findBulletTextByScoreKey(phrenPath, project, scoreKey) {
try {
const file = path.join(phrenPath, project, FINDINGS_FILENAME);
if (!fs.existsSync(file))
return null;
for (const line of fs.readFileSync(file, "utf8").split("\n")) {
const tag = line.match(/^-\s+\[([a-z_-]+)\]\s+(.+?)(?:\s*<!--.*-->)?$/);
const plain = tag ? null : line.match(/^-\s+(.+?)(?:\s*<!--.*-->)?$/);
const entry = tag ? `[${tag[1]}] ${tag[2].trim()}` : plain ? plain[1].trim() : null;
if (!entry)
continue;
if (entryScoreKey(project, FINDINGS_FILENAME, entry) === scoreKey)
return entry;
}
}
catch {
// unreadable file — fall back to text matching
}
return null;
}
function handleFindingsWrite(req, res, url, pathname, ctx) {
const project = decodeURIComponent(pathname.slice("/api/findings/".length));
if (!project || !isValidProjectName(project))
return jsonErr(res, "Invalid project name", 400);
if (req.method === "POST") {
withPostBody(req, res, url, ctx, (parsed) => {
const text = String(parsed.text || "");
if (!text)
return jsonErr(res, "text is required");
const result = addFindingStore(ctx.phrenPath, project, text);
jsonOk(res, { ok: result.ok, message: result.ok ? result.data : undefined, error: result.ok ? undefined : result.error });
});
}
else if (req.method === "PUT") {
withPostBody(req, res, url, ctx, (parsed) => {
const oldText = String(parsed.old_text || "");
const newText = String(parsed.new_text || "");
if (!oldText || !newText)
return jsonErr(res, "old_text and new_text are required");
const scoreKey = String(parsed.score_key || "");
const resolved = scoreKey ? findBulletTextByScoreKey(ctx.phrenPath, project, scoreKey) : null;
const result = editFinding(ctx.phrenPath, project, resolved || oldText, newText);
jsonOk(res, { ok: result.ok, error: result.ok ? undefined : result.error });
});
}
else {
// DELETE
withPostBody(req, res, url, ctx, (parsed) => {
const text = String(parsed.text || "");
if (!text)
return jsonErr(res, "text is required");
const scoreKey = String(parsed.score_key || "");
const resolved = scoreKey ? findBulletTextByScoreKey(ctx.phrenPath, project, scoreKey) : null;
const result = removeFinding(ctx.phrenPath, project, resolved || text);
jsonOk(res, { ok: result.ok, error: result.ok ? undefined : result.error });
});
}
}
function handleNotesWrite(req, res, url, pathname, ctx) {
const project = decodeURIComponent(pathname.slice("/api/notes/".length));
if (!project || !isValidProjectName(project))
return jsonErr(res, "Invalid project name", 400);
const basePath = resolveProjectBasePath(ctx.phrenPath, project);
withPostBody(req, res, url, ctx, (parsed) => {
const selector = String(parsed.note || "");
if (req.method === "POST" && String(parsed.action || "") === "promote") {
if (!selector)
return jsonErr(res, "note is required", 400);
const denied = permissionDeniedError(basePath, "promote_note", project);
if (denied)
return jsonErr(res, denied, 403);
const findingTypeRaw = String(parsed.finding_type || "");
if (findingTypeRaw && !FINDING_TYPES.includes(findingTypeRaw)) {
return jsonErr(res, `Invalid finding type: ${findingTypeRaw}`, 400);
}
const result = promoteNote(basePath, project, selector, findingTypeRaw);
return jsonOk(res, result.ok
? { ok: true, data: result.data, message: `Promoted ${result.data.note.id} to a finding.` }
: { ok: false, error: result.error, errorCode: result.code });
}
if (req.method === "POST") {
const text = String(parsed.text || "");
if (!text)
return jsonErr(res, "text is required", 400);
const denied = permissionDeniedError(basePath, "add_note", project);
if (denied)
return jsonErr(res, denied, 403);
const date = String(parsed.date || "") || undefined;
const result = addNote(basePath, project, text, { date });
return jsonOk(res, result.ok
? { ok: true, data: { note: result.data }, message: `Added ${result.data.id}.` }
: { ok: false, error: result.error, errorCode: result.code });
}
if (!selector)
return jsonErr(res, "note is required", 400);
if (req.method === "PUT") {
const text = String(parsed.text || "");
if (!text)
return jsonErr(res, "text is required", 400);
const denied = permissionDeniedError(basePath, "edit_note", project);
if (denied)
return jsonErr(res, denied, 403);
const result = editNote(basePath, project, selector, text);
return jsonOk(res, result.ok
? { ok: true, data: { note: result.data }, message: `Updated ${result.data.id}.` }
: { ok: false, error: result.error, errorCode: result.code });
}
const denied = permissionDeniedError(basePath, "remove_note", project);
if (denied)
return jsonErr(res, denied, 403);
const result = removeNote(basePath, project, selector);
return jsonOk(res, result.ok
? { ok: true, data: { note: result.data }, message: `Removed ${result.data.id}.` }
: { ok: false, error: result.error, errorCode: result.code });
});
}
// ── Main router ───────────────────────────────────────────────────────────────
export function createWebUiHttpServer(phrenPath, renderPage, profile, opts) {
try {
repairPreexistingInstall(phrenPath);
}
catch (err) {
logger.debug("web-ui", `web-ui repair: ${errorMessage(err)}`);
}
const modules = moduleSnapshot(phrenPath, profile);
const ctx = {
phrenPath, profile, authToken: opts?.authToken, csrfTokens: opts?.csrfTokens, renderPage,
};
const handleRequest = async (req, res) => {
const url = req.url || "/";
const pathname = url.includes("?") ? url.slice(0, url.indexOf("?")) : url;
// Home page
if (req.method === "GET" && pathname === "/") {
if (!requireGetAuth(req, res, url, ctx.authToken))
return;
pruneExpiredCsrfTokens(ctx.csrfTokens);
if (ctx.csrfTokens)
ctx.csrfTokens.set(crypto.randomUUID(), Date.now());
return handleGetHome(res, ctx);
}
setCommonHeaders(res);
// Auth gate for all GET /api/* routes
if (pathname.startsWith("/api/") && req.method === "GET" && !requireGetAuth(req, res, url, ctx.authToken))
return;
if (!modules.has("tasks") && (pathname === "/api/tasks" || pathname.startsWith("/api/tasks/") || pathname === "/api/settings/task-mode")) {
return jsonErr(res, disabledHint("tasks"), 404);
}
// ── GET routes ──
if (req.method === "GET") {
switch (pathname) {
case "/api/projects": return handleGetProjects(res, ctx);
case "/api/change-token": return handleGetChangeToken(res, ctx);
case "/api/stores": return handleGetStores(res, ctx);
case "/api/runtime-health": return handleGetRuntimeHealth(res, ctx);
case "/api/review-queue": return handleGetReviewQueue(res, ctx);
case "/api/review-activity": return handleGetReviewActivity(res, ctx);
case "/api/lookups": return handleGetLookups(res, ctx);
case "/api/lookups/stream": return handleLookupStream(req, res, ctx);
case "/api/project-topics": return handleGetProjectTopics(res, url, ctx);
case "/api/project-reference-list": return handleGetProjectReferenceList(res, url, ctx);
case "/api/project-reference-content": return handleGetProjectReferenceContent(res, url, ctx);
case "/api/skills": return handleGetSkills(res, ctx);
case "/api/hooks": return handleGetHooks(res, ctx);
case "/api/scores": return handleGetScores(res, ctx);
case "/api/tasks": return handleGetTasks(res, ctx);
case "/api/settings": return handleGetSettings(res, url, ctx);
case "/api/config": return handleGetConfig(res, url, ctx);
case "/api/config/view": return handleGetConfigView(res, url, ctx);
case "/api/csrf-token": return handleGetCsrfToken(res, ctx);
case "/api/profiles": return handleGetProfiles(res, ctx);
case "/api/search": return await handleGetSearch(res, url, ctx);
}
// Prefix-matched GET routes
if (pathname.startsWith("/api/project-content"))
return handleGetProjectContent(res, url, ctx);
if (pathname.startsWith("/api/skill-content"))
return handleGetSkillContent(res, url, ctx);
if (pathname.startsWith("/api/graph"))
return await handleGetGraph(res, url, ctx);
if (pathname.startsWith("/api/findings/"))
return handleGetFindings(res, pathname, ctx);
if (pathname.startsWith("/api/notes/"))
return handleGetNotes(res, url, pathname, ctx);
}
// ── POST/PUT/DELETE routes ──
if (req.method === "POST" || req.method === "PUT" || req.method === "DELETE") {
switch (pathname) {
case "/api/sync": return handlePostSync(req, res, url, ctx);
case "/api/approve": return handlePostApprove(req, res, url, ctx);
case "/api/reject": return handlePostReject(req, res, url, ctx);
case "/api/edit": return handlePostEdit(req, res, url, ctx);
case "/api/skill-save": return handlePostSkillSave(req, res, url, ctx);
case "/api/skill-toggle": return handlePostSkillToggle(req, res, url, ctx);
case "/api/hook-toggle": return handlePostHookToggle(req, res, url, ctx);
case "/api/profile": return handlePostProfile(req, res, url, ctx);
case "/api/stores/subscribe": return handlePostStoreSubscribe(req, res, url, ctx);
case "/api/stores/unsubscribe": return handlePostStoreUnsubscribe(req, res, url, ctx);
case "/api/project-topics/save": return handlePostTopicsSave(req, res, url, ctx);
case "/api/project-topics/reclassify": return handlePostTopicsReclassify(req, res, url, ctx);
case "/api/project-topics/pin": return handlePostTopicsPin(req, res, url, ctx);
case "/api/project-topics/unpin": return handlePostTopicsUnpin(req, res, url, ctx);
case "/api/tasks/complete": return handlePostTaskAction(req, res, url, ctx, "complete");
case "/api/tasks/add": return handlePostTaskAction(req, res, url, ctx, "add");
case "/api/tasks/remove": return handlePostTaskAction(req, res, url, ctx, "remove");
case "/api/tasks/update": return handlePostTaskUpdate(req, res, url, ctx);
case "/api/settings/finding-sensitivity": return handlePostSettingsFindingSensitivity(req, res, url, ctx);
case "/api/settings/task-mode": return handlePostSettingsTaskMode(req, res, url, ctx);
case "/api/settings/proactivity": return handlePostSettingsProactivity(req, res, url, ctx);
case "/api/settings/auto-capture": return handlePostSettingsAutoCapture(req, res, url, ctx);
case "/api/settings/mcp-enabled": return handlePostSettingsMcpEnabled(req, res, url, ctx);
case "/api/settings/index-policy": return handlePostSettingsIndexPolicy(req, res, url, ctx);
case "/api/settings/access": return handlePostSettingsAccess(req, res, url, ctx);
case "/api/settings/project-overrides": return handlePostSettingsProjectOverrides(req, res, url, ctx);
}
// Prefix-matched write routes
if (pathname.startsWith("/api/findings/"))
return handleFindingsWrite(req, res, url, pathname, ctx);
if (pathname.startsWith("/api/notes/"))
return handleNotesWrite(req, res, url, pathname, ctx);
}
res.writeHead(404, { "content-type": "text/plain" });
res.end("Not found");
};
return http.createServer((req, res) => {
// Node does not handle rejected promises from request listeners. Contain
// malformed URI components and unexpected route failures to this request.
void handleRequest(req, res).catch((err) => handleRequestError(res, err));
});
}
export async function startWebUiServer(phrenPath, port, renderPage, profile, opts = {}) {
const authToken = crypto.randomUUID();
const csrfTokens = new Map();
const server = createWebUiHttpServer(phrenPath, renderPage, profile, { authToken, csrfTokens });
const boundPort = await bindWebUiPort(server, port, Boolean(opts.allowPortFallback && port !== 0));
const publicUrl = `http://127.0.0.1:${boundPort}`;
const reviewUrl = `${publicUrl}/?_auth=${encodeURIComponent(authToken)}`;
const ready = await waitForWebUiReady(reviewUrl);
process.stdout.write(`phren web-ui running at ${publicUrl}\n`);
process.stderr.write(`open: ${reviewUrl}\n`);
if (!ready) {
logger.warn("web-ui", "health check did not confirm readiness before launch");
}
const shouldAutoOpen = opts.autoOpen ?? Boolean(process.stdout.isTTY);
if (shouldAutoOpen && ready) {
try {
if (opts.browserLauncher)
await opts.browserLauncher(reviewUrl);
else
await launchWebUiBrowser(reviewUrl);
}
catch (err) {
logger.warn("web-ui", `browser launch failed: ${errorMessage(err)}`);
process.stdout.write(`secure session URL: ${reviewUrl}\n`);
}
}
else if (shouldAutoOpen && !ready) {
logger.warn("web-ui", "skipped auto-open because readiness check failed; use the secure URL below");
process.stdout.write(`secure session URL: ${reviewUrl}\n`);
}
else {
process.stdout.write(`secure session URL: ${reviewUrl}\n`);
}
await new Promise((resolve) => {
const shutdown = () => {
server.close(() => resolve());
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
});
}