mirror of
https://github.com/mksglu/context-mode.git
synced 2026-09-19 03:27:16 +08:00
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:
+45
-4
@@ -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
@@ -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("");
|
||||
|
||||
@@ -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
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user