mirror of
https://github.com/mksglu/context-mode.git
synced 2026-10-03 12:49:34 +08:00
fix(stats): restore Auto-memory, Opus pricing, and business-value footer
Commitb392c2frewrote src/session/analytics.ts as part of the opt-in concurrency feature and inadvertently dropped the user-facing stats improvements landed in4742160(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:
+13
-5
@@ -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
@@ -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("");
|
||||
|
||||
Reference in New Issue
Block a user