@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
255 lines (254 loc) • 19.9 kB
JavaScript
import * as fs from "node:fs";
import * as path from "node:path";
import * as yaml from "js-yaml";
import { z } from "zod";
import { VERSION } from "../package-metadata.js";
const core = (names) => names.map(name => ({ name, profiles: ["core", "full"] }));
const full = (names) => names.map(name => ({ name, profiles: ["full"] }));
const routes = (method, paths) => paths.map(path => ({ method, path }));
// DELETE is only used by conductor grants today; the manifest type stays open.
export const BUILTIN_MODULES = [
{
schemaVersion: 1, name: "memory", version: VERSION, defaultEnabled: true, requires: [],
tools: [
...core(["search_knowledge", "get_memory_detail", "get_project_summary", "add_finding", "revise_finding", "session", "phren_admin"]),
...full([
"list_projects", "get_findings", "store_list", "supersede_finding", "retract_finding",
"resolve_contradiction", "get_contradictions", "edit_finding", "remove_finding", "push_changes",
"pin_memory", "get_truths", "memory_feedback", "export_project", "import_project", "manage_project",
"search_fragments", "get_related_docs", "read_graph", "link_findings", "cross_project_fragments",
"session_start", "session_end", "session_context", "session_history",
"add_project", "health_check", "doctor_fix", "list_hook_errors", "get_review_queue", "manage_review_item",
"list_skills", "read_skill", "write_skill", "remove_skill", "toggle_skill",
"list_hooks", "toggle_hooks", "add_custom_hook", "remove_custom_hook", "get_config", "set_config",
"get_notes", "add_note", "edit_note", "remove_note", "promote_note", "get_topic_summaries", "set_topic_summary",
]),
],
cliCommands: [
"init", "quickstart", "add", "agent", "projects", "search", "status", "doctor", "web-ui", "graph", "shell",
"add-finding", "pin", "review", "session-context", "sessions", "finding", "note", "notes",
"search-fragments", "related-docs", "truths", "promote", "skills", "detect-skills", "hooks", "config",
"maintain", "consolidation-status", "quality-feedback", "mcp-mode", "hooks-mode", "preset", "snippet",
"verify", "uninstall", "update", "profile", "store", "team", "modules list", "modules enable", "modules disable",
"hook-prompt", "hook-session-start", "hook-stop", "hook-context", "hook-tool", "background-sync",
"background-maintenance", "background-reindex", "debug-injection", "inspect-index", "skill-list",
"policy", "workflow", "index-policy", "govern-memories", "prune-memories", "consolidate-memories", "link",
],
agentHooks: [
{ agents: ["claude", "codex", "copilot", "cursor"], events: ["SessionStart"], handler: "phren hook-session-start" },
{ agents: ["claude", "codex", "copilot", "cursor"], events: ["UserPromptSubmit"], handler: "phren hook-prompt" },
{ agents: ["claude", "codex", "copilot", "cursor"], events: ["Stop"], handler: "phren hook-stop" },
{ agents: ["claude"], events: ["PostToolUse"], handler: "phren hook-tool" },
],
hookRoutes: [...routes("GET", ["/v1/store/head", "/v1/store/tree", "/v1/store/blob"]), ...routes("POST", ["/v1/store/pull-now", "/v1/store/blobs", "/v1/store/file", "/v1/store/delete"])],
capabilities: ["memory", "memoryStore", "memoryStoreBatch", "projectMemory"],
storeFiles: [
"phren.root.yaml", "machines.yaml", "profiles/*.yaml", ".config/modules.yaml", ".config/*.json",
"<project>/AGENTS.md", "<project>/summary.md", "<project>/FINDINGS.md", "<project>/truths.md",
"<project>/review.md", "<project>/notes/*.md", "<project>/reference/**", "<project>/skills/**",
"<project>/phren.project.yaml", "<project>/preferences.json", ".runtime/sessions/session-*.json",
".runtime/sessions/last-summary.json",
],
localFiles: [],
phoneScreens: [{ screen: "ProjectsView", capability: "memory" }, { screen: "SearchView", capability: "memory" }],
skills: ["phren-init", "phren-sync", "phren-profiles", "phren-discover", "phren-consolidate", "phren-summarize"],
},
{
schemaVersion: 1, name: "tasks", version: VERSION, defaultEnabled: false, requires: ["memory"],
tools: [
...core(["get_tasks", "add_task", "manage_task"]),
...full(["complete_task", "remove_task", "update_task", "tidy_done_tasks", "pin_task", "claim_task"]),
],
cliCommands: ["task", "tasks", "config task-mode", "config proactivity.tasks"], agentHooks: [], hookRoutes: [{ method: "GET", path: "/v1/tasks" }, { method: "GET", path: "/v1/tasks/stores" }, { method: "POST", path: "/v1/tasks/update" }, { method: "POST", path: "/v1/tasks/create" }, { method: "POST", path: "/v1/tasks/save" }, { method: "POST", path: "/v1/tasks/launch" }], capabilities: ["tasks", "taskDependencies", "taskWriterSafety", "taskAtomicCreate", "taskAtomicSave", "taskBoundLaunch"],
storeFiles: ["<project>/tasks.md", ".sessions/checkpoint-*.json", ".config/task-format.json", ".config/task-archive/*.md", "stores.yaml"], localFiles: [".runtime/task-dependencies.lock"],
phoneScreens: [{ screen: "TasksView", capability: "tasks" }], skills: [],
},
{
schemaVersion: 1, name: "hook", version: VERSION, defaultEnabled: false, requires: ["memory"],
tools: [],
cliCommands: [
"bridge", "bridge install", "bridge update", "bridge uninstall", "bridge rollback", "bridge status",
"bridge doctor", "bridge usage", "bridge usage-statusline", "bridge hook", "bridge serve", "bridge ssh", "pair",
"bridge fanouts archive", "bridge accounts", "bridge askpass", "canary", "computers", "computers mcp", "usage", "sudo",
],
agentHooks: [
{ agents: ["codex"], events: ["SessionStart", "UserPromptSubmit", "Stop", "PermissionRequest"], handler: "bridge-hook.mjs hook codex" },
{ agents: ["claude"], events: ["SessionStart", "UserPromptSubmit", "Stop", "PermissionRequest", "PreCompact"], handler: "claude-hook.mjs claude" },
{ agents: ["copilot"], events: ["SessionStart", "UserPromptSubmit"], handler: "bridge-hook.mjs hook copilot" },
],
hookRoutes: [
...routes("GET", ["/v1/approval-rules"]),
...routes("POST", ["/v1/approval-rules"]), ...routes("DELETE", ["/v1/approval-rules"]),
...routes("GET", [
"/v1/health", "/v1/health/details", "/v1/health/peers", "/v1/muxes", "/v1/activity", "/v1/metrics", "/v1/web-servers", "/v1/simulators",
"/v1/simulators/screenshot", "/v1/simulators/apps", "/v1/files", "/v1/models", "/v1/harnesses", "/v1/projects/files",
"/v1/uploads/image", "/v1/files/range", "/v1/files/resolve", "/v1/usage", "/v1/resources", "/v1/speech/voices", "/v1/push/status", "/v1/projects/locate", "/v1/projects/repos",
"/v1/workspaces", "/v1/workspaces/panes", "/v1/workspaces/layout", "/v1/transcripts/blob", "/v1/transcripts/history", "/v1/sudo",
"/v1/subagents", "/v1/subagents/transcript", "/v1/subagents/messages", "/v1/sessions/move", "/v1/sessions/moves", "/v1/sessions/move/computers", "/v1/sessions/move/availability", "/v1/sessions/move/intent", "/v1/sessions/move/launch", "/v1/dispatch/relocation", "/v1/dispatch/arrival",
]),
...routes("POST", [
"/v1/push/register", "/v1/push/answer", "/v1/push/target", "/v1/push/presence", "/v1/files", "/v1/files/delete", "/v1/projects/add", "/v1/simulators/action",
"/v1/workspaces/launch", "/v1/workspaces/create", "/v1/workspaces/focus", "/v1/workspaces/rename",
"/v1/workspaces/close", "/v1/sessions/rename", "/v1/sessions/move", "/v1/accounts/claude/add", "/v1/sessions/move/abort", "/v1/sessions/move/launch", "/v1/prompt", "/v1/prompt/status", "/v1/model", "/v1/settings", "/v1/agents/permission-mode", "/v1/side-question/dismiss", "/v1/keys", "/v1/secret", "/v1/upload",
"/v1/approvals/answer", "/v1/questions/answer", "/v1/subagents/resume", "/v1/subagents/archive-finished", "/v1/canary",
"/v1/speech", "/v1/sudo/answer", "/v1/jobs/cleanup",
]),
...routes("WS", ["/v1/transcripts", "/v1/status", "/v1/overview", "/v1/speech/transcribe", "/v1/speech/live"]),
],
capabilities: [
"hook", "transcript", "progress", "images", "prompt", "stop", "terminal", "shell", "paneTerminal", "paneLayout", "deskPresence", "herdr",
"webServers", "webPreview", "activity", "approvals", "questions", "accountUsage", "providers",
"files", "repositoryFiles", "fileResolution", "subagents", "sideQuestions", "approvalPush", "simulators", "overviewStream", "speech", "speechTimestamps", "speechTimestampStream", "speechLive", "speechVoices", "speechFormats", "transcribe", "promptOnce", "promptStatus", "deliveryFrames", "resources", "sessionRename", "sudo", "sudoOutcome", "previewDeltas", "quickChat", "agentFolder", "launchPermissionMode", "sessionMove", "moveNewChat", "moveConductor", "moveToComputer", "claudeAccountAdd", "workerRelocation",
],
storeFiles: [".runtime/sessions/opencode-*.events.jsonl", ".runtime/approvals/opencode-*.json"],
localFiles: ["<bridge>/gitboy-webhooks.json", "<bridge>/installed.json", "<bridge>/versions/**", "<bridge>/current", "<bridge>/dispatch", "<bridge>/askpass", "<bridge>/computer-id", "<bridge>/move-intents/*.json", "<bridge>/canary.json", "<bridge>/canary-daily", "<bridge>/moves/*.json", "<bridge>/move-launches/*.json"],
phoneScreens: [{ screen: "LiveSessionsView", capability: "hook" }, { screen: "AgentChatView", capability: "transcript" }],
skills: [],
},
{
schemaVersion: 1, name: "git", version: VERSION, defaultEnabled: false, requires: ["memory"],
tools: full(["auto_extract_findings"]), cliCommands: ["maintain extract", "extract-memories"],
agentHooks: [
{ agents: ["codex"], events: ["PreToolUse", "PostToolUse"], handler: "bridge-hook.mjs hook codex" },
{ agents: ["claude"], events: ["PreToolUse", "PostToolUse"], handler: "claude-hook.mjs claude" },
],
hookRoutes: [...routes("GET", ["/v1/git/status"]), ...routes("POST", [
"/v1/diff", "/v1/web-servers/session", "/v1/git/status", "/v1/git/log", "/v1/git/branches", "/v1/git/pulls",
"/v1/git/tree", "/v1/git/worktrees", "/v1/git/stage", "/v1/git/unstage", "/v1/git/discard",
"/v1/git/commit", "/v1/git/push", "/v1/git/pr", "/v1/git/pr/merge", "/v1/git/host-token", "/v1/git/show", "/v1/git/checkout", "/v1/git/fetch", "/v1/git/pull", "/v1/git/file", "/v1/git/apply", "/v1/git/session-changes", "/v1/files/write", "/v1/files/search", "/v1/files/list",
])],
capabilities: ["git", "diff", "fileWrite", "fileSearch", "gitReview", "gitHosts", "projectHostToken"], storeFiles: [], localFiles: ["<bridge>/changes/**", "<bridge>/changes-scratch/**"],
phoneScreens: [{ screen: "AgentChangesView", capability: "git" }], skills: [],
},
{
schemaVersion: 1, name: "schedules", version: VERSION, defaultEnabled: false, requires: ["memory"],
tools: [], cliCommands: ["schedule"], agentHooks: [],
hookRoutes: routes("POST", ["/v1/schedules", "/v1/schedules/run", "/v1/schedules/history", "/v1/schedules/update"]),
capabilities: ["schedules", "scheduleProjects", "scheduleUpdate"], storeFiles: ["<project>/schedules.yaml", ".runtime/agent-fanouts/<run-id>/**"],
localFiles: ["<bridge>/schedule-runs.jsonl"],
phoneScreens: [{ screen: "SchedulesView", capability: "schedules" }], skills: [],
},
{
schemaVersion: 1, name: "conductor", version: VERSION, defaultEnabled: false, requires: ["memory", "hook"],
tools: full(["dispatch", "dispatch_returns", "dispatch_approve", "hand_off", "live_sessions", "account_usage", "authority", "dispatch_report", "owner_inbox", "move_session"]), cliCommands: ["dispatch", "dispatch status", "dispatch returns", "dispatch sessions", "dispatch usage", "dispatch report", "hand-off", "move", "conductor", "conductor grants", "conductor status", "conductor make", "conductor stop", "conductor sets", "conductor integrator", "owner-inbox", "authority", "bridge enroll-computer"], agentHooks: [],
hookRoutes: [...routes("GET", ["/v1/dispatch", "/v1/dispatch/capacity", "/v1/conductor/move/status", "/v1/conductor/move/launch", "/v1/conductor/move/operation", "/v1/conductor/move/notification", "/v1/dispatch/brief", "/v1/conductor", "/v1/conductor/grants", "/v1/computers", "/v1/sets", "/v1/authority", "/v1/owner-inbox", "/v1/conductor/integrator"]),
...routes("POST", ["/v1/conductor/call", "/v1/conductor/move/prepare", "/v1/conductor/move/commit", "/v1/conductor/move/operate", "/v1/conductor/move/ack", "/v1/conductor/move/notice", "/v1/dispatch", "/v1/dispatch/workers", "/v1/dispatch/returns", "/v1/dispatch/approve", "/v1/dispatch/report", "/v1/dispatch/close", "/v1/dispatch/close-self", "/v1/owner-inbox", "/v1/conductor/integrator", "/v1/hand-off", "/v1/hand-off/status", "/v1/conductor/grants", "/v1/conductor/make", "/v1/conductor/stop", "/v1/sets/name", "/v1/sets/link", "/v1/authority", "/v1/authority/confirm"]), ...routes("DELETE", ["/v1/conductor/grants", "/v1/authority"])],
capabilities: ["dispatch", "moveConductorToComputer", "conductorSets", "setLink", "ownerInbox", "workerReports", "queuedHandOff"], storeFiles: ["global/skills/conductor/**"],
localFiles: ["<bridge>/hooks.yaml", "<bridge>/conductor.yaml", "<bridge>/conductor-role.json", "<bridge>/conductor-moves/**", "<bridge>/conductor/brief.md", "<bridge>/authority.yaml", "<bridge>/authority-confirmations.json", "<bridge>/dispatches/*.json", "<bridge>/hand-offs/*.json", "<bridge>/worker-reports/*.json", "<bridge>/closed-workers/*.json", "<bridge>/owner-inbox.json", "<bridge>/integrator.json"], phoneScreens: [], skills: ["conductor"],
},
{
schemaVersion: 1, name: "fanout", version: VERSION, defaultEnabled: false, requires: ["memory", "hook"],
tools: [], cliCommands: ["fanout"], agentHooks: [], hookRoutes: [], capabilities: [],
storeFiles: [".config/fanout.yaml"], localFiles: ["<store>/.runtime/agent-fanouts/**"],
phoneScreens: [], skills: ["fanout"],
},
{
schemaVersion: 1, name: "code", version: VERSION, defaultEnabled: false, requires: ["memory"],
tools: full(["code_search", "code_definition", "code_references", "code_outline", "code_usage"]),
cliCommands: ["code index", "code status", "code search", "code outline", "code refs", "code def", "code usage"],
agentHooks: [],
hookRoutes: [...routes("GET", ["/v1/code/tree", "/v1/code/usage-page", "/v1/code/changed", "/v1/code/change-counts", "/v1/code/status", "/v1/code/search", "/v1/code/files", "/v1/code/outline", "/v1/code/outline-summary", "/v1/code/file-references", "/v1/code/definition", "/v1/code/references", "/v1/code/usage"]), ...routes("POST", ["/v1/code/note", "/v1/code/reindex", "/v1/code/disable"])],
capabilities: ["code", "codeFiles"],
storeFiles: [], localFiles: ["<store>/.runtime/code/*.sqlite"],
phoneScreens: [{ screen: "CodeView", capability: "code" }], skills: ["code"],
},
];
const overrides = z.record(z.string(), z.boolean());
const configSchema = z.object({
version: z.literal(1),
enabled: overrides.optional(),
profiles: z.record(z.string(), z.object({ enabled: overrides.optional() }).strict()).optional(),
}).strict();
export function readConfig(store) {
let source;
try {
source = fs.readFileSync(path.join(store, ".config", "modules.yaml"), "utf8");
}
catch (error) {
if (error.code === "ENOENT")
return undefined;
throw new Error("Cannot read .config/modules.yaml.");
}
let config;
try {
config = configSchema.parse(yaml.load(source, { schema: yaml.CORE_SCHEMA }));
}
catch {
throw new Error("Invalid .config/modules.yaml: expected version: 1 and boolean module overrides.");
}
return validateConfig(config, true);
}
/** Warned once per process so serve's migrate-then-snapshot double read stays one line. */
const warnedUnknownModules = new Set();
/**
* Unknown keys belong to a newer CLI that enabled a module this build (the
* Hook) does not know; they are ignored, kept for writes, and warned about
* once instead of failing every Hook connection.
*/
export function validateConfig(input, warnUnknown = false) {
const parsed = configSchema.safeParse(input);
if (!parsed.success)
throw new Error("Invalid .config/modules.yaml: expected version: 1 and boolean module overrides.");
const config = parsed.data;
const names = new Set(BUILTIN_MODULES.map(module => module.name));
const unknown = new Set();
for (const values of [config.enabled, ...Object.values(config.profiles ?? {}).map(profile => profile.enabled)]) {
for (const [name, value] of Object.entries(values ?? {})) {
if (!names.has(name)) {
unknown.add(name);
continue;
}
if (name === "memory" && !value)
throw new Error("The memory module cannot be disabled.");
}
}
for (const name of unknown) {
if (!warnUnknown || warnedUnknownModules.has(name))
continue;
warnedUnknownModules.add(name);
console.error(`warning: unknown module "${name}" in .config/modules.yaml ignored by Hook ${VERSION}`);
}
return config;
}
/** Reads never mutate configuration or enable dependencies implicitly. */
export function enabled(store, profile) {
return resolveModules(readConfig(store), profile);
}
export function resolveModules(config, profile) {
const selected = profile && config?.profiles && Object.hasOwn(config.profiles, profile) ? config.profiles[profile].enabled : undefined;
const modules = BUILTIN_MODULES.filter(module => selected?.[module.name] ?? config?.enabled?.[module.name] ?? module.defaultEnabled);
const names = new Set(modules.map(module => module.name));
for (const module of modules) {
for (const dependency of module.requires) {
if (!names.has(dependency))
throw new Error(`Module "${module.name}" requires enabled module "${dependency}".`);
}
}
return modules;
}
export function moduleSource(config, name, profile) {
if (profile && Object.hasOwn(config?.profiles ?? {}, profile) && Object.hasOwn(config?.profiles?.[profile].enabled ?? {}, name))
return "profile";
return Object.hasOwn(config?.enabled ?? {}, name) ? "store" : "default";
}
export function toolOwner(name) {
return BUILTIN_MODULES.find(module => module.tools.some(tool => tool.name === name));
}
export function commandOwner(command) {
command = command.toLowerCase();
let owner;
let length = 0;
for (const module of BUILTIN_MODULES)
for (const entry of module.cliCommands) {
if ((command === entry || command.startsWith(entry + " ")) && entry.length > length) {
owner = module;
length = entry.length;
}
}
return owner;
}
export function disabledHint(name) {
if (name === "code")
return "phren code needs @phren/code: run phren modules enable code";
return `module ${name} is disabled; enable it with phren modules enable ${name}`;
}