context-mode 1.0.101 → 1.0.104

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.openclaw-plugin/openclaw.plugin.json +1 -1
  4. package/.openclaw-plugin/package.json +1 -1
  5. package/README.md +66 -5
  6. package/bin/statusline.mjs +321 -0
  7. package/build/adapters/antigravity/index.d.ts +6 -0
  8. package/build/adapters/antigravity/index.js +10 -0
  9. package/build/adapters/base.d.ts +23 -0
  10. package/build/adapters/base.js +29 -0
  11. package/build/adapters/codex/index.d.ts +10 -0
  12. package/build/adapters/codex/index.js +22 -4
  13. package/build/adapters/cursor/index.d.ts +7 -0
  14. package/build/adapters/cursor/index.js +11 -0
  15. package/build/adapters/detect.d.ts +12 -1
  16. package/build/adapters/detect.js +69 -7
  17. package/build/adapters/gemini-cli/index.d.ts +8 -1
  18. package/build/adapters/gemini-cli/index.js +19 -7
  19. package/build/adapters/jetbrains-copilot/index.d.ts +7 -0
  20. package/build/adapters/jetbrains-copilot/index.js +12 -0
  21. package/build/adapters/kiro/index.d.ts +8 -0
  22. package/build/adapters/kiro/index.js +12 -0
  23. package/build/adapters/openclaw/index.d.ts +17 -0
  24. package/build/adapters/openclaw/index.js +29 -4
  25. package/build/adapters/opencode/index.d.ts +8 -0
  26. package/build/adapters/opencode/index.js +18 -6
  27. package/build/adapters/qwen-code/index.d.ts +1 -0
  28. package/build/adapters/qwen-code/index.js +3 -0
  29. package/build/adapters/types.d.ts +33 -0
  30. package/build/adapters/vscode-copilot/index.d.ts +6 -0
  31. package/build/adapters/vscode-copilot/index.js +10 -0
  32. package/build/adapters/zed/index.d.ts +1 -0
  33. package/build/adapters/zed/index.js +3 -0
  34. package/build/cli.d.ts +15 -0
  35. package/build/cli.js +62 -16
  36. package/build/concurrency/runPool.d.ts +36 -0
  37. package/build/concurrency/runPool.js +51 -0
  38. package/build/executor.d.ts +11 -1
  39. package/build/executor.js +59 -16
  40. package/build/fetch-cache.d.ts +13 -0
  41. package/build/fetch-cache.js +15 -0
  42. package/build/lifecycle.d.ts +6 -2
  43. package/build/lifecycle.js +29 -2
  44. package/build/opencode-plugin.d.ts +6 -0
  45. package/build/opencode-plugin.js +60 -1
  46. package/build/routing-block.d.ts +8 -0
  47. package/build/routing-block.js +86 -0
  48. package/build/runtime.d.ts +1 -0
  49. package/build/runtime.js +54 -3
  50. package/build/search/auto-memory.d.ts +23 -10
  51. package/build/search/auto-memory.js +64 -26
  52. package/build/search/unified.d.ts +3 -0
  53. package/build/search/unified.js +2 -2
  54. package/build/server.d.ts +42 -0
  55. package/build/server.js +693 -164
  56. package/build/session/analytics.d.ts +49 -1
  57. package/build/session/analytics.js +278 -16
  58. package/build/session/db.d.ts +39 -8
  59. package/build/session/db.js +170 -19
  60. package/build/session/extract.js +124 -2
  61. package/build/tool-naming.d.ts +4 -0
  62. package/build/tool-naming.js +24 -0
  63. package/cli.bundle.mjs +201 -159
  64. package/configs/antigravity/GEMINI.md +11 -0
  65. package/configs/claude-code/CLAUDE.md +11 -0
  66. package/configs/codex/AGENTS.md +11 -0
  67. package/configs/cursor/context-mode.mdc +11 -0
  68. package/configs/gemini-cli/GEMINI.md +11 -0
  69. package/configs/jetbrains-copilot/copilot-instructions.md +3 -0
  70. package/configs/kilo/AGENTS.md +11 -0
  71. package/configs/kiro/KIRO.md +11 -0
  72. package/configs/openclaw/AGENTS.md +11 -0
  73. package/configs/opencode/AGENTS.md +11 -0
  74. package/configs/pi/AGENTS.md +11 -0
  75. package/configs/qwen-code/QWEN.md +11 -0
  76. package/configs/vscode-copilot/copilot-instructions.md +3 -0
  77. package/configs/zed/AGENTS.md +11 -0
  78. package/hooks/auto-injection.mjs +36 -10
  79. package/hooks/cache-heal-utils.mjs +231 -0
  80. package/hooks/codex/sessionstart.mjs +7 -4
  81. package/hooks/core/routing.mjs +5 -0
  82. package/hooks/cursor/sessionstart.mjs +7 -4
  83. package/hooks/formatters/claude-code.mjs +20 -0
  84. package/hooks/gemini-cli/sessionstart.mjs +7 -2
  85. package/hooks/jetbrains-copilot/sessionstart.mjs +7 -2
  86. package/hooks/normalize-hooks.mjs +184 -0
  87. package/hooks/session-db.bundle.mjs +33 -14
  88. package/hooks/session-extract.bundle.mjs +2 -2
  89. package/hooks/session-helpers.mjs +68 -20
  90. package/hooks/session-loaders.mjs +8 -2
  91. package/hooks/sessionstart.mjs +8 -2
  92. package/hooks/vscode-copilot/sessionstart.mjs +7 -2
  93. package/insight/src/routes/index.tsx +1 -1
  94. package/openclaw.plugin.json +1 -1
  95. package/package.json +2 -1
  96. package/server.bundle.mjs +164 -125
  97. package/skills/ctx-insight/SKILL.md +1 -1
  98. package/start.mjs +63 -3
@@ -41,6 +41,13 @@ export interface SandboxIO {
41
41
  inputBytes: number;
42
42
  outputBytes: number;
43
43
  }
44
+ /** MCP tool usage row — concurrency stats for batch-style tools. */
45
+ export interface McpToolUsageRow {
46
+ tool_name: string;
47
+ calls: number;
48
+ median_concurrency: number | null;
49
+ max_concurrency: number | null;
50
+ }
44
51
  /** Runtime stats tracked by server.ts during a live session. */
45
52
  export interface RuntimeStats {
46
53
  bytesReturned: Record<string, number>;
@@ -150,6 +157,18 @@ export declare class AnalyticsEngine {
150
157
  * Stub: requires PolyglotExecutor byte counters.
151
158
  */
152
159
  static sandboxIO(inputBytes: number, outputBytes: number): SandboxIO;
160
+ /**
161
+ * MCP tool usage — call counts and concurrency stats per MCP tool.
162
+ *
163
+ * Reads `mcp_tool_call` events, parses the JSON payload, and aggregates:
164
+ * - call count per tool_name
165
+ * - median + max of `params.concurrency` (only for tools that take it,
166
+ * e.g. ctx_batch_execute, ctx_fetch_and_index). Returns null when the
167
+ * tool doesn't carry a concurrency param so callers can render N/A.
168
+ *
169
+ * Best-effort: malformed rows or truncated payloads are skipped silently.
170
+ */
171
+ getMcpToolUsage(): McpToolUsageRow[];
153
172
  /**
154
173
  * Build a FullReport by merging runtime stats (passed in)
155
174
  * with continuity data from the DB.
@@ -158,6 +177,32 @@ export declare class AnalyticsEngine {
158
177
  */
159
178
  queryAll(runtimeStats: RuntimeStats): FullReport;
160
179
  }
180
+ /** Aggregated stats spanning every SessionDB + auto-memory under the user's profile. */
181
+ export interface LifetimeStats {
182
+ totalEvents: number;
183
+ totalSessions: number;
184
+ autoMemoryCount: number;
185
+ autoMemoryProjects: number;
186
+ /** Per-prefix breakdown of auto-memory files (user/feedback/project/...). */
187
+ autoMemoryByPrefix: Record<string, number>;
188
+ }
189
+ /**
190
+ * Aggregate lifetime stats from all SessionDB files in `sessionsDir` and
191
+ * all auto-memory markdown files under `memoryRoot/<project>/memory/`.
192
+ *
193
+ * Best-effort: silently ignores missing/unreadable files so ctx_stats
194
+ * can never be broken by a corrupt sidecar.
195
+ */
196
+ export declare function getLifetimeStats(opts?: {
197
+ sessionsDir?: string;
198
+ memoryRoot?: string;
199
+ /** Override for tests — defaults to db-base loadDatabase(). */
200
+ loadDatabase?: () => unknown;
201
+ }): LifetimeStats;
202
+ /** Opus 4 input price: $15 per 1M tokens. */
203
+ export declare const OPUS_INPUT_PRICE_PER_TOKEN: number;
204
+ /** Convert a token count to a USD string at the Opus input rate. */
205
+ export declare function tokensToUsd(tokens: number): string;
161
206
  /**
162
207
  * Render a FullReport as a visual savings dashboard designed for screenshotting.
163
208
  *
@@ -168,4 +213,7 @@ export declare class AnalyticsEngine {
168
213
  * - Project memory: category bars showing persistent data across sessions
169
214
  * - No: Pct column, category tables, tips, jargon
170
215
  */
171
- export declare function formatReport(report: FullReport, version?: string, latestVersion?: string | null): string;
216
+ export declare function formatReport(report: FullReport, version?: string, latestVersion?: string | null, opts?: {
217
+ lifetime?: LifetimeStats;
218
+ mcpUsage?: McpToolUsageRow[];
219
+ }): string;
@@ -8,6 +8,10 @@
8
8
  * const engine = new AnalyticsEngine(sessionDb);
9
9
  * const report = engine.queryAll(runtimeStats);
10
10
  */
11
+ import { existsSync, readdirSync, statSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { homedir } from "node:os";
14
+ import { loadDatabase as loadDatabaseImpl } from "../db-base.js";
11
15
  function semverNewer(a, b) {
12
16
  const pa = a.split(".").map(Number);
13
17
  const pb = b.split(".").map(Number);
@@ -42,10 +46,10 @@ export const categoryLabels = {
42
46
  };
43
47
  /** Explains why each category matters for continuity. */
44
48
  export const categoryHints = {
45
- file: "Restored after compact \u2014 no need to re-read",
49
+ file: "Restored after compact no need to re-read",
46
50
  rule: "Your project instructions survive context resets",
47
51
  prompt: "Continues exactly where you left off",
48
- decision: "Applied automatically \u2014 won\u2019t ask again",
52
+ decision: "Applied automatically won’t ask again",
49
53
  task: "Picks up from where it stopped",
50
54
  error: "Tracked and monitored across compacts",
51
55
  git: "Branch, commit, and repo state preserved",
@@ -115,6 +119,73 @@ export class AnalyticsEngine {
115
119
  static sandboxIO(inputBytes, outputBytes) {
116
120
  return { inputBytes, outputBytes };
117
121
  }
122
+ /**
123
+ * MCP tool usage — call counts and concurrency stats per MCP tool.
124
+ *
125
+ * Reads `mcp_tool_call` events, parses the JSON payload, and aggregates:
126
+ * - call count per tool_name
127
+ * - median + max of `params.concurrency` (only for tools that take it,
128
+ * e.g. ctx_batch_execute, ctx_fetch_and_index). Returns null when the
129
+ * tool doesn't carry a concurrency param so callers can render N/A.
130
+ *
131
+ * Best-effort: malformed rows or truncated payloads are skipped silently.
132
+ */
133
+ getMcpToolUsage() {
134
+ let rows;
135
+ try {
136
+ rows = this.db.prepare("SELECT data FROM session_events WHERE category = 'mcp_tool_call'").all();
137
+ }
138
+ catch {
139
+ return [];
140
+ }
141
+ // toolName -> { calls, concurrencies }
142
+ const agg = new Map();
143
+ for (const row of rows) {
144
+ let parsed;
145
+ try {
146
+ parsed = JSON.parse(row.data);
147
+ }
148
+ catch {
149
+ continue;
150
+ }
151
+ const toolName = typeof parsed.tool_name === "string" ? parsed.tool_name : null;
152
+ if (!toolName)
153
+ continue;
154
+ const bucket = agg.get(toolName) ?? { calls: 0, concurrencies: [] };
155
+ bucket.calls += 1;
156
+ // Skip concurrency extraction when the row was truncated — the params
157
+ // blob is a substring of JSON that may not parse cleanly.
158
+ if (parsed.truncated !== true && parsed.params && typeof parsed.params === "object") {
159
+ const c = parsed.params.concurrency;
160
+ if (typeof c === "number" && Number.isFinite(c) && c > 0) {
161
+ bucket.concurrencies.push(c);
162
+ }
163
+ }
164
+ agg.set(toolName, bucket);
165
+ }
166
+ const out = [];
167
+ for (const [tool_name, b] of agg) {
168
+ let median = null;
169
+ let max = null;
170
+ if (b.concurrencies.length > 0) {
171
+ const sorted = [...b.concurrencies].sort((a, c) => a - c);
172
+ const mid = Math.floor(sorted.length / 2);
173
+ median = sorted.length % 2 === 0
174
+ ? (sorted[mid - 1] + sorted[mid]) / 2
175
+ : sorted[mid];
176
+ max = sorted[sorted.length - 1];
177
+ }
178
+ out.push({
179
+ tool_name,
180
+ calls: b.calls,
181
+ median_concurrency: median,
182
+ max_concurrency: max,
183
+ });
184
+ }
185
+ // Stable sort: most-called first, then alphabetical
186
+ out.sort((a, c) => c.calls - a.calls || a.tool_name.localeCompare(c.tool_name));
187
+ return out;
188
+ }
118
189
  // ═══════════════════════════════════════════════════════
119
190
  // queryAll — single unified report from ONE source
120
191
  // ═══════════════════════════════════════════════════════
@@ -241,6 +312,110 @@ export class AnalyticsEngine {
241
312
  };
242
313
  }
243
314
  }
315
+ /** Extract leading prefix from auto-memory filename: `feedback_push.md` → `feedback`. */
316
+ function autoMemoryPrefix(filename) {
317
+ const base = filename.replace(/\.md$/i, "");
318
+ const m = base.match(/^([a-z]+)/i);
319
+ return m ? m[1].toLowerCase() : "other";
320
+ }
321
+ /**
322
+ * Aggregate lifetime stats from all SessionDB files in `sessionsDir` and
323
+ * all auto-memory markdown files under `memoryRoot/<project>/memory/`.
324
+ *
325
+ * Best-effort: silently ignores missing/unreadable files so ctx_stats
326
+ * can never be broken by a corrupt sidecar.
327
+ */
328
+ export function getLifetimeStats(opts) {
329
+ const sessionsDir = opts?.sessionsDir
330
+ ?? join(homedir(), ".claude", "context-mode", "sessions");
331
+ const memoryRoot = opts?.memoryRoot
332
+ ?? join(homedir(), ".claude", "projects");
333
+ let totalEvents = 0;
334
+ let totalSessions = 0;
335
+ // ── SessionDB aggregation ──
336
+ if (existsSync(sessionsDir)) {
337
+ let dbFiles = [];
338
+ try {
339
+ dbFiles = readdirSync(sessionsDir).filter((f) => f.endsWith(".db"));
340
+ }
341
+ catch { /* unreadable */ }
342
+ if (dbFiles.length > 0) {
343
+ // Lazy-load better-sqlite3 / bun-sqlite via the same path the runtime uses.
344
+ let DatabaseCtor = null;
345
+ try {
346
+ DatabaseCtor = opts?.loadDatabase
347
+ ? opts.loadDatabase()
348
+ : loadDatabaseImpl();
349
+ }
350
+ catch { /* sqlite unavailable */ }
351
+ if (DatabaseCtor) {
352
+ for (const file of dbFiles) {
353
+ const dbPath = join(sessionsDir, file);
354
+ try {
355
+ const sdb = new DatabaseCtor(dbPath, { readonly: true });
356
+ try {
357
+ const ev = sdb.prepare("SELECT COUNT(*) AS cnt FROM session_events").get();
358
+ const ss = sdb.prepare("SELECT COUNT(*) AS cnt FROM session_meta").get();
359
+ totalEvents += ev?.cnt ?? 0;
360
+ totalSessions += ss?.cnt ?? 0;
361
+ }
362
+ finally {
363
+ sdb.close();
364
+ }
365
+ }
366
+ catch {
367
+ // missing tables / corrupt file — skip
368
+ }
369
+ }
370
+ }
371
+ }
372
+ }
373
+ // ── Auto-memory file scan ──
374
+ let autoMemoryCount = 0;
375
+ let autoMemoryProjects = 0;
376
+ const autoMemoryByPrefix = {};
377
+ if (existsSync(memoryRoot)) {
378
+ let projectDirs = [];
379
+ try {
380
+ projectDirs = readdirSync(memoryRoot).filter((entry) => {
381
+ try {
382
+ return statSync(join(memoryRoot, entry)).isDirectory();
383
+ }
384
+ catch {
385
+ return false;
386
+ }
387
+ });
388
+ }
389
+ catch { /* unreadable */ }
390
+ for (const proj of projectDirs) {
391
+ const memDir = join(memoryRoot, proj, "memory");
392
+ if (!existsSync(memDir))
393
+ continue;
394
+ let mdFiles = [];
395
+ try {
396
+ mdFiles = readdirSync(memDir).filter((f) => f.endsWith(".md"));
397
+ }
398
+ catch {
399
+ continue;
400
+ }
401
+ if (mdFiles.length === 0)
402
+ continue;
403
+ autoMemoryProjects++;
404
+ autoMemoryCount += mdFiles.length;
405
+ for (const f of mdFiles) {
406
+ const prefix = autoMemoryPrefix(f);
407
+ autoMemoryByPrefix[prefix] = (autoMemoryByPrefix[prefix] ?? 0) + 1;
408
+ }
409
+ }
410
+ }
411
+ return {
412
+ totalEvents,
413
+ totalSessions,
414
+ autoMemoryCount,
415
+ autoMemoryProjects,
416
+ autoMemoryByPrefix,
417
+ };
418
+ }
244
419
  // ─────────────────────────────────────────────────────────
245
420
  // formatReport — renders FullReport as sales-grade savings dashboard
246
421
  // ─────────────────────────────────────────────────────────
@@ -271,6 +446,16 @@ function fmtNum(n) {
271
446
  return `${(n / 1_000).toFixed(1)}K`;
272
447
  return String(n);
273
448
  }
449
+ // ─────────────────────────────────────────────────────────
450
+ // Pricing (Bug #6) — Anthropic Opus input rate
451
+ // ─────────────────────────────────────────────────────────
452
+ /** Opus 4 input price: $15 per 1M tokens. */
453
+ export const OPUS_INPUT_PRICE_PER_TOKEN = 15 / 1_000_000;
454
+ /** Convert a token count to a USD string at the Opus input rate. */
455
+ export function tokensToUsd(tokens) {
456
+ const safe = Number.isFinite(tokens) && tokens > 0 ? tokens : 0;
457
+ return `$${(safe * OPUS_INPUT_PRICE_PER_TOKEN).toFixed(2)}`;
458
+ }
274
459
  /**
275
460
  * Build a proportional bar using █ chars, scaled to a fixed width.
276
461
  * Returns e.g. "████████████████████████████████████████" for full width.
@@ -283,20 +468,72 @@ function dataBar(bytes, maxBytes, width = 40) {
283
468
  }
284
469
  /**
285
470
  * Render project memory section with category bars.
286
- * Shows persistent event data across all sessions.
471
+ *
472
+ * Shows persistent event data, and — when supplied — lifetime totals
473
+ * across every project's SessionDB so users see the cumulative value
474
+ * (Bug #3).
475
+ *
476
+ * Caps the category list at `topN` and prints "N more categories" with the
477
+ * actual remaining count (Bug #5 — was hardcoded "9 more").
287
478
  */
288
- function renderProjectMemory(pm) {
289
- if (pm.total_events === 0)
479
+ function renderProjectMemory(pm, opts) {
480
+ if (pm.total_events === 0 && (opts?.lifetime?.totalEvents ?? 0) === 0)
290
481
  return [];
482
+ const topN = opts?.topN ?? 2;
291
483
  const out = [];
292
484
  out.push("");
293
- const sessionLabel = pm.session_count === 1 ? "1 session" : `${pm.session_count} sessions`;
294
- out.push(`${fmtNum(pm.total_events)} events remembered across ${sessionLabel} \u2014 searchable after compact & restart`);
485
+ out.push("Persistent memory ✓ preserved across compact, restart & upgrade");
486
+ // Lifetime line (Bug #3) collapses to project-only when lifetime missing.
487
+ const lifeEvents = opts?.lifetime?.totalEvents ?? pm.total_events;
488
+ const lifeSessions = opts?.lifetime?.totalSessions ?? pm.session_count;
489
+ const sessionLabel = lifeSessions === 1 ? "1 session" : `${fmtNum(lifeSessions)} sessions`;
490
+ // Estimate lifetime savings: ~1KB per event → ~256 tokens/event at Opus rates.
491
+ const lifetimeTokens = lifeEvents * 256;
492
+ out.push(` ${fmtNum(lifeEvents)} events · ${sessionLabel} · ~${tokensToUsd(lifetimeTokens)} saved lifetime`);
295
493
  out.push("");
296
- const maxCount = pm.by_category.length > 0 ? pm.by_category[0].count : 1;
297
- for (const cat of pm.by_category) {
494
+ const cats = pm.by_category;
495
+ const visible = cats.slice(0, topN);
496
+ const maxCount = visible.length > 0 ? visible[0].count : 1;
497
+ for (const cat of visible) {
298
498
  out.push(` ${cat.label.padEnd(18)} ${String(cat.count).padStart(5)} ${dataBar(cat.count, maxCount, 30)}`);
299
499
  }
500
+ // Bug #5: real overflow count, not hardcoded.
501
+ const remaining = Math.max(0, cats.length - topN);
502
+ if (remaining > 0) {
503
+ out.push(` ... ${remaining} more categor${remaining === 1 ? "y" : "ies"}`);
504
+ }
505
+ return out;
506
+ }
507
+ /**
508
+ * Render the auto-memory section (Bug #4) — files Claude Code captured
509
+ * under ~/.claude/projects/<project>/memory/ across the user's machine.
510
+ */
511
+ function renderAutoMemory(lifetime) {
512
+ if (!lifetime || lifetime.autoMemoryCount === 0)
513
+ return [];
514
+ const out = [];
515
+ out.push("");
516
+ out.push(`Auto-memory ✓ ${lifetime.autoMemoryCount} preference${lifetime.autoMemoryCount === 1 ? "" : "s"} learned across ${lifetime.autoMemoryProjects} project${lifetime.autoMemoryProjects === 1 ? "" : "s"}`);
517
+ const entries = Object.entries(lifetime.autoMemoryByPrefix)
518
+ .sort((a, b) => b[1] - a[1])
519
+ .slice(0, 6);
520
+ for (const [prefix, count] of entries) {
521
+ out.push(` ${prefix.padEnd(12)} ${String(count).padStart(2)}`);
522
+ }
523
+ return out;
524
+ }
525
+ /** Render the closing "Bottom line" footer (Bug #8). */
526
+ function renderBottomLine(sessionTokensSaved, lifetime) {
527
+ const out = [];
528
+ const sessionUsd = tokensToUsd(sessionTokensSaved);
529
+ // Lifetime estimate: ~1KB/event ÷ 4 bytes/token = 256 tokens/event.
530
+ const lifetimeTokens = (lifetime?.totalEvents ?? 0) * 256;
531
+ const lifetimeUsd = tokensToUsd(lifetimeTokens);
532
+ out.push("");
533
+ out.push("─".repeat(65));
534
+ out.push("Your AI talks less, remembers more, costs less.");
535
+ out.push(`${sessionUsd} this session · ${lifetimeUsd} lifetime`);
536
+ out.push("─".repeat(65));
300
537
  return out;
301
538
  }
302
539
  /**
@@ -309,9 +546,11 @@ function renderProjectMemory(pm) {
309
546
  * - Project memory: category bars showing persistent data across sessions
310
547
  * - No: Pct column, category tables, tips, jargon
311
548
  */
312
- export function formatReport(report, version, latestVersion) {
549
+ export function formatReport(report, version, latestVersion, opts) {
313
550
  const lines = [];
314
551
  const duration = formatDuration(report.session.uptime_min);
552
+ const lifetime = opts?.lifetime;
553
+ const mcpUsage = opts?.mcpUsage;
315
554
  // ── Compute real savings ──
316
555
  const totalKeptOut = report.savings.kept_out + (report.cache ? report.cache.bytes_saved : 0);
317
556
  const totalReturned = report.savings.total_bytes_returned;
@@ -319,6 +558,9 @@ export function formatReport(report, version, latestVersion) {
319
558
  const grandTotal = totalKeptOut + totalReturned;
320
559
  const savingsPct = grandTotal > 0 ? (totalKeptOut / grandTotal) * 100 : 0;
321
560
  const tokensSaved = Math.round(totalKeptOut / 4);
561
+ const ratioMultiplier = totalReturned > 0
562
+ ? Math.max(1, Math.round(grandTotal / Math.max(totalReturned, 1)))
563
+ : 0;
322
564
  // ── Fresh session: no savings yet ──
323
565
  if (totalKeptOut === 0) {
324
566
  lines.push(`context-mode ${duration} ${totalCalls} calls`);
@@ -329,8 +571,10 @@ export function formatReport(report, version, latestVersion) {
329
571
  else {
330
572
  lines.push(`${kb(totalReturned)} entered context | 0 tokens saved`);
331
573
  }
332
- // Project memory
333
- lines.push(...renderProjectMemory(report.projectMemory));
574
+ // Project memory + auto-memory + bottom line
575
+ lines.push(...renderProjectMemory(report.projectMemory, { lifetime }));
576
+ lines.push(...renderAutoMemory(lifetime));
577
+ lines.push(...renderBottomLine(0, lifetime));
334
578
  // Footer
335
579
  lines.push("");
336
580
  const versionStr = version ? `v${version}` : "context-mode";
@@ -342,14 +586,21 @@ export function formatReport(report, version, latestVersion) {
342
586
  }
343
587
  // ── Active session: visual savings dashboard ──
344
588
  // Line 1: Hero metric — the screenshottable number
345
- lines.push(`${fmtNum(tokensSaved)} tokens saved · ${savingsPct.toFixed(1)}% reduction · ${duration}`);
589
+ // Bug #6: include Opus pricing on the hero line for credibility.
590
+ lines.push(`${fmtNum(tokensSaved)} tokens saved · ${savingsPct.toFixed(1)}% reduction · ${duration} · ~${tokensToUsd(tokensSaved)} saved (Opus)`);
346
591
  lines.push("");
347
592
  // Lines 2-3: Before/After comparison bars — the visual proof
348
593
  lines.push(`Without context-mode |${dataBar(grandTotal, grandTotal)}| ${kb(grandTotal)}`);
349
594
  lines.push(`With context-mode |${dataBar(totalReturned, grandTotal)}| ${kb(totalReturned)}`);
350
595
  lines.push("");
351
596
  // Value statement — the line people share
352
- lines.push(`${kb(totalKeptOut)} kept out of your conversation. Never entered context.`);
597
+ // Bug #7: replace meaningless "3.0x" ratio with "3× longer sessions".
598
+ if (ratioMultiplier >= 2) {
599
+ lines.push(`${kb(totalKeptOut)} kept out of your conversation — ${ratioMultiplier}× longer sessions before compact.`);
600
+ }
601
+ else {
602
+ lines.push(`${kb(totalKeptOut)} kept out of your conversation. Never entered context.`);
603
+ }
353
604
  lines.push("");
354
605
  // Compact stats row
355
606
  const statParts = [`${totalCalls} calls`];
@@ -376,8 +627,19 @@ export function formatReport(report, version, latestVersion) {
376
627
  lines.push(` ${name.padEnd(22)} ${String(t.calls).padStart(4)} calls ${kb(t.estimatedSaved).padStart(8)} saved`);
377
628
  }
378
629
  }
379
- // ── Project memory persistent across sessions ──
380
- lines.push(...renderProjectMemory(report.projectMemory));
630
+ // ── MCP concurrency usage (only when batch tools recorded a concurrency) ──
631
+ if (mcpUsage && mcpUsage.length > 0) {
632
+ const concurrent = mcpUsage.filter((u) => u.median_concurrency != null);
633
+ for (const u of concurrent) {
634
+ lines.push(`MCP concurrency usage: ${u.tool_name} median=${u.median_concurrency} max=${u.max_concurrency} (${u.calls} calls)`);
635
+ }
636
+ }
637
+ // ── Project memory — persistent across sessions (Bug #3 + #5) ──
638
+ lines.push(...renderProjectMemory(report.projectMemory, { lifetime }));
639
+ // ── Auto-memory — Claude Code's preference learnings (Bug #4) ──
640
+ lines.push(...renderAutoMemory(lifetime));
641
+ // ── Bottom line — business value framing (Bug #8) ──
642
+ lines.push(...renderBottomLine(tokensSaved, lifetime));
381
643
  // ── Footer ──
382
644
  lines.push("");
383
645
  const versionStr = version ? `v${version}` : "context-mode";
@@ -8,15 +8,8 @@
8
8
  import { SQLiteBase } from "../db-base.js";
9
9
  import type { SessionEvent } from "../types.js";
10
10
  import type { ProjectAttribution } from "./project-attribution.js";
11
- /**
12
- * Returns the worktree suffix to append to session identifiers.
13
- * Returns empty string when running in the main working tree.
14
- *
15
- * Set CONTEXT_MODE_SESSION_SUFFIX to an explicit value to override
16
- * (useful in CI environments or when git is unavailable).
17
- * Set to empty string to disable isolation entirely.
18
- */
19
11
  export declare function getWorktreeSuffix(): string;
12
+ export declare function _resetWorktreeSuffixCacheForTests(): void;
20
13
  /** A stored event row from the session_events table. */
21
14
  export interface StoredEvent {
22
15
  id: number;
@@ -47,6 +40,15 @@ export interface ResumeRow {
47
40
  event_count: number;
48
41
  consumed: number;
49
42
  }
43
+ /** Aggregated tool-call stats for a single session. */
44
+ export interface ToolCallStats {
45
+ totalCalls: number;
46
+ totalBytesReturned: number;
47
+ byTool: Record<string, {
48
+ calls: number;
49
+ bytesReturned: number;
50
+ }>;
51
+ }
50
52
  export declare class SessionDB extends SQLiteBase {
51
53
  /**
52
54
  * Cached prepared statements. Stored in a Map to avoid the JS private-field
@@ -76,6 +78,18 @@ export declare class SessionDB extends SQLiteBase {
76
78
  * lowest-priority (then oldest) event.
77
79
  */
78
80
  insertEvent(sessionId: string, event: SessionEvent, sourceHook?: string, attribution?: Partial<ProjectAttribution>): void;
81
+ /**
82
+ * Bulk-insert N events in a SINGLE transaction.
83
+ *
84
+ * PostToolUse hooks emit 5–15 events per tool call. Calling insertEvent()
85
+ * in a loop runs N transactions = N WAL commits = N fsync candidates,
86
+ * which is painful on Windows NTFS where commit latency dominates.
87
+ * One transaction = one commit, dedup/evict checks reuse cached statements.
88
+ *
89
+ * Cross-platform: uses the same WAL-mode transaction primitive as
90
+ * insertEvent — behavior identical on macOS / Linux / Windows.
91
+ */
92
+ bulkInsertEvents(sessionId: string, events: SessionEvent[], sourceHook?: string, attributions?: Array<Partial<ProjectAttribution> | undefined>): void;
79
93
  /**
80
94
  * Retrieve events for a session with optional filtering.
81
95
  */
@@ -134,6 +148,23 @@ export declare class SessionDB extends SQLiteBase {
134
148
  * Mark the resume snapshot as consumed (already injected into conversation).
135
149
  */
136
150
  markResumeConsumed(sessionId: string): void;
151
+ /**
152
+ * Return the most recent session_id from session_meta, or null if none.
153
+ * Used by the runtime to attach persistent counters to the right session
154
+ * after a process restart.
155
+ */
156
+ getLatestSessionId(): string | null;
157
+ /**
158
+ * Increment the persistent tool-call counter for `tool` in `sessionId`.
159
+ * Adds `bytesReturned` to the cumulative total. Idempotent across
160
+ * SessionDB instances — counters survive process restart.
161
+ */
162
+ incrementToolCall(sessionId: string, tool: string, bytesReturned?: number): void;
163
+ /**
164
+ * Get aggregated tool-call stats for `sessionId`. Returns zero-stats
165
+ * when the session has no recorded calls.
166
+ */
167
+ getToolCallStats(sessionId: string): ToolCallStats;
137
168
  /**
138
169
  * Delete all data for a session (events, meta, resume).
139
170
  */