feat: show memory summary in status

This commit is contained in:
Peter Steinberger
2026-01-18 01:57:37 +00:00
parent 14e6b21b50
commit 8013c4717c
5 changed files with 95 additions and 2 deletions

View File

@@ -7,7 +7,7 @@ Docs: https://docs.clawd.bot
### Changes ### Changes
- Memory: add hybrid BM25 + vector search (FTS5) with weighted merging and fallback. - Memory: add hybrid BM25 + vector search (FTS5) with weighted merging and fallback.
- Memory: add SQLite embedding cache to speed up reindexing and frequent updates. - Memory: add SQLite embedding cache to speed up reindexing and frequent updates.
- CLI: surface FTS + embedding cache state in `clawdbot memory status`. - CLI: surface memory search state in `clawdbot status` and detailed FTS + embedding cache state in `clawdbot memory status`.
## 2026.1.18-1 ## 2026.1.18-1

View File

@@ -42,6 +42,8 @@ describe("memory cli", () => {
provider: "openai", provider: "openai",
model: "text-embedding-3-small", model: "text-embedding-3-small",
requestedProvider: "openai", requestedProvider: "openai",
cache: { enabled: true, entries: 123, maxEntries: 50000 },
fts: { enabled: true, available: true },
vector: { vector: {
enabled: true, enabled: true,
available: true, available: true,
@@ -62,6 +64,8 @@ describe("memory cli", () => {
expect(log).toHaveBeenCalledWith(expect.stringContaining("Vector: ready")); expect(log).toHaveBeenCalledWith(expect.stringContaining("Vector: ready"));
expect(log).toHaveBeenCalledWith(expect.stringContaining("Vector dims: 1024")); expect(log).toHaveBeenCalledWith(expect.stringContaining("Vector dims: 1024"));
expect(log).toHaveBeenCalledWith(expect.stringContaining("Vector path: /opt/sqlite-vec.dylib")); expect(log).toHaveBeenCalledWith(expect.stringContaining("Vector path: /opt/sqlite-vec.dylib"));
expect(log).toHaveBeenCalledWith(expect.stringContaining("FTS: ready"));
expect(log).toHaveBeenCalledWith(expect.stringContaining("Embedding cache: enabled (123 entries)"));
expect(close).toHaveBeenCalled(); expect(close).toHaveBeenCalled();
}); });

View File

@@ -64,6 +64,7 @@ export async function statusCommand(
agentStatus, agentStatus,
channels, channels,
summary, summary,
memory,
} = scan; } = scan;
const securityAudit = await withProgress( const securityAudit = await withProgress(
@@ -114,6 +115,7 @@ export async function statusCommand(
...summary, ...summary,
os: osSummary, os: osSummary,
update, update,
memory,
gateway: { gateway: {
mode: gatewayMode, mode: gatewayMode,
url: gatewayConnection.url, url: gatewayConnection.url,
@@ -232,6 +234,43 @@ export async function statusCommand(
? `${summary.sessions.paths.length} stores` ? `${summary.sessions.paths.length} stores`
: (summary.sessions.paths[0] ?? "unknown"); : (summary.sessions.paths[0] ?? "unknown");
const memoryValue = (() => {
if (!memory) return muted("disabled");
const parts: string[] = [];
const dirtySuffix = memory.dirty ? ` · ${warn("dirty")}` : "";
parts.push(`${memory.files} files · ${memory.chunks} chunks${dirtySuffix}`);
if (memory.sources?.length) parts.push(`sources ${memory.sources.join(", ")}`);
const vector = memory.vector;
parts.push(
vector?.enabled === false
? muted("vector off")
: vector?.available
? ok("vector ready")
: vector?.available === false
? warn("vector unavailable")
: muted("vector unknown"),
);
const fts = memory.fts;
if (fts) {
parts.push(
fts.enabled === false
? muted("fts off")
: fts.available
? ok("fts ready")
: warn("fts unavailable"),
);
}
const cache = memory.cache;
if (cache) {
parts.push(
cache.enabled
? ok(`cache on${typeof cache.entries === "number" ? ` (${cache.entries})` : ""}`)
: muted("cache off"),
);
}
return parts.join(" · ");
})();
const updateAvailability = resolveUpdateAvailability(update); const updateAvailability = resolveUpdateAvailability(update);
const updateLine = formatUpdateOneLiner(update).replace(/^Update:\s*/i, ""); const updateLine = formatUpdateOneLiner(update).replace(/^Update:\s*/i, "");
@@ -254,6 +293,7 @@ export async function statusCommand(
{ Item: "Gateway", Value: gatewayValue }, { Item: "Gateway", Value: gatewayValue },
{ Item: "Daemon", Value: daemonValue }, { Item: "Daemon", Value: daemonValue },
{ Item: "Agents", Value: agentsValue }, { Item: "Agents", Value: agentsValue },
{ Item: "Memory", Value: memoryValue },
{ Item: "Probes", Value: probesValue }, { Item: "Probes", Value: probesValue },
{ Item: "Events", Value: eventsValue }, { Item: "Events", Value: eventsValue },
{ Item: "Heartbeat", Value: heartbeatValue }, { Item: "Heartbeat", Value: heartbeatValue },

View File

@@ -6,6 +6,7 @@ import { probeGateway } from "../gateway/probe.js";
import { collectChannelStatusIssues } from "../infra/channels-status-issues.js"; import { collectChannelStatusIssues } from "../infra/channels-status-issues.js";
import { resolveOsSummary } from "../infra/os-summary.js"; import { resolveOsSummary } from "../infra/os-summary.js";
import { getTailnetHostname } from "../infra/tailscale.js"; import { getTailnetHostname } from "../infra/tailscale.js";
import { MemoryIndexManager } from "../memory/manager.js";
import { runExec } from "../process/exec.js"; import { runExec } from "../process/exec.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { getAgentLocalStatuses } from "./status.agent-local.js"; import { getAgentLocalStatuses } from "./status.agent-local.js";
@@ -14,6 +15,10 @@ import { getStatusSummary } from "./status.summary.js";
import { getUpdateCheckResult } from "./status.update.js"; import { getUpdateCheckResult } from "./status.update.js";
import { buildChannelsTable } from "./status-all/channels.js"; import { buildChannelsTable } from "./status-all/channels.js";
type MemoryStatusSnapshot = ReturnType<(typeof MemoryIndexManager)["prototype"]["status"]> & {
agentId: string;
};
export type StatusScanResult = { export type StatusScanResult = {
cfg: ReturnType<typeof loadConfig>; cfg: ReturnType<typeof loadConfig>;
osSummary: ReturnType<typeof resolveOsSummary>; osSummary: ReturnType<typeof resolveOsSummary>;
@@ -31,6 +36,7 @@ export type StatusScanResult = {
agentStatus: Awaited<ReturnType<typeof getAgentLocalStatuses>>; agentStatus: Awaited<ReturnType<typeof getAgentLocalStatuses>>;
channels: Awaited<ReturnType<typeof buildChannelsTable>>; channels: Awaited<ReturnType<typeof buildChannelsTable>>;
summary: Awaited<ReturnType<typeof getStatusSummary>>; summary: Awaited<ReturnType<typeof getStatusSummary>>;
memory: MemoryStatusSnapshot | null;
}; };
export async function scanStatus( export async function scanStatus(
@@ -44,7 +50,7 @@ export async function scanStatus(
return await withProgress( return await withProgress(
{ {
label: "Scanning status…", label: "Scanning status…",
total: 9, total: 10,
enabled: opts.json !== true, enabled: opts.json !== true,
}, },
async (progress) => { async (progress) => {
@@ -122,6 +128,20 @@ export async function scanStatus(
}); });
progress.tick(); progress.tick();
progress.setLabel("Checking memory…");
const memory = await (async (): Promise<MemoryStatusSnapshot | null> => {
const agentId = agentStatus.defaultId ?? "main";
const manager = await MemoryIndexManager.get({ cfg, agentId }).catch(() => null);
if (!manager) return null;
try {
await manager.probeVectorAvailability();
} catch {}
const status = manager.status();
await manager.close().catch(() => {});
return { agentId, ...status };
})();
progress.tick();
progress.setLabel("Reading sessions…"); progress.setLabel("Reading sessions…");
const summary = await getStatusSummary(); const summary = await getStatusSummary();
progress.tick(); progress.tick();
@@ -146,6 +166,7 @@ export async function scanStatus(
agentStatus, agentStatus,
channels, channels,
summary, summary,
memory,
}; };
}, },
); );

View File

@@ -71,6 +71,31 @@ const mocks = vi.hoisted(() => ({
}), }),
})); }));
vi.mock("../memory/manager.js", () => ({
MemoryIndexManager: {
get: vi.fn(async ({ agentId }: { agentId: string }) => ({
probeVectorAvailability: vi.fn(async () => true),
status: () => ({
files: 2,
chunks: 3,
dirty: false,
workspaceDir: "/tmp/clawd",
dbPath: "/tmp/memory.sqlite",
provider: "openai",
model: "text-embedding-3-small",
requestedProvider: "openai",
sources: ["memory"],
sourceCounts: [{ source: "memory", files: 2, chunks: 3 }],
cache: { enabled: true, entries: 10, maxEntries: 500 },
fts: { enabled: true, available: true },
vector: { enabled: true, available: true, extensionPath: "/opt/vec0.dylib", dims: 1024 },
}),
close: vi.fn(async () => {}),
__agentId: agentId,
})),
},
}));
vi.mock("../config/sessions.js", () => ({ vi.mock("../config/sessions.js", () => ({
loadSessionStore: mocks.loadSessionStore, loadSessionStore: mocks.loadSessionStore,
resolveMainSessionKey: mocks.resolveMainSessionKey, resolveMainSessionKey: mocks.resolveMainSessionKey,
@@ -234,6 +259,8 @@ describe("statusCommand", () => {
await statusCommand({ json: true }, runtime as never); await statusCommand({ json: true }, runtime as never);
const payload = JSON.parse((runtime.log as vi.Mock).mock.calls[0][0]); const payload = JSON.parse((runtime.log as vi.Mock).mock.calls[0][0]);
expect(payload.linkChannel.linked).toBe(true); expect(payload.linkChannel.linked).toBe(true);
expect(payload.memory.agentId).toBe("main");
expect(payload.memory.vector.available).toBe(true);
expect(payload.sessions.count).toBe(1); expect(payload.sessions.count).toBe(1);
expect(payload.sessions.paths).toContain("/tmp/sessions.json"); expect(payload.sessions.paths).toContain("/tmp/sessions.json");
expect(payload.sessions.defaults.model).toBeTruthy(); expect(payload.sessions.defaults.model).toBeTruthy();
@@ -256,6 +283,7 @@ describe("statusCommand", () => {
expect(logs.some((l) => l.includes("CRITICAL"))).toBe(true); expect(logs.some((l) => l.includes("CRITICAL"))).toBe(true);
expect(logs.some((l) => l.includes("Dashboard"))).toBe(true); expect(logs.some((l) => l.includes("Dashboard"))).toBe(true);
expect(logs.some((l) => l.includes("macos 14.0 (arm64)"))).toBe(true); expect(logs.some((l) => l.includes("macos 14.0 (arm64)"))).toBe(true);
expect(logs.some((l) => l.includes("Memory"))).toBe(true);
expect(logs.some((l) => l.includes("Channels"))).toBe(true); expect(logs.some((l) => l.includes("Channels"))).toBe(true);
expect(logs.some((l) => l.includes("WhatsApp"))).toBe(true); expect(logs.some((l) => l.includes("WhatsApp"))).toBe(true);
expect(logs.some((l) => l.includes("Sessions"))).toBe(true); expect(logs.some((l) => l.includes("Sessions"))).toBe(true);