fix(stats): persist counter + show lifetime + auto-memory + business value framing

- Add tool_calls table to SessionDB — counter survives upgrades and --continue
- Show persistent memory totals (events across all sessions)
- Show auto-memory count from ~/.claude/projects/*/memory/
- Replace hardcoded '9 more' with actual category count
- Use Opus pricing ($15/M) for cost calculations
- Replace '3.0x' with '3x longer sessions' phrasing
- Add 'Bottom line' footer with session/lifetime cost summary

Closes the upgrade-resets-stats bug. ctx_stats now correctly shows
that data persists across compaction, restart, and upgrade.
This commit is contained in:
Mert Koseoglu
2026-05-02 16:58:37 +03:00
parent 0501887810
commit 4742160d30
7 changed files with 745 additions and 38 deletions
+45 -4
View File
@@ -31,7 +31,7 @@ import { getWorktreeSuffix, SessionDB } from "./session/db.js";
import { searchAllSources } from "./search/unified.js";
import { buildNodeCommand, type HookAdapter } from "./adapters/types.js";
import { loadDatabase } from "./db-base.js";
import { AnalyticsEngine, formatReport } from "./session/analytics.js";
import { AnalyticsEngine, formatReport, getLifetimeStats } from "./session/analytics.js";
const __pkg_dir = dirname(fileURLToPath(import.meta.url));
const VERSION: string = (() => {
for (const rel of ["../package.json", "./package.json"]) {
@@ -345,9 +345,44 @@ function trackResponse(toolName: string, response: ToolResult): ToolResult {
sessionStats.calls[toolName] = (sessionStats.calls[toolName] || 0) + 1;
sessionStats.bytesReturned[toolName] =
(sessionStats.bytesReturned[toolName] || 0) + bytes;
// Persist to SessionDB so counters survive process restart, --continue, upgrade.
// Best-effort: never throws, never blocks.
persistToolCallCounter(toolName, bytes);
return response;
}
/**
* Increment the per-session, per-tool counter in SessionDB so ctx_stats
* keeps showing the right numbers after the server restarts mid-session
* (e.g. on `npm update -g context-mode` or `claude --continue`).
*
* The session_id used is whatever session_meta currently holds as the
* most recent session — populated by the SessionStart hook.
*/
function persistToolCallCounter(toolName: string, bytes: number): void {
try {
const dbHash = hashProjectDir();
const worktreeSuffix = getWorktreeSuffix();
const sessionDbPath = join(
getSessionDir(),
`${dbHash}${worktreeSuffix}.db`,
);
if (!existsSync(sessionDbPath)) return;
const sdb = new SessionDB({ dbPath: sessionDbPath });
try {
const sid = sdb.getLatestSessionId();
if (!sid) return;
sdb.incrementToolCall(sid, toolName, bytes);
} finally {
sdb.close();
}
} catch {
// best-effort: counter must never throw
}
}
function trackIndexed(bytes: number): void {
sessionStats.bytesIndexed += bytes;
}
@@ -1882,6 +1917,12 @@ server.registerTool(
async () => {
// ONE call, ONE source — AnalyticsEngine.queryAll()
let text: string;
// Lifetime stats (across all SessionDBs + auto-memory) — best-effort.
let lifetime;
try {
lifetime = getLifetimeStats({ sessionsDir: getSessionDir() });
} catch { /* ignore — formatReport tolerates undefined */ }
try {
const dbHash = hashProjectDir();
const worktreeSuffix = getWorktreeSuffix();
@@ -1896,7 +1937,7 @@ server.registerTool(
try {
const engine = new AnalyticsEngine(sdb);
const report = engine.queryAll(sessionStats);
text = formatReport(report, VERSION, _latestVersion);
text = formatReport(report, VERSION, _latestVersion, { lifetime });
} finally {
sdb.close();
}
@@ -1904,13 +1945,13 @@ server.registerTool(
// No session DB — build a minimal report from runtime stats only
const engine = new AnalyticsEngine(createMinimalDb());
const report = engine.queryAll(sessionStats);
text = formatReport(report, VERSION, _latestVersion);
text = formatReport(report, VERSION, _latestVersion, { lifetime });
}
} catch {
// Session DB not available or incompatible — build minimal report from runtime stats
const engine = new AnalyticsEngine(createMinimalDb());
const report = engine.queryAll(sessionStats);
text = formatReport(report, VERSION, _latestVersion);
text = formatReport(report, VERSION, _latestVersion, { lifetime });
}
return trackResponse("ctx_stats", {
+271 -20
View File
@@ -9,6 +9,11 @@
* const report = engine.queryAll(runtimeStats);
*/
import { existsSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import { loadDatabase as loadDatabaseImpl } from "../db-base.js";
function semverNewer(a: string, b: string): boolean {
const pa = a.split(".").map(Number);
const pb = b.split(".").map(Number);
@@ -255,11 +260,36 @@ export class AnalyticsEngine {
).get() as { session_id: string } | undefined;
const sid = latestSession?.session_id ?? "";
// ── Hydrate runtime stats from persistent tool_calls table ──
// Bug #1 + #2: counters survive process restart, --continue, upgrade.
// The persistent values include the in-memory ones (we write to DB on
// every trackResponse), so REPLACE rather than ADD to avoid double-count.
const mergedCalls: Record<string, number> = { ...runtimeStats.calls };
const mergedBytes: Record<string, number> = { ...runtimeStats.bytesReturned };
if (sid) {
try {
const persistedRows = this.db.prepare(
"SELECT tool, calls, bytes_returned FROM tool_calls WHERE session_id = ?",
).all(sid) as Array<{ tool: string; calls: number; bytes_returned: number }>;
for (const row of persistedRows) {
// Take MAX so a fresh process (in-memory = 0) inherits prior totals,
// but a long-running process with more in-memory than DB still wins.
mergedCalls[row.tool] = Math.max(mergedCalls[row.tool] || 0, row.calls);
mergedBytes[row.tool] = Math.max(
mergedBytes[row.tool] || 0,
row.bytes_returned,
);
}
} catch {
// tool_calls table missing on older DBs — fall back to runtime only
}
}
// ── Runtime savings ──
const totalBytesReturned = Object.values(runtimeStats.bytesReturned).reduce(
const totalBytesReturned = Object.values(mergedBytes).reduce(
(sum, b) => sum + b, 0,
);
const totalCalls = Object.values(runtimeStats.calls).reduce(
const totalCalls = Object.values(mergedCalls).reduce(
(sum, c) => sum + c, 0,
);
const keptOut = runtimeStats.bytesIndexed + runtimeStats.bytesSandboxed;
@@ -270,14 +300,14 @@ export class AnalyticsEngine {
: 0;
const toolNames = new Set([
...Object.keys(runtimeStats.calls),
...Object.keys(runtimeStats.bytesReturned),
...Object.keys(mergedCalls),
...Object.keys(mergedBytes),
]);
const byTool = Array.from(toolNames).sort().map((tool) => ({
tool,
calls: runtimeStats.calls[tool] || 0,
context_kb: Math.round((runtimeStats.bytesReturned[tool] || 0) / 1024 * 10) / 10,
tokens: Math.round((runtimeStats.bytesReturned[tool] || 0) / 4),
calls: mergedCalls[tool] || 0,
context_kb: Math.round((mergedBytes[tool] || 0) / 1024 * 10) / 10,
tokens: Math.round((mergedBytes[tool] || 0) / 4),
}));
const uptimeMs = Date.now() - runtimeStats.sessionStart;
@@ -398,6 +428,126 @@ export class AnalyticsEngine {
}
}
// ─────────────────────────────────────────────────────────
// Lifetime stats (Bug #3 + #4)
// ─────────────────────────────────────────────────────────
/** Aggregated stats spanning every SessionDB + auto-memory under the user's profile. */
export interface LifetimeStats {
totalEvents: number;
totalSessions: number;
autoMemoryCount: number;
autoMemoryProjects: number;
/** Per-prefix breakdown of auto-memory files (user/feedback/project/...). */
autoMemoryByPrefix: Record<string, number>;
}
/** Extract leading prefix from auto-memory filename: `feedback_push.md` → `feedback`. */
function autoMemoryPrefix(filename: string): string {
const base = filename.replace(/\.md$/i, "");
const m = base.match(/^([a-z]+)/i);
return m ? m[1].toLowerCase() : "other";
}
/**
* Aggregate lifetime stats from all SessionDB files in `sessionsDir` and
* all auto-memory markdown files under `memoryRoot/<project>/memory/`.
*
* Best-effort: silently ignores missing/unreadable files so ctx_stats
* can never be broken by a corrupt sidecar.
*/
export function getLifetimeStats(opts?: {
sessionsDir?: string;
memoryRoot?: string;
/** Override for tests — defaults to db-base loadDatabase(). */
loadDatabase?: () => unknown;
}): LifetimeStats {
const sessionsDir = opts?.sessionsDir
?? join(homedir(), ".claude", "context-mode", "sessions");
const memoryRoot = opts?.memoryRoot
?? join(homedir(), ".claude", "projects");
let totalEvents = 0;
let totalSessions = 0;
// ── SessionDB aggregation ──
if (existsSync(sessionsDir)) {
let dbFiles: string[] = [];
try {
dbFiles = readdirSync(sessionsDir).filter((f) => f.endsWith(".db"));
} catch { /* unreadable */ }
if (dbFiles.length > 0) {
// Lazy-load better-sqlite3 / bun-sqlite via the same path the runtime uses.
let DatabaseCtor: ReturnType<typeof loadDatabaseImpl> | null = null;
try {
DatabaseCtor = opts?.loadDatabase
? (opts.loadDatabase() as ReturnType<typeof loadDatabaseImpl>)
: loadDatabaseImpl();
} catch { /* sqlite unavailable */ }
if (DatabaseCtor) {
for (const file of dbFiles) {
const dbPath = join(sessionsDir, file);
try {
const sdb = new DatabaseCtor(dbPath, { readonly: true });
try {
const ev = sdb.prepare("SELECT COUNT(*) AS cnt FROM session_events").get() as { cnt: number } | undefined;
const ss = sdb.prepare("SELECT COUNT(*) AS cnt FROM session_meta").get() as { cnt: number } | undefined;
totalEvents += ev?.cnt ?? 0;
totalSessions += ss?.cnt ?? 0;
} finally {
sdb.close();
}
} catch {
// missing tables / corrupt file — skip
}
}
}
}
}
// ── Auto-memory file scan ──
let autoMemoryCount = 0;
let autoMemoryProjects = 0;
const autoMemoryByPrefix: Record<string, number> = {};
if (existsSync(memoryRoot)) {
let projectDirs: string[] = [];
try {
projectDirs = readdirSync(memoryRoot).filter((entry) => {
try {
return statSync(join(memoryRoot, entry)).isDirectory();
} catch { return false; }
});
} catch { /* unreadable */ }
for (const proj of projectDirs) {
const memDir = join(memoryRoot, proj, "memory");
if (!existsSync(memDir)) continue;
let mdFiles: string[] = [];
try {
mdFiles = readdirSync(memDir).filter((f) => f.endsWith(".md"));
} catch { continue; }
if (mdFiles.length === 0) continue;
autoMemoryProjects++;
autoMemoryCount += mdFiles.length;
for (const f of mdFiles) {
const prefix = autoMemoryPrefix(f);
autoMemoryByPrefix[prefix] = (autoMemoryByPrefix[prefix] ?? 0) + 1;
}
}
}
return {
totalEvents,
totalSessions,
autoMemoryCount,
autoMemoryProjects,
autoMemoryByPrefix,
};
}
// ─────────────────────────────────────────────────────────
// formatReport — renders FullReport as sales-grade savings dashboard
// ─────────────────────────────────────────────────────────
@@ -426,6 +576,19 @@ function fmtNum(n: number): string {
return String(n);
}
// ─────────────────────────────────────────────────────────
// Pricing (Bug #6) — Anthropic Opus input rate
// ─────────────────────────────────────────────────────────
/** Opus 4 input price: $15 per 1M tokens. */
export const OPUS_INPUT_PRICE_PER_TOKEN = 15 / 1_000_000;
/** Convert a token count to a USD string at the Opus input rate. */
export function tokensToUsd(tokens: number): string {
const safe = Number.isFinite(tokens) && tokens > 0 ? tokens : 0;
return `$${(safe * OPUS_INPUT_PRICE_PER_TOKEN).toFixed(2)}`;
}
/**
* Build a proportional bar using █ chars, scaled to a fixed width.
* Returns e.g. "████████████████████████████████████████" for full width.
@@ -438,19 +601,82 @@ function dataBar(bytes: number, maxBytes: number, width: number = 40): string {
/**
* Render project memory section with category bars.
* Shows persistent event data across all sessions.
*
* Shows persistent event data, and \u2014 when supplied \u2014 lifetime totals
* across every project's SessionDB so users see the cumulative value
* (Bug #3).
*
* Caps the category list at `topN` and prints "N more categories" with the
* actual remaining count (Bug #5 \u2014 was hardcoded "9 more").
*/
function renderProjectMemory(pm: FullReport["projectMemory"]): string[] {
if (pm.total_events === 0) return [];
function renderProjectMemory(
pm: FullReport["projectMemory"],
opts?: { lifetime?: LifetimeStats; topN?: number },
): string[] {
if (pm.total_events === 0 && (opts?.lifetime?.totalEvents ?? 0) === 0) return [];
const out: string[] = [];
const topN = opts?.topN ?? 2;
out.push("");
const sessionLabel = pm.session_count === 1 ? "1 session" : `${pm.session_count} sessions`;
out.push(`${fmtNum(pm.total_events)} events remembered across ${sessionLabel} \u2014 searchable after compact & restart`);
out.push(`Persistent memory \u2713 preserved across compact, restart & upgrade`);
// Lifetime line (Bug #3) \u2014 collapses to project-only when lifetime missing.
const lifeEvents = opts?.lifetime?.totalEvents ?? pm.total_events;
const lifeSessions = opts?.lifetime?.totalSessions ?? pm.session_count;
const sessionLabel = lifeSessions === 1 ? "1 session" : `${fmtNum(lifeSessions)} sessions`;
// Estimate lifetime savings: ~4 bytes/token (~1KB/event) \u2192 at Opus rates.
const lifetimeTokens = lifeEvents * 256; // ~1KB per event / 4 bytes per token
out.push(` ${fmtNum(lifeEvents)} events \u00b7 ${sessionLabel} \u00b7 ~${tokensToUsd(lifetimeTokens)} saved lifetime`);
out.push("");
const maxCount = pm.by_category.length > 0 ? pm.by_category[0].count : 1;
for (const cat of pm.by_category) {
const cats = pm.by_category;
const visible = cats.slice(0, topN);
const maxCount = visible.length > 0 ? visible[0].count : 1;
for (const cat of visible) {
out.push(` ${cat.label.padEnd(18)} ${String(cat.count).padStart(5)} ${dataBar(cat.count, maxCount, 30)}`);
}
// Bug #5: real overflow count, not hardcoded.
const remaining = Math.max(0, cats.length - topN);
if (remaining > 0) {
out.push(` ... ${remaining} more categor${remaining === 1 ? "y" : "ies"}`);
}
return out;
}
/**
* Render the auto-memory section (Bug #4) \u2014 files Claude Code captured
* under ~/.claude/projects/<project>/memory/ across the user's machine.
*/
function renderAutoMemory(lifetime: LifetimeStats | undefined): string[] {
if (!lifetime || lifetime.autoMemoryCount === 0) return [];
const out: string[] = [];
out.push("");
out.push(
`Auto-memory \u2713 ${lifetime.autoMemoryCount} preference${lifetime.autoMemoryCount === 1 ? "" : "s"} learned across ${lifetime.autoMemoryProjects} project${lifetime.autoMemoryProjects === 1 ? "" : "s"}`,
);
const entries = Object.entries(lifetime.autoMemoryByPrefix)
.sort((a, b) => b[1] - a[1])
.slice(0, 6);
for (const [prefix, count] of entries) {
out.push(` ${prefix.padEnd(12)} ${String(count).padStart(2)}`);
}
return out;
}
/** Render the closing "Bottom line" footer (Bug #8). */
function renderBottomLine(sessionTokensSaved: number, lifetime: LifetimeStats | undefined): string[] {
const out: string[] = [];
const sessionUsd = tokensToUsd(sessionTokensSaved);
// Lifetime estimate: ~1KB/event \u00f7 4 bytes/token = 256 tokens/event.
const lifetimeTokens = (lifetime?.totalEvents ?? 0) * 256;
const lifetimeUsd = tokensToUsd(lifetimeTokens);
out.push("");
out.push("\u2500".repeat(65));
out.push("Your AI talks less, remembers more, costs less.");
out.push(`${sessionUsd} this session, ${lifetimeUsd} lifetime, and counting.`);
out.push("\u2500".repeat(65));
return out;
}
@@ -464,9 +690,15 @@ function renderProjectMemory(pm: FullReport["projectMemory"]): string[] {
* - Project memory: category bars showing persistent data across sessions
* - No: Pct column, category tables, tips, jargon
*/
export function formatReport(report: FullReport, version?: string, latestVersion?: string | null): string {
export function formatReport(
report: FullReport,
version?: string,
latestVersion?: string | null,
opts?: { lifetime?: LifetimeStats },
): string {
const lines: string[] = [];
const duration = formatDuration(report.session.uptime_min);
const lifetime = opts?.lifetime;
// ── Compute real savings ──
const totalKeptOut =
@@ -476,6 +708,9 @@ export function formatReport(report: FullReport, version?: string, latestVersion
const grandTotal = totalKeptOut + totalReturned;
const savingsPct = grandTotal > 0 ? (totalKeptOut / grandTotal) * 100 : 0;
const tokensSaved = Math.round(totalKeptOut / 4);
const ratioMultiplier = totalReturned > 0
? Math.max(1, Math.round(grandTotal / Math.max(totalReturned, 1)))
: 0;
// ── Fresh session: no savings yet ──
if (totalKeptOut === 0) {
@@ -489,7 +724,9 @@ export function formatReport(report: FullReport, version?: string, latestVersion
}
// Project memory
lines.push(...renderProjectMemory(report.projectMemory));
lines.push(...renderProjectMemory(report.projectMemory, { lifetime }));
lines.push(...renderAutoMemory(lifetime));
lines.push(...renderBottomLine(0, lifetime));
// Footer
lines.push("");
@@ -504,7 +741,10 @@ export function formatReport(report: FullReport, version?: string, latestVersion
// ── Active session: visual savings dashboard ──
// Line 1: Hero metric — the screenshottable number
lines.push(`${fmtNum(tokensSaved)} tokens saved · ${savingsPct.toFixed(1)}% reduction · ${duration}`);
// Bug #6: include Opus pricing on the hero line for credibility.
lines.push(
`${fmtNum(tokensSaved)} tokens saved · ${savingsPct.toFixed(1)}% reduction · ${duration} · ~${tokensToUsd(tokensSaved)} saved (Opus)`,
);
lines.push("");
// Lines 2-3: Before/After comparison bars — the visual proof
@@ -513,7 +753,12 @@ export function formatReport(report: FullReport, version?: string, latestVersion
lines.push("");
// Value statement — the line people share
lines.push(`${kb(totalKeptOut)} kept out of your conversation. Never entered context.`);
// Bug #7: replace meaningless "3.0x" ratio with "3× longer sessions".
if (ratioMultiplier >= 2) {
lines.push(`${kb(totalKeptOut)} kept out of your conversation — ${ratioMultiplier}× longer sessions before compact.`);
} else {
lines.push(`${kb(totalKeptOut)} kept out of your conversation. Never entered context.`);
}
lines.push("");
// Compact stats row
@@ -545,8 +790,14 @@ export function formatReport(report: FullReport, version?: string, latestVersion
}
}
// ── Project memory — persistent across sessions ──
lines.push(...renderProjectMemory(report.projectMemory));
// ── Project memory — persistent across sessions (Bug #3 + #5) ──
lines.push(...renderProjectMemory(report.projectMemory, { lifetime }));
// ── Auto-memory — Claude Code's preference learnings (Bug #4) ──
lines.push(...renderAutoMemory(lifetime));
// ── Bottom line — business value framing (Bug #8) ──
lines.push(...renderBottomLine(tokensSaved, lifetime));
// ── Footer ──
lines.push("");
+105
View File
@@ -95,6 +95,13 @@ export interface ResumeRow {
consumed: number;
}
/** Aggregated tool-call stats for a single session. */
export interface ToolCallStats {
totalCalls: number;
totalBytesReturned: number;
byTool: Record<string, { calls: number; bytesReturned: number }>;
}
// ─────────────────────────────────────────────────────────
// Constants
// ─────────────────────────────────────────────────────────
@@ -131,6 +138,9 @@ const S = {
deleteResume: "deleteResume",
getOldSessions: "getOldSessions",
searchEvents: "searchEvents",
incrementToolCall: "incrementToolCall",
getToolCallTotals: "getToolCallTotals",
getToolCallByTool: "getToolCallByTool",
} as const;
// ─────────────────────────────────────────────────────────
@@ -211,6 +221,17 @@ export class SessionDB extends SQLiteBase {
created_at TEXT NOT NULL DEFAULT (datetime('now')),
consumed INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS tool_calls (
session_id TEXT NOT NULL,
tool TEXT NOT NULL,
calls INTEGER NOT NULL DEFAULT 0,
bytes_returned INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (session_id, tool)
);
CREATE INDEX IF NOT EXISTS idx_tool_calls_session ON tool_calls(session_id);
`);
// Migration: add per-event attribution columns for existing DBs.
@@ -348,6 +369,23 @@ export class SessionDB extends SQLiteBase {
p(S.getOldSessions,
`SELECT session_id FROM session_meta WHERE started_at < datetime('now', ? || ' days')`);
// ── Tool calls (persistent counter) ──
p(S.incrementToolCall,
`INSERT INTO tool_calls (session_id, tool, calls, bytes_returned)
VALUES (?, ?, 1, ?)
ON CONFLICT(session_id, tool) DO UPDATE SET
calls = calls + 1,
bytes_returned = bytes_returned + excluded.bytes_returned,
updated_at = datetime('now')`);
p(S.getToolCallTotals,
`SELECT COALESCE(SUM(calls), 0) AS calls,
COALESCE(SUM(bytes_returned), 0) AS bytes_returned
FROM tool_calls WHERE session_id = ?`);
p(S.getToolCallByTool,
`SELECT tool, calls, bytes_returned
FROM tool_calls WHERE session_id = ? ORDER BY calls DESC`);
}
// ═══════════════════════════════════════════
@@ -565,6 +603,73 @@ export class SessionDB extends SQLiteBase {
this.stmt(S.markResumeConsumed).run(sessionId);
}
/**
* Return the most recent session_id from session_meta, or null if none.
* Used by the runtime to attach persistent counters to the right session
* after a process restart.
*/
getLatestSessionId(): string | null {
try {
const row = this.db.prepare(
"SELECT session_id FROM session_meta ORDER BY started_at DESC LIMIT 1",
).get() as { session_id?: string } | undefined;
return row?.session_id ?? null;
} catch {
return null;
}
}
// ═══════════════════════════════════════════
// Tool call counters (Bug #1 + #2 — survive restart, --continue, upgrade)
// ═══════════════════════════════════════════
/**
* Increment the persistent tool-call counter for `tool` in `sessionId`.
* Adds `bytesReturned` to the cumulative total. Idempotent across
* SessionDB instances — counters survive process restart.
*/
incrementToolCall(sessionId: string, tool: string, bytesReturned: number = 0): void {
const safeBytes = Number.isFinite(bytesReturned) && bytesReturned > 0 ? Math.round(bytesReturned) : 0;
try {
this.stmt(S.incrementToolCall).run(sessionId, tool, safeBytes);
} catch {
// best-effort: counter must never throw and break the parent call
}
}
/**
* Get aggregated tool-call stats for `sessionId`. Returns zero-stats
* when the session has no recorded calls.
*/
getToolCallStats(sessionId: string): ToolCallStats {
try {
const totals = this.stmt(S.getToolCallTotals).get(sessionId) as
| { calls: number; bytes_returned: number }
| undefined;
const rows = this.stmt(S.getToolCallByTool).all(sessionId) as Array<{
tool: string;
calls: number;
bytes_returned: number;
}>;
const byTool: ToolCallStats["byTool"] = {};
for (const row of rows) {
byTool[row.tool] = {
calls: row.calls,
bytesReturned: row.bytes_returned,
};
}
return {
totalCalls: totals?.calls ?? 0,
totalBytesReturned: totals?.bytes_returned ?? 0,
byTool,
};
} catch {
return { totalCalls: 0, totalBytesReturned: 0, byTool: {} };
}
}
// ═══════════════════════════════════════════
// Lifecycle
// ═══════════════════════════════════════════
+23 -14
View File
@@ -300,12 +300,13 @@ describe("formatReport", () => {
});
const output = formatReport(report, "1.0.71");
expect(output).toContain("1.7K events remembered across 6 sessions");
expect(output).toContain("searchable after compact & restart");
// New format (Bug #3 fix): "Persistent memory" header with lifetime line.
expect(output).toContain("Persistent memory");
expect(output).toContain("1.7K events");
expect(output).toContain("6 sessions");
expect(output).toContain("Files tracked");
expect(output).toContain("Prompts saved");
expect(output).toContain("Git operations");
// Bars should contain unicode block characters
// Only top 2 categories are visible; rest collapse to "N more categories".
// Bars should contain unicode block characters.
expect(output).toMatch(/[█░]/);
});
@@ -322,7 +323,10 @@ describe("formatReport", () => {
});
const output = formatReport(report, "1.0.71");
expect(output).toContain("100 events remembered across 2 sessions");
// New format: "Persistent memory" header + cumulative line.
expect(output).toContain("Persistent memory");
expect(output).toContain("100 events");
expect(output).toContain("2 sessions");
expect(output).toContain("Files tracked");
expect(output).toContain("Git operations");
expect(output).toMatch(/█/);
@@ -350,9 +354,9 @@ describe("formatReport", () => {
const lines = output.split("\n");
const fileLine = lines.findIndex((l: string) => l.includes("Files tracked"));
const gitLine = lines.findIndex((l: string) => l.includes("Git operations"));
const errorLine = lines.findIndex((l: string) => l.includes("Errors caught"));
// Top-2 cap (Bug #5): "Errors caught" rolls into "1 more category".
expect(fileLine).toBeLessThan(gitLine);
expect(gitLine).toBeLessThan(errorLine);
expect(output).toContain("1 more categor");
});
it("hides project memory when no events", () => {
@@ -386,8 +390,9 @@ describe("formatReport", () => {
});
const output = formatReport(report);
expect(output).toContain("across 1 session \u2014");
expect(output).not.toContain("sessions");
// New format includes "1 session" (no plural "s").
expect(output).toContain("1 session");
expect(output).not.toMatch(/\d+ sessions/);
});
});
@@ -438,11 +443,13 @@ describe("formatReport", () => {
expect(lineCount).toBeLessThanOrEqual(32);
});
it("fresh session output is under 8 lines without project memory", () => {
it("fresh session output is under 14 lines without project memory", () => {
// After Bug #8 we always render a 5-line "Bottom line" footer, so the
// empty-state header now fits within ~13 lines instead of the old 8.
const report = makeReport();
const output = formatReport(report, "1.0.71");
const lineCount = output.split("\n").length;
expect(lineCount).toBeLessThanOrEqual(8);
expect(lineCount).toBeLessThanOrEqual(14);
});
});
@@ -579,8 +586,10 @@ describe("formatReport", () => {
// Cache
expect(output).toContain("cache hits");
// Project memory
expect(output).toContain("1.1K events remembered across 4 sessions");
// Project memory (new format — "Persistent memory" header + lifetime line).
expect(output).toContain("Persistent memory");
expect(output).toContain("1.1K events");
expect(output).toContain("4 sessions");
expect(output).toContain("Files tracked");
// Footer
+106
View File
@@ -0,0 +1,106 @@
/**
* lifetime-stats — Bug #3 + #4
*
* Bug #3: Persistent memory totals (events across all sessions, not just
* the current one) must be visible in ctx_stats so the user sees the
* cumulative value of context-mode.
*
* Bug #4: Auto-memory captured by Claude Code under
* ~/.claude/projects/<project>/memory/*.md is invisible today. ctx_stats
* should surface the count and the projects involved.
*/
import { join } from "node:path";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import { mkdirSync, writeFileSync, rmSync } from "node:fs";
import { afterAll, describe, expect, test } from "vitest";
import { SessionDB } from "../../src/session/db.js";
import { getLifetimeStats } from "../../src/session/analytics.js";
const cleanups: Array<() => void> = [];
afterAll(() => {
for (const fn of cleanups) {
try { fn(); } catch { /* ignore */ }
}
});
function tmpDir(prefix: string): string {
const dir = join(tmpdir(), `${prefix}-${randomUUID()}`);
mkdirSync(dir, { recursive: true });
cleanups.push(() => rmSync(dir, { recursive: true, force: true }));
return dir;
}
function tmpDbPath(dir: string, name: string): string {
return join(dir, `${name}.db`);
}
function makeEvent(data: string) {
return {
type: "file",
category: "file",
data,
priority: 2,
data_hash: "",
};
}
describe("getLifetimeStats — cross-session totals + auto-memory", () => {
test("aggregates totalEvents and totalSessions across multiple SessionDBs", () => {
const sessionsDir = tmpDir("sessions");
const db1 = new SessionDB({ dbPath: tmpDbPath(sessionsDir, "proj-a") });
cleanups.push(() => db1.cleanup());
db1.ensureSession("sess-a1", "/p/a");
db1.insertEvent("sess-a1", makeEvent("/p/a/x.ts"), "PostToolUse");
db1.ensureSession("sess-a2", "/p/a");
db1.insertEvent("sess-a2", makeEvent("/p/a/y.ts"), "PostToolUse");
db1.close();
const db2 = new SessionDB({ dbPath: tmpDbPath(sessionsDir, "proj-b") });
cleanups.push(() => db2.cleanup());
db2.ensureSession("sess-b1", "/p/b");
db2.insertEvent("sess-b1", makeEvent("/p/b/m.ts"), "PostToolUse");
db2.insertEvent("sess-b1", makeEvent("/p/b/n.ts"), "PostToolUse");
db2.close();
const memoryRoot = tmpDir("projects-empty");
const stats = getLifetimeStats({ sessionsDir, memoryRoot });
expect(stats.totalEvents).toBe(4);
expect(stats.totalSessions).toBe(3);
});
test("counts auto-memory files across project subdirs", () => {
const sessionsDir = tmpDir("sessions-empty");
const memoryRoot = tmpDir("projects-with-memory");
// ~/.claude/projects/<project>/memory/<file>.md
const projA = join(memoryRoot, "proj-a", "memory");
const projB = join(memoryRoot, "proj-b", "memory");
mkdirSync(projA, { recursive: true });
mkdirSync(projB, { recursive: true });
writeFileSync(join(projA, "user_identity.md"), "name: Mert");
writeFileSync(join(projA, "feedback_push.md"), "always push to next");
writeFileSync(join(projB, "project_notes.md"), "hello");
// Non-md file should be ignored
writeFileSync(join(projB, "ignore.txt"), "skip me");
const stats = getLifetimeStats({ sessionsDir, memoryRoot });
expect(stats.autoMemoryCount).toBe(3);
expect(stats.autoMemoryProjects).toBe(2);
});
test("returns zero stats when no DBs and no memory dirs exist", () => {
const sessionsDir = tmpDir("none-sessions");
const memoryRoot = tmpDir("none-memory");
const stats = getLifetimeStats({ sessionsDir, memoryRoot });
expect(stats.totalEvents).toBe(0);
expect(stats.totalSessions).toBe(0);
expect(stats.autoMemoryCount).toBe(0);
expect(stats.autoMemoryProjects).toBe(0);
});
});
+115
View File
@@ -0,0 +1,115 @@
/**
* stats-output-format — Bugs #5, #6, #7, #8
*
* #5: "9 more categories" was hardcoded — must compute the real overflow.
* #6: "~$0.42 saved" was a guess — must use Opus pricing ($15 / 1M tokens).
* #7: "3.0x" is meaningless — must read "3× longer sessions".
* #8: No business-value framing — must end with a "Bottom line" footer.
*/
import { describe, expect, test } from "vitest";
import { formatReport, tokensToUsd } from "../../src/session/analytics.js";
import type { FullReport, LifetimeStats } from "../../src/session/analytics.js";
function baseReport(): FullReport {
return {
savings: {
processed_kb: 50,
entered_kb: 10,
saved_kb: 40,
pct: 80,
savings_ratio: 5,
by_tool: [
{ tool: "ctx_search", calls: 3, context_kb: 5, tokens: 1280 },
{ tool: "ctx_fetch_and_index", calls: 1, context_kb: 5, tokens: 1280 },
],
total_calls: 4,
total_bytes_returned: 10 * 1024,
kept_out: 40 * 1024,
total_processed: 50 * 1024,
},
session: { id: "sess-x", uptime_min: "3.0" },
continuity: { total_events: 0, by_category: [], compact_count: 0, resume_ready: false },
projectMemory: {
total_events: 160,
session_count: 40,
by_category: [
{ category: "file", count: 391, label: "Files tracked" },
{ category: "cwd", count: 173, label: "Working directory" },
{ category: "rule", count: 80, label: "Project rules (CLAUDE.md)" },
{ category: "git", count: 50, label: "Git operations" },
{ category: "env", count: 40, label: "Environment setup" },
{ category: "task", count: 30, label: "Tasks in progress" },
{ category: "skill",count: 20, label: "Skills used" },
{ category: "data", count: 10, label: "Data references" },
// 8 categories total — first 2 shown, 6 more remaining.
],
},
};
}
function emptyLifetime(): LifetimeStats {
return { totalEvents: 0, totalSessions: 0, autoMemoryCount: 0, autoMemoryProjects: 0, autoMemoryByPrefix: {} };
}
describe("Opus pricing", () => {
test("tokensToUsd uses $15 per 1M input tokens", () => {
expect(tokensToUsd(1_000_000)).toBe("$15.00");
expect(tokensToUsd(42_000)).toBe("$0.63");
expect(tokensToUsd(0)).toBe("$0.00");
});
});
describe("formatReport — Bugs #5/#6/#7/#8", () => {
test("includes Opus pricing line for the active session", () => {
const text = formatReport(baseReport(), "1.0.103", null, {
lifetime: emptyLifetime(),
});
expect(text).toMatch(/\$\d+\.\d{2}.*Opus/);
});
test("uses '× longer sessions' phrasing instead of bare ratio", () => {
const text = formatReport(baseReport(), "1.0.103", null, {
lifetime: emptyLifetime(),
});
// Tolerate either '×' or 'x' depending on glyph choice, but require the phrase.
expect(text).toMatch(/\d+\s*[×x]\s+longer sessions/i);
// And it should NOT use the meaningless bare "3.0x" form alone.
expect(text).not.toMatch(/\b\d+\.\dx\b(?!\s+longer)/);
});
test("computes the real overflow count (not hardcoded '9 more')", () => {
const text = formatReport(baseReport(), "1.0.103", null, {
lifetime: emptyLifetime(),
});
// baseReport has 8 categories; we render 2 → 6 more.
expect(text).toMatch(/6 more categories/);
expect(text).not.toMatch(/9 more categories/);
});
test("ends with a 'Bottom line' / business-value footer", () => {
const text = formatReport(baseReport(), "1.0.103", null, {
lifetime: { ...emptyLifetime(), totalEvents: 160, totalSessions: 40 },
});
// Footer must include the session $ and lifetime $ summary.
expect(text).toMatch(/talks less, remembers more, costs less/i);
expect(text).toMatch(/\$\d+\.\d{2} this session/);
expect(text).toMatch(/\$\d+(\.\d{2})? lifetime/);
});
test("renders auto-memory section when files are present", () => {
const text = formatReport(baseReport(), "1.0.103", null, {
lifetime: {
totalEvents: 160,
totalSessions: 40,
autoMemoryCount: 18,
autoMemoryProjects: 6,
autoMemoryByPrefix: { user: 4, feedback: 7, project: 5, reference: 2 },
},
});
expect(text).toMatch(/Auto-memory/);
expect(text).toMatch(/18 preferences learned/);
expect(text).toMatch(/across 6 projects/);
expect(text).toMatch(/feedback\s+7/);
});
});
@@ -0,0 +1,80 @@
/**
* tool-calls-persistence — Bug #1 + #2
*
* The ctx_stats counter currently lives in process memory only. When the
* server restarts (upgrade) or the user runs `claude --continue`, the
* counter resets to zero even though the session is logically the same.
*
* Fix: persist tool call counters in SessionDB so they survive process
* restarts as long as the session_id is reused.
*/
import { join } from "node:path";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import { afterAll, describe, expect, test } from "vitest";
import { SessionDB } from "../../src/session/db.js";
const cleanups: Array<() => void> = [];
afterAll(() => {
for (const fn of cleanups) {
try { fn(); } catch { /* ignore */ }
}
});
function tmpDbPath(): string {
return join(tmpdir(), `tool-calls-${randomUUID()}.db`);
}
describe("Tool call counter persistence", () => {
test("incrementToolCall + getToolCallStats round-trip in same instance", () => {
const dbPath = tmpDbPath();
const db = new SessionDB({ dbPath });
cleanups.push(() => db.cleanup());
db.incrementToolCall("sess-A", "ctx_search", 1024);
db.incrementToolCall("sess-A", "ctx_search", 2048);
db.incrementToolCall("sess-A", "ctx_fetch_and_index", 4096);
const stats = db.getToolCallStats("sess-A");
expect(stats.totalCalls).toBe(3);
expect(stats.totalBytesReturned).toBe(1024 + 2048 + 4096);
expect(stats.byTool.ctx_search.calls).toBe(2);
expect(stats.byTool.ctx_search.bytesReturned).toBe(1024 + 2048);
expect(stats.byTool.ctx_fetch_and_index.calls).toBe(1);
expect(stats.byTool.ctx_fetch_and_index.bytesReturned).toBe(4096);
});
test("tool call counts persist across SessionDB instances (upgrade scenario)", () => {
const dbPath = tmpDbPath();
// Instance A — simulates the running server before upgrade
const dbA = new SessionDB({ dbPath });
dbA.incrementToolCall("sess-resume", "ctx_search", 100);
dbA.incrementToolCall("sess-resume", "ctx_search", 200);
dbA.incrementToolCall("sess-resume", "ctx_execute", 50);
// close() — keeps file on disk so a fresh instance can re-open
dbA.close();
// Instance B — simulates the new server after upgrade / --continue
const dbB = new SessionDB({ dbPath });
cleanups.push(() => dbB.cleanup());
const stats = dbB.getToolCallStats("sess-resume");
expect(stats.totalCalls).toBe(3);
expect(stats.totalBytesReturned).toBe(350);
expect(stats.byTool.ctx_search.calls).toBe(2);
expect(stats.byTool.ctx_execute.calls).toBe(1);
});
test("getToolCallStats returns zero stats for unknown session", () => {
const db = new SessionDB({ dbPath: tmpDbPath() });
cleanups.push(() => db.cleanup());
const stats = db.getToolCallStats("never-seen");
expect(stats.totalCalls).toBe(0);
expect(stats.totalBytesReturned).toBe(0);
expect(stats.byTool).toEqual({});
});
});