@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
410 lines (409 loc) • 19 kB
JavaScript
import * as fs from "fs";
import * as path from "path";
import { findPhrenPathWithArg, debugLog, ensureFtsCacheRootPrivate, runtimeDir, } from "./shared.js";
import { log as structuredLog, logger } from "./logger.js";
import { errorMessage } from "./utils.js";
import { nonInteractiveGitEnv } from "./utils-helpers.js";
import { printIntegratedHelp, printIntegratedVersion, resolveTopLevelInvocation, runTopLevelCommand, } from "./entrypoint.js";
import { pluginSetupReason, runPluginSetupServer } from "./mcp/plugin-mode.js";
// NOTE: the MCP-server-only module graph (MCP SDK, FTS indexer, tool registries,
// startup-embedding, custom-hook engine) is intentionally NOT imported here. It is
// dynamically imported inside main() so that top-level commands — hook-prompt,
// --version, --help — which never reach main() do not pay its ~1.8s cold-start cost.
// This matters most for the PostToolUse `hook-tool`, which spawns per tool call.
// Covers inherited Git subprocesses, including commands launched by plugins.
Object.assign(process.env, nonInteractiveGitEnv());
const invocation = resolveTopLevelInvocation(process.argv.slice(2));
if (invocation.kind === "help") {
printIntegratedHelp();
process.exit(0);
}
if (invocation.kind === "version") {
printIntegratedVersion();
process.exit(0);
}
if (invocation.kind === "manage") {
const handled = await runTopLevelCommand(invocation.argv, { allowDefaultShell: true });
if (!handled) {
console.error(`Unknown command: ${invocation.argv[0]}\nRun 'phren --help' for available commands.`);
process.exit(1);
}
process.exit(process.exitCode ?? 0);
}
// `phren mcp` (the Claude Code plugin) with no store yet, or with `phren init`'s
// own server already registered, serves a small setup / empty server instead.
const pluginSetup = invocation.kind === "mcp-serve" ? pluginSetupReason() : null;
const phrenPath = pluginSetup
? ""
: findPhrenPathWithArg(invocation.kind === "mcp" ? invocation.phrenArg : undefined);
const STALE_LOCK_MS = 120_000; // 2 min — slightly above EXEC_TIMEOUT_MS (30s) to avoid blocking healthy writers
function cleanStaleLocks(phrenPath) {
const dir = runtimeDir(phrenPath);
try {
if (!fs.existsSync(dir))
return;
for (const entry of fs.readdirSync(dir)) {
if (!entry.endsWith(".lock"))
continue;
const lockPath = path.join(dir, entry);
try {
const stat = fs.statSync(lockPath);
if (Date.now() - stat.mtimeMs > STALE_LOCK_MS) {
const pid = Number.parseInt(fs.readFileSync(lockPath, "utf8").split("\n")[0], 10);
if (pid > 0) {
try {
process.kill(pid, 0);
continue;
}
catch (err) {
if (err.code !== "ESRCH")
continue;
}
}
fs.unlinkSync(lockPath);
debugLog(`Cleaned stale lock: ${entry}`);
}
}
catch (err) {
logger.warn("cleanStaleLocks", `statFile: ${errorMessage(err)}`);
}
}
}
catch (err) {
logger.warn("cleanStaleLocks", `readdir: ${errorMessage(err)}`);
}
}
async function main() {
// Lazy-load the MCP-server-only module graph. Only the long-lived MCP server
// reaches main(), so paying the import cost here — not at module scope — keeps
// every hook subprocess fast (hook-prompt, hook-tool, --version never get here).
const [{ McpServer }, { StdioServerTransport }, { buildIndex, captureIndexInputs, flushEmbeddingQueue, isIndexBusyError, updateFileInIndex: updateFileInIndexFn }, { runCustomHooks }, { mcpResponse }, { startEmbeddingWarmup }, { resolveRuntimeProfile }, { VERSION: PACKAGE_VERSION },] = await Promise.all([
import("@modelcontextprotocol/sdk/server/mcp.js"),
import("@modelcontextprotocol/sdk/server/stdio.js"),
import("./shared/index.js"),
import("./hooks.js"),
import("./tools/types.js"),
import("./startup-embedding.js"),
import("./runtime-profile.js"),
import("./package-metadata.js"),
]);
let profile = resolveRuntimeProfile(phrenPath);
const { activateModules: moduleSnapshot } = await import("./modules/runtime.js");
const { BUILTIN_MODULES, toolOwner, disabledHint } = await import("./modules/registry.js");
const { resolveAllStores } = await import("./store-registry.js");
const { resolveStoreForProject } = await import("./tools/types.js");
const snapshots = new Map(resolveAllStores(phrenPath).filter(store => store.available !== false)
.map(store => [store.path, moduleSnapshot(store.path, profile)]));
const enabledModules = BUILTIN_MODULES.filter(module => [...snapshots.values()].some(snapshot => snapshot.has(module.name)));
const hasModule = (name) => enabledModules.some(module => module.name === name);
cleanStaleLocks(phrenPath);
// Before buildIndex() writes the first snapshot: the FTS cache is a full
// SQLite export of the store, and on Linux it lands in a world-readable
// /tmp. A 0700 root makes it unreachable to other local accounts.
ensureFtsCacheRootPrivate();
let db = null;
// The first call also verifies startup's possibly stale lock-contention fallback.
let localInputs;
const { localIndexRefresh, indexReaders, retryWhileBusy, indexBusyWaitMs } = await import("./mcp/local-index.js");
// Another process (a hook's detached reindex, another session's server)
// holding the rebuild lock is waited out for this long before a call falls
// back to the last good index. A store rebuild takes ~1.5 s idle, ~4 s p90.
const busyWait = { isBusy: isIndexBusyError, maxWaitMs: indexBusyWaitMs() };
const readers = indexReaders(() => {
if (!db)
throw new Error("Index unavailable - check phren setup");
return db;
}, (retired) => {
try {
retired.close();
}
catch (error) {
logger.warn("rebuildIndex", errorMessage(error));
}
});
let indexReady = false;
let shuttingDown = false;
try {
db = await buildIndex(phrenPath, profile);
indexReady = true;
// Load embedding cache and kick off background embedding (fire-and-forget)
const { getEmbeddingCache } = await import("./shared/embedding-cache.js");
const embCache = getEmbeddingCache(phrenPath);
void startEmbeddingWarmup(db, embCache);
}
catch (error) {
const msg = error instanceof Error ? error.message : String(error);
structuredLog("error", "startup", `Failed to build phren index: ${msg}`);
console.error("Failed to build phren index at startup:", msg);
process.exit(1);
}
let writeQueue = Promise.resolve();
let writeQueueDepth = 0;
const MAX_QUEUE_DEPTH = 50;
const WRITE_TIMEOUT_MS = 30_000;
async function rebuildIndex(force = false) {
runCustomHooks(phrenPath, "pre-index");
const oldDb = db;
try {
const nextProfile = resolveRuntimeProfile(phrenPath);
const inputs = await captureIndexInputs(phrenPath, nextProfile);
db = await buildIndex(phrenPath, nextProfile, { force, requireFresh: true });
profile = nextProfile;
localInputs = inputs;
indexReady = true;
// buildIndex() hands back its cached handle inside the debounce
// window, so oldDb can be the very database we just installed.
// An already-running search may retain its handle across an await.
// Close it once its own readers finish, even while newer calls stay busy.
if (oldDb && oldDb !== db)
readers.retire(oldDb);
}
catch (err) {
// Restore old state on failure
db = oldDb;
indexReady = !!oldDb;
throw err;
}
runCustomHooks(phrenPath, "post-index");
}
async function withWriteQueue(fn) {
if (writeQueueDepth >= MAX_QUEUE_DEPTH) {
return mcpResponse({ ok: false, error: `Write queue full (${MAX_QUEUE_DEPTH} items). Try again shortly.`, errorCode: "TIMEOUT" });
}
writeQueueDepth++;
const run = writeQueue.then(async () => {
try {
return await Promise.race([
fn(),
new Promise((_, reject) => setTimeout(() => reject(new Error("Write timeout after 30s")), WRITE_TIMEOUT_MS))
]);
}
catch (err) {
const message = errorMessage(err);
if (message.includes("Write timeout") || message.includes("Write queue full")) {
debugLog(`Write queue timeout: ${message}`);
return mcpResponse({ ok: false, error: `Write queue timeout: ${message}`, errorCode: "TIMEOUT" });
}
throw err;
}
finally {
writeQueueDepth = Math.max(0, writeQueueDepth - 1);
}
});
writeQueue = run.then(() => undefined).catch((error) => {
try {
const message = error instanceof Error
? error.stack || error.message
: String(error);
debugLog(`Write queue error: ${message}`);
}
catch (logError) {
const message = logError instanceof Error ? logError.message : String(logError);
structuredLog("error", "write-queue", `Failed to log write queue error: ${message}`);
}
});
return run;
}
const runIndexExclusive = (fn) => {
const run = writeQueue.then(fn);
writeQueue = run.catch((error) => {
// Busy is expected under concurrent writers and retried by the caller.
if (isIndexBusyError(error))
debugLog(`index-refresh: ${errorMessage(error)}`);
else
logger.warn("index-refresh", errorMessage(error));
});
return run;
};
const localIndex = localIndexRefresh({
isFresh: () => localInputs?.() ?? false,
refresh: () => rebuildIndex(true),
runExclusive: runIndexExclusive,
busyWait,
});
// Tools registered by a module that exports `readsIndex = true` answer from
// the FTS index and refresh it first. Everything else (tasks, findings,
// sessions, account usage, live sessions, dispatch...) reads and writes the
// store's files directly and must not wait on, or fail because of, a rebuild.
let registeringIndexModule = false;
const server = new McpServer({
name: "phren-mcp",
version: PACKAGE_VERSION,
});
// Track MCP tool calls for telemetry (opt-in only, best-effort)
const { trackToolCall } = await import("./telemetry.js");
const { createToolGate, resolveMcpProfile } = await import("./mcp/profile.js");
const origRegisterTool = server.registerTool.bind(server);
// Tools Claude reaches for during normal work in the full profile. Marked
// anthropic/alwaysLoad so Claude Code keeps their schemas resident instead of
// deferring them behind ToolSearch. The core profile marks all of its tools.
const ALWAYS_LOAD_TOOLS = ["add_note", "complete_task", "session_start", "session_end", "get_findings"];
// Every module registers its tools as before; the gate decides what the
// client sees. `core` (the default) exposes ten tools and folds the rest
// behind phren_admin; `full` is the whole surface. See mcp/profile.ts.
const mcpProfile = resolveMcpProfile(phrenPath);
const gate = createToolGate({
profile: mcpProfile,
modules: enabledModules,
alwaysLoad: ALWAYS_LOAD_TOOLS,
register: (name, config, handler) => origRegisterTool(name, config, handler),
wrap: (registeredName, handler) => {
const readsIndex = registeringIndexModule;
return async (...args) => {
if (shuttingDown) {
return {
content: [{
type: "text",
text: JSON.stringify({
ok: false,
error: "phren-mcp server is shutting down; retry in a new session",
}, null, 2),
}],
};
}
if (readsIndex) {
try {
await localIndex.ensureFresh();
}
catch (error) {
// Serve the last good index rather than failing the call: it lags
// the newest write at most, the same trade the prompt hook makes.
// The baseline is not advanced, so the next call refreshes again.
logger.warn("local-index", `${errorMessage(error)}${db ? " (serving last good index)" : ""}`);
if (!db)
return mcpResponse({ ok: false, error: "Could not refresh the local index; retry shortly." });
}
}
if (!indexReady || !db) {
return {
content: [{
type: "text",
text: JSON.stringify({
ok: false,
error: "Index unavailable - check phren setup",
}, null, 2),
}],
};
}
const owner = toolOwner(registeredName);
if (owner && owner.name !== "memory") {
const input = args[0];
const store = typeof input?.project === "string" ? resolveStoreForProject(ctx, input.project).phrenPath : phrenPath;
if (!snapshots.get(store)?.has(owner.name))
return mcpResponse({ ok: false, error: disabledHint(owner.name), errorCode: "UNAVAILABLE" });
}
try {
trackToolCall(phrenPath, registeredName);
}
catch (err) {
logger.warn("trackToolCall", errorMessage(err));
}
return readers.run(() => handler(...args));
};
},
});
server.registerTool = gate.registerTool;
// Register all tool handlers from domain modules
const ctx = {
phrenPath,
get profile() { return profile; },
db: () => readers.get(),
rebuildIndex: () => retryWhileBusy(() => rebuildIndex(), busyWait),
withWriteQueue,
updateFileInIndex: (filePath) => {
if (!db)
throw new Error("Index unavailable - check phren setup");
updateFileInIndexFn(db, filePath, phrenPath);
},
};
// Lazy-imported tool registries — only the MCP server needs them. Registration
// order is irrelevant (each module registers a disjoint set of tools).
const codeAvailable = hasModule("code") && !!(await (await import("./modules/code-package.js")).loadCodePackage(phrenPath));
const toolModules = await Promise.all([
import("./tools/search.js"),
...(hasModule("tasks") ? [import("./tools/tasks.js")] : []),
import("./tools/finding.js"),
import("./tools/memory.js"),
import("./tools/data.js"),
import("./tools/graph.js"),
import("./tools/session.js"),
import("./tools/ops.js"),
import("./tools/skills.js"),
import("./tools/hooks.js"),
...(hasModule("git") ? [import("./tools/extract.js")] : []),
import("./tools/config.js"),
import("./tools/notes.js"),
import("./tools/summaries.js"),
...(codeAvailable ? [import("./tools/code.js")] : []),
...(hasModule("conductor") ? [import("./tools/dispatch.js")] : []),
]);
for (const mod of toolModules) {
registeringIndexModule = "readsIndex" in mod && mod.readsIndex === true;
try {
mod.register(server, ctx);
}
finally {
registeringIndexModule = false;
}
}
gate.finish();
const transport = new StdioServerTransport();
await server.connect(transport);
console.error(`phren-mcp running (${phrenPath}) · profile ${mcpProfile} · ${gate.exposed.size} tools`);
const { startPullPolling } = await import("./sync/pull.js");
const { refreshLinkedContext } = await import("./link/refresh.js");
const polling = startPullPolling(phrenPath, {
onChange: async () => {
// Index refresh still succeeds if an external mirror is temporarily unwritable.
try {
refreshLinkedContext(phrenPath, profile);
}
catch (err) {
logger.warn("periodic-pull", `context refresh: ${errorMessage(err)}`);
}
await rebuildIndex(true);
},
// No timeout races: the queue stays owned until the actual work ends.
runExclusive: runIndexExclusive,
});
server.server.onclose = () => { void shutdown("transport closed"); };
// Graceful shutdown: drain write queue and close DB before exit
async function shutdown(signal) {
if (shuttingDown)
return;
shuttingDown = true;
structuredLog("info", "shutdown", `Received ${signal}, draining write queue...`);
await polling.stop();
try {
await writeQueue;
}
catch {
// Write queue errors already logged
}
// Persist any pending embeddings; bounded so a slow/unreachable Ollama
// can't stall shutdown.
try {
await Promise.race([
flushEmbeddingQueue(),
new Promise((resolve) => setTimeout(resolve, 3000)),
]);
}
catch (err) {
logger.warn("shutdown", `embFlush: ${errorMessage(err)}`);
}
try {
db?.close();
}
catch (err) {
logger.warn("shutdown", `dbClose: ${errorMessage(err)}`);
}
process.exit(0);
}
process.on("SIGTERM", () => void shutdown("SIGTERM"));
process.on("SIGINT", () => void shutdown("SIGINT"));
}
(pluginSetup ? runPluginSetupServer(pluginSetup) : main()).catch((err) => {
console.error("Failed to start phren-mcp:", err);
process.exit(1);
});