context-mode 1.0.111 → 1.0.112

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 (150) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.openclaw-plugin/index.ts +3 -2
  4. package/.openclaw-plugin/openclaw.plugin.json +1 -1
  5. package/.openclaw-plugin/package.json +1 -1
  6. package/README.md +152 -34
  7. package/bin/statusline.mjs +144 -127
  8. package/build/adapters/base.d.ts +8 -5
  9. package/build/adapters/base.js +8 -18
  10. package/build/adapters/claude-code/index.d.ts +24 -3
  11. package/build/adapters/claude-code/index.js +44 -11
  12. package/build/adapters/codex/hooks.d.ts +10 -5
  13. package/build/adapters/codex/hooks.js +10 -5
  14. package/build/adapters/codex/index.d.ts +17 -5
  15. package/build/adapters/codex/index.js +337 -37
  16. package/build/adapters/codex/paths.d.ts +1 -0
  17. package/build/adapters/codex/paths.js +12 -0
  18. package/build/adapters/cursor/index.d.ts +6 -0
  19. package/build/adapters/cursor/index.js +83 -2
  20. package/build/adapters/detect.d.ts +1 -1
  21. package/build/adapters/detect.js +29 -6
  22. package/build/adapters/omp/index.d.ts +65 -0
  23. package/build/adapters/omp/index.js +182 -0
  24. package/build/adapters/omp/plugin.d.ts +75 -0
  25. package/build/adapters/omp/plugin.js +220 -0
  26. package/build/adapters/openclaw/mcp-tools.d.ts +54 -0
  27. package/build/adapters/openclaw/mcp-tools.js +198 -0
  28. package/build/adapters/openclaw/plugin.d.ts +130 -0
  29. package/build/adapters/openclaw/plugin.js +629 -0
  30. package/build/adapters/openclaw/workspace-router.d.ts +29 -0
  31. package/build/adapters/openclaw/workspace-router.js +64 -0
  32. package/build/adapters/opencode/plugin.d.ts +145 -0
  33. package/build/adapters/opencode/plugin.js +457 -0
  34. package/build/adapters/pi/extension.d.ts +26 -0
  35. package/build/adapters/pi/extension.js +552 -0
  36. package/build/adapters/pi/index.d.ts +57 -0
  37. package/build/adapters/pi/index.js +173 -0
  38. package/build/adapters/pi/mcp-bridge.d.ts +113 -0
  39. package/build/adapters/pi/mcp-bridge.js +251 -0
  40. package/build/adapters/types.d.ts +11 -6
  41. package/build/cli.js +186 -170
  42. package/build/db-base.d.ts +15 -2
  43. package/build/db-base.js +50 -5
  44. package/build/executor.d.ts +2 -0
  45. package/build/executor.js +15 -2
  46. package/build/runPool.d.ts +36 -0
  47. package/build/runPool.js +51 -0
  48. package/build/runtime.js +64 -5
  49. package/build/search/auto-memory.js +6 -4
  50. package/build/security.js +30 -10
  51. package/build/server.d.ts +23 -1
  52. package/build/server.js +652 -174
  53. package/build/session/analytics.d.ts +404 -1
  54. package/build/session/analytics.js +1347 -42
  55. package/build/session/db.d.ts +114 -5
  56. package/build/session/db.js +275 -27
  57. package/build/session/event-emit.d.ts +48 -0
  58. package/build/session/event-emit.js +101 -0
  59. package/build/session/extract.d.ts +1 -0
  60. package/build/session/extract.js +79 -12
  61. package/build/session/purge.d.ts +111 -0
  62. package/build/session/purge.js +138 -0
  63. package/build/store.d.ts +7 -0
  64. package/build/store.js +69 -6
  65. package/build/util/claude-config.d.ts +26 -0
  66. package/build/util/claude-config.js +91 -0
  67. package/build/util/hook-config.d.ts +4 -0
  68. package/build/util/hook-config.js +39 -0
  69. package/cli.bundle.mjs +411 -208
  70. package/configs/antigravity/GEMINI.md +0 -3
  71. package/configs/claude-code/CLAUDE.md +1 -4
  72. package/configs/codex/AGENTS.md +1 -4
  73. package/configs/codex/config.toml +3 -0
  74. package/configs/codex/hooks.json +8 -0
  75. package/configs/cursor/context-mode.mdc +0 -3
  76. package/configs/gemini-cli/GEMINI.md +0 -3
  77. package/configs/jetbrains-copilot/copilot-instructions.md +0 -3
  78. package/configs/kilo/AGENTS.md +0 -3
  79. package/configs/kiro/KIRO.md +0 -3
  80. package/configs/omp/SYSTEM.md +85 -0
  81. package/configs/omp/mcp.json +7 -0
  82. package/configs/openclaw/AGENTS.md +0 -3
  83. package/configs/opencode/AGENTS.md +0 -3
  84. package/configs/pi/AGENTS.md +0 -3
  85. package/configs/qwen-code/QWEN.md +1 -4
  86. package/configs/vscode-copilot/copilot-instructions.md +0 -3
  87. package/configs/zed/AGENTS.md +0 -3
  88. package/hooks/codex/posttooluse.mjs +9 -2
  89. package/hooks/codex/precompact.mjs +69 -0
  90. package/hooks/codex/sessionstart.mjs +13 -9
  91. package/hooks/codex/stop.mjs +1 -2
  92. package/hooks/codex/userpromptsubmit.mjs +1 -2
  93. package/hooks/core/routing.mjs +237 -18
  94. package/hooks/cursor/afteragentresponse.mjs +1 -1
  95. package/hooks/cursor/hooks.json +31 -0
  96. package/hooks/cursor/posttooluse.mjs +1 -1
  97. package/hooks/cursor/sessionstart.mjs +5 -5
  98. package/hooks/cursor/stop.mjs +1 -1
  99. package/hooks/ensure-deps.mjs +12 -13
  100. package/hooks/gemini-cli/aftertool.mjs +1 -1
  101. package/hooks/gemini-cli/beforeagent.mjs +1 -1
  102. package/hooks/gemini-cli/precompress.mjs +3 -2
  103. package/hooks/gemini-cli/sessionstart.mjs +9 -9
  104. package/hooks/jetbrains-copilot/posttooluse.mjs +1 -1
  105. package/hooks/jetbrains-copilot/precompact.mjs +3 -2
  106. package/hooks/jetbrains-copilot/sessionstart.mjs +9 -9
  107. package/hooks/kiro/agentspawn.mjs +5 -5
  108. package/hooks/kiro/posttooluse.mjs +2 -2
  109. package/hooks/kiro/userpromptsubmit.mjs +1 -1
  110. package/hooks/posttooluse.mjs +45 -0
  111. package/hooks/precompact.mjs +17 -0
  112. package/hooks/pretooluse.mjs +23 -0
  113. package/hooks/routing-block.mjs +0 -12
  114. package/hooks/run-hook.mjs +16 -3
  115. package/hooks/session-db.bundle.mjs +27 -18
  116. package/hooks/session-extract.bundle.mjs +2 -2
  117. package/hooks/session-helpers.mjs +101 -64
  118. package/hooks/sessionstart.mjs +51 -2
  119. package/hooks/vscode-copilot/posttooluse.mjs +1 -1
  120. package/hooks/vscode-copilot/precompact.mjs +3 -2
  121. package/hooks/vscode-copilot/sessionstart.mjs +9 -9
  122. package/openclaw.plugin.json +1 -1
  123. package/package.json +14 -8
  124. package/server.bundle.mjs +349 -147
  125. package/skills/UPSTREAM-CREDITS.md +0 -51
  126. package/skills/context-mode-ops/SKILL.md +0 -299
  127. package/skills/context-mode-ops/agent-teams.md +0 -198
  128. package/skills/context-mode-ops/communication.md +0 -224
  129. package/skills/context-mode-ops/marketing.md +0 -124
  130. package/skills/context-mode-ops/release.md +0 -214
  131. package/skills/context-mode-ops/review-pr.md +0 -269
  132. package/skills/context-mode-ops/tdd.md +0 -329
  133. package/skills/context-mode-ops/triage-issue.md +0 -266
  134. package/skills/context-mode-ops/validation.md +0 -307
  135. package/skills/diagnose/SKILL.md +0 -122
  136. package/skills/diagnose/scripts/hitl-loop.template.sh +0 -41
  137. package/skills/grill-me/SKILL.md +0 -15
  138. package/skills/grill-with-docs/ADR-FORMAT.md +0 -47
  139. package/skills/grill-with-docs/CONTEXT-FORMAT.md +0 -77
  140. package/skills/grill-with-docs/SKILL.md +0 -93
  141. package/skills/improve-codebase-architecture/DEEPENING.md +0 -37
  142. package/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +0 -44
  143. package/skills/improve-codebase-architecture/LANGUAGE.md +0 -53
  144. package/skills/improve-codebase-architecture/SKILL.md +0 -76
  145. package/skills/tdd/SKILL.md +0 -114
  146. package/skills/tdd/deep-modules.md +0 -33
  147. package/skills/tdd/interface-design.md +0 -31
  148. package/skills/tdd/mocking.md +0 -59
  149. package/skills/tdd/refactoring.md +0 -10
  150. package/skills/tdd/tests.md +0 -61
@@ -8,10 +8,12 @@
8
8
  * const engine = new AnalyticsEngine(sessionDb);
9
9
  * const report = engine.queryAll(runtimeStats);
10
10
  */
11
+ import { execFileSync } from "node:child_process";
11
12
  import { existsSync, readdirSync, statSync } from "node:fs";
12
- import { join } from "node:path";
13
13
  import { homedir } from "node:os";
14
+ import { join, sep } from "node:path";
14
15
  import { loadDatabase as loadDatabaseImpl } from "../db-base.js";
16
+ import { resolveClaudeConfigDir } from "../util/claude-config.js";
15
17
  function semverNewer(a, b) {
16
18
  const pa = a.split(".").map(Number);
17
19
  const pb = b.split(".").map(Number);
@@ -26,23 +28,50 @@ function semverNewer(a, b) {
26
28
  // ─────────────────────────────────────────────────────────
27
29
  // Category labels and hints for session continuity display
28
30
  // ─────────────────────────────────────────────────────────
29
- /** Human-readable labels for event categories. */
31
+ /**
32
+ * Human-readable labels for event categories.
33
+ *
34
+ * Each label is a sentence-case phrase that reads like a benefit, not a
35
+ * column name. The user shouldn't see raw schema words like "external-ref"
36
+ * or "agent-finding" — those leak the database into the UX. When a new
37
+ * category lands without an entry here, the renderer falls through to the
38
+ * raw category id; that's a copy-debt signal, fix it here.
39
+ */
30
40
  export const categoryLabels = {
41
+ // Code & filesystem
31
42
  file: "Files tracked",
43
+ cwd: "Working directory",
44
+ // Configuration & intent
32
45
  rule: "Project rules (CLAUDE.md)",
33
46
  prompt: "Your requests saved",
34
- mcp: "Plugin tools used",
35
- git: "Git operations",
36
- env: "Environment setup",
37
- error: "Errors caught",
38
- task: "Tasks in progress",
39
- decision: "Your decisions",
40
- cwd: "Working directory",
47
+ intent: "Session goal",
48
+ role: "Behavior rules",
49
+ constraint: "Constraints you set",
50
+ // Tools & delegation
51
+ mcp: "MCP tools called",
41
52
  skill: "Skills used",
42
53
  subagent: "Delegated work",
43
- intent: "Session mode",
54
+ // Knowledge & decisions
55
+ decision: "Your decisions",
56
+ "agent-finding": "Agent insights kept",
57
+ "rejected-approach": "Approaches you rejected",
58
+ "external-ref": "External docs indexed",
44
59
  data: "Data references",
45
- role: "Behavioral directives",
60
+ // System events
61
+ git: "Git operations",
62
+ env: "Environment setup",
63
+ task: "Tasks in progress",
64
+ error: "Errors caught",
65
+ // Continuity proof
66
+ compact: "Compactions weathered",
67
+ resume: "Sessions resumed cleanly",
68
+ snapshot: "Snapshots restored",
69
+ cache: "Cache hits saved",
70
+ // Operational
71
+ latency: "Slow tools recorded",
72
+ "user-prompt": "Your messages remembered",
73
+ plan: "Plans drafted",
74
+ "blocked-on": "Blockers logged",
46
75
  };
47
76
  /** Explains why each category matters for continuity. */
48
77
  export const categoryHints = {
@@ -312,6 +341,45 @@ export class AnalyticsEngine {
312
341
  };
313
342
  }
314
343
  }
344
+ /**
345
+ * Enumerate every known adapter's sessions + content dirs under `home`.
346
+ * Used by `getMultiAdapterLifetimeStats` and `getMultiAdapterRealBytesStats`
347
+ * so a single call surfaces "your work everywhere on this machine across
348
+ * all AI tools" (the marketing line).
349
+ *
350
+ * Returns ALL 15 adapters even when the dir doesn't exist on disk — the
351
+ * scanner functions filter to existing dirs. That keeps the enumeration
352
+ * pure / testable without filesystem dependencies.
353
+ */
354
+ export function enumerateAdapterDirs(opts) {
355
+ const home = opts?.home ?? homedir();
356
+ // Mirrors `getSessionDirSegments` in src/adapters/detect.ts:92-111.
357
+ const map = [
358
+ ["claude-code", [".claude"]],
359
+ ["gemini-cli", [".gemini"]],
360
+ ["antigravity", [".gemini"]],
361
+ ["openclaw", [".openclaw"]],
362
+ ["codex", [".codex"]],
363
+ ["cursor", [".cursor"]],
364
+ ["vscode-copilot", [".vscode"]],
365
+ ["kiro", [".kiro"]],
366
+ ["pi", [".pi"]],
367
+ ["omp", [".omp"]],
368
+ ["qwen-code", [".qwen"]],
369
+ ["kilo", [".config", "kilo"]],
370
+ ["opencode", [".config", "opencode"]],
371
+ ["zed", [".config", "zed"]],
372
+ ["jetbrains-copilot", [".config", "JetBrains"]],
373
+ ];
374
+ return map.map(([name, segments]) => {
375
+ const base = join(home, ...segments, "context-mode");
376
+ return {
377
+ name,
378
+ sessionsDir: join(base, "sessions"),
379
+ contentDir: join(base, "content"),
380
+ };
381
+ });
382
+ }
315
383
  /** Extract leading prefix from auto-memory filename: `feedback_push.md` → `feedback`. */
316
384
  function autoMemoryPrefix(filename) {
317
385
  const base = filename.replace(/\.md$/i, "");
@@ -326,12 +394,20 @@ function autoMemoryPrefix(filename) {
326
394
  * can never be broken by a corrupt sidecar.
327
395
  */
328
396
  export function getLifetimeStats(opts) {
397
+ // Issue #460 round-3: route through resolveClaudeConfigDir so lifetime
398
+ // stats aggregation tracks $CLAUDE_CONFIG_DIR instead of the literal
399
+ // ~/.claude tree. Otherwise users who relocate config see "no sessions"
400
+ // even though the SessionDB sidecars exist under the override.
401
+ const claudeRoot = resolveClaudeConfigDir();
329
402
  const sessionsDir = opts?.sessionsDir
330
- ?? join(homedir(), ".claude", "context-mode", "sessions");
403
+ ?? join(claudeRoot, "context-mode", "sessions");
331
404
  const memoryRoot = opts?.memoryRoot
332
- ?? join(homedir(), ".claude", "projects");
405
+ ?? join(claudeRoot, "projects");
333
406
  let totalEvents = 0;
334
407
  let totalSessions = 0;
408
+ let rescueBytes = 0;
409
+ let firstEventMs = Number.POSITIVE_INFINITY;
410
+ const distinctProjectsSet = new Set();
335
411
  const categoryCounts = {};
336
412
  // ── SessionDB aggregation ──
337
413
  if (existsSync(sessionsDir)) {
@@ -373,6 +449,34 @@ export function getLifetimeStats(opts) {
373
449
  catch {
374
450
  // older schema / no category column — ignore
375
451
  }
452
+ // Lifetime rescue: compact-snapshot bytes restored across every DB.
453
+ // Without this, the lifetime $ silently undercounts the killer
454
+ // continuity-after-/compact feature.
455
+ try {
456
+ const snap = sdb.prepare("SELECT COALESCE(SUM(length(snapshot)), 0) AS bytes FROM session_resume WHERE consumed = 1").get();
457
+ if (snap?.bytes)
458
+ rescueBytes += snap.bytes;
459
+ }
460
+ catch { /* old schema */ }
461
+ // Earliest event timestamp + distinct project_dirs for the
462
+ // "since X · Y projects" lifetime narrative.
463
+ try {
464
+ const mn = sdb.prepare("SELECT MIN(created_at) AS t FROM session_events").get();
465
+ if (mn?.t) {
466
+ const stamp = mn.t.endsWith("Z") ? mn.t : mn.t + "Z";
467
+ const ms = Date.parse(stamp);
468
+ if (Number.isFinite(ms) && ms < firstEventMs)
469
+ firstEventMs = ms;
470
+ }
471
+ }
472
+ catch { /* old schema */ }
473
+ try {
474
+ const projRows = sdb.prepare("SELECT DISTINCT project_dir AS p FROM session_events WHERE project_dir != ''").all();
475
+ for (const row of projRows)
476
+ if (row.p)
477
+ distinctProjectsSet.add(row.p);
478
+ }
479
+ catch { /* old schema */ }
376
480
  }
377
481
  finally {
378
482
  sdb.close();
@@ -430,18 +534,546 @@ export function getLifetimeStats(opts) {
430
534
  autoMemoryProjects,
431
535
  autoMemoryByPrefix,
432
536
  categoryCounts,
537
+ rescueBytes,
538
+ firstEventMs: Number.isFinite(firstEventMs) ? firstEventMs : 0,
539
+ distinctProjects: distinctProjectsSet.size,
540
+ };
541
+ }
542
+ /**
543
+ * Aggregate every event for one `session_id` across all SessionDB files in
544
+ * `sessionsDir` plus the compact-rescue snapshot bytes from `session_resume`.
545
+ *
546
+ * Why this exists: the Claude Code session_id can persist across days while
547
+ * the underlying DB file rotates (size cap), and a compact-rescue snapshot
548
+ * carries hundreds of KB of context that would otherwise have been lost. The
549
+ * old in-memory `tool_call_counter` saw none of this — it counted only `ctx_*`
550
+ * MCP calls against the current MCP server PID and reset on every restart.
551
+ * Reading from `session_events` + `session_resume` is the source-of-truth
552
+ * version that matches what users actually experienced.
553
+ */
554
+ export function getConversationStats(opts) {
555
+ const sessionsDir = opts.sessionsDir
556
+ ?? join(homedir(), ".claude", "context-mode", "sessions");
557
+ const sessionId = opts.sessionId;
558
+ const empty = {
559
+ sessionId,
560
+ events: 0,
561
+ dbCount: 0,
562
+ daysAlive: 0,
563
+ snapshotBytes: 0,
564
+ snapshotsConsumed: 0,
565
+ byCategory: [],
566
+ };
567
+ if (!sessionId || !existsSync(sessionsDir))
568
+ return empty;
569
+ let dbFiles = [];
570
+ try {
571
+ dbFiles = readdirSync(sessionsDir).filter((f) => {
572
+ if (!f.endsWith(".db"))
573
+ return false;
574
+ if (opts.worktreeHash && !f.startsWith(opts.worktreeHash))
575
+ return false;
576
+ return true;
577
+ });
578
+ }
579
+ catch {
580
+ return empty;
581
+ }
582
+ if (dbFiles.length === 0)
583
+ return empty;
584
+ let DatabaseCtor = null;
585
+ try {
586
+ DatabaseCtor = opts.loadDatabase
587
+ ? opts.loadDatabase()
588
+ : loadDatabaseImpl();
589
+ }
590
+ catch {
591
+ return empty;
592
+ }
593
+ if (!DatabaseCtor)
594
+ return empty;
595
+ const catCounts = {};
596
+ let events = 0;
597
+ let dbCount = 0;
598
+ let snapshotBytes = 0;
599
+ let snapshotsConsumed = 0;
600
+ let firstMs = Number.POSITIVE_INFINITY;
601
+ let lastMs = 0;
602
+ let lastRescueMs = 0;
603
+ // Per-day captures aggregated across every DB. Key is the UTC midnight ms
604
+ // of the day; value tracks both the event count and any rescueBytes (latter
605
+ // overlays the ◆ /compact glyph in the section-1 horizontal timeline).
606
+ const byDayMap = new Map();
607
+ const dayKey = (ms) => Math.floor(ms / 86_400_000) * 86_400_000;
608
+ for (const file of dbFiles) {
609
+ const dbPath = join(sessionsDir, file);
610
+ let touched = false;
611
+ try {
612
+ const sdb = new DatabaseCtor(dbPath, { readonly: true });
613
+ try {
614
+ const cats = sdb.prepare("SELECT category, COUNT(*) AS cnt FROM session_events WHERE session_id = ? GROUP BY category").all(sessionId);
615
+ for (const row of cats) {
616
+ if (!row.category)
617
+ continue;
618
+ catCounts[row.category] = (catCounts[row.category] ?? 0) + (row.cnt ?? 0);
619
+ events += row.cnt ?? 0;
620
+ touched = true;
621
+ }
622
+ const range = sdb.prepare("SELECT MIN(created_at) AS mn, MAX(created_at) AS mx FROM session_events WHERE session_id = ?").get(sessionId);
623
+ if (range?.mn) {
624
+ const t = Date.parse(range.mn + (range.mn.endsWith("Z") ? "" : "Z"));
625
+ if (Number.isFinite(t) && t < firstMs)
626
+ firstMs = t;
627
+ }
628
+ if (range?.mx) {
629
+ const t = Date.parse(range.mx + (range.mx.endsWith("Z") ? "" : "Z"));
630
+ if (Number.isFinite(t) && t > lastMs)
631
+ lastMs = t;
632
+ }
633
+ // Per-day captures + per-day rescue overlay for the narrative timeline.
634
+ // Best-effort: silently skip when the schema lacks created_at.
635
+ try {
636
+ const dayRows = sdb.prepare("SELECT strftime('%s', created_at) AS sec, COUNT(*) AS cnt FROM session_events WHERE session_id = ? GROUP BY date(created_at)").all(sessionId);
637
+ for (const row of dayRows) {
638
+ if (!row.sec)
639
+ continue;
640
+ const ms = parseInt(row.sec, 10) * 1000;
641
+ if (!Number.isFinite(ms))
642
+ continue;
643
+ const k = dayKey(ms);
644
+ const cur = byDayMap.get(k) ?? { count: 0, rescueBytes: 0 };
645
+ cur.count += row.cnt ?? 0;
646
+ byDayMap.set(k, cur);
647
+ }
648
+ }
649
+ catch { /* old schema */ }
650
+ try {
651
+ const snap = sdb.prepare("SELECT COALESCE(SUM(length(snapshot)), 0) AS bytes, COUNT(*) AS n, MAX(strftime('%s', created_at)) AS lastSec FROM session_resume WHERE session_id = ? AND consumed = 1").get(sessionId);
652
+ if (snap?.bytes)
653
+ snapshotBytes += snap.bytes;
654
+ if (snap?.n)
655
+ snapshotsConsumed += snap.n;
656
+ if (snap?.lastSec) {
657
+ const t = parseInt(snap.lastSec, 10) * 1000;
658
+ if (Number.isFinite(t) && t > lastRescueMs)
659
+ lastRescueMs = t;
660
+ // Overlay the rescue bytes onto the day bucket for the timeline.
661
+ if (Number.isFinite(t) && (snap?.bytes ?? 0) > 0) {
662
+ const k = dayKey(t);
663
+ const cur = byDayMap.get(k) ?? { count: 0, rescueBytes: 0 };
664
+ cur.rescueBytes = Math.max(cur.rescueBytes, snap.bytes);
665
+ byDayMap.set(k, cur);
666
+ }
667
+ }
668
+ }
669
+ catch { /* old schema */ }
670
+ }
671
+ finally {
672
+ sdb.close();
673
+ }
674
+ }
675
+ catch { /* missing tables / corrupt */ }
676
+ if (touched)
677
+ dbCount++;
678
+ }
679
+ const daysAlive = firstMs < lastMs ? (lastMs - firstMs) / 86_400_000 : 0;
680
+ const byCategory = Object.entries(catCounts)
681
+ .filter(([, n]) => n > 0)
682
+ .map(([category, count]) => ({
683
+ category,
684
+ count,
685
+ label: categoryLabels[category] || category,
686
+ }))
687
+ .sort((a, b) => b.count - a.count);
688
+ const byDay = [...byDayMap.entries()]
689
+ .sort((a, b) => a[0] - b[0])
690
+ .map(([ms, v]) => ({
691
+ ms,
692
+ count: v.count,
693
+ ...(v.rescueBytes > 0 ? { rescueBytes: v.rescueBytes } : {}),
694
+ }));
695
+ return {
696
+ sessionId,
697
+ events,
698
+ dbCount,
699
+ daysAlive,
700
+ snapshotBytes,
701
+ snapshotsConsumed,
702
+ byCategory,
703
+ firstEventMs: Number.isFinite(firstMs) ? firstMs : 0,
704
+ lastEventMs: lastMs > 0 ? lastMs : 0,
705
+ lastRescueMs: lastRescueMs > 0 ? lastRescueMs : undefined,
706
+ byDay,
707
+ };
708
+ }
709
+ /**
710
+ * Compute real-bytes stats across one session, one project (worktree
711
+ * filter), or every session on disk (lifetime).
712
+ *
713
+ * - Pass `sessionId` for the conversation tier.
714
+ * - Pass `worktreeHash` to filter `*.db` files by name prefix
715
+ * (per-project lifetime — `sha256(cwd).slice(0, 16)`).
716
+ * - Pass neither — full lifetime aggregate.
717
+ *
718
+ * Best-effort: returns zeroes when the dir is missing, the DB is
719
+ * corrupt, or the session has no events. Never throws — same
720
+ * contract as `getConversationStats` / `getLifetimeStats` so the
721
+ * stats-render path can never crash on a bad sidecar.
722
+ */
723
+ export function getRealBytesStats(opts) {
724
+ const empty = {
725
+ eventDataBytes: 0,
726
+ bytesAvoided: 0,
727
+ bytesReturned: 0,
728
+ snapshotBytes: 0,
729
+ totalSavedTokens: 0,
730
+ };
731
+ const sessionsDir = opts.sessionsDir
732
+ ?? join(homedir(), ".claude", "context-mode", "sessions");
733
+ if (!existsSync(sessionsDir))
734
+ return empty;
735
+ let dbFiles = [];
736
+ try {
737
+ dbFiles = readdirSync(sessionsDir).filter((f) => {
738
+ if (!f.endsWith(".db"))
739
+ return false;
740
+ if (opts.worktreeHash && !f.startsWith(opts.worktreeHash))
741
+ return false;
742
+ return true;
743
+ });
744
+ }
745
+ catch {
746
+ return empty;
747
+ }
748
+ if (dbFiles.length === 0)
749
+ return empty;
750
+ let DatabaseCtor = null;
751
+ try {
752
+ DatabaseCtor = opts.loadDatabase
753
+ ? opts.loadDatabase()
754
+ : loadDatabaseImpl();
755
+ }
756
+ catch {
757
+ return empty;
758
+ }
759
+ if (!DatabaseCtor)
760
+ return empty;
761
+ let eventDataBytes = 0;
762
+ let bytesAvoided = 0;
763
+ let bytesReturned = 0;
764
+ let snapshotBytes = 0;
765
+ // Each branch returns the tuple in the SAME column order so callers
766
+ // don't need to type-narrow per row.
767
+ for (const file of dbFiles) {
768
+ const dbPath = join(sessionsDir, file);
769
+ try {
770
+ const sdb = new DatabaseCtor(dbPath, { readonly: true });
771
+ try {
772
+ if (opts.sessionId) {
773
+ const row = sdb.prepare(`SELECT
774
+ COALESCE(SUM(LENGTH(data)), 0) AS data_bytes,
775
+ COALESCE(SUM(bytes_avoided), 0) AS bytes_avoided,
776
+ COALESCE(SUM(bytes_returned), 0) AS bytes_returned
777
+ FROM session_events WHERE session_id = ?`).get(opts.sessionId);
778
+ if (row) {
779
+ eventDataBytes += Number(row.data_bytes ?? 0);
780
+ bytesAvoided += Number(row.bytes_avoided ?? 0);
781
+ bytesReturned += Number(row.bytes_returned ?? 0);
782
+ }
783
+ try {
784
+ const snap = sdb.prepare("SELECT COALESCE(SUM(LENGTH(snapshot)), 0) AS bytes FROM session_resume WHERE session_id = ?").get(opts.sessionId);
785
+ if (snap?.bytes)
786
+ snapshotBytes += Number(snap.bytes);
787
+ }
788
+ catch { /* old schema */ }
789
+ }
790
+ else {
791
+ const row = sdb.prepare(`SELECT
792
+ COALESCE(SUM(LENGTH(data)), 0) AS data_bytes,
793
+ COALESCE(SUM(bytes_avoided), 0) AS bytes_avoided,
794
+ COALESCE(SUM(bytes_returned), 0) AS bytes_returned
795
+ FROM session_events`).get();
796
+ if (row) {
797
+ eventDataBytes += Number(row.data_bytes ?? 0);
798
+ bytesAvoided += Number(row.bytes_avoided ?? 0);
799
+ bytesReturned += Number(row.bytes_returned ?? 0);
800
+ }
801
+ try {
802
+ const snap = sdb.prepare("SELECT COALESCE(SUM(LENGTH(snapshot)), 0) AS bytes FROM session_resume").get();
803
+ if (snap?.bytes)
804
+ snapshotBytes += Number(snap.bytes);
805
+ }
806
+ catch { /* old schema */ }
807
+ }
808
+ }
809
+ finally {
810
+ sdb.close();
811
+ }
812
+ }
813
+ catch { /* missing tables / corrupt — skip */ }
814
+ }
815
+ const totalSavedTokens = Math.floor((eventDataBytes + bytesAvoided + snapshotBytes) / 4);
816
+ return { eventDataBytes, bytesAvoided, bytesReturned, snapshotBytes, totalSavedTokens };
817
+ }
818
+ const DEFAULT_REAL_USAGE_FILTER = {
819
+ minEvents: 100,
820
+ minProjects: 5,
821
+ recencyMs: 30 * 86_400_000,
822
+ minAvgBytes: 50,
823
+ };
824
+ /**
825
+ * Scan one adapter's sessions dir. Always returns a result — never throws.
826
+ * When the dir is missing, the result has zeroed counts and `isReal=false`.
827
+ *
828
+ * Mirrors the inner SessionDB-walk inside `getLifetimeStats`
829
+ * (analytics.ts:677-752) so the new multi-adapter path stays in lock-step
830
+ * with the per-DB queries the single-dir path already trusts.
831
+ */
832
+ function scanOneAdapter(entry, loadDb, filter) {
833
+ const result = {
834
+ name: entry.name,
835
+ eventCount: 0,
836
+ sessionCount: 0,
837
+ dataBytes: 0,
838
+ rescueBytes: 0,
839
+ contentBytes: 0,
840
+ uuidConvs: 0,
841
+ projectDirs: [],
842
+ firstMs: Number.POSITIVE_INFINITY,
843
+ lastMs: 0,
844
+ isReal: false,
845
+ };
846
+ if (!existsSync(entry.sessionsDir))
847
+ return result;
848
+ let dbFiles = [];
849
+ try {
850
+ dbFiles = readdirSync(entry.sessionsDir).filter((f) => f.endsWith(".db"));
851
+ }
852
+ catch {
853
+ return result;
854
+ }
855
+ if (dbFiles.length === 0)
856
+ return result;
857
+ let DatabaseCtor = null;
858
+ try {
859
+ DatabaseCtor = loadDb();
860
+ }
861
+ catch {
862
+ return result;
863
+ }
864
+ if (!DatabaseCtor)
865
+ return result;
866
+ const projectsSet = new Set();
867
+ const sessionsSet = new Set();
868
+ for (const file of dbFiles) {
869
+ const dbPath = join(entry.sessionsDir, file);
870
+ try {
871
+ const sdb = new DatabaseCtor(dbPath, { readonly: true });
872
+ try {
873
+ const ev = sdb.prepare("SELECT COUNT(*) AS cnt, COALESCE(SUM(LENGTH(data)), 0) AS bytes FROM session_events").get();
874
+ if (ev) {
875
+ result.eventCount += Number(ev.cnt ?? 0);
876
+ result.dataBytes += Number(ev.bytes ?? 0);
877
+ }
878
+ try {
879
+ const ss = sdb.prepare("SELECT COUNT(*) AS cnt FROM session_meta").get();
880
+ result.sessionCount += Number(ss?.cnt ?? 0);
881
+ }
882
+ catch { /* old schema */ }
883
+ try {
884
+ const snap = sdb.prepare("SELECT COALESCE(SUM(length(snapshot)), 0) AS bytes FROM session_resume WHERE consumed = 1").get();
885
+ if (snap?.bytes)
886
+ result.rescueBytes += Number(snap.bytes);
887
+ }
888
+ catch { /* old schema */ }
889
+ try {
890
+ const range = sdb.prepare("SELECT MIN(created_at) AS mn, MAX(created_at) AS mx FROM session_events").get();
891
+ if (range?.mn) {
892
+ const t = Date.parse(range.mn + (range.mn.endsWith("Z") ? "" : "Z"));
893
+ if (Number.isFinite(t) && t < result.firstMs)
894
+ result.firstMs = t;
895
+ }
896
+ if (range?.mx) {
897
+ const t = Date.parse(range.mx + (range.mx.endsWith("Z") ? "" : "Z"));
898
+ if (Number.isFinite(t) && t > result.lastMs)
899
+ result.lastMs = t;
900
+ }
901
+ }
902
+ catch { /* old schema */ }
903
+ try {
904
+ const projRows = sdb.prepare("SELECT DISTINCT project_dir AS p FROM session_events WHERE project_dir != ''").all();
905
+ for (const row of projRows)
906
+ if (row.p)
907
+ projectsSet.add(row.p);
908
+ }
909
+ catch { /* old schema */ }
910
+ try {
911
+ const sidRows = sdb.prepare("SELECT DISTINCT session_id AS s FROM session_events").all();
912
+ for (const row of sidRows)
913
+ if (row.s)
914
+ sessionsSet.add(row.s);
915
+ }
916
+ catch { /* old schema */ }
917
+ }
918
+ finally {
919
+ sdb.close();
920
+ }
921
+ }
922
+ catch { /* missing tables / corrupt — skip */ }
923
+ }
924
+ result.projectDirs = Array.from(projectsSet);
925
+ result.uuidConvs = sessionsSet.size;
926
+ // Real-usage filter — see RealUsageFilter docstring.
927
+ const avgBytes = result.eventCount > 0 ? result.dataBytes / result.eventCount : 0;
928
+ const recentEnough = result.lastMs > 0 && (filter.nowMs - result.lastMs) <= filter.recencyMs;
929
+ result.isReal =
930
+ result.eventCount >= filter.minEvents &&
931
+ projectsSet.size >= filter.minProjects &&
932
+ recentEnough &&
933
+ avgBytes >= filter.minAvgBytes;
934
+ return result;
935
+ }
936
+ /**
937
+ * Aggregate lifetime stats across every adapter dir under `home`.
938
+ * The marketing line — "your work everywhere on this machine across all
939
+ * AI tools" — depends on this. Existing `getLifetimeStats` (single dir)
940
+ * is untouched; this is purely additive.
941
+ */
942
+ export function getMultiAdapterLifetimeStats(opts) {
943
+ const dirs = enumerateAdapterDirs({ home: opts?.home });
944
+ const loadDb = opts?.loadDatabase ?? loadDatabaseImpl;
945
+ const filter = {
946
+ ...DEFAULT_REAL_USAGE_FILTER,
947
+ ...(opts?.filter ?? {}),
948
+ nowMs: opts?.filter?.nowMs ?? Date.now(),
433
949
  };
950
+ const perAdapter = [];
951
+ let totalEvents = 0;
952
+ let totalSessions = 0;
953
+ let totalBytes = 0;
954
+ for (const entry of dirs) {
955
+ if (!existsSync(entry.sessionsDir))
956
+ continue; // only surface adapters with a sessions dir
957
+ const r = scanOneAdapter(entry, loadDb, filter);
958
+ perAdapter.push(r);
959
+ totalEvents += r.eventCount;
960
+ totalSessions += r.sessionCount;
961
+ totalBytes += r.dataBytes + r.rescueBytes;
962
+ }
963
+ return { totalEvents, totalSessions, totalBytes, perAdapter };
964
+ }
965
+ /**
966
+ * Aggregate real-bytes stats across every adapter dir under `home`.
967
+ * Mirrors `getRealBytesStats` (single dir, analytics.ts:887-989) but
968
+ * iterates {@link enumerateAdapterDirs}. Optional `sessionId` /
969
+ * `worktreeHash` filters apply uniformly to every dir.
970
+ */
971
+ export function getMultiAdapterRealBytesStats(opts) {
972
+ const dirs = enumerateAdapterDirs({ home: opts?.home });
973
+ const sum = {
974
+ eventDataBytes: 0,
975
+ bytesAvoided: 0,
976
+ bytesReturned: 0,
977
+ snapshotBytes: 0,
978
+ totalSavedTokens: 0,
979
+ };
980
+ const perAdapter = [];
981
+ for (const entry of dirs) {
982
+ if (!existsSync(entry.sessionsDir))
983
+ continue;
984
+ const one = getRealBytesStats({
985
+ sessionsDir: entry.sessionsDir,
986
+ sessionId: opts?.sessionId,
987
+ worktreeHash: opts?.worktreeHash,
988
+ loadDatabase: opts?.loadDatabase,
989
+ });
990
+ perAdapter.push({ name: entry.name, ...one });
991
+ sum.eventDataBytes += one.eventDataBytes;
992
+ sum.bytesAvoided += one.bytesAvoided;
993
+ sum.bytesReturned += one.bytesReturned;
994
+ sum.snapshotBytes += one.snapshotBytes;
995
+ }
996
+ sum.totalSavedTokens = Math.floor((sum.eventDataBytes + sum.bytesAvoided + sum.snapshotBytes) / 4);
997
+ return { ...sum, perAdapter };
998
+ }
999
+ /**
1000
+ * Marketing-grade labels for auto-memory file prefixes. The renderer sees raw
1001
+ * filename prefixes (`project_codex_hooks.md` → `project`) — without this map
1002
+ * the user gets schema words in the UI, which leaks the database into UX.
1003
+ */
1004
+ export const autoMemoryLabels = {
1005
+ project: "What you're building",
1006
+ feedback: "How you work",
1007
+ user: "Who you are",
1008
+ reference: "Where to look",
1009
+ memory: "Long-term context",
1010
+ other: "Other notes",
1011
+ };
1012
+ /**
1013
+ * Marketing-grade labels for adapter ids surfaced by
1014
+ * {@link enumerateAdapterDirs} / {@link getMultiAdapterLifetimeStats}.
1015
+ * The renderer never shows raw IDs — UX uses the names users see in
1016
+ * each tool's own surface area.
1017
+ */
1018
+ export const adapterLabels = {
1019
+ "claude-code": "Claude Code",
1020
+ "gemini-cli": "Gemini CLI",
1021
+ "antigravity": "Antigravity",
1022
+ "openclaw": "Openclaw",
1023
+ "codex": "Codex CLI",
1024
+ "cursor": "Cursor",
1025
+ "vscode-copilot": "VS Code Copilot",
1026
+ "kiro": "Kiro",
1027
+ "pi": "Pi",
1028
+ "omp": "OMP",
1029
+ "qwen-code": "Qwen Code",
1030
+ "kilo": "Kilo",
1031
+ "opencode": "OpenCode",
1032
+ "zed": "Zed",
1033
+ "jetbrains-copilot": "JetBrains",
1034
+ };
1035
+ /** Look up an adapter's marketing label. Falls back to the raw id. */
1036
+ function adapterLabel(name) {
1037
+ return adapterLabels[name] ?? name;
434
1038
  }
435
1039
  // ─────────────────────────────────────────────────────────
436
1040
  // formatReport — renders FullReport as sales-grade savings dashboard
437
1041
  // ─────────────────────────────────────────────────────────
438
- /** Format bytes as human-readable KB or MB. */
1042
+ /**
1043
+ * Format a byte count for the narrative dashboard.
1044
+ *
1045
+ * Single-unit auto-scale (Grafana / CloudWatch / Datadog convention).
1046
+ * Decimals shrink as the integer part grows so the number stays readable
1047
+ * at every magnitude. Max output width is 8 characters which fits the
1048
+ * existing `padStart(8)` callsites in Sections 1, 3, 4.
1049
+ *
1050
+ * < 1 KB → "X B" e.g. "100 B"
1051
+ * 1 KB – < 100 KB → "X.Y KB" e.g. "4.7 KB", "92.8 KB"
1052
+ * 100 KB – < 1 MB → "X KB" e.g. "227 KB", "976 KB"
1053
+ * 1 MB – < 100 MB → "X.Y MB" e.g. "4.5 MB", "11.6 MB"
1054
+ * 100 MB – < 1 GB → "X MB" e.g. "178 MB", "906 MB"
1055
+ * 1 GB – < 100 GB → "X.YY GB" e.g. "1.00 GB", "11.36 GB"
1056
+ * ≥ 100 GB → "X.Y GB" e.g. "216.6 GB"
1057
+ *
1058
+ * Replaced the dual-unit "X KB (0.YY MB)" form because the parenthetical
1059
+ * rounded to 0.00 / 0.01 in the common range and added noise without
1060
+ * information. Scale awareness comes from the unit jump between rows.
1061
+ */
439
1062
  function kb(b) {
440
- if (b >= 1024 * 1024)
441
- return `${(b / 1024 / 1024).toFixed(1)} MB`;
442
- if (b >= 1024)
443
- return `${(b / 1024).toFixed(1)} KB`;
444
- return `${Math.round(b)} B`;
1063
+ if (!Number.isFinite(b) || b <= 0)
1064
+ return "0 B";
1065
+ if (b < 1024)
1066
+ return `${Math.round(b)} B`;
1067
+ const KB = b / 1024;
1068
+ if (KB < 1024) {
1069
+ return KB < 100 ? `${KB.toFixed(1)} KB` : `${Math.round(KB)} KB`;
1070
+ }
1071
+ const MB = KB / 1024;
1072
+ if (MB < 1024) {
1073
+ return MB < 100 ? `${MB.toFixed(1)} MB` : `${Math.round(MB)} MB`;
1074
+ }
1075
+ const GB = MB / 1024;
1076
+ return GB < 100 ? `${GB.toFixed(2)} GB` : `${GB.toFixed(1)} GB`;
445
1077
  }
446
1078
  /** Format session uptime as human-readable duration. */
447
1079
  function formatDuration(uptimeMin) {
@@ -454,6 +1086,482 @@ function formatDuration(uptimeMin) {
454
1086
  const m = Math.round(min % 60);
455
1087
  return m > 0 ? `${h}h ${m}m` : `${h}h`;
456
1088
  }
1089
+ /**
1090
+ * Locale + IANA-timezone detection for the narrative renderer.
1091
+ *
1092
+ * Cascade (each level overrides the next):
1093
+ * 1. CONTEXT_MODE_LOCALE / CONTEXT_MODE_TZ env overrides
1094
+ * (used by tests + by users who want to pin output regardless of OS).
1095
+ * 2. macOS `defaults read -g AppleLocale` → `en_TR` style → `en-TR`.
1096
+ * 3. Linux `LANG` / `LC_TIME` env vars.
1097
+ * 4. Fallback: `Intl.DateTimeFormat().resolvedOptions().locale`.
1098
+ *
1099
+ * Timezone always uses `Intl.DateTimeFormat().resolvedOptions().timeZone`
1100
+ * — that one's always available and correct regardless of platform.
1101
+ */
1102
+ export function detectLocaleAndTz() {
1103
+ const env = (process.env ?? {});
1104
+ let locale = env.CONTEXT_MODE_LOCALE ?? "";
1105
+ if (!locale) {
1106
+ if (process.platform === "darwin") {
1107
+ try {
1108
+ // Top-level import — `require()` throws "Dynamic require ... not
1109
+ // supported" under esbuild's ESM shim and pure ESM Node, which silently
1110
+ // dropped this branch and forced en-US fallback in production.
1111
+ const out = execFileSync("defaults", ["read", "-g", "AppleLocale"], {
1112
+ encoding: "utf8",
1113
+ timeout: 500,
1114
+ }).trim();
1115
+ if (out)
1116
+ locale = out.replace(/_/g, "-");
1117
+ }
1118
+ catch { /* defaults missing or sandbox */ }
1119
+ }
1120
+ if (!locale && (env.LC_TIME || env.LANG)) {
1121
+ const raw = (env.LC_TIME || env.LANG || "").split(".")[0];
1122
+ if (raw)
1123
+ locale = raw.replace(/_/g, "-");
1124
+ }
1125
+ if (!locale) {
1126
+ try {
1127
+ locale = new Intl.DateTimeFormat().resolvedOptions().locale;
1128
+ }
1129
+ catch {
1130
+ locale = "en-US";
1131
+ }
1132
+ }
1133
+ }
1134
+ let tz = env.CONTEXT_MODE_TZ ?? "";
1135
+ if (!tz) {
1136
+ try {
1137
+ tz = new Intl.DateTimeFormat().resolvedOptions().timeZone;
1138
+ }
1139
+ catch {
1140
+ tz = "UTC";
1141
+ }
1142
+ }
1143
+ return { locale: locale || "en-US", tz: tz || "UTC" };
1144
+ }
1145
+ /**
1146
+ * Format an absolute path as a human-friendly display string by
1147
+ * collapsing `$HOME` → `~`. Returns the input unchanged when no home
1148
+ * prefix matches (e.g. for paths outside $HOME on a CI box).
1149
+ */
1150
+ function shortPath(abs) {
1151
+ const home = homedir();
1152
+ if (!home)
1153
+ return abs;
1154
+ if (abs === home)
1155
+ return "~";
1156
+ // Use platform separator so `C:\Users\Mert\projects\x` collapses to `~\projects\x`
1157
+ // on Windows; previous `home + "/"` check was vacuously false on Windows and
1158
+ // left full absolute paths in the Section 1 narrative opener (round-5 finding).
1159
+ if (abs.startsWith(home + sep))
1160
+ return "~" + abs.slice(home.length);
1161
+ return abs;
1162
+ }
1163
+ /**
1164
+ * Render the section-4 "For example: what would that cost?" block.
1165
+ *
1166
+ * Translates a lifetime token total into a relatable Opus-4 dollar figure
1167
+ * + 3 tangible comparisons (Cursor Pro / Claude Max / weekends of API
1168
+ * coding) + 10-dev team scale projection + alternate-model scale row,
1169
+ * capped with an EXAMPLES disclaimer. The renderer is intentionally
1170
+ * liberal with rounding (whole-month Cursor counts, integer weekends)
1171
+ * because this section is illustrative — the EXAMPLES line tells users
1172
+ * not to confuse it for a bill.
1173
+ *
1174
+ * Returns [] when there's nothing to scale (lifetimeTokens === 0) so
1175
+ * the section disappears cleanly on a fresh install.
1176
+ *
1177
+ * Math constants:
1178
+ * Opus 4 = $15.00 per 1M input tokens (matches OPUS_INPUT_PRICE_PER_TOKEN)
1179
+ * Sonnet 4 = $3.00 per 1M input tokens
1180
+ * GPT-4o = $2.50 per 1M input tokens
1181
+ * Gemini 2 = $1.25 per 1M input tokens
1182
+ * Haiku 4 = $0.80 per 1M input tokens
1183
+ * Cursor Pro = $20 / month → "X months of Cursor Pro"
1184
+ * Claude Max = $200 / month → "X.X months of Claude Max"
1185
+ * Weekend coding ≈ $73.67 → "X weekends of nonstop API coding"
1186
+ * Team multiplier = 10× → "At a 10-dev team scale: ~$X over Y days, or ~$Z/year"
1187
+ */
1188
+ export function renderCostExample(lifetimeBytes, lifetimeTokens, lifetimeDays) {
1189
+ if (!Number.isFinite(lifetimeTokens) || lifetimeTokens <= 0)
1190
+ return [];
1191
+ const opusUsd = (lifetimeTokens * 15) / 1_000_000;
1192
+ const usdStr = (n, dp = 2) => n.toFixed(dp);
1193
+ // Comparison units — kept locally so they're easy to tune without touching
1194
+ // the renderer logic. Cursor Pro & Claude Max are public list prices; the
1195
+ // weekend constant is an intentional approximation calibrated to make
1196
+ // $1399.73 → "19 weekends" line up with the demo target.
1197
+ const cursorMonths = Math.round(opusUsd / 20);
1198
+ const claudeMaxMonths = (opusUsd / 200).toFixed(1);
1199
+ const weekendCount = Math.round(opusUsd / 73.67);
1200
+ const teamUsd = Math.round(opusUsd * 10);
1201
+ const teamYearUsd = lifetimeDays > 0
1202
+ ? Math.round((opusUsd * 10) / lifetimeDays * 365)
1203
+ : 0;
1204
+ // Alternate-model scale row — same token count, different per-1M rates.
1205
+ const sonnetUsd = ((lifetimeTokens * 3.0) / 1_000_000).toFixed(2);
1206
+ const gpt4oUsd = ((lifetimeTokens * 2.5) / 1_000_000).toFixed(2);
1207
+ const geminiUsd = ((lifetimeTokens * 1.25) / 1_000_000).toFixed(2);
1208
+ const haikuUsd = ((lifetimeTokens * 0.8) / 1_000_000).toFixed(2);
1209
+ // Mert: "daha marketing ve business value e vermeli, math hesaplamalari ile
1210
+ // kalabalik yapma" — collapse the old 4-block render (5 prose lines + 3
1211
+ // comparison lines + 2 team lines + scaling table + disclaimer) into ONE
1212
+ // headline number, ONE relatable comparison, ONE team-scale callout. Drop
1213
+ // the alternate-model scaling row (engineer-curiosity, not value framing).
1214
+ const out = [];
1215
+ out.push(` $${usdStr(opusUsd)} of Opus 4 tokens your team didn't burn.`);
1216
+ out.push(` context-mode kept ${kb(lifetimeBytes)} out of context — that's ${cursorMonths} months of Cursor Pro paid for itself.`);
1217
+ if (teamUsd > 0 && teamYearUsd > 0) {
1218
+ out.push("");
1219
+ out.push(` Scale across a 10-dev team and that's ~$${teamYearUsd.toLocaleString("en-US")}/year saved.`);
1220
+ }
1221
+ out.push("");
1222
+ out.push(` (Opus rates shown for context. On cheaper models the dollar number drops; the savings ratio holds.)`);
1223
+ return out;
1224
+ }
1225
+ /**
1226
+ * Render the full 5-section narrative ("kitap gibi") layout — the
1227
+ * Mert-approved screenshot format the production ctx_stats handler
1228
+ * produces for users with conversation + lifetime + multi-adapter data.
1229
+ *
1230
+ * Order:
1231
+ * Opener
1232
+ * Section 1 — Where you are now (datetime, /compact, timeline)
1233
+ * Section 2 — What this chat captured (per-category bars)
1234
+ * Section 3 — The receipt — getting wider (this conv vs all-work)
1235
+ * Section 4 — For example: what would that cost?
1236
+ * Section 5 — What context-mode learned about how you work (auto-memory)
1237
+ * Footer
1238
+ *
1239
+ * Pure renderer: every input arrives via the args object so this
1240
+ * function is trivially testable end-to-end without mocking process or
1241
+ * Date. The caller (formatReport) is responsible for choosing a `now`
1242
+ * value that matches the conversation's age math and a `cwd` that
1243
+ * matches the user's project — defaults are sensible for production.
1244
+ */
1245
+ function renderNarrative5Section(args) {
1246
+ const { conversation, lifetime, multiAdapter, realBytes, cwd, locale, tz, now, version, latestVersion } = args;
1247
+ const out = [];
1248
+ // ── Token math (same monotonic-growth invariant as the legacy branch).
1249
+ const convEventsTokens = conversation.events * TOKENS_PER_EVENT;
1250
+ const convRescueTokens = Math.round((conversation.snapshotBytes ?? 0) / 4);
1251
+ const convLegacyTokens = convEventsTokens + convRescueTokens;
1252
+ const convRealTokens = realBytes?.conversation?.totalSavedTokens ?? 0;
1253
+ const conversationTokens = Math.max(convLegacyTokens, convRealTokens);
1254
+ const lifetimeEventsTokens = (lifetime?.totalEvents ?? 0) * TOKENS_PER_EVENT;
1255
+ const lifetimeRescueTokens = Math.round((lifetime?.rescueBytes ?? 0) / 4);
1256
+ const lifetimeLegacyTokens = lifetimeEventsTokens + lifetimeRescueTokens;
1257
+ const lifetimeRealTokens = realBytes?.lifetime?.totalSavedTokens ?? 0;
1258
+ const lifetimeTokensWithout = Math.max(lifetimeLegacyTokens, lifetimeRealTokens);
1259
+ const lifetimeTokensWith = Math.max(1, Math.round(lifetimeTokensWithout * 0.02));
1260
+ // Bytes from realBytes when present, else derive from tokens (×4 — same
1261
+ // ratio Phase 8 uses everywhere). All-work bytes drives the opener tally
1262
+ // + the section-3 receipt + section-4 cost example.
1263
+ const lifetimeBytes = (multiAdapter?.totalBytes && multiAdapter.totalBytes > 0)
1264
+ ? multiAdapter.totalBytes
1265
+ : lifetimeTokensWithout * 4;
1266
+ const convBytes = realBytes?.conversation
1267
+ ? (realBytes.conversation.eventDataBytes + realBytes.conversation.bytesAvoided + realBytes.conversation.snapshotBytes)
1268
+ : conversationTokens * 4;
1269
+ // ── Days alive of THE CONVERSATION (section 1).
1270
+ const convDays = conversation.daysAlive >= 1
1271
+ ? `${conversation.daysAlive.toFixed(1)} days alive · still going`
1272
+ : `${Math.max(1, Math.round(conversation.daysAlive * 24))} hr alive · still going`;
1273
+ // ── Lifetime span (opener + receipt) — across every adapter / DB on disk.
1274
+ const sinceMs = lifetime?.firstEventMs ?? multiAdapter?.perAdapter?.[0]?.firstMs ?? 0;
1275
+ const lifetimeDays = sinceMs > 0
1276
+ ? Math.max(1, Math.round((now - sinceMs) / 86_400_000))
1277
+ : 0;
1278
+ const totalConversations = multiAdapter?.totalSessions ?? lifetime?.totalSessions ?? 1;
1279
+ const realAdapterCount = multiAdapter?.perAdapter.filter((a) => a.isReal).length ?? 0;
1280
+ let where;
1281
+ if (multiAdapter && realAdapterCount >= 2) {
1282
+ where = `across ${realAdapterCount} AI tools`;
1283
+ }
1284
+ else if (multiAdapter && realAdapterCount === 1) {
1285
+ const onlyReal = multiAdapter.perAdapter.find((a) => a.isReal);
1286
+ where = `in ${onlyReal ? adapterLabel(onlyReal.name) : "Claude Code"}`;
1287
+ }
1288
+ else {
1289
+ where = "in Claude Code";
1290
+ }
1291
+ // ── Opener.
1292
+ if (lifetimeDays > 0) {
1293
+ out.push(` Across ${lifetimeDays} days you ran ${fmtNum(totalConversations)} conversations ${where}.`);
1294
+ }
1295
+ else {
1296
+ out.push(` You ran ${fmtNum(totalConversations)} conversations ${where}.`);
1297
+ }
1298
+ // Daily-average sub-line — never tease users with a tiny number when the
1299
+ // average is sub-MB (still informative); fall back to KB display.
1300
+ const dailyBytes = lifetimeDays > 0 ? lifetimeBytes / lifetimeDays : 0;
1301
+ out.push(` context-mode kept ${kb(lifetimeBytes)} out of your context window — about ${kb(dailyBytes)} every single day.`);
1302
+ out.push("");
1303
+ out.push("");
1304
+ // ── Section 1 — Where you are now.
1305
+ out.push(" ─── 1. Where you are now ───");
1306
+ out.push("");
1307
+ const startedStr = conversation.firstEventMs && conversation.firstEventMs > 0
1308
+ ? formatLocalDateTime(conversation.firstEventMs, locale, tz)
1309
+ : "";
1310
+ if (startedStr) {
1311
+ out.push(` This conversation started ${startedStr} in ${shortPath(cwd)}.`);
1312
+ }
1313
+ else {
1314
+ out.push(` This conversation lives in ${shortPath(cwd)}.`);
1315
+ }
1316
+ out.push(` ${convDays}.`);
1317
+ if (conversation.snapshotsConsumed > 0 && conversation.snapshotBytes > 0) {
1318
+ const rescueAt = conversation.lastRescueMs && conversation.lastRescueMs > 0
1319
+ ? formatLocalDateTime(conversation.lastRescueMs, locale, tz)
1320
+ : "";
1321
+ const rescueKb = Math.round(conversation.snapshotBytes / 1024);
1322
+ if (rescueAt) {
1323
+ out.push(` On ${rescueAt}, /compact fired — ${rescueKb} KB rescued from snapshot.`);
1324
+ }
1325
+ else {
1326
+ out.push(` /compact fired — ${rescueKb} KB rescued from snapshot.`);
1327
+ }
1328
+ out.push(` Without that, you'd be re-explaining everything to a blank model right now.`);
1329
+ }
1330
+ out.push("");
1331
+ // Without/With bars — the screenshottable proof for THIS conversation.
1332
+ const convTokensWith = Math.max(1, Math.round(conversationTokens * 0.02));
1333
+ const withoutBar = dataBar(conversationTokens, conversationTokens, 32);
1334
+ const withBar = dataBar(convTokensWith, conversationTokens, 32);
1335
+ const convPct = conversationTokens > 0 ? (1 - convTokensWith / conversationTokens) * 100 : 0;
1336
+ out.push(` Without context-mode ${kb(convBytes).padStart(8)} ${withoutBar} ${fmtNum(conversationTokens).padStart(7)} tokens`);
1337
+ out.push(` With context-mode ${kb(Math.max(1, Math.round(convBytes * 0.02))).padStart(8)} ${withBar} ${fmtNum(convTokensWith).padStart(7)} tokens`);
1338
+ out.push(` ${convPct.toFixed(0)}% kept out of context · your AI ran ${Math.max(1, Math.round(conversationTokens / convTokensWith))}× longer before /compact fired`);
1339
+ out.push("");
1340
+ // Timeline — drop-in if conversation has byDay.
1341
+ if (conversation.byDay && conversation.byDay.length > 0) {
1342
+ const totalConvDays = conversation.lastEventMs && conversation.firstEventMs
1343
+ ? Math.max(1, Math.round((conversation.lastEventMs - conversation.firstEventMs) / 86_400_000) + 1)
1344
+ : conversation.byDay.length;
1345
+ out.push(` How that ${kb(convBytes)} built up — ${totalConvDays} days, ${conversation.byDay.length} active:`);
1346
+ out.push("");
1347
+ out.push(...renderHorizontalTimeline(conversation.byDay, locale, tz));
1348
+ }
1349
+ out.push("");
1350
+ out.push("");
1351
+ // ── Section 2 — What this chat captured.
1352
+ out.push(" ─── 2. What this chat captured (used when you --continue or /resume here) ───");
1353
+ out.push("");
1354
+ const capturedTotal = conversation.byCategory.reduce((s, c) => s + c.count, 0);
1355
+ // Format with locale separator (en-* → "1,277"; en-TR → "1.277").
1356
+ const totalStr = capturedTotal.toLocaleString(locale);
1357
+ out.push(` ${totalStr} things — files, errors, decisions, agent runs:`);
1358
+ out.push("");
1359
+ // ALL categories, no truncation (Slice 5).
1360
+ const max = conversation.byCategory[0]?.count ?? 1;
1361
+ for (const cat of conversation.byCategory) {
1362
+ out.push(` ${cat.label.padEnd(26)} ${String(cat.count).padStart(5)} ${dataBar(cat.count, max, 28)}`);
1363
+ }
1364
+ out.push("");
1365
+ out.push("");
1366
+ // ── Section 3 — Scope ladder, prose form (Mert: "cok daginik" → drop columns).
1367
+ // Two short sentences instead of a 4-column table — the same numbers framed
1368
+ // as "this chat" → "all your work" so the reader sees the scope getting wider
1369
+ // without being asked to scan a wide grid.
1370
+ out.push(" ─── 3. The scope, getting wider ───");
1371
+ out.push("");
1372
+ const convStartedYMD = conversation.firstEventMs && conversation.firstEventMs > 0
1373
+ ? new Intl.DateTimeFormat(locale, { timeZone: tz, year: "numeric", month: "short", day: "numeric" })
1374
+ .format(new Date(conversation.firstEventMs))
1375
+ : "";
1376
+ const lifeStartedYMD = sinceMs > 0
1377
+ ? new Intl.DateTimeFormat(locale, { timeZone: tz, year: "numeric", month: "short", day: "numeric" })
1378
+ .format(new Date(sinceMs))
1379
+ : "";
1380
+ const distinctProj = lifetime?.distinctProjects ?? 0;
1381
+ const allCaps = lifetime?.totalEvents ?? multiAdapter?.totalEvents ?? 0;
1382
+ out.push(` This chat: ${kb(convBytes)} kept out · ${conversation.events.toLocaleString(locale)} captures${convStartedYMD ? ` · started ${convStartedYMD}` : ""}.`);
1383
+ out.push(` All your work: ${kb(lifetimeBytes)} kept out · ${allCaps.toLocaleString(locale)} captures across ${distinctProj} project${distinctProj === 1 ? "" : "s"}${lifeStartedYMD ? ` · since ${lifeStartedYMD}` : ""}.`);
1384
+ out.push("");
1385
+ out.push("");
1386
+ // ── Section 4 — Marketing-grade cost framing (Mert: "math hesaplamalari ile
1387
+ // kalabalik yapma" → less math, more business value). One headline, one
1388
+ // optional team-scale callout, no scaling table, no math footnotes.
1389
+ out.push(" ─── 4. The bottom line ───");
1390
+ out.push("");
1391
+ out.push(...renderCostExample(lifetimeBytes, lifetimeTokensWithout, lifetimeDays));
1392
+ out.push("");
1393
+ out.push("");
1394
+ // ── Section 5 — What context-mode learned about how you work.
1395
+ out.push(" ─── 5. What context-mode learned about how you work ───");
1396
+ out.push("");
1397
+ if (lifetime && lifetime.autoMemoryCount > 0) {
1398
+ out.push(` ${lifetime.autoMemoryCount} preferences picked up across ${lifetime.autoMemoryProjects} project${lifetime.autoMemoryProjects === 1 ? "" : "s"}:`);
1399
+ const entries = Object.entries(lifetime.autoMemoryByPrefix).sort((a, b) => b[1] - a[1]);
1400
+ const maxAm = entries.length > 0 ? entries[0][1] : 1;
1401
+ for (const [prefix, count] of entries) {
1402
+ const label = autoMemoryLabels[prefix] ?? prefix;
1403
+ out.push(` ${label.padEnd(26)} ${String(count).padStart(2)} ${dataBar(count, maxAm, 20)}`);
1404
+ }
1405
+ }
1406
+ else {
1407
+ out.push(" No preferences learned yet — context-mode picks them up automatically.");
1408
+ }
1409
+ out.push("");
1410
+ out.push("");
1411
+ // ── Footer.
1412
+ out.push(" Your AI talks less, remembers more, costs less.");
1413
+ out.push(` Locale ${locale} · timezone ${tz} · pricing examples for illustration only.`);
1414
+ out.push("");
1415
+ const versionStr = version ? `v${version}` : "context-mode";
1416
+ out.push(` ${versionStr}`);
1417
+ if (version && latestVersion && latestVersion !== "unknown" && semverNewer(latestVersion, version)) {
1418
+ out.push(` Update available: v${version} -> v${latestVersion} | ctx_upgrade`);
1419
+ }
1420
+ // Suppress consecutive blank lines / leading blanks for tidier output —
1421
+ // we use `push("")` liberally above as paragraph separators, easier to
1422
+ // collapse here than to track flag state inline.
1423
+ return collapseBlanks(out);
1424
+ }
1425
+ /** Drop runs of >2 consecutive blank strings so the renderer never emits visual gaps. */
1426
+ function collapseBlanks(lines) {
1427
+ const out = [];
1428
+ let blankRun = 0;
1429
+ for (const ln of lines) {
1430
+ if (ln === "") {
1431
+ blankRun++;
1432
+ if (blankRun <= 2)
1433
+ out.push(ln);
1434
+ }
1435
+ else {
1436
+ blankRun = 0;
1437
+ out.push(ln);
1438
+ }
1439
+ }
1440
+ // Trim trailing blanks.
1441
+ while (out.length > 0 && out[out.length - 1] === "")
1442
+ out.pop();
1443
+ return out;
1444
+ }
1445
+ /**
1446
+ * Render the proportional-spacing horizontal day strip used in section 1
1447
+ * of the 5-section narrative. Returns the lines verbatim ready to splice
1448
+ * into the formatReport line buffer:
1449
+ *
1450
+ * apr 28 ●──────────────────────●────█──────────────────────◆────● may 10
1451
+ *
1452
+ * apr 28 277 captures
1453
+ * may 4 438 captures ← peak
1454
+ * may 9 261 captures ◆ /compact rescued 1552 KB
1455
+ * may 10 100 captures
1456
+ *
1457
+ * ● active day █ peak day ◆ /compact rescue
1458
+ *
1459
+ * The strip body is exactly 56 chars wide. Day positions are computed as
1460
+ * `round((day - first) / (last - first) * 55)`. Glyph priority for a
1461
+ * column: rescue (◆) > peak (█) > active (●). Filler is the box-drawing
1462
+ * `─` character so the strip reads cleanly in monospace terminals.
1463
+ */
1464
+ export function renderHorizontalTimeline(days, locale, tz) {
1465
+ if (days.length === 0)
1466
+ return [];
1467
+ // Sort ascending so first/last bookends + bar positions are stable.
1468
+ const sorted = [...days].sort((a, b) => a.ms - b.ms);
1469
+ const first = sorted[0];
1470
+ const last = sorted[sorted.length - 1];
1471
+ const span = Math.max(1, last.ms - first.ms);
1472
+ // Locate the peak day (max count). Ties: earliest wins so the visual
1473
+ // pin matches the chronologically first big day.
1474
+ let peak = sorted[0];
1475
+ for (const d of sorted)
1476
+ if (d.count > peak.count)
1477
+ peak = d;
1478
+ // Build the 56-char strip body.
1479
+ const WIDTH = 56;
1480
+ const body = Array.from({ length: WIDTH }, () => "─");
1481
+ for (const d of sorted) {
1482
+ const col = Math.round(((d.ms - first.ms) / span) * (WIDTH - 1));
1483
+ let glyph = "●";
1484
+ if (d === peak)
1485
+ glyph = "█";
1486
+ if ((d.rescueBytes ?? 0) > 0)
1487
+ glyph = "◆"; // rescue beats peak
1488
+ body[col] = glyph;
1489
+ }
1490
+ // Lowercase short month names ("apr"/"may"/"jan") matching the target.
1491
+ const monthDay = (ms) => {
1492
+ const dt = new Intl.DateTimeFormat(locale, {
1493
+ timeZone: tz,
1494
+ month: "short",
1495
+ day: "numeric",
1496
+ }).formatToParts(new Date(ms));
1497
+ const month = (dt.find((p) => p.type === "month")?.value ?? "").toLowerCase();
1498
+ const day = dt.find((p) => p.type === "day")?.value ?? "";
1499
+ return `${month} ${day}`;
1500
+ };
1501
+ const out = [];
1502
+ out.push(` ${monthDay(first.ms)} ${body.join("")} ${monthDay(last.ms)}`);
1503
+ out.push("");
1504
+ // Daily detail rows — count + " ← peak" + "◆ /compact rescued N KB".
1505
+ for (const d of sorted) {
1506
+ const label = monthDay(d.ms).padEnd(7);
1507
+ const captures = `${d.count} captures`;
1508
+ const peakStr = d === peak ? " ← peak" : "";
1509
+ const rescue = (d.rescueBytes ?? 0) > 0
1510
+ ? ` ◆ /compact rescued ${Math.round((d.rescueBytes ?? 0) / 1024)} KB`
1511
+ : "";
1512
+ out.push(` ${label} ${captures}${peakStr}${rescue}`);
1513
+ }
1514
+ out.push("");
1515
+ out.push(" ● active day █ peak day ◆ /compact rescue");
1516
+ return out;
1517
+ }
1518
+ /**
1519
+ * Render a UTC ms timestamp as a human-readable local datetime string in
1520
+ * the canonical Mert-approved format:
1521
+ *
1522
+ * "28 Apr 2026 at 12:16 (Europe/Istanbul)"
1523
+ *
1524
+ * Used by the 5-section narrative renderer (formatReport) so users see
1525
+ * exactly when their conversation started + when /compact rescues fired
1526
+ * in their wall-clock timezone — never UTC, never ambiguous.
1527
+ *
1528
+ * - 24-hour clock with zero-padded minutes ("20:54", not "8:54 PM").
1529
+ * - Day is NOT zero-padded ("9 May", not "09 May") to match the target.
1530
+ * - IANA timezone is appended verbatim in parentheses regardless of
1531
+ * locale so users never misread Istanbul-time as UTC.
1532
+ * - Returns "" for ms === 0 or NaN so callers can guard the rendered
1533
+ * line ("started …") without an extra timestamp-validity check.
1534
+ */
1535
+ export function formatLocalDateTime(ms, locale, tz) {
1536
+ if (!Number.isFinite(ms) || ms <= 0)
1537
+ return "";
1538
+ const date = new Date(ms);
1539
+ if (Number.isNaN(date.getTime()))
1540
+ return "";
1541
+ // Intl.DateTimeFormat's "day"/"month"/"year" parts give us the locale's
1542
+ // ordering (en-* → "DD MMM YYYY"), and the explicit numeric hour/minute
1543
+ // forces 24-hour with leading zero on minute when in en-* with hour12=false.
1544
+ const dt = new Intl.DateTimeFormat(locale, {
1545
+ timeZone: tz,
1546
+ year: "numeric",
1547
+ month: "short",
1548
+ day: "numeric",
1549
+ hour: "2-digit",
1550
+ minute: "2-digit",
1551
+ hour12: false,
1552
+ }).formatToParts(date);
1553
+ const get = (type) => dt.find((p) => p.type === type)?.value ?? "";
1554
+ const day = get("day");
1555
+ const month = get("month");
1556
+ const year = get("year");
1557
+ let hour = get("hour");
1558
+ const min = get("minute");
1559
+ // Some locales / some Node versions emit "24" for midnight under hour12=false.
1560
+ // Coerce back to "00" so the displayed time is always wall-clock-correct.
1561
+ if (hour === "24")
1562
+ hour = "00";
1563
+ return `${day} ${month} ${year} at ${hour}:${min} (${tz})`;
1564
+ }
457
1565
  /** Format large numbers with K/M suffixes */
458
1566
  function fmtNum(n) {
459
1567
  if (n >= 1_000_000)
@@ -497,27 +1605,45 @@ function renderProjectMemory(pm, opts) {
497
1605
  // Render when EITHER disk has data OR current session has earnings.
498
1606
  if (pm.total_events === 0 &&
499
1607
  (opts?.lifetime?.totalEvents ?? 0) === 0 &&
500
- sessionTokensSaved === 0) {
1608
+ sessionTokensSaved === 0 &&
1609
+ (opts?.multiAdapter?.totalEvents ?? 0) === 0) {
501
1610
  return [];
502
1611
  }
503
- const topN = opts?.topN ?? 2;
1612
+ // Slice 5 Mert: "honest, no tease". Show ALL categories. The legacy
1613
+ // topN cap silently hid real data; users would screenshot a stats card
1614
+ // missing half their work. The opts.topN parameter stays in the signature
1615
+ // for back-compat with any external caller that explicitly passes a cap.
1616
+ const topN = opts?.topN ?? Number.POSITIVE_INFINITY;
504
1617
  const out = [];
505
1618
  out.push("");
506
- out.push("Persistent memory ✓ preserved across compact, restart & upgrade");
507
- // Lifetime line disk-aggregated lifetime PLUS current session's in-memory
508
- // savings. Two separate accounting pipelines (server bytes vs hook events)
509
- // get unified at the render edge so the user always sees a monotonic total
510
- // (lifetime session). Without this, fresh users / pre-b8e11bf sidecars /
511
- // not-yet-flushed events show $0 lifetime even when the session earned $X.
512
- const lifeEvents = opts?.lifetime?.totalEvents ?? pm.total_events;
513
- const lifeSessions = opts?.lifetime?.totalSessions ?? pm.session_count;
514
- // Current session counts as 1 when no prior session has been recorded yet.
515
- const effectiveSessions = lifeSessions === 0 && sessionTokensSaved > 0 ? 1 : lifeSessions;
516
- const sessionLabel = effectiveSessions === 1 ? "1 session" : `${fmtNum(effectiveSessions)} sessions`;
517
- // Estimate lifetime savings: ~1KB per event → ~256 tokens/event at Opus rates,
518
- // plus current session's already-tracked token savings (in-memory).
519
- const lifetimeTokens = lifeEvents * 256 + sessionTokensSaved;
520
- out.push(` ${fmtNum(lifeEvents)} events · ${sessionLabel} · ~${tokensToUsd(lifetimeTokens)} saved lifetime`);
1619
+ // Header switches based on whether we have rich lifetime data from the new
1620
+ // pipeline. With it: forward-leaning "All your work" framing. Without it:
1621
+ // legacy "Persistent memory" line for back-compat with older fixtures + tests.
1622
+ // Slice 3.6: promote to "All your work everywhere" when multi-adapter
1623
+ // aggregation is supplied so the receipt scope matches the rendered totals.
1624
+ const ma = opts?.multiAdapter;
1625
+ const realAdapters = ma?.perAdapter.filter((a) => a.isReal).length ?? 0;
1626
+ const lifeEvents = ma?.totalEvents
1627
+ ?? opts?.lifetime?.totalEvents
1628
+ ?? pm.total_events;
1629
+ const lifeSessions = ma?.totalSessions
1630
+ ?? opts?.lifetime?.totalSessions
1631
+ ?? pm.session_count;
1632
+ const distinctProj = opts?.lifetime?.distinctProjects;
1633
+ if (lifeEvents > 0 && distinctProj && distinctProj > 0) {
1634
+ const everywhere = realAdapters >= 2 ? " everywhere" : "";
1635
+ out.push(` All your work${everywhere} · ${fmtNum(lifeEvents)} events captured across ${distinctProj} project${distinctProj === 1 ? "" : "s"} · ${fmtNum(lifeSessions)} conversations`);
1636
+ }
1637
+ else {
1638
+ out.push("Persistent memory ✓ preserved across compact, restart & upgrade");
1639
+ // Current session counts as 1 when no prior session has been recorded yet.
1640
+ const effectiveSessions = lifeSessions === 0 && sessionTokensSaved > 0 ? 1 : lifeSessions;
1641
+ const sessionLabel = effectiveSessions === 1 ? "1 session" : `${fmtNum(effectiveSessions)} sessions`;
1642
+ // Estimate lifetime savings: ~1KB per event → ~256 tokens/event at Opus rates,
1643
+ // plus current session's already-tracked token savings (in-memory).
1644
+ const lifetimeTokens = lifeEvents * 256 + sessionTokensSaved;
1645
+ out.push(` ${fmtNum(lifeEvents)} events · ${sessionLabel} · ~${tokensToUsd(lifetimeTokens)} saved lifetime`);
1646
+ }
521
1647
  out.push("");
522
1648
  // Prefer lifetime categoryCounts (aggregated across every SessionDB) so
523
1649
  // the bar block matches the lifetime header above. Falls back to the
@@ -536,12 +1662,14 @@ function renderProjectMemory(pm, opts) {
536
1662
  .sort((a, b) => b.count - a.count);
537
1663
  }
538
1664
  else {
539
- cats = pm.by_category;
1665
+ // Defensive: filter zero/null counts on the fallback path too — bumping
1666
+ // topN to 15 made any leaked empty rows visible as "label 0 ░░░░░░".
1667
+ cats = (pm.by_category ?? []).filter((c) => c && c.count > 0);
540
1668
  }
541
1669
  const visible = cats.slice(0, topN);
542
1670
  const maxCount = visible.length > 0 ? visible[0].count : 1;
543
1671
  for (const cat of visible) {
544
- out.push(` ${cat.label.padEnd(18)} ${String(cat.count).padStart(5)} ${dataBar(cat.count, maxCount, 30)}`);
1672
+ out.push(` ${cat.label.padEnd(26)} ${String(cat.count).padStart(5)} ${dataBar(cat.count, maxCount, 30)}`);
545
1673
  }
546
1674
  // Bug #5: real overflow count, not hardcoded.
547
1675
  const remaining = Math.max(0, cats.length - topN);
@@ -559,7 +1687,7 @@ function renderAutoMemory(lifetime) {
559
1687
  return [];
560
1688
  const out = [];
561
1689
  out.push("");
562
- out.push(`Auto-memory ${lifetime.autoMemoryCount} preference${lifetime.autoMemoryCount === 1 ? "" : "s"} learned across ${lifetime.autoMemoryProjects} project${lifetime.autoMemoryProjects === 1 ? "" : "s"}`);
1690
+ out.push(` Preferences learned · ${lifetime.autoMemoryCount} across ${lifetime.autoMemoryProjects} project${lifetime.autoMemoryProjects === 1 ? "" : "s"}`);
563
1691
  const entries = Object.entries(lifetime.autoMemoryByPrefix)
564
1692
  .sort((a, b) => b[1] - a[1])
565
1693
  .slice(0, 6);
@@ -567,7 +1695,8 @@ function renderAutoMemory(lifetime) {
567
1695
  // the absolute counts are tiny. Entries are pre-sorted desc.
568
1696
  const maxCount = entries.length > 0 ? entries[0][1] : 1;
569
1697
  for (const [prefix, count] of entries) {
570
- out.push(` ${prefix.padEnd(12)} ${String(count).padStart(2)} ${dataBar(count, maxCount, 20)}`);
1698
+ const label = autoMemoryLabels[prefix] ?? prefix;
1699
+ out.push(` ${label.padEnd(26)} ${String(count).padStart(2)} ${dataBar(count, maxCount, 20)}`);
571
1700
  }
572
1701
  return out;
573
1702
  }
@@ -588,6 +1717,113 @@ function renderBottomLine(sessionTokensSaved, lifetime) {
588
1717
  out.push("─".repeat(65));
589
1718
  return out;
590
1719
  }
1720
+ /**
1721
+ * Constant token-per-event used everywhere we estimate session/lifetime $.
1722
+ * Kept in lockstep with `bin/statusline.mjs`'s persisted lifetime conversion.
1723
+ */
1724
+ const TOKENS_PER_EVENT = 256;
1725
+ /**
1726
+ * Render the LIFETIME Without/With hero — the screenshottable receipt.
1727
+ *
1728
+ * Why lifetime and not session: the "$X saved this session" framing is
1729
+ * arbitrary (a fresh PID can show $0 even while the user has weeks of work
1730
+ * banked). Lifetime is real, accumulating, and the number worth screenshotting.
1731
+ * The current conversation's contribution still shows below as a sub-block.
1732
+ */
1733
+ function renderHero(args) {
1734
+ const { lifetimeTokensWithout, lifetimeTokensWith, lifetimeUsd, lifetimeWithUsd, savedPct, totalConversations, firstDate } = args;
1735
+ const out = [];
1736
+ const since = firstDate ? ` · since ${firstDate}` : "";
1737
+ out.push(` ${lifetimeUsd} saved with context-mode · ${savedPct.toFixed(1)}% reduction${since}`);
1738
+ out.push("");
1739
+ const withoutBar = dataBar(lifetimeTokensWithout, lifetimeTokensWithout, 32);
1740
+ const withBar = dataBar(lifetimeTokensWith, lifetimeTokensWithout, 32);
1741
+ out.push(` Without context-mode ${fmtNum(lifetimeTokensWithout).padStart(7)} tokens ${withoutBar} ${lifetimeUsd}`);
1742
+ out.push(` With context-mode ${fmtNum(lifetimeTokensWith).padStart(7)} tokens ${withBar} ${lifetimeWithUsd}`);
1743
+ const kept = lifetimeTokensWithout - lifetimeTokensWith;
1744
+ out.push(` ${fmtNum(kept).padStart(7)} tokens kept out · across ${totalConversations.toLocaleString("en-US")} conversations`);
1745
+ return out;
1746
+ }
1747
+ /**
1748
+ * Render the current conversation as a contribution narrative — not a hero.
1749
+ * Highlights the slice of lifetime savings this chat earned + concrete proof
1750
+ * (events, days alive, compact rescues).
1751
+ */
1752
+ function renderConversation(c, conversationUsd, contribPct) {
1753
+ const out = [];
1754
+ const daysStr = c.daysAlive >= 1 ? `${c.daysAlive.toFixed(1)} days` : `${Math.max(1, Math.round(c.daysAlive * 24))} hr`;
1755
+ const pctStr = contribPct >= 1 ? `${contribPct.toFixed(0)}% of all-time` : `<1% of all-time`;
1756
+ out.push(` This conversation contributed ${conversationUsd} · ${pctStr}`);
1757
+ out.push(` ${c.events.toLocaleString("en-US")} events · ${daysStr} alive`);
1758
+ if (c.snapshotsConsumed > 0 && c.snapshotBytes > 0) {
1759
+ const rescuedTokens = Math.round(c.snapshotBytes / 4);
1760
+ out.push(` ${c.snapshotsConsumed} compact weathered · ${fmtNum(rescuedTokens)} tokens rescued from a ${(c.snapshotBytes / 1024).toFixed(0)} KB snapshot`);
1761
+ }
1762
+ out.push("");
1763
+ if (c.byCategory.length === 0)
1764
+ return out;
1765
+ const max = c.byCategory[0].count || 1;
1766
+ for (const cat of c.byCategory) {
1767
+ out.push(` ${cat.label.padEnd(26)} ${String(cat.count).padStart(5)} ${dataBar(cat.count, max, 28)}`);
1768
+ }
1769
+ return out;
1770
+ }
1771
+ /**
1772
+ * B3b Slice 3.2/3.3 — render the "Where it came from" sub-block from a
1773
+ * `MultiAdapterLifetimeStats` (analytics.ts:1231-1240). Two layers:
1774
+ *
1775
+ * 1. Real adapters (`isReal=true`) become a table row each:
1776
+ * Tool Captures Indexed Total kept out
1777
+ * Claude Code 17.4K 276.7 MB 291.1 MB
1778
+ * JetBrains — 8.6 MB 8.6 MB
1779
+ *
1780
+ * 2. Filtered adapters (`isReal=false` but with at least one .db on disk)
1781
+ * become a single "Skipped (N): name1, name2, ..." disclosure line so
1782
+ * the user sees that fixtures/probes were intentionally hidden.
1783
+ *
1784
+ * Returns [] when `multiAdapter` is undefined OR when there are no real
1785
+ * adapters AND nothing skipped — keeping the renderer additive (Slice 3.5).
1786
+ */
1787
+ function renderMultiAdapter(multiAdapter) {
1788
+ if (!multiAdapter)
1789
+ return [];
1790
+ const real = multiAdapter.perAdapter.filter((a) => a.isReal);
1791
+ const skipped = multiAdapter.perAdapter.filter((a) => !a.isReal);
1792
+ if (real.length === 0 && skipped.length === 0)
1793
+ return [];
1794
+ const out = [];
1795
+ if (real.length > 0) {
1796
+ out.push("");
1797
+ out.push("Where it came from (tools you actually used — fixtures + probes filtered):");
1798
+ out.push("");
1799
+ // Column widths chosen so the demo render stays visually aligned even
1800
+ // for adapters with very long marketing names. Right-aligned numerics.
1801
+ const NAME_W = 16;
1802
+ const CAP_W = 10;
1803
+ const IDX_W = 10;
1804
+ const TOT_W = 16;
1805
+ out.push(` ${"Tool".padEnd(NAME_W)}${"Captures".padStart(CAP_W)}${"Indexed".padStart(IDX_W)}${"Total kept out".padStart(TOT_W)}`);
1806
+ // Sort by total kept out desc — biggest contributor first.
1807
+ const sorted = [...real].sort((a, b) => (b.dataBytes + b.rescueBytes) - (a.dataBytes + a.rescueBytes));
1808
+ for (const a of sorted) {
1809
+ const total = a.dataBytes + a.rescueBytes;
1810
+ // Em-dash for zero captures so the column reads "—" not "0".
1811
+ const captures = a.eventCount > 0 ? fmtNum(a.eventCount) : "—";
1812
+ const indexed = kb(a.dataBytes);
1813
+ const totalStr = kb(total);
1814
+ out.push(` ${adapterLabel(a.name).padEnd(NAME_W)}${captures.padStart(CAP_W)}${indexed.padStart(IDX_W)}${totalStr.padStart(TOT_W)}`);
1815
+ }
1816
+ }
1817
+ if (skipped.length > 0) {
1818
+ if (real.length > 0)
1819
+ out.push("");
1820
+ const names = skipped.map((a) => adapterLabel(a.name)).join(", ");
1821
+ out.push(` Skipped (${skipped.length}): ${names}`);
1822
+ out.push(" These adapters have DBs on disk but only test fixtures, dev skeletons,");
1823
+ out.push(" or detection probes — no real chat activity.");
1824
+ }
1825
+ return out;
1826
+ }
591
1827
  /**
592
1828
  * Render a FullReport as a visual savings dashboard designed for screenshotting.
593
1829
  *
@@ -603,6 +1839,70 @@ export function formatReport(report, version, latestVersion, opts) {
603
1839
  const duration = formatDuration(report.session.uptime_min);
604
1840
  const lifetime = opts?.lifetime;
605
1841
  const mcpUsage = opts?.mcpUsage;
1842
+ const conversation = opts?.conversation;
1843
+ const realBytes = opts?.realBytes;
1844
+ const multiAdapter = opts?.multiAdapter;
1845
+ // Real-adapter count drives the "across N AI tools" headline copy
1846
+ // (Slice 3.4) — we only call something a "tool you used" once it
1847
+ // passes the isReal filter inside getMultiAdapterLifetimeStats.
1848
+ const realAdapterCount = multiAdapter?.perAdapter.filter((a) => a.isReal).length ?? 0;
1849
+ // ── B3b Slice 3.4: opening tagline — runs in EVERY render path so the
1850
+ // multi-adapter headline appears regardless of which formatReport branch
1851
+ // executes (active session / fresh / per-conversation). Falls back to
1852
+ // "in Claude Code" when only one adapter qualifies as real, matching the
1853
+ // Mert-approved demo wording. Suppressed entirely without multiAdapter
1854
+ // so legacy single-adapter renders stay byte-identical (Slice 3.5).
1855
+ if (multiAdapter && realAdapterCount > 0) {
1856
+ const totalConvs = multiAdapter.totalSessions || lifetime?.totalSessions || 0;
1857
+ const sinceMs = lifetime?.firstEventMs ?? 0;
1858
+ const days = sinceMs > 0
1859
+ ? Math.max(1, Math.round((Date.now() - sinceMs) / 86_400_000))
1860
+ : 0;
1861
+ const daySegment = days > 0 ? `Across ${days} day${days === 1 ? "" : "s"} ` : "";
1862
+ const convStr = totalConvs > 0
1863
+ ? `you ran ${fmtNum(totalConvs)} conversation${totalConvs === 1 ? "" : "s"} `
1864
+ : "you ran ";
1865
+ let where;
1866
+ if (realAdapterCount >= 2) {
1867
+ where = `across ${realAdapterCount} AI tools`;
1868
+ }
1869
+ else {
1870
+ // Single real adapter — use its marketing label (defaults to Claude Code
1871
+ // if for some reason the only real adapter has no entry in adapterLabels).
1872
+ const onlyReal = multiAdapter.perAdapter.find((a) => a.isReal);
1873
+ where = `in ${onlyReal ? adapterLabel(onlyReal.name) : "Claude Code"}`;
1874
+ }
1875
+ lines.push(`${daySegment}${convStr}${where}.`);
1876
+ lines.push("");
1877
+ }
1878
+ // ── 5-section narrative ("kitap gibi") layout — Mert-approved
1879
+ // screenshot format produced when the MCP handler has wired
1880
+ // conversation + lifetime + multi-adapter through. Replaces the
1881
+ // legacy hero/contribution/auto-memory stack with the:
1882
+ // Opener
1883
+ // 1. Where you are now (datetime, /compact, timeline)
1884
+ // 2. What this chat captured (per-category bars)
1885
+ // 3. The receipt — getting wider
1886
+ // 4. For example: what would that cost?
1887
+ // 5. What context-mode learned about how you work
1888
+ // Footer
1889
+ // The opener block above (lines 1989-2005) is suppressed because
1890
+ // renderNarrative5Section emits its own.
1891
+ if (conversation && conversation.events > 0) {
1892
+ // Strip the previous-block opener — narrative renderer emits its own.
1893
+ if (lines.length > 0)
1894
+ lines.length = 0;
1895
+ const detected = detectLocaleAndTz();
1896
+ const cwd = opts?.cwd ?? process.cwd();
1897
+ const now = opts?.now ?? Date.now();
1898
+ const locale = opts?.locale ?? detected.locale;
1899
+ const tz = opts?.tz ?? detected.tz;
1900
+ lines.push(...renderNarrative5Section({
1901
+ conversation, lifetime, multiAdapter, realBytes,
1902
+ cwd, locale, tz, now, version, latestVersion,
1903
+ }));
1904
+ return lines.join("\n");
1905
+ }
606
1906
  // ── Compute real savings ──
607
1907
  const totalKeptOut = report.savings.kept_out + (report.cache ? report.cache.bytes_saved : 0);
608
1908
  const totalReturned = report.savings.total_bytes_returned;
@@ -624,7 +1924,8 @@ export function formatReport(report, version, latestVersion, opts) {
624
1924
  lines.push(`${kb(totalReturned)} entered context | 0 tokens saved`);
625
1925
  }
626
1926
  // Project memory + auto-memory + bottom line
627
- lines.push(...renderProjectMemory(report.projectMemory, { lifetime, sessionTokensSaved: 0 }));
1927
+ lines.push(...renderProjectMemory(report.projectMemory, { lifetime, multiAdapter, sessionTokensSaved: 0 }));
1928
+ lines.push(...renderMultiAdapter(multiAdapter));
628
1929
  lines.push(...renderAutoMemory(lifetime));
629
1930
  lines.push(...renderBottomLine(0, lifetime));
630
1931
  // Footer
@@ -695,7 +1996,11 @@ export function formatReport(report, version, latestVersion, opts) {
695
1996
  }
696
1997
  }
697
1998
  // ── Project memory — persistent across sessions (Bug #3 + #5) ──
698
- lines.push(...renderProjectMemory(report.projectMemory, { lifetime, sessionTokensSaved: tokensSaved }));
1999
+ lines.push(...renderProjectMemory(report.projectMemory, { lifetime, multiAdapter, sessionTokensSaved: tokensSaved }));
2000
+ // ── B3b Slice 3.2/3.3 — "Where it came from" per-adapter sub-block.
2001
+ // Sits under the lifetime memory block so the receipt-to-source flow is
2002
+ // visually contiguous (lifetime totals → which tools produced them).
2003
+ lines.push(...renderMultiAdapter(multiAdapter));
699
2004
  // ── Auto-memory — Claude Code's preference learnings (Bug #4) ──
700
2005
  lines.push(...renderAutoMemory(lifetime));
701
2006
  // ── Bottom line — business value framing (Bug #8) ──