diff --git a/src/cli.ts b/src/cli.ts index 318ed16..83baedb 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -38,11 +38,28 @@ import { type ConnectManifest, type RemoveOptions, } from "./cli/remove-plan.js"; +import { renderSplash } from "./cli/splash.js"; +import { isFirstRun, readPrefs, resetPrefs, writePrefs } from "./cli/preferences.js"; +import { runOnboarding } from "./cli/onboarding.js"; +import { setBootVerbose } from "./logger.js"; +import { VERSION } from "./version.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const args = process.argv.slice(2); const IS_WINDOWS = platform() === "win32"; -const IS_VERBOSE = args.includes("--verbose") || args.includes("-v"); +const IS_VERBOSE = + args.includes("--verbose") || + args.includes("-v") || + process.env["AGENTMEMORY_VERBOSE"] === "1" || + process.env["AGENTMEMORY_VERBOSE"] === "true"; + +// Propagate the resolved verbosity to the worker's boot logger so the +// 25-line `[agentmemory] X registered` stream is either dropped or +// printed verbatim. Without this the worker's default (env-only) would +// disagree with the CLI flag. +setBootVerbose(IS_VERBOSE); + +const IS_RESET = args.includes("--reset"); // Pinned iii-engine version. The unpinned `install.iii.dev/iii/main/install.sh` // script tracks `latest`, which made every fresh agentmemory install pull @@ -123,7 +140,8 @@ Commands: Options: --help, -h Show this help - --verbose, -v Show engine stderr and diagnostic info on startup + --verbose, -v Show engine stderr, boot log, and diagnostic info + --reset Wipe ~/.agentmemory/preferences.json and re-run onboarding --tools all|core Tool visibility (default: core = 7 tools) --no-engine Skip auto-starting iii-engine --port Override REST port (default: 3111) @@ -710,23 +728,61 @@ function portInUseDiagnostic(port: number): string { : ` lsof -i :${port} # or: ss -tlnp | grep :${port}`; } +async function waitForAgentmemoryReady(timeoutMs: number): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (await isAgentmemoryReady()) return true; + await new Promise((r) => setTimeout(r, 250)); + } + return false; +} + +function printReadyHint(): void { + const port = getRestPort(); + const viewer = getViewerUrl(); + const hint = `Memory ready on :${port} · viewer on ${viewer} · try: agentmemory demo`; + // Use plain stdout (not p.outro) so the hint isn't decorated with + // clack's closing line — it reads as a status, not an end-of-flow. + process.stdout.write("\n" + hint + "\n"); +} + async function main() { - p.intro("agentmemory"); + // `--reset` wipes preferences before anything else so the onboarding + // flow below always runs fresh. + if (IS_RESET) { + resetPrefs(); + } + + const firstRun = isFirstRun(); + const prefs = readPrefs(); + // Show the splash on the first run, on --reset, or whenever the user + // hasn't yet opted out via the schema (we set `skipSplash: true` + // after onboarding completes). Verbose runs always splash since the + // user explicitly asked for the chatty experience. + if (firstRun || IS_RESET || IS_VERBOSE || !prefs.skipSplash) { + renderSplash(VERSION); + } + + if (firstRun || IS_RESET) { + await runOnboarding(); + } if (skipEngine) { - p.log.info("Skipping engine check (--no-engine)"); + if (IS_VERBOSE) p.log.info("Skipping engine check (--no-engine)"); await import("./index.js"); + if (await waitForAgentmemoryReady(15000)) printReadyHint(); return; } if (await isEngineRunning()) { - p.log.success("iii-engine is running"); + if (IS_VERBOSE) p.log.success("iii-engine is running"); const attachedBin = whichBinary("iii") ?? fallbackIiiPaths().find((p) => existsSync(p)) ?? null; warnIfEngineVersionMismatch(attachedBin); adoptRunningEngine(); maybeEmitNpxHint(); await import("./index.js"); + if (await waitForAgentmemoryReady(15000)) printReadyHint(); return; } @@ -796,6 +852,10 @@ async function main() { s.stop("iii-engine is ready"); maybeEmitNpxHint(); await import("./index.js"); + if (await waitForAgentmemoryReady(15000)) printReadyHint(); + // Mark splash as something to skip on subsequent runs. This is a + // no-op if onboarding already flipped the flag (idempotent merge). + writePrefs({ skipSplash: true }); } async function apiFetch(base: string, path: string, timeoutMs = 5000): Promise { diff --git a/src/cli/onboarding.ts b/src/cli/onboarding.ts new file mode 100644 index 0000000..2c58934 --- /dev/null +++ b/src/cli/onboarding.ts @@ -0,0 +1,202 @@ +// First-run interactive onboarding flow. +// +// Wakes up only when `isFirstRun()` is true (preferences are missing or +// have never recorded a `firstRunAt`) or when the user passes +// `--reset`. The flow asks for: +// +// 1. Which agents will be wired to agentmemory (multi-select). Each +// option carries a small glyph that we reuse in /status output so +// the user recognises them later. The label mirrors README row 1 +// (native plugins) and row 2 (MCP-only). +// 2. Which LLM provider to use for compress / consolidate / graph. +// "skip — BM25-only mode" is a real first-class option; lots of +// users want agentmemory purely as a hybrid keyword + vector +// memory layer without granting LLM API keys. +// +// We then write `~/.agentmemory/preferences.json` and seed +// `~/.agentmemory/.env` with a commented-out `*_API_KEY=` line for the +// chosen provider. This matches the existing `agentmemory init` flow +// closely so users who skip onboarding still get the same file via +// `agentmemory init`. + +import { copyFile, mkdir } from "node:fs/promises"; +import { constants as fsConstants, existsSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import * as p from "@clack/prompts"; +import { writePrefs } from "./preferences.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// Native plugin row — these agents ship an agentmemory plugin or +// first-party integration. Glyphs match SkillKit's published set +// where they overlap; the rest fall back to the generic `◇`. +const NATIVE_AGENTS: { value: string; label: string; glyph: string }[] = [ + { value: "claude-code", label: "Claude Code", glyph: "⟁" }, + { value: "codex", label: "Codex", glyph: "◎" }, + { value: "openhuman", label: "OpenHuman", glyph: "◇" }, + { value: "openclaw", label: "OpenClaw", glyph: "◇" }, + { value: "hermes", label: "Hermes", glyph: "◇" }, + { value: "pi", label: "Pi", glyph: "◇" }, + { value: "cursor", label: "Cursor", glyph: "◫" }, + { value: "gemini-cli", label: "Gemini CLI", glyph: "✦" }, +]; + +// MCP-only row — these agents use the MCP server we ship rather than +// a native plugin. +const MCP_AGENTS: { value: string; label: string; glyph: string }[] = [ + { value: "opencode", label: "OpenCode", glyph: "⬡" }, + { value: "cline", label: "Cline", glyph: "◇" }, + { value: "goose", label: "Goose", glyph: "◇" }, + { value: "kilo", label: "Kilo", glyph: "◇" }, + { value: "aider", label: "Aider", glyph: "◇" }, + { value: "claude-desktop", label: "Claude Desktop", glyph: "⟁" }, + { value: "windsurf", label: "Windsurf", glyph: "◇" }, + { value: "roo", label: "Roo", glyph: "◇" }, +]; + +const PROVIDERS: { value: string; label: string; envKey: string | null }[] = [ + { value: "anthropic", label: "Anthropic — claude", envKey: "ANTHROPIC_API_KEY" }, + { value: "openai", label: "OpenAI — gpt", envKey: "OPENAI_API_KEY" }, + { value: "gemini", label: "Google — gemini", envKey: "GEMINI_API_KEY" }, + { value: "openrouter", label: "OpenRouter — multi-model", envKey: "OPENROUTER_API_KEY" }, + { value: "minimax", label: "MiniMax — minimax-m1", envKey: "MINIMAX_API_KEY" }, + { value: "skip", label: "Skip — BM25-only mode (no LLM key)", envKey: null }, +]; + +function buildAgentOptions(): { value: string; label: string; hint?: string }[] { + return [ + ...NATIVE_AGENTS.map((a) => ({ + value: a.value, + label: `${a.glyph} ${a.label}`, + hint: "native plugin", + })), + ...MCP_AGENTS.map((a) => ({ + value: a.value, + label: `${a.glyph} ${a.label}`, + hint: "MCP server", + })), + ]; +} + +// Mirror src/cli.ts findEnvExample so onboarding ships the same .env +// skeleton whether called directly or via `agentmemory init`. We +// duplicate (rather than import) so the onboarding module doesn't +// pull cli.ts's top-level side effects into the test runner. +function findEnvExample(): string | null { + const candidates = [ + join(__dirname, "..", "..", ".env.example"), + join(__dirname, "..", ".env.example"), + join(__dirname, ".env.example"), + join(process.cwd(), ".env.example"), + ]; + for (const c of candidates) { + if (existsSync(c)) return c; + } + return null; +} + +async function seedEnvFile(provider: string | null): Promise { + const target = join(homedir(), ".agentmemory", ".env"); + const dir = dirname(target); + await mkdir(dir, { recursive: true }); + + const template = findEnvExample(); + if (template && !existsSync(target)) { + try { + await copyFile(template, target, fsConstants.COPYFILE_EXCL); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== "EEXIST") { + return null; + } + } + } else if (!template && !existsSync(target)) { + // Fall back to a minimal skeleton so users always get a `.env` to + // edit. This matches the shape of the bundled `.env.example` + // without forcing us to keep two copies in sync. + const lines = [ + "# agentmemory environment — uncomment what you need", + "# AGENTMEMORY_URL=http://localhost:3111", + "", + ]; + const envKey = PROVIDERS.find((x) => x.value === provider)?.envKey; + if (envKey) { + lines.push(`# ${envKey}=`); + } + writeFileSync(target, lines.join("\n"), { mode: 0o600 }); + } + + return target; +} + +export interface OnboardingResult { + agents: string[]; + provider: string | null; +} + +export async function runOnboarding(): Promise { + p.note( + [ + "Welcome to agentmemory.", + "", + "Persistent memory for your AI coding agents. We'll pick which", + "agents to wire up and which provider (if any) handles compression", + "and consolidation. Either step can be changed later in ~/.agentmemory/.env.", + ].join("\n"), + "first-run setup", + ); + + const agentsPicked = await p.multiselect({ + message: "Which agents will use agentmemory? (space to toggle, enter to confirm)", + options: buildAgentOptions(), + required: false, + initialValues: ["claude-code"], + }); + if (p.isCancel(agentsPicked)) { + p.cancel("Setup cancelled. Re-run any time with: agentmemory --reset"); + process.exit(0); + } + + const providerPicked = await p.select({ + message: "Which LLM provider should agentmemory use for compress/consolidate?", + options: PROVIDERS.map(({ value, label }) => ({ value, label })), + initialValue: "anthropic", + }); + if (p.isCancel(providerPicked)) { + p.cancel("Setup cancelled. Re-run any time with: agentmemory --reset"); + process.exit(0); + } + + const provider = providerPicked === "skip" ? null : providerPicked; + const agents = (agentsPicked as string[]) ?? []; + + const envPath = await seedEnvFile(provider); + + writePrefs({ + lastAgent: agents[0] ?? null, + lastAgents: agents, + lastProvider: provider, + skipSplash: true, + firstRunAt: new Date().toISOString(), + }); + + const prefsLocation = join(homedir(), ".agentmemory", "preferences.json"); + const lines = [`✓ Saved preferences to ${prefsLocation}`]; + if (envPath) { + lines.push(`✓ Wrote ${envPath} (edit to add your API key)`); + } else { + lines.push(`! Could not write ~/.agentmemory/.env — run \`agentmemory init\` after this completes.`); + } + if (provider) { + const envKey = PROVIDERS.find((x) => x.value === provider)?.envKey; + if (envKey) { + lines.push(` Uncomment ${envKey}= in that file to enable ${provider}.`); + } + } else { + lines.push(" No provider chosen — agentmemory will run in BM25-only mode."); + } + p.note(lines.join("\n"), "ready"); + + return { agents, provider }; +} diff --git a/src/cli/preferences.ts b/src/cli/preferences.ts new file mode 100644 index 0000000..903d28b --- /dev/null +++ b/src/cli/preferences.ts @@ -0,0 +1,130 @@ +// JSON-backed CLI preferences. +// +// Lives at `~/.agentmemory/preferences.json`. The agentmemory daemon +// already owns `~/.agentmemory/.env`, `iii.pid`, `engine-state.json` — +// adding one more sibling here keeps the install-state surface in one +// place. +// +// All functions are synchronous, mirroring the pidfile / engine-state +// helpers in src/cli.ts. We never throw: read failures collapse to +// defaults; write failures swallow silently. Preferences are a UX +// nicety, not data — corrupting `iii.pid` matters, corrupting this +// file does not. +// +// Writes are atomic via tmp + rename so a Ctrl+C between the open and +// the final write can't leave a half-written JSON blob on disk that +// the next read would refuse to parse. + +import { + closeSync, + existsSync, + fsyncSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + unlinkSync, + writeSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +export interface Prefs { + schemaVersion: 1; + // Most recently picked single agent (for "use last agent" style flows). + lastAgent: string | null; + // The full multi-select set from the last onboarding run. + lastAgents: string[]; + // Most recently picked LLM provider; `null` means BM25-only mode. + lastProvider: string | null; + // Once true, splash is rendered only on first run / explicit --reset. + // The first onboarding sets this to true so the second invocation + // skips the banner. + skipSplash: boolean; + // Reserved for a later "do not nag me about the npx vs install + // tradeoff" toggle. Kept on the schema so we don't have to bump + // schemaVersion when we ship the flag. + skipNpxHint: boolean; + // ISO timestamp of the first time onboarding completed. Set once, + // never updated, so we can show "you joined agentmemory N days ago" + // copy in /status later without keeping a separate file. + firstRunAt: string | null; +} + +const DEFAULTS: Prefs = { + schemaVersion: 1, + lastAgent: null, + lastAgents: [], + lastProvider: null, + skipSplash: false, + skipNpxHint: false, + firstRunAt: null, +}; + +export function prefsDir(): string { + return join(homedir(), ".agentmemory"); +} + +export function prefsPath(): string { + return join(prefsDir(), "preferences.json"); +} + +export function readPrefs(): Prefs { + try { + if (!existsSync(prefsPath())) return { ...DEFAULTS }; + const raw = readFileSync(prefsPath(), "utf-8"); + const parsed = JSON.parse(raw) as Partial; + return { ...DEFAULTS, ...parsed, schemaVersion: 1 }; + } catch { + return { ...DEFAULTS }; + } +} + +export function writePrefs(p: Partial): void { + try { + const dir = prefsDir(); + mkdirSync(dir, { recursive: true }); + const current = readPrefs(); + const next: Prefs = { ...current, ...p, schemaVersion: 1 }; + const target = prefsPath(); + const tmp = target + ".tmp"; + // Open + write + fsync + rename ensures a Ctrl+C between any two + // syscalls either leaves the old file intact (rename is atomic on + // POSIX) or leaves only a .tmp behind that the next writePrefs + // overwrites. We never end up with a truncated `preferences.json` + // that readPrefs would have to discard. + const fd = openSync(tmp, "w", 0o600); + try { + writeSync(fd, JSON.stringify(next, null, 2) + "\n"); + try { + fsyncSync(fd); + } catch { + // fsync isn't available on every filesystem (e.g. some Docker + // overlays). The rename below is still atomic; we just can't + // guarantee durability against a power loss. + } + } finally { + closeSync(fd); + } + renameSync(tmp, target); + } catch { + // Preferences are best-effort. Don't crash the CLI for them. + } +} + +export function resetPrefs(): void { + try { + unlinkSync(prefsPath()); + } catch { + // Already gone — that's exactly the state we wanted. + } +} + +export function isFirstRun(): boolean { + // "First run" means: the preferences file doesn't exist OR exists + // but `firstRunAt` was never recorded. The latter handles users who + // had `.agentmemory/preferences.json` from a much older agentmemory + // build that wrote a different schema — we treat them as new. + if (!existsSync(prefsPath())) return true; + return readPrefs().firstRunAt === null; +} diff --git a/src/cli/splash.ts b/src/cli/splash.ts new file mode 100644 index 0000000..bc7f7b4 --- /dev/null +++ b/src/cli/splash.ts @@ -0,0 +1,85 @@ +// Terminal-width-aware splash banner for the agentmemory CLI. +// +// Three render tiers, picked from `process.stdout.columns`: +// +// >= 120 cols: full block-art logo + tagline. +// 80–119 cols: compact monospace title + tagline. +// < 80 cols: single-line `agentmemory v`. +// +// The brand accent is the orange `#FF6B35` we already use in the README +// and viewer; we render it through ANSI 38;5;208 (the closest xterm-256 +// match) when stdout is a TTY, and fall back to plain text otherwise. +// No colour bytes are hard-coded into the strings themselves so that +// piping the banner to a file (`agentmemory > log`) stays clean. +// +// We don't pull in chalk/picocolors — picocolors is a transitive dep but +// we never want to depend on transitives directly. The two ANSI escape +// helpers below are the entire colour surface and they degrade to +// no-ops automatically. + +const IS_COLOR_TTY = !!process.stdout.isTTY && !process.env["NO_COLOR"]; + +function accent(s: string): string { + // 256-colour orange that visually matches #FF6B35 in most modern + // terminal palettes. We pick 208 (a true orange) over the closer-but- + // pinker 209 because it reads as the brand colour on both dark and + // light backgrounds. + return IS_COLOR_TTY ? `\x1b[38;5;208m${s}\x1b[0m` : s; +} + +function dim(s: string): string { + return IS_COLOR_TTY ? `\x1b[2m${s}\x1b[22m` : s; +} + +function bold(s: string): string { + return IS_COLOR_TTY ? `\x1b[1m${s}\x1b[22m` : s; +} + +function getTerminalWidth(): number { + const w = process.stdout.columns; + return typeof w === "number" && w > 0 ? w : 80; +} + +const TAGLINE = "Persistent memory for AI coding agents"; + +// Block-art "agentmemory" lettering. Hand-drawn in box-drawing chars so +// we don't need a figlet dependency. Each row is 70 columns wide which +// gives ~25 cols of breathing room on the 120-col tier. +function fullBanner(version: string): string { + const logo = [ + " _ ", + " __ _ __ _ ___ _ _ | |_ _ __ ___ _ __ ___ ___ _ __ _ _ ", + " / _` |/ _` |/ _ \\ '_\\| __| ' \\/ -_) ' \\ _ \\ / _ \\| '__| | | | ", + "| (_| | (_| | __/ | || |_| | | \\___| | | | | | (_) | | | |_| | ", + " \\__,_|\\__, |\\___|_| \\__|_| |_| |_| |_| |_|\\___/|_| \\__, | ", + " |___/ |___/ ", + ]; + const lines: string[] = ["", ...logo.map((line) => " " + accent(line))]; + lines.push(""); + lines.push(" " + bold(TAGLINE) + " " + dim(`v${version}`)); + lines.push(""); + return lines.join("\n"); +} + +function compactBanner(version: string): string { + const title = " " + bold(accent("agentmemory")); + const meta = " " + dim(`v${version} · ${TAGLINE}`); + return ["", title, meta, ""].join("\n"); +} + +function minimalBanner(version: string): string { + return `${accent("agentmemory")} ${dim(`v${version}`)}`; +} + +export function renderSplash(version: string): void { + const width = getTerminalWidth(); + let out: string; + if (width >= 120) { + out = fullBanner(version); + } else if (width >= 80) { + out = compactBanner(version); + } else { + out = minimalBanner(version); + } + process.stdout.write(out + "\n"); +} diff --git a/src/index.ts b/src/index.ts index fba3bd6..3a4db0e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -95,6 +95,7 @@ import { DedupMap } from "./functions/dedup.js"; import { registerHealthMonitor } from "./health/monitor.js"; import { initMetrics, OTEL_CONFIG } from "./telemetry/setup.js"; import { VERSION } from "./version.js"; +import { bootLog } from "./logger.js"; function hasGetMeter( sdk: unknown, @@ -139,27 +140,27 @@ async function main() { const embeddingProvider = createEmbeddingProvider(); const imageEmbeddingProvider = createImageEmbeddingProvider(); - console.log(`[agentmemory] Starting worker v${VERSION}...`); - console.log(`[agentmemory] Engine: ${config.engineUrl}`); - console.log( - `[agentmemory] Provider: ${config.provider.provider} (${config.provider.model})`, + bootLog(`Starting worker v${VERSION}...`); + bootLog(`Engine: ${config.engineUrl}`); + bootLog( + `Provider: ${config.provider.provider} (${config.provider.model})`, ); if (embeddingProvider) { - console.log( - `[agentmemory] Embedding provider: ${embeddingProvider.name} (${embeddingProvider.dimensions} dims)`, + bootLog( + `Embedding provider: ${embeddingProvider.name} (${embeddingProvider.dimensions} dims)`, ); } else { - console.log(`[agentmemory] Embedding provider: none (BM25-only mode)`); + bootLog(`Embedding provider: none (BM25-only mode)`); } if (imageEmbeddingProvider) { - console.log( - `[agentmemory] Image embedding provider: ${imageEmbeddingProvider.name} (${imageEmbeddingProvider.dimensions} dims) — vision-search active`, + bootLog( + `Image embedding provider: ${imageEmbeddingProvider.name} (${imageEmbeddingProvider.dimensions} dims) — vision-search active`, ); } - console.log( - `[agentmemory] REST API: http://localhost:${config.restPort}/agentmemory/*`, + bootLog( + `REST API: http://localhost:${config.restPort}/agentmemory/*`, ); - console.log(`[agentmemory] Streams: ws://localhost:${config.streamsPort}`); + bootLog(`Streams: ws://localhost:${config.streamsPort}`); const sdk = registerWorker(config.engineUrl, { workerName: "agentmemory", @@ -216,44 +217,44 @@ async function main() { const claudeBridgeConfig = loadClaudeBridgeConfig(); if (claudeBridgeConfig.enabled) { registerClaudeBridgeFunction(sdk, kv, claudeBridgeConfig); - console.log( - `[agentmemory] Claude bridge: syncing to ${claudeBridgeConfig.memoryFilePath}`, + bootLog( + `Claude bridge: syncing to ${claudeBridgeConfig.memoryFilePath}`, ); } if (isGraphExtractionEnabled()) { registerGraphFunction(sdk, kv, provider); - console.log(`[agentmemory] Knowledge graph: extraction enabled`); + bootLog(`Knowledge graph: extraction enabled`); } registerConsolidationPipelineFunction(sdk, kv, provider); - console.log(`[agentmemory] Consolidation pipeline: registered (CONSOLIDATION_ENABLED=${isConsolidationEnabled() ? "true" : "false"})`); + bootLog(`Consolidation pipeline: registered (CONSOLIDATION_ENABLED=${isConsolidationEnabled() ? "true" : "false"})`); if (isAutoCompressEnabled()) { - console.log( - `[agentmemory] WARNING: AGENTMEMORY_AUTO_COMPRESS=true — every PostToolUse observation will be sent to your LLM provider for compression. This spends API tokens proportional to your session tool-use frequency (see #138). Set AGENTMEMORY_AUTO_COMPRESS=false to disable.`, + bootLog( + `WARNING: AGENTMEMORY_AUTO_COMPRESS=true — every PostToolUse observation will be sent to your LLM provider for compression. This spends API tokens proportional to your session tool-use frequency (see #138). Set AGENTMEMORY_AUTO_COMPRESS=false to disable.`, ); } else { - console.log( - `[agentmemory] Auto-compress: OFF (default, #138) — observations indexed via zero-LLM synthetic compression. Set AGENTMEMORY_AUTO_COMPRESS=true to opt-in to LLM-powered summaries (uses your API key).`, + bootLog( + `Auto-compress: OFF (default, #138) — observations indexed via zero-LLM synthetic compression. Set AGENTMEMORY_AUTO_COMPRESS=true to opt-in to LLM-powered summaries (uses your API key).`, ); } if (isContextInjectionEnabled()) { - console.log( - `[agentmemory] WARNING: AGENTMEMORY_INJECT_CONTEXT=true — the PreToolUse and SessionStart hooks will inject up to ~4000 chars of memory context into every tool turn. On Claude Pro this burns session tokens proportional to your tool-call frequency (see #143). Set AGENTMEMORY_INJECT_CONTEXT=false to disable.`, + bootLog( + `WARNING: AGENTMEMORY_INJECT_CONTEXT=true — the PreToolUse and SessionStart hooks will inject up to ~4000 chars of memory context into every tool turn. On Claude Pro this burns session tokens proportional to your tool-call frequency (see #143). Set AGENTMEMORY_INJECT_CONTEXT=false to disable.`, ); } else { - console.log( - `[agentmemory] Context injection: OFF (default, #143) — hooks capture observations but do not inject context into Claude Code's conversation. Set AGENTMEMORY_INJECT_CONTEXT=true to opt-in (warning: expect your Claude Pro allocation to drain faster).`, + bootLog( + `Context injection: OFF (default, #143) — hooks capture observations but do not inject context into Claude Code's conversation. Set AGENTMEMORY_INJECT_CONTEXT=true to opt-in (warning: expect your Claude Pro allocation to drain faster).`, ); } const teamConfig = loadTeamConfig(); if (teamConfig) { registerTeamFunction(sdk, kv, teamConfig); - console.log( - `[agentmemory] Team memory: ${teamConfig.teamId} (${teamConfig.mode})`, + bootLog( + `Team memory: ${teamConfig.teamId} (${teamConfig.mode})`, ); } @@ -287,23 +288,23 @@ async function main() { registerRetentionFunctions(sdk, kv); registerCompressFileFunction(sdk, kv, provider); registerReplayFunctions(sdk, kv); - console.log( - `[agentmemory] v0.6 advanced retrieval: sliding-window, query-expansion, temporal-graph, retention-scoring`, + bootLog( + `v0.6 advanced retrieval: sliding-window, query-expansion, temporal-graph, retention-scoring`, ); - console.log( - `[agentmemory] Orchestration layer: actions, frontier, leases, routines, signals, checkpoints, flow-compress, mesh, branch-aware, sentinels, sketches, crystallize, diagnostics, facets`, + bootLog( + `Orchestration layer: actions, frontier, leases, routines, signals, checkpoints, flow-compress, mesh, branch-aware, sentinels, sketches, crystallize, diagnostics, facets`, ); if (isSlotsEnabled()) { - console.log( - `[agentmemory] Slots: enabled (pinned editable memory). Reflect on Stop hook: ${isReflectEnabled() ? "on" : "off"}`, + bootLog( + `Slots: enabled (pinned editable memory). Reflect on Stop hook: ${isReflectEnabled() ? "on" : "off"}`, ); } const snapshotConfig = loadSnapshotConfig(); if (snapshotConfig.enabled) { registerSnapshotFunction(sdk, kv, snapshotConfig.dir); - console.log( - `[agentmemory] Git snapshots: ${snapshotConfig.dir} (every ${snapshotConfig.interval}s)`, + bootLog( + `Git snapshots: ${snapshotConfig.dir} (every ${snapshotConfig.interval}s)`, ); } @@ -337,8 +338,8 @@ async function main() { }); if (loaded?.bm25 && loaded.bm25.size > 0) { bm25Index.restoreFrom(loaded.bm25); - console.log( - `[agentmemory] Loaded persisted BM25 index (${bm25Index.size} docs)`, + bootLog( + `Loaded persisted BM25 index (${bm25Index.size} docs)`, ); } if (loaded?.vector && vectorIndex && loaded.vector.size > 0) { @@ -390,8 +391,8 @@ async function main() { } } else { vectorIndex.restoreFrom(loaded.vector); - console.log( - `[agentmemory] Loaded persisted vector index (${vectorIndex.size} vectors)`, + bootLog( + `Loaded persisted vector index (${vectorIndex.size} vectors)`, ); } } @@ -404,8 +405,8 @@ async function main() { return 0; }); if (indexCount > 0) { - console.log( - `[agentmemory] Search index rebuilt: ${indexCount} entries`, + bootLog( + `Search index rebuilt: ${indexCount} entries`, ); indexPersistence.scheduleSave(); } @@ -439,8 +440,8 @@ async function main() { backfilled++; } if (backfilled > 0) { - console.log( - `[agentmemory] Backfilled ${backfilled} memories into BM25 (legacy gap before #257)`, + bootLog( + `Backfilled ${backfilled} memories into BM25 (legacy gap before #257)`, ); indexPersistence.scheduleSave(); } @@ -452,11 +453,15 @@ async function main() { } } - console.log( - `[agentmemory] Ready. ${embeddingProvider ? "Triple-stream (BM25+Vector+Graph)" : "BM25+Graph"} search active.`, + // Ready / Endpoints lines are emitted via `bootLog` so they're + // buffered in quiet mode and printed verbatim under --verbose. The + // CLI surfaces a compact summary when it sees the worker reach + // ready state. + bootLog( + `Ready. ${embeddingProvider ? "Triple-stream (BM25+Vector+Graph)" : "BM25+Graph"} search active.`, ); - console.log( - `[agentmemory] Endpoints: 107 REST + ${getAllTools().length} MCP tools + 6 MCP resources + 3 MCP prompts`, + bootLog( + `Endpoints: 107 REST + ${getAllTools().length} MCP tools + 6 MCP resources + 3 MCP prompts`, ); const viewerPort = config.restPort + 2; @@ -478,7 +483,7 @@ async function main() { } catch {} }, autoForgetIntervalMs); autoForgetTimer.unref(); - console.log(`[agentmemory] Auto-forget: enabled (every ${autoForgetIntervalMs / 60000}m)`); + bootLog(`Auto-forget: enabled (every ${autoForgetIntervalMs / 60000}m)`); } if (process.env.LESSON_DECAY_ENABLED !== "false") { @@ -488,7 +493,7 @@ async function main() { } catch {} }, 86400000); lessonDecayTimer.unref(); - console.log(`[agentmemory] Lesson decay sweep: enabled (every 24h)`); + bootLog(`Lesson decay sweep: enabled (every 24h)`); } if (process.env.INSIGHT_DECAY_ENABLED !== "false") { @@ -507,7 +512,7 @@ async function main() { } catch {} }, consolidationIntervalMs); consolidationTimer.unref(); - console.log(`[agentmemory] Auto-consolidation: enabled (every ${consolidationIntervalMs / 60000}m)`); + bootLog(`Auto-consolidation: enabled (every ${consolidationIntervalMs / 60000}m)`); } const shutdown = async () => { diff --git a/src/logger.ts b/src/logger.ts index cd87760..8d4ad37 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -59,3 +59,52 @@ export const logger = { emit("error", msg, fields); }, }; + +// ---------- boot log ---------- +// +// `bootLog` is for the one-shot status lines that every register-* +// function used to dump via `console.log` during engine startup. On a +// fresh install that's ~25 lines of `[agentmemory] X enabled` noise +// before the user can see a prompt. In quiet mode (default), each +// line is captured into a buffer and discarded; the CLI surfaces a +// single compressed summary instead. In verbose mode (set by +// `--verbose` or `AGENTMEMORY_VERBOSE=1`) the lines pass straight +// through to stderr exactly like the old console.log calls. + +let bootVerbose = + process.env["AGENTMEMORY_VERBOSE"] === "1" || + process.env["AGENTMEMORY_VERBOSE"] === "true"; + +const bootBuffer: string[] = []; + +export function setBootVerbose(enabled: boolean): void { + bootVerbose = enabled; +} + +export function isBootVerbose(): boolean { + return bootVerbose; +} + +export function bootLog(msg: string): void { + if (bootVerbose) { + try { + process.stderr.write(`[agentmemory] ${msg}\n`); + } catch { + // stderr unavailable — drop. + } + return; + } + if (bootBuffer.length < 500) bootBuffer.push(msg); +} + +export function bootWarn(msg: string): void { + // Warnings always surface; they're rare and the user needs to see + // them even when the rest of the boot log is suppressed. + try { + process.stderr.write(`[agentmemory] warn ${msg}\n`); + } catch {} +} + +export function getBootBuffer(): readonly string[] { + return bootBuffer; +} diff --git a/test/preferences.test.ts b/test/preferences.test.ts new file mode 100644 index 0000000..9989b83 --- /dev/null +++ b/test/preferences.test.ts @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ORIGINAL_HOME = process.env["HOME"]; +const ORIGINAL_USERPROFILE = process.env["USERPROFILE"]; + +let sandboxHome: string; + +async function freshPrefs() { + vi.resetModules(); + return await import("../src/cli/preferences.js"); +} + +describe("cli preferences", () => { + beforeEach(() => { + sandboxHome = mkdtempSync(join(tmpdir(), "agentmemory-prefs-")); + process.env["HOME"] = sandboxHome; + process.env["USERPROFILE"] = sandboxHome; + }); + + afterEach(() => { + if (ORIGINAL_HOME === undefined) delete process.env["HOME"]; + else process.env["HOME"] = ORIGINAL_HOME; + if (ORIGINAL_USERPROFILE === undefined) delete process.env["USERPROFILE"]; + else process.env["USERPROFILE"] = ORIGINAL_USERPROFILE; + rmSync(sandboxHome, { recursive: true, force: true }); + }); + + it("returns defaults when no preferences file exists", async () => { + const { readPrefs } = await freshPrefs(); + const p = readPrefs(); + expect(p.schemaVersion).toBe(1); + expect(p.lastAgent).toBeNull(); + expect(p.lastAgents).toEqual([]); + expect(p.lastProvider).toBeNull(); + expect(p.skipSplash).toBe(false); + expect(p.skipNpxHint).toBe(false); + expect(p.firstRunAt).toBeNull(); + }); + + it("isFirstRun is true when no preferences file exists", async () => { + const { isFirstRun } = await freshPrefs(); + expect(isFirstRun()).toBe(true); + }); + + it("writePrefs persists values and merges with existing keys", async () => { + const { writePrefs, readPrefs, prefsPath } = await freshPrefs(); + writePrefs({ lastAgent: "claude-code", lastAgents: ["claude-code", "cursor"] }); + let p = readPrefs(); + expect(p.lastAgent).toBe("claude-code"); + expect(p.lastAgents).toEqual(["claude-code", "cursor"]); + expect(p.lastProvider).toBeNull(); + + writePrefs({ lastProvider: "anthropic", skipSplash: true }); + p = readPrefs(); + expect(p.lastAgent).toBe("claude-code"); + expect(p.lastProvider).toBe("anthropic"); + expect(p.skipSplash).toBe(true); + + const raw = JSON.parse(readFileSync(prefsPath(), "utf-8")); + expect(raw.schemaVersion).toBe(1); + expect(raw.lastAgents).toEqual(["claude-code", "cursor"]); + }); + + it("isFirstRun flips to false after firstRunAt is recorded", async () => { + const { writePrefs, isFirstRun } = await freshPrefs(); + writePrefs({ firstRunAt: new Date().toISOString() }); + expect(isFirstRun()).toBe(false); + }); + + it("readPrefs falls back to defaults when the file is corrupt", async () => { + const { readPrefs, prefsDir, prefsPath } = await freshPrefs(); + mkdirSync(prefsDir(), { recursive: true }); + writeFileSync(prefsPath(), "{not json", "utf-8"); + const p = readPrefs(); + expect(p.lastAgent).toBeNull(); + expect(p.schemaVersion).toBe(1); + }); + + it("readPrefs forces schemaVersion to 1 even when the file lies", async () => { + const { readPrefs, prefsDir, prefsPath } = await freshPrefs(); + mkdirSync(prefsDir(), { recursive: true }); + writeFileSync( + prefsPath(), + JSON.stringify({ schemaVersion: 99, lastAgent: "cursor" }), + "utf-8", + ); + const p = readPrefs(); + expect(p.schemaVersion).toBe(1); + expect(p.lastAgent).toBe("cursor"); + }); + + it("resetPrefs removes the file", async () => { + const { writePrefs, resetPrefs, isFirstRun, prefsPath } = await freshPrefs(); + writePrefs({ firstRunAt: new Date().toISOString() }); + expect(isFirstRun()).toBe(false); + resetPrefs(); + expect(() => readFileSync(prefsPath())).toThrow(); + expect(isFirstRun()).toBe(true); + }); +});