UNPKG

@phren/cli

Version:

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

620 lines (619 loc) • 29.5 kB
/** * Interactive walkthrough for first-time phren init. * Prompts the user through storage, identity, MCP, hooks, and feature configuration. */ import * as path from "path"; import { MANAGEMENT_PRESETS, presetSummaryLines } from "./management-preset.js"; import { PROJECT_OWNERSHIP_MODES } from "../project-config.js"; import { getMachineName } from "../machine-identity.js"; import { expandHomePath, homePath } from "../shared.js"; import { errorMessage } from "../utils.js"; import { logger } from "../logger.js"; import { log } from "./shared.js"; import { detectProjectDir, inferInitScaffoldFromRepo, } from "./setup.js"; export function withFallbackColors(style) { return { header: style?.header ?? ((text) => text), success: style?.success ?? ((text) => text), warning: style?.warning ?? ((text) => text), }; } export async function createWalkthroughStyle() { try { const chalkModule = await import(String("chalk")); const chalkAny = chalkModule.default ?? chalkModule.chalk ?? chalkModule; const chalk = chalkAny; return withFallbackColors({ header: (text) => chalk.bold.cyan(text), success: (text) => chalk.green(text), warning: (text) => chalk.yellow(text), }); } catch { return withFallbackColors(); } } export async function createWalkthroughPrompts() { try { const ui = walkthroughPromptsFrom(await import(String("inquirer"))); if (ui) return ui; } catch { // fallback below } return readlineWalkthroughPrompts(); } /** Adapts an Inquirer module, or returns null when it offers neither API. */ export function walkthroughPromptsFrom(inquirerModule) { { const maybeFns = inquirerModule; if (typeof maybeFns.input === "function" && typeof maybeFns.confirm === "function" && typeof maybeFns.select === "function") { return { input: async (message, initialValue) => (await maybeFns.input({ message, default: initialValue })).trim(), confirm: async (message, defaultValue = false) => Boolean(await maybeFns.confirm({ message, default: defaultValue })), select: async (message, choices, defaultValue) => maybeFns.select({ message, choices: choices.map((choice) => ({ value: choice.value, name: choice.name, description: choice.description })), default: defaultValue, }), }; } const prompt = maybeFns.default?.prompt ?? maybeFns.prompt; if (typeof prompt === "function") { return { input: async (message, initialValue) => { const answer = await prompt([{ type: "input", name: "value", message, default: initialValue }]); return String(answer.value ?? "").trim(); }, confirm: async (message, defaultValue = false) => { const answer = await prompt([{ type: "confirm", name: "value", message, default: defaultValue }]); return Boolean(answer.value); }, select: async (message, choices, defaultValue) => { const answer = await prompt([{ type: "select", name: "value", message, choices: choices.map((choice) => ({ value: choice.value, name: choice.name })), default: defaultValue, }]); return String(answer.value); }, }; } } return null; } async function readlineWalkthroughPrompts() { const readline = await import("readline"); const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); const ask = (message) => new Promise((resolve) => rl.question(message, resolve)); process.once("exit", () => rl.close()); return { input: async (message, initialValue) => { const prompt = initialValue ? `${message} (${initialValue}): ` : `${message}: `; const answer = (await ask(prompt)).trim(); return answer || (initialValue ?? ""); }, confirm: async (message, defaultValue = false) => { const suffix = defaultValue ? "[Y/n]" : "[y/N]"; const answer = (await ask(`${message} ${suffix}: `)).trim().toLowerCase(); if (!answer) return defaultValue; return answer === "y" || answer === "yes"; }, select: async (message, choices, defaultValue) => { log(`${message}`); for (const [index, choice] of choices.entries()) { log(` ${index + 1}. ${choice.name}`); } const selected = (await ask(`Select [1-${choices.length}]${defaultValue ? " (Enter for default)" : ""}: `)).trim(); if (!selected && defaultValue) return defaultValue; const idx = Number.parseInt(selected, 10) - 1; if (!Number.isNaN(idx) && idx >= 0 && idx < choices.length) return choices[idx].value; return defaultValue ?? choices[0].value; }, }; } function detectRepoRootForStorage(phrenPath) { return detectProjectDir(process.cwd(), phrenPath); } /** The login gh is signed in as, or undefined when gh is missing or signed out. */ export async function ghLogin() { const { execFile } = await import("child_process"); return new Promise(resolve => { execFile("gh", ["api", "user", "--jq", ".login"], { timeout: 8000 }, (error, stdout) => { const login = String(stdout ?? "").trim(); resolve(!error && /^[A-Za-z0-9-]{1,39}$/.test(login) ? login : undefined); }); }); } // Interactive walkthrough for first-time init export async function runWalkthrough(phrenPath, options) { const prompts = await createWalkthroughPrompts(); const style = await createWalkthroughStyle(); const divider = style.header("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); const printSection = (title) => { log(""); log(divider); log(style.header(title)); log(divider); }; const printSummary = (items) => { printSection("Configuration Summary"); for (const item of items) { log(style.success(`✓ ${item}`)); } }; const { renderPhrenArt } = await import("../phren-art.js"); log(""); log(renderPhrenArt(" ")); log(""); printSection("Welcome"); log("Let's set up persistent memory for your AI agents."); log("Every option can be changed later.\n"); // Express mode: skip the entire walkthrough with recommended defaults const useExpress = options?.express === true || (options?.express !== false && options?.advanced !== true && await prompts.confirm("Use recommended settings? (global storage, MCP on, hooks on, auto tasks)", true)); if (useExpress) { const expressResult = { storageChoice: "global", storagePath: path.resolve(homePath(".phren")), machine: getMachineName(), profile: "personal", mcp: "on", hooks: "on", projectOwnershipDefault: "phren-managed", managementPreset: "managed", findingsProactivity: "high", taskProactivity: "high", lowConfidenceThreshold: 0.7, riskySections: ["Stale", "Conflicts"], taskMode: "auto", bootstrapCurrentProject: false, ollamaEnabled: false, autoCaptureEnabled: false, semanticDedupEnabled: false, semanticConflictEnabled: false, findingSensitivity: "balanced", domain: "software", }; printSummary([ `Storage: global (${expressResult.storagePath})`, `Machine: ${expressResult.machine}`, "MCP: enabled", "Hooks: enabled", "Project ownership: phren-managed", "Task mode: auto", "Domain: software", ]); // Only a live terminal gets the follow-up offers; --express stays silent. if (options?.express !== true) { const login = await (options?.githubLogin ?? ghLogin)(); if (login && await prompts.confirm(`Sync memory to a private GitHub repo (${login}/my-phren)?`, true)) { expressResult.githubUsername = login; expressResult.githubRepo = "my-phren"; expressResult.githubCreate = true; } if (["darwin", "linux"].includes(process.platform) && await prompts.confirm("Connect your phone now? (shows a QR code to scan in the Phren app)", false)) { expressResult.connectPhone = true; } } return expressResult; } printSection("Storage Location"); log("Where should phren store data?"); const storageChoice = await prompts.select("Storage location", [ { value: "global", name: "global (~/.phren/ - default, shared across projects)", }, { value: "project", name: "per-project (<repo>/.phren/ - scoped to this repo, add to .gitignore)", }, { value: "custom", name: "custom path", }, ], "global"); let storagePath = path.resolve(homePath(".phren")); let storageRepoRoot; if (storageChoice === "project") { const repoRoot = detectRepoRootForStorage(phrenPath); if (!repoRoot) { throw new Error("Per-project storage requires running init from a repository directory."); } storageRepoRoot = repoRoot; storagePath = path.join(repoRoot, ".phren"); } else if (storageChoice === "custom") { const customInput = await prompts.input("Custom phren path", phrenPath); storagePath = path.resolve(expandHomePath(customInput || phrenPath)); } printSection("Existing Phren"); log("If you've already set up phren on another machine, paste the git clone URL."); log("Otherwise, leave blank."); const cloneAnswer = await prompts.input("Clone URL (leave blank to skip)"); if (cloneAnswer) { const cloneConfig = { storageChoice, storagePath, storageRepoRoot, machine: getMachineName(), profile: "personal", mcp: "on", hooks: "on", projectOwnershipDefault: "phren-managed", managementPreset: "managed", findingsProactivity: "high", taskProactivity: "high", lowConfidenceThreshold: 0.7, riskySections: ["Stale", "Conflicts"], taskMode: "auto", bootstrapCurrentProject: false, ollamaEnabled: false, autoCaptureEnabled: false, semanticDedupEnabled: false, semanticConflictEnabled: false, findingSensitivity: "balanced", cloneUrl: cloneAnswer, domain: "software", }; printSummary([ `Storage: ${storageChoice} (${storagePath})`, `Existing memory clone: ${cloneAnswer}`, `Machine: ${cloneConfig.machine}`, `Profile: ${cloneConfig.profile}`, "MCP: enabled", "Hooks: enabled", "Project ownership default: phren-managed", "Task mode: auto", "Domain: software", ]); return cloneConfig; } const defaultMachine = getMachineName(); printSection("Identity"); const machine = await prompts.input("Machine name", defaultMachine); const profile = await prompts.input("Profile name", "personal"); const repoForInference = detectProjectDir(process.cwd(), storagePath); const inferredScaffold = repoForInference ? inferInitScaffoldFromRepo(repoForInference) : null; const inferredDomain = inferredScaffold?.domain ?? "software"; printSection("Project Domain"); log("What kind of project is this?"); if (repoForInference && inferredScaffold) { log(`Detected repo signals from ${repoForInference} (${inferredScaffold.reason}).`); if (inferredScaffold.referenceHints.length > 0) { log(`Reference hints: ${inferredScaffold.referenceHints.join(", ")}`); } } // Use inferred domain directly — adaptive init derives domain from repo content. // Only ask if inference was weak (fell back to default "software" with no signals). let domain = inferredDomain; if (inferredDomain === "software" && !inferredScaffold) { domain = await prompts.select("Project domain", [ { value: "software", name: "software" }, { value: "research", name: "research" }, { value: "creative", name: "creative" }, { value: "other", name: "other" }, ], "software"); } else { log(`Domain: ${inferredDomain} (inferred from project content)`); } printSection("Management Level"); log("How much of your machine should phren wire up?"); for (const p of MANAGEMENT_PRESETS) log(` ${p}: ${presetSummaryLines(p)}`); log(" Change later: phren preset <managed|assisted|manual>"); const managementPreset = await prompts.select("Management level", MANAGEMENT_PRESETS.map((p) => ({ value: p, name: p === "managed" ? "managed (recommended)" : p })), "managed"); printSection("Project Ownership"); let projectOwnershipDefault; if (managementPreset !== "managed") { projectOwnershipDefault = "detached"; log(`Using detached ownership — the ${managementPreset} preset never writes into your repos.`); } else { log("Choose who owns repo-facing instruction files for projects you add."); log(" phren-managed: Phren may mirror AGENTS.md plus vendor compatibility files into the repo"); log(" detached: Phren keeps its own docs but does not write into the repo"); log(" repo-managed: keep the repo's existing CLAUDE/AGENTS files as canonical"); log(" Change later: phren config project-ownership <mode>"); projectOwnershipDefault = await prompts.select("Default project ownership", [ { value: "detached", name: "detached (default)" }, { value: "phren-managed", name: "phren-managed" }, { value: "repo-managed", name: "repo-managed" }, ], "detached"); } printSection("MCP"); log("MCP mode registers phren as a tool server so your AI agent can call it"); log("directly: search memory, manage tasks, save findings, etc."); log(" Recommended for: Claude Code, Cursor, Copilot CLI, Codex"); log(" Alternative: hooks-only mode (read-only context injection, any agent)"); log(" Change later: phren mcp-mode on|off"); const mcp = (await prompts.confirm("Enable MCP?", true)) ? "on" : "off"; printSection("Hooks"); let hooks; if (managementPreset === "manual") { hooks = "off"; log("Skipped — the manual preset runs phren as an MCP server only (no hooks)."); log(" Enable later: phren preset assisted (or phren hooks-mode on)"); } else { log("Hooks run shell commands at session start, prompt submit, and session end."); log(" - SessionStart: git pull (keeps memory in sync across machines)"); log(" - UserPromptSubmit: searches phren and injects relevant context"); log(" - Stop: commits and pushes any new findings after each response"); log(" What they touch: ~/.claude/settings.json (hooks section only)"); log(" Change later: phren hooks-mode on|off"); hooks = (await prompts.confirm("Enable hooks?", true)) ? "on" : "off"; } printSection("Semantic Search (Optional)"); log("Phren can use a local embedding model for semantic (fuzzy) search via Ollama."); log(" Best fit: paraphrase-heavy or weak-lexical queries."); log(" Skip it if you mostly search by filenames, function names, commands, or exact phrases."); log(" - Model: nomic-embed-text (274 MB, one-time download)"); log(" - Ollama runs locally, no cloud, no cost"); log(" - Falls back to FTS5 keyword search if disabled or unavailable"); log(" Change later: set PHREN_OLLAMA_URL=off to disable"); let ollamaEnabled = false; try { const { checkOllamaStatus } = await import("../shared/ollama.js"); const status = await checkOllamaStatus(); if (status === "ready") { log(" Ollama detected with nomic-embed-text ready."); ollamaEnabled = await prompts.confirm("Enable semantic search for fuzzy/paraphrase recovery?", false); } else if (status === "no_model") { log(" Ollama detected, but nomic-embed-text is not pulled yet."); ollamaEnabled = await prompts.confirm("Enable semantic search for fuzzy/paraphrase recovery? (will pull nomic-embed-text)", false); if (ollamaEnabled) { log(" Run after init: ollama pull nomic-embed-text"); } } else if (status === "not_running") { log(" Ollama not detected. Install it to enable semantic search:"); log(" https://ollama.com → then: ollama pull nomic-embed-text"); ollamaEnabled = await prompts.confirm("Enable semantic search (Ollama not installed yet)?", false); if (ollamaEnabled) { log(style.success(" Semantic search enabled — will activate once Ollama is running.")); log(" To disable: set PHREN_OLLAMA_URL=off in your shell profile"); } } } catch (err) { logger.debug("init", `init ollamaCheck: ${errorMessage(err)}`); } printSection("Auto-Capture (Optional)"); log("After each session, phren scans the conversation for insight-signal phrases"); log("(\"always\", \"never\", \"pitfall\", \"gotcha\", etc.) and saves them automatically."); log(" - Runs silently in the Stop hook; captured findings go to FINDINGS.md"); log(" - You can review and remove any auto-captured entry at any time"); log(" - Can be toggled: set PHREN_FEATURE_AUTO_CAPTURE=0 to disable"); const autoCaptureEnabled = await prompts.confirm("Enable auto-capture?", true); let findingsProactivity = "high"; if (autoCaptureEnabled) { log(" Findings capture level controls how eager phren is to save lessons automatically."); log(" Change later: phren config proactivity.findings <high|medium|low>"); findingsProactivity = await prompts.select("Findings capture level", [ { value: "high", name: "high (recommended)" }, { value: "medium", name: "medium" }, { value: "low", name: "low" }, ], "high"); } else { findingsProactivity = "low"; } printSection("Task Management"); log("Choose how phren handles tasks as you work."); log(" auto (recommended): captures tasks naturally as you work, links findings to tasks"); log(" suggest: proposes tasks but waits for approval before writing"); log(" manual: tasks are fully manual — you add them yourself"); log(" off: never touch tasks automatically"); log(" Change later: phren config workflow set --taskMode=<mode>"); const taskMode = await prompts.select("Task mode", [ { value: "auto", name: "auto (recommended)" }, { value: "suggest", name: "suggest" }, { value: "manual", name: "manual" }, { value: "off", name: "off" }, ], "auto"); let taskProactivity = "high"; if (taskMode === "auto" || taskMode === "suggest") { log(" Task proactivity controls how much evidence phren needs before capturing tasks."); log(" high (recommended): captures tasks as they come up naturally"); log(" medium: only when you explicitly mention a task"); log(" low: minimal auto-capture"); log(" Change later: phren config proactivity.tasks <high|medium|low>"); taskProactivity = await prompts.select("Task proactivity", [ { value: "high", name: "high (recommended)" }, { value: "medium", name: "medium" }, { value: "low", name: "low" }, ], "high"); } printSection("Workflow Guardrails"); log("Choose how strict review gates should be for risky or low-confidence writes."); log(" lowConfidenceThreshold: confidence cutoff used to mark writes as risky"); log(" riskySections: sections always treated as risky"); log(" Change later: phren config workflow set --lowConfidenceThreshold=0.7 --riskySections=Stale,Conflicts"); const thresholdAnswer = await prompts.input("Low-confidence threshold [0.0-1.0]", "0.7"); const lowConfidenceThreshold = parseLowConfidenceThreshold(thresholdAnswer, 0.7); const riskySectionsAnswer = await prompts.input("Risky sections [Review,Stale,Conflicts]", "Stale,Conflicts"); const riskySections = parseRiskySectionsAnswer(riskySectionsAnswer, ["Stale", "Conflicts"]); // Only offer semantic dedup/conflict when an LLM endpoint is explicitly configured. // These features call /chat/completions, not an embedding endpoint, so we gate on // PHREN_LLM_ENDPOINT (primary) or the presence of a known API key as a fallback. // PHREN_EMBEDDING_API_URL alone is NOT sufficient — it only enables embeddings, // not the LLM chat call that callLlm() makes. const hasLlmApi = Boolean((process.env.PHREN_LLM_ENDPOINT) || process.env.ANTHROPIC_API_KEY || process.env.OPENAI_API_KEY); let semanticDedupEnabled = false; let semanticConflictEnabled = false; if (hasLlmApi) { printSection("LLM-Powered Memory Quality (Optional)"); log("Phren can use an LLM to catch near-duplicate or conflicting findings."); log(" Requires: PHREN_LLM_ENDPOINT or ANTHROPIC_API_KEY/OPENAI_API_KEY set"); log(""); log("Semantic dedup: before saving a finding, ask the LLM whether it means the"); log("same thing as an existing one (catches same idea with different wording)."); semanticDedupEnabled = await prompts.confirm("Enable LLM-powered duplicate detection?", false); log(""); log("Conflict detection: after saving a finding, check whether it contradicts an"); log("existing one (e.g. \"always use X\" vs \"never use X\"). Adds an inline annotation."); semanticConflictEnabled = await prompts.confirm("Enable LLM-powered conflict detection?", false); if (semanticDedupEnabled || semanticConflictEnabled) { const currentModel = (process.env.PHREN_LLM_MODEL) || "gpt-4o-mini / claude-haiku-4-5-20251001 (default)"; log(""); log(" Cost note: each semantic check is ~80 input + ~5 output tokens, cached 24h."); log(` Current model: ${currentModel}`); const llmModel = (process.env.PHREN_LLM_MODEL); const isExpensive = llmModel && /opus|sonnet|gpt-4(?!o-mini)/i.test(llmModel); if (isExpensive) { log(style.warning(` Warning: ${llmModel} is 20x more expensive than Haiku for yes/no checks.`)); log(" Consider: PHREN_LLM_MODEL=claude-haiku-4-5-20251001"); } else { log(" With Haiku: fractions of a cent/session. With Opus: ~$0.20/session for heavy use."); log(" Tip: set PHREN_LLM_MODEL=claude-haiku-4-5-20251001 to keep costs low."); } } } printSection("Finding Sensitivity"); log("Controls how eagerly agents save findings to memory."); log(" minimal — only when you explicitly ask"); log(" conservative — decisions and pitfalls only"); log(" balanced — non-obvious patterns, decisions, pitfalls, bugs (recommended)"); log(" aggressive — everything worth remembering, err on the side of capturing"); log(" Change later: phren config finding-sensitivity <level>"); const findingSensitivity = await prompts.select("Finding sensitivity", [ { value: "balanced", name: "balanced (recommended)" }, { value: "conservative", name: "conservative" }, { value: "minimal", name: "minimal" }, { value: "aggressive", name: "aggressive" }, ], "balanced"); printSection("GitHub Sync"); log(`Phren stores memory as plain Markdown files in a git repo (${storagePath}).`); log("Push it to a private GitHub repo to sync memory across machines."); log(" Hooks will auto-commit + push after every session and pull on start."); log(" Skip this if you just want to try phren locally first."); const githubAnswer = await prompts.input("GitHub username (leave blank to skip)"); const githubUsername = githubAnswer || undefined; let githubRepo; if (githubUsername) { const repoAnswer = await prompts.input("Repo name", "my-phren"); githubRepo = repoAnswer || "my-phren"; } let bootstrapCurrentProject = false; let bootstrapOwnership; const detectedProject = detectProjectDir(process.cwd(), storagePath); if (detectedProject) { const detectedProjectName = path.basename(detectedProject); printSection("Current Project"); log(`Detected project: ${detectedProjectName}`); bootstrapCurrentProject = await prompts.confirm("Add this project to phren now?", true); if (!bootstrapCurrentProject) { bootstrapCurrentProject = false; log(style.warning(` Skipped. Later: cd ${detectedProject} && phren add`)); } else { bootstrapOwnership = await prompts.select("Ownership for detected project", [ { value: projectOwnershipDefault, name: `${projectOwnershipDefault} (default)` }, ...PROJECT_OWNERSHIP_MODES .filter((mode) => mode !== projectOwnershipDefault) .map((mode) => ({ value: mode, name: mode })), ], projectOwnershipDefault); } } const summaryItems = [ `Storage: ${storageChoice} (${storagePath})`, `Machine: ${machine}`, `Profile: ${profile}`, `Domain: ${domain}`, `Project ownership default: ${projectOwnershipDefault}`, `MCP: ${mcp === "on" ? "enabled" : "disabled"}`, `Hooks: ${hooks === "on" ? "enabled" : "disabled"}`, `Auto-capture: ${autoCaptureEnabled ? "enabled" : "disabled"}`, `Findings capture level: ${findingsProactivity}`, `Task mode: ${taskMode}`, `Task proactivity: ${taskProactivity}`, `Low-confidence threshold: ${lowConfidenceThreshold}`, `Risky sections: ${riskySections.join(", ")}`, `Finding sensitivity: ${findingSensitivity}`, `Semantic search: ${ollamaEnabled ? "enabled" : "disabled"}`, `Semantic dedup: ${semanticDedupEnabled ? "enabled" : "disabled"}`, `Semantic conflict detection: ${semanticConflictEnabled ? "enabled" : "disabled"}`, `GitHub sync: ${githubUsername ? `${githubUsername}/${githubRepo ?? "my-phren"}` : "skipped"}`, `Add detected project: ${bootstrapCurrentProject ? `yes (${bootstrapOwnership ?? projectOwnershipDefault})` : "no"}`, ]; if (inferredScaffold) { summaryItems.push(`Inference: ${inferredScaffold.reason}`); } printSummary(summaryItems); return { storageChoice, storagePath, storageRepoRoot, machine, profile, mcp, hooks, projectOwnershipDefault, managementPreset, findingsProactivity, taskProactivity, lowConfidenceThreshold, riskySections, taskMode, bootstrapCurrentProject, bootstrapOwnership, ollamaEnabled, autoCaptureEnabled, semanticDedupEnabled, semanticConflictEnabled, findingSensitivity, githubUsername, githubRepo, domain, inferredScaffold: inferredScaffold ? (domain === inferredScaffold.domain ? inferredScaffold : { ...inferredScaffold, domain, topics: [] }) : undefined, }; } function parseLowConfidenceThreshold(raw, fallback) { if (!raw) return fallback; const value = Number.parseFloat(raw.trim()); if (!Number.isFinite(value) || value < 0 || value > 1) return fallback; return value; } function parseRiskySectionsAnswer(raw, fallback) { if (!raw) return [...fallback]; const aliases = { review: "Review", stale: "Stale", conflict: "Conflicts", conflicts: "Conflicts", }; const parsed = raw .split(/[,\s]+/) .map((token) => aliases[token.trim().toLowerCase()]) .filter((section) => Boolean(section)); if (!parsed.length) return [...fallback]; return Array.from(new Set(parsed)); }