fix(stats): restore Auto-memory, Opus pricing, and business-value footer

Commit b392c2f rewrote src/session/analytics.ts as part of the
opt-in concurrency feature and inadvertently dropped the user-facing
stats improvements landed in 4742160 (bugs #5/#6/#7/#8): the
Auto-memory preferences-learned line, the "Your AI talks less,
remembers more, costs less" tagline, the Opus pricing breakdown,
the "$X this session / $Y lifetime" footer, and the per-prefix
auto-memory bars. The 4th arg to formatReport also collapsed from
an options object {lifetime, mcpUsage} into bare mcpUsage, breaking
every test that passed lifetime data.

Restores the options-object signature, re-renders all dropped
sections under their original guards, and keeps b392c2f's runPool
and getMcpToolUsage infrastructure intact (no concurrency revert).
Updates the src/server.ts call site to match.

tests/session/stats-output-format.test.ts back to green (12/12 pass);
no other tests regress.
This commit is contained in:
Mert Koseoglu
2026-05-02 22:53:14 +03:00
parent b392c2fe2f
commit e638bd6283
2 changed files with 250 additions and 21 deletions
+13 -5
View File
@@ -33,7 +33,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"]) {
@@ -2172,21 +2172,29 @@ server.registerTool(
const report = engine.queryAll(sessionStats);
// MCP usage is read-only and cheap; only available when DB exists.
const mcpUsage = engine.getMcpToolUsage();
text = formatReport(report, VERSION, _latestVersion, mcpUsage);
// Lifetime stats span every project's SessionDB + auto-memory dir
// (Bugs #3/#4); failures are absorbed inside getLifetimeStats so a
// corrupt sidecar can never break ctx_stats.
const lifetime = getLifetimeStats();
text = formatReport(report, VERSION, _latestVersion, { lifetime, mcpUsage });
} finally {
sdb.close();
}
} else {
// No session DB — build a minimal report from runtime stats only
// No session DB — build a minimal report from runtime stats only.
// Lifetime still meaningful (other projects, auto-memory) so include it.
const engine = new AnalyticsEngine(createMinimalDb());
const report = engine.queryAll(sessionStats);
text = formatReport(report, VERSION, _latestVersion);
const lifetime = getLifetimeStats();
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);
let lifetime;
try { lifetime = getLifetimeStats(); } catch { /* never block ctx_stats */ }
text = formatReport(report, VERSION, _latestVersion, lifetime ? { lifetime } : undefined);
}
return trackResponse("ctx_stats", {
+237 -16
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);
@@ -162,10 +167,10 @@ export const categoryLabels: Record<string, string> = {
/** Explains why each category matters for continuity. */
export const categoryHints: Record<string, string> = {
file: "Restored after compact \u2014 no need to re-read",
file: "Restored after compact — no need to re-read",
rule: "Your project instructions survive context resets",
prompt: "Continues exactly where you left off",
decision: "Applied automatically \u2014 won\u2019t ask again",
decision: "Applied automatically — won’t ask again",
task: "Picks up from where it stopped",
error: "Tracked and monitored across compacts",
git: "Branch, commit, and repo state preserved",
@@ -480,6 +485,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
// ─────────────────────────────────────────────────────────
@@ -508,6 +633,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.
@@ -520,19 +658,81 @@ 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 — when supplied — 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 — 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 topN = opts?.topN ?? 2;
const out: string[] = [];
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 ✓ preserved across compact, restart & upgrade");
// Lifetime line (Bug #3) — 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: ~1KB per event → ~256 tokens/event at Opus rates.
const lifetimeTokens = lifeEvents * 256;
out.push(` ${fmtNum(lifeEvents)} events · ${sessionLabel} · ~${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) — 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 ✓ ${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 ÷ 4 bytes/token = 256 tokens/event.
const lifetimeTokens = (lifetime?.totalEvents ?? 0) * 256;
const lifetimeUsd = tokensToUsd(lifetimeTokens);
out.push("");
out.push("─".repeat(65));
out.push("Your AI talks less, remembers more, costs less.");
out.push(`${sessionUsd} this session · ${lifetimeUsd} lifetime`);
out.push("─".repeat(65));
return out;
}
@@ -550,10 +750,12 @@ export function formatReport(
report: FullReport,
version?: string,
latestVersion?: string | null,
mcpUsage?: McpToolUsageRow[],
opts?: { lifetime?: LifetimeStats; mcpUsage?: McpToolUsageRow[] },
): string {
const lines: string[] = [];
const duration = formatDuration(report.session.uptime_min);
const lifetime = opts?.lifetime;
const mcpUsage = opts?.mcpUsage;
// ── Compute real savings ──
const totalKeptOut =
@@ -563,6 +765,9 @@ export function formatReport(
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) {
@@ -575,8 +780,10 @@ export function formatReport(
lines.push(`${kb(totalReturned)} entered context | 0 tokens saved`);
}
// Project memory
lines.push(...renderProjectMemory(report.projectMemory));
// Project memory + auto-memory + bottom line
lines.push(...renderProjectMemory(report.projectMemory, { lifetime }));
lines.push(...renderAutoMemory(lifetime));
lines.push(...renderBottomLine(0, lifetime));
// Footer
lines.push("");
@@ -591,7 +798,10 @@ export function formatReport(
// ── 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
@@ -600,7 +810,12 @@ export function formatReport(
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
@@ -642,8 +857,14 @@ export function formatReport(
}
}
// ── 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("");