diff --git a/src/cli/memory-cli.test.ts b/src/cli/memory-cli.test.ts index a02efe0a3..c9fcd0fc9 100644 --- a/src/cli/memory-cli.test.ts +++ b/src/cli/memory-cli.test.ts @@ -26,6 +26,7 @@ describe("memory cli", () => { it("prints vector status when available", async () => { const { registerMemoryCli } = await import("./memory-cli.js"); const { defaultRuntime } = await import("../runtime.js"); + const close = vi.fn(async () => {}); getMemorySearchManager.mockResolvedValueOnce({ manager: { status: () => ({ @@ -44,6 +45,7 @@ describe("memory cli", () => { dims: 1024, }, }), + close, }, }); @@ -56,11 +58,13 @@ describe("memory cli", () => { expect(log).toHaveBeenCalledWith(expect.stringContaining("Vector: ready")); expect(log).toHaveBeenCalledWith(expect.stringContaining("Vector dims: 1024")); expect(log).toHaveBeenCalledWith(expect.stringContaining("Vector path: /opt/sqlite-vec.dylib")); + expect(close).toHaveBeenCalled(); }); it("prints vector error when unavailable", async () => { const { registerMemoryCli } = await import("./memory-cli.js"); const { defaultRuntime } = await import("../runtime.js"); + const close = vi.fn(async () => {}); getMemorySearchManager.mockResolvedValueOnce({ manager: { status: () => ({ @@ -78,6 +82,7 @@ describe("memory cli", () => { loadError: "load failed", }, }), + close, }, }); @@ -89,5 +94,6 @@ describe("memory cli", () => { expect(log).toHaveBeenCalledWith(expect.stringContaining("Vector: unavailable")); expect(log).toHaveBeenCalledWith(expect.stringContaining("Vector error: load failed")); + expect(close).toHaveBeenCalled(); }); }); diff --git a/src/cli/memory-cli.ts b/src/cli/memory-cli.ts index 418f4b691..93c5c2426 100644 --- a/src/cli/memory-cli.ts +++ b/src/cli/memory-cli.ts @@ -1,4 +1,3 @@ -import chalk from "chalk"; import type { Command } from "commander"; import { resolveDefaultAgentId } from "../agents/agent-scope.js"; @@ -6,7 +5,7 @@ import { loadConfig } from "../config/config.js"; import { getMemorySearchManager } from "../memory/index.js"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; -import { theme } from "../terminal/theme.js"; +import { colorize, isRich, theme } from "../terminal/theme.js"; type MemoryCommandOptions = { agent?: string; @@ -42,42 +41,72 @@ export function registerMemoryCli(program: Command) { defaultRuntime.log(error ?? "Memory search disabled."); return; } - const status = manager.status(); - if (opts.json) { - defaultRuntime.log(JSON.stringify(status, null, 2)); - return; - } - const lines = [ - `${chalk.bold.cyan("Memory Search")} (${agentId})`, - `Provider: ${status.provider} (requested: ${status.requestedProvider})`, - status.fallback ? chalk.yellow(`Fallback: ${status.fallback.from}`) : null, - status.sources?.length ? `Sources: ${status.sources.join(", ")}` : null, - `Files: ${status.files}`, - `Chunks: ${status.chunks}`, - `Dirty: ${status.dirty ? "yes" : "no"}`, - `Index: ${status.dbPath}`, - ].filter(Boolean) as string[]; - if (status.vector) { - const vectorState = status.vector.enabled - ? status.vector.available - ? "ready" - : "unavailable" - : "disabled"; - lines.push(`Vector: ${vectorState}`); - if (status.vector.dims) { - lines.push(`Vector dims: ${status.vector.dims}`); + try { + const status = manager.status(); + if (opts.json) { + defaultRuntime.log(JSON.stringify(status, null, 2)); + return; } - if (status.vector.extensionPath) { - lines.push(`Vector path: ${status.vector.extensionPath}`); + const rich = isRich(); + const heading = (text: string) => colorize(rich, theme.heading, text); + const muted = (text: string) => colorize(rich, theme.muted, text); + const info = (text: string) => colorize(rich, theme.info, text); + const success = (text: string) => colorize(rich, theme.success, text); + const warn = (text: string) => colorize(rich, theme.warn, text); + const accent = (text: string) => colorize(rich, theme.accent, text); + const label = (text: string) => muted(`${text}:`); + const lines = [ + `${heading("Memory Search")} ${muted(`(${agentId})`)}`, + `${label("Provider")} ${info(status.provider)} ${muted( + `(requested: ${status.requestedProvider})`, + )}`, + `${label("Model")} ${info(status.model)}`, + status.sources?.length ? `${label("Sources")} ${info(status.sources.join(", "))}` : null, + `${label("Indexed")} ${success(`${status.files} files · ${status.chunks} chunks`)}`, + `${label("Dirty")} ${status.dirty ? warn("yes") : muted("no")}`, + `${label("Store")} ${info(status.dbPath)}`, + `${label("Workspace")} ${info(status.workspaceDir)}`, + ].filter(Boolean) as string[]; + if (status.sourceCounts?.length) { + lines.push(label("By source")); + for (const entry of status.sourceCounts) { + const counts = `${entry.files} files · ${entry.chunks} chunks`; + lines.push(` ${accent(entry.source)} ${muted("·")} ${muted(counts)}`); + } } - if (status.vector.loadError) { - lines.push(chalk.yellow(`Vector error: ${status.vector.loadError}`)); + if (status.fallback) { + lines.push(`${label("Fallback")} ${warn(status.fallback.from)}`); } + if (status.vector) { + const vectorState = status.vector.enabled + ? status.vector.available + ? "ready" + : "unavailable" + : "disabled"; + const vectorColor = + vectorState === "ready" + ? theme.success + : vectorState === "unavailable" + ? theme.warn + : theme.muted; + lines.push(`${label("Vector")} ${colorize(rich, vectorColor, vectorState)}`); + if (status.vector.dims) { + lines.push(`${label("Vector dims")} ${info(String(status.vector.dims))}`); + } + if (status.vector.extensionPath) { + lines.push(`${label("Vector path")} ${info(status.vector.extensionPath)}`); + } + if (status.vector.loadError) { + lines.push(`${label("Vector error")} ${warn(status.vector.loadError)}`); + } + } + if (status.fallback?.reason) { + lines.push(muted(status.fallback.reason)); + } + defaultRuntime.log(lines.join("\n")); + } finally { + await manager.close(); } - if (status.fallback?.reason) { - lines.push(chalk.gray(status.fallback.reason)); - } - defaultRuntime.log(lines.join("\n")); }); memory @@ -100,6 +129,8 @@ export function registerMemoryCli(program: Command) { const message = err instanceof Error ? err.message : String(err); defaultRuntime.error(`Memory index failed: ${message}`); process.exitCode = 1; + } finally { + await manager.close(); } }); @@ -140,6 +171,8 @@ export function registerMemoryCli(program: Command) { defaultRuntime.error(`Memory search failed: ${message}`); process.exitCode = 1; return; + } finally { + await manager.close(); } if (opts.json) { defaultRuntime.log(JSON.stringify({ results }, null, 2)); @@ -149,12 +182,17 @@ export function registerMemoryCli(program: Command) { defaultRuntime.log("No matches."); return; } + const rich = isRich(); const lines: string[] = []; for (const result of results) { lines.push( - `${chalk.green(result.score.toFixed(3))} ${result.path}:${result.startLine}-${result.endLine}`, + `${colorize(rich, theme.success, result.score.toFixed(3))} ${colorize( + rich, + theme.accent, + `${result.path}:${result.startLine}-${result.endLine}`, + )}`, ); - lines.push(chalk.gray(result.snippet)); + lines.push(colorize(rich, theme.muted, result.snippet)); lines.push(""); } defaultRuntime.log(lines.join("\n").trim()); diff --git a/src/memory/manager.ts b/src/memory/manager.ts index faf9e1392..9c8c15d89 100644 --- a/src/memory/manager.ts +++ b/src/memory/manager.ts @@ -297,6 +297,7 @@ export class MemoryIndexManager { model: string; requestedProvider: string; sources: MemorySource[]; + sourceCounts: Array<{ source: MemorySource; files: number; chunks: number }>; fallback?: { from: string; reason?: string }; vector?: { enabled: boolean; @@ -317,6 +318,35 @@ export class MemoryIndexManager { .get(...sourceFilter.params) as { c: number; }; + const sourceCounts = (() => { + const sources = Array.from(this.sources); + if (sources.length === 0) return []; + const bySource = new Map(); + for (const source of sources) { + bySource.set(source, { files: 0, chunks: 0 }); + } + const fileRows = this.db + .prepare( + `SELECT source, COUNT(*) as c FROM files WHERE 1=1${sourceFilter.sql} GROUP BY source`, + ) + .all(...sourceFilter.params) as Array<{ source: MemorySource; c: number }>; + for (const row of fileRows) { + const entry = bySource.get(row.source) ?? { files: 0, chunks: 0 }; + entry.files = row.c ?? 0; + bySource.set(row.source, entry); + } + const chunkRows = this.db + .prepare( + `SELECT source, COUNT(*) as c FROM chunks WHERE 1=1${sourceFilter.sql} GROUP BY source`, + ) + .all(...sourceFilter.params) as Array<{ source: MemorySource; c: number }>; + for (const row of chunkRows) { + const entry = bySource.get(row.source) ?? { files: 0, chunks: 0 }; + entry.chunks = row.c ?? 0; + bySource.set(row.source, entry); + } + return sources.map((source) => ({ source, ...bySource.get(source)! })); + })(); return { files: files?.c ?? 0, chunks: chunks?.c ?? 0, @@ -327,6 +357,7 @@ export class MemoryIndexManager { model: this.provider.model, requestedProvider: this.requestedProvider, sources: Array.from(this.sources), + sourceCounts, fallback: this.fallbackReason ? { from: "local", reason: this.fallbackReason } : undefined, vector: { enabled: this.vector.enabled,