UNPKG

@phren/cli

Version:

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

255 lines (254 loc) • 19.9 kB
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}`; }