@mxalbert/context-mode 2.0.0
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.
- package/.claude-plugin/marketplace.json +33 -0
- package/.claude-plugin/plugin.json +31 -0
- package/.codex-plugin/hooks.json +65 -0
- package/.codex-plugin/mcp.json +13 -0
- package/.codex-plugin/plugin.json +31 -0
- package/.openclaw-plugin/index.ts +12 -0
- package/.openclaw-plugin/openclaw.plugin.json +38 -0
- package/.openclaw-plugin/package.json +28 -0
- package/LICENSE +94 -0
- package/README.md +1619 -0
- package/bin/statusline.mjs +378 -0
- package/build/adapters/antigravity/index.d.ts +51 -0
- package/build/adapters/antigravity/index.js +169 -0
- package/build/adapters/antigravity-cli/index.d.ts +51 -0
- package/build/adapters/antigravity-cli/index.js +342 -0
- package/build/adapters/base.d.ts +92 -0
- package/build/adapters/base.js +135 -0
- package/build/adapters/claude-code/config.d.ts +8 -0
- package/build/adapters/claude-code/config.js +8 -0
- package/build/adapters/claude-code/hooks.d.ts +106 -0
- package/build/adapters/claude-code/hooks.js +183 -0
- package/build/adapters/claude-code/index.d.ts +80 -0
- package/build/adapters/claude-code/index.js +569 -0
- package/build/adapters/claude-code-base.d.ts +49 -0
- package/build/adapters/claude-code-base.js +113 -0
- package/build/adapters/client-map.d.ts +10 -0
- package/build/adapters/client-map.js +42 -0
- package/build/adapters/codex/config.d.ts +8 -0
- package/build/adapters/codex/config.js +8 -0
- package/build/adapters/codex/hooks.d.ts +55 -0
- package/build/adapters/codex/hooks.js +64 -0
- package/build/adapters/codex/index.d.ts +87 -0
- package/build/adapters/codex/index.js +995 -0
- package/build/adapters/codex/paths.d.ts +1 -0
- package/build/adapters/codex/paths.js +12 -0
- package/build/adapters/codex/usage.d.ts +107 -0
- package/build/adapters/codex/usage.js +227 -0
- package/build/adapters/copilot-base.d.ts +94 -0
- package/build/adapters/copilot-base.js +297 -0
- package/build/adapters/copilot-cli/hooks.d.ts +33 -0
- package/build/adapters/copilot-cli/hooks.js +64 -0
- package/build/adapters/copilot-cli/index.d.ts +48 -0
- package/build/adapters/copilot-cli/index.js +341 -0
- package/build/adapters/cursor/config.d.ts +4 -0
- package/build/adapters/cursor/config.js +4 -0
- package/build/adapters/cursor/hooks.d.ts +55 -0
- package/build/adapters/cursor/hooks.js +82 -0
- package/build/adapters/cursor/index.d.ts +64 -0
- package/build/adapters/cursor/index.js +517 -0
- package/build/adapters/detect.d.ts +136 -0
- package/build/adapters/detect.js +623 -0
- package/build/adapters/gemini-cli/config.d.ts +8 -0
- package/build/adapters/gemini-cli/config.js +8 -0
- package/build/adapters/gemini-cli/hooks.d.ts +83 -0
- package/build/adapters/gemini-cli/hooks.js +115 -0
- package/build/adapters/gemini-cli/index.d.ts +80 -0
- package/build/adapters/gemini-cli/index.js +483 -0
- package/build/adapters/jetbrains-copilot/config.d.ts +8 -0
- package/build/adapters/jetbrains-copilot/config.js +8 -0
- package/build/adapters/jetbrains-copilot/hooks.d.ts +59 -0
- package/build/adapters/jetbrains-copilot/hooks.js +87 -0
- package/build/adapters/jetbrains-copilot/index.d.ts +31 -0
- package/build/adapters/jetbrains-copilot/index.js +131 -0
- package/build/adapters/kimi/config.d.ts +8 -0
- package/build/adapters/kimi/config.js +8 -0
- package/build/adapters/kimi/hooks.d.ts +28 -0
- package/build/adapters/kimi/hooks.js +34 -0
- package/build/adapters/kimi/index.d.ts +66 -0
- package/build/adapters/kimi/index.js +537 -0
- package/build/adapters/kimi/paths.d.ts +21 -0
- package/build/adapters/kimi/paths.js +52 -0
- package/build/adapters/kimi/usage.d.ts +82 -0
- package/build/adapters/kimi/usage.js +217 -0
- package/build/adapters/kiro/hooks.d.ts +61 -0
- package/build/adapters/kiro/hooks.js +94 -0
- package/build/adapters/kiro/index.d.ts +61 -0
- package/build/adapters/kiro/index.js +301 -0
- package/build/adapters/omp/index.d.ts +65 -0
- package/build/adapters/omp/index.js +182 -0
- package/build/adapters/omp/plugin.d.ts +81 -0
- package/build/adapters/omp/plugin.js +331 -0
- package/build/adapters/omp/usage.d.ts +49 -0
- package/build/adapters/omp/usage.js +110 -0
- package/build/adapters/openclaw/config.d.ts +8 -0
- package/build/adapters/openclaw/config.js +8 -0
- package/build/adapters/openclaw/hooks.d.ts +50 -0
- package/build/adapters/openclaw/hooks.js +61 -0
- package/build/adapters/openclaw/index.d.ts +74 -0
- package/build/adapters/openclaw/index.js +439 -0
- package/build/adapters/openclaw/mcp-tools.d.ts +54 -0
- package/build/adapters/openclaw/mcp-tools.js +207 -0
- package/build/adapters/openclaw/plugin.d.ts +141 -0
- package/build/adapters/openclaw/plugin.js +818 -0
- package/build/adapters/openclaw/session-db.d.ts +55 -0
- package/build/adapters/openclaw/session-db.js +88 -0
- package/build/adapters/openclaw/usage.d.ts +34 -0
- package/build/adapters/openclaw/usage.js +52 -0
- package/build/adapters/openclaw/workspace-router.d.ts +29 -0
- package/build/adapters/openclaw/workspace-router.js +64 -0
- package/build/adapters/opencode/config.d.ts +8 -0
- package/build/adapters/opencode/config.js +8 -0
- package/build/adapters/opencode/hooks.d.ts +38 -0
- package/build/adapters/opencode/hooks.js +50 -0
- package/build/adapters/opencode/index.d.ts +144 -0
- package/build/adapters/opencode/index.js +638 -0
- package/build/adapters/opencode/plugin.d.ts +249 -0
- package/build/adapters/opencode/plugin.js +1536 -0
- package/build/adapters/opencode/v2.d.ts +117 -0
- package/build/adapters/opencode/v2.js +224 -0
- package/build/adapters/opencode/zod3tov4.d.ts +1 -0
- package/build/adapters/opencode/zod3tov4.js +132 -0
- package/build/adapters/pi/extension.d.ts +81 -0
- package/build/adapters/pi/extension.js +861 -0
- package/build/adapters/pi/index.d.ts +57 -0
- package/build/adapters/pi/index.js +173 -0
- package/build/adapters/pi/mcp-bridge.d.ts +268 -0
- package/build/adapters/pi/mcp-bridge.js +894 -0
- package/build/adapters/qwen-code/hooks.d.ts +26 -0
- package/build/adapters/qwen-code/hooks.js +29 -0
- package/build/adapters/qwen-code/index.d.ts +40 -0
- package/build/adapters/qwen-code/index.js +361 -0
- package/build/adapters/qwen-code/usage.d.ts +90 -0
- package/build/adapters/qwen-code/usage.js +222 -0
- package/build/adapters/types.d.ts +360 -0
- package/build/adapters/types.js +118 -0
- package/build/adapters/vscode-copilot/config.d.ts +8 -0
- package/build/adapters/vscode-copilot/config.js +8 -0
- package/build/adapters/vscode-copilot/hooks.d.ts +71 -0
- package/build/adapters/vscode-copilot/hooks.js +98 -0
- package/build/adapters/vscode-copilot/index.d.ts +32 -0
- package/build/adapters/vscode-copilot/index.js +222 -0
- package/build/adapters/zed/index.d.ts +40 -0
- package/build/adapters/zed/index.js +163 -0
- package/build/cli.d.ts +34 -0
- package/build/cli.js +1828 -0
- package/build/db-base.d.ts +250 -0
- package/build/db-base.js +756 -0
- package/build/executor.d.ts +72 -0
- package/build/executor.js +678 -0
- package/build/exit-classify.d.ts +19 -0
- package/build/exit-classify.js +12 -0
- package/build/fetch/blocks.d.ts +145 -0
- package/build/fetch/blocks.js +244 -0
- package/build/fetch/extract.d.ts +68 -0
- package/build/fetch/extract.js +123 -0
- package/build/fetch/page-store.d.ts +66 -0
- package/build/fetch/page-store.js +174 -0
- package/build/fetch-cache.d.ts +13 -0
- package/build/fetch-cache.js +15 -0
- package/build/lifecycle.d.ts +124 -0
- package/build/lifecycle.js +278 -0
- package/build/package-identity.d.ts +26 -0
- package/build/package-identity.js +28 -0
- package/build/runPool.d.ts +36 -0
- package/build/runPool.js +51 -0
- package/build/runtime.d.ts +71 -0
- package/build/runtime.js +659 -0
- package/build/search/auto-memory.d.ts +47 -0
- package/build/search/auto-memory.js +170 -0
- package/build/search/ctx-search-schema.d.ts +90 -0
- package/build/search/ctx-search-schema.js +135 -0
- package/build/search/flood-guard.d.ts +57 -0
- package/build/search/flood-guard.js +80 -0
- package/build/search/unified.d.ts +56 -0
- package/build/search/unified.js +104 -0
- package/build/security.d.ts +206 -0
- package/build/security.js +780 -0
- package/build/server.d.ts +240 -0
- package/build/server.js +5034 -0
- package/build/session/analytics.d.ts +773 -0
- package/build/session/analytics.js +2420 -0
- package/build/session/db.d.ts +468 -0
- package/build/session/db.js +1330 -0
- package/build/session/error-classifier.d.ts +87 -0
- package/build/session/error-classifier.js +303 -0
- package/build/session/event-emit.d.ts +48 -0
- package/build/session/event-emit.js +101 -0
- package/build/session/extract.d.ts +333 -0
- package/build/session/extract.js +2688 -0
- package/build/session/model-prices.json +429 -0
- package/build/session/persist-tool-calls.d.ts +54 -0
- package/build/session/persist-tool-calls.js +105 -0
- package/build/session/pricing.d.ts +64 -0
- package/build/session/pricing.js +151 -0
- package/build/session/project-attribution.d.ts +73 -0
- package/build/session/project-attribution.js +245 -0
- package/build/session/purge.d.ts +138 -0
- package/build/session/purge.js +240 -0
- package/build/session/retrieval-marker.d.ts +39 -0
- package/build/session/retrieval-marker.js +65 -0
- package/build/session/snapshot.d.ts +46 -0
- package/build/session/snapshot.js +532 -0
- package/build/store-directory.d.ts +56 -0
- package/build/store-directory.js +254 -0
- package/build/store.d.ts +147 -0
- package/build/store.js +1714 -0
- package/build/truncate.d.ts +57 -0
- package/build/truncate.js +142 -0
- package/build/types.d.ts +117 -0
- package/build/types.js +20 -0
- package/build/util/claude-config.d.ts +32 -0
- package/build/util/claude-config.js +84 -0
- package/build/util/hook-config.d.ts +27 -0
- package/build/util/hook-config.js +76 -0
- package/build/util/jsonc.d.ts +14 -0
- package/build/util/jsonc.js +104 -0
- package/build/util/plugin-cache-integrity.d.ts +51 -0
- package/build/util/plugin-cache-integrity.js +146 -0
- package/build/util/project-dir.d.ts +149 -0
- package/build/util/project-dir.js +340 -0
- package/build/util/sibling-mcp.d.ts +79 -0
- package/build/util/sibling-mcp.js +181 -0
- package/cli.bundle.mjs +1495 -0
- package/configs/antigravity/GEMINI.md +68 -0
- package/configs/antigravity/mcp_config.json +7 -0
- package/configs/antigravity-cli/hooks/hooks.json +37 -0
- package/configs/antigravity-cli/hooks.json +37 -0
- package/configs/antigravity-cli/mcp_config.json +10 -0
- package/configs/antigravity-cli/plugin.json +14 -0
- package/configs/antigravity-cli/rules/context-mode.md +77 -0
- package/configs/antigravity-cli/skills/context-mode/SKILL.md +77 -0
- package/configs/claude-code/CLAUDE.md +91 -0
- package/configs/codex/AGENTS.md +99 -0
- package/configs/codex/config.toml +8 -0
- package/configs/codex/hooks.json +47 -0
- package/configs/copilot-cli/.github/plugin/plugin.json +23 -0
- package/configs/copilot-cli/.mcp.json +12 -0
- package/configs/copilot-cli/README.md +47 -0
- package/configs/copilot-cli/hooks.json +41 -0
- package/configs/copilot-cli/skills/context-mode/SKILL.md +38 -0
- package/configs/cursor/context-mode.mdc +76 -0
- package/configs/cursor/hooks.json +21 -0
- package/configs/cursor/mcp.json +7 -0
- package/configs/gemini-cli/GEMINI.md +88 -0
- package/configs/gemini-cli/mcp.json +7 -0
- package/configs/gemini-cli/settings.json +60 -0
- package/configs/jetbrains-copilot/copilot-instructions.md +80 -0
- package/configs/jetbrains-copilot/hooks.json +16 -0
- package/configs/jetbrains-copilot/mcp.json +7 -0
- package/configs/kilo/AGENTS.md +84 -0
- package/configs/kilo/kilo.json +6 -0
- package/configs/kimi/hooks.json +54 -0
- package/configs/kiro/KIRO.md +84 -0
- package/configs/kiro/agent.json +18 -0
- package/configs/kiro/mcp.json +7 -0
- package/configs/omp/SYSTEM.md +85 -0
- package/configs/omp/mcp.json +7 -0
- package/configs/openclaw/AGENTS.md +87 -0
- package/configs/openclaw/openclaw.json +13 -0
- package/configs/opencode/AGENTS.md +84 -0
- package/configs/opencode/opencode.json +6 -0
- package/configs/pi/AGENTS.md +3 -0
- package/configs/qwen-code/QWEN.md +91 -0
- package/configs/vscode-copilot/copilot-instructions.md +80 -0
- package/configs/vscode-copilot/hooks.json +16 -0
- package/configs/vscode-copilot/mcp.json +7 -0
- package/configs/zed/AGENTS.md +68 -0
- package/hooks/antigravity-cli/payload.mjs +98 -0
- package/hooks/antigravity-cli/posttooluse.mjs +138 -0
- package/hooks/antigravity-cli/pretooluse.mjs +78 -0
- package/hooks/antigravity-cli/stop.mjs +58 -0
- package/hooks/auto-injection.mjs +102 -0
- package/hooks/cache-heal-utils.mjs +379 -0
- package/hooks/codex/platform.mjs +1 -0
- package/hooks/codex/posttooluse.mjs +66 -0
- package/hooks/codex/precompact.mjs +70 -0
- package/hooks/codex/pretooluse.mjs +42 -0
- package/hooks/codex/sessionstart.mjs +121 -0
- package/hooks/codex/stop.mjs +138 -0
- package/hooks/codex/userpromptsubmit.mjs +75 -0
- package/hooks/copilot-cli/posttooluse.mjs +79 -0
- package/hooks/copilot-cli/precompact.mjs +66 -0
- package/hooks/copilot-cli/pretooluse.mjs +41 -0
- package/hooks/copilot-cli/sessionstart.mjs +121 -0
- package/hooks/copilot-cli/stop.mjs +59 -0
- package/hooks/copilot-cli/userpromptsubmit.mjs +77 -0
- package/hooks/core/codex-caps.mjs +112 -0
- package/hooks/core/formatters.mjs +356 -0
- package/hooks/core/mcp-ready.mjs +109 -0
- package/hooks/core/platform-detect.mjs +49 -0
- package/hooks/core/routing.mjs +1050 -0
- package/hooks/core/stdin.mjs +90 -0
- package/hooks/core/tool-naming.mjs +57 -0
- package/hooks/cursor/afteragentresponse.mjs +74 -0
- package/hooks/cursor/hooks.json +31 -0
- package/hooks/cursor/posttooluse.mjs +72 -0
- package/hooks/cursor/pretooluse.mjs +26 -0
- package/hooks/cursor/sessionstart.mjs +97 -0
- package/hooks/cursor/stop.mjs +48 -0
- package/hooks/ensure-deps.mjs +254 -0
- package/hooks/formatters/claude-code.mjs +113 -0
- package/hooks/formatters/cursor.mjs +37 -0
- package/hooks/formatters/gemini-cli.mjs +55 -0
- package/hooks/formatters/vscode-copilot.mjs +55 -0
- package/hooks/gemini-cli/aftermodel.mjs +70 -0
- package/hooks/gemini-cli/aftertool.mjs +61 -0
- package/hooks/gemini-cli/beforeagent.mjs +99 -0
- package/hooks/gemini-cli/beforetool.mjs +27 -0
- package/hooks/gemini-cli/precompress.mjs +55 -0
- package/hooks/gemini-cli/sessionstart.mjs +137 -0
- package/hooks/heal-partial-install.mjs +712 -0
- package/hooks/hooks.json +143 -0
- package/hooks/jetbrains-copilot/posttooluse.mjs +61 -0
- package/hooks/jetbrains-copilot/precompact.mjs +55 -0
- package/hooks/jetbrains-copilot/pretooluse.mjs +27 -0
- package/hooks/jetbrains-copilot/sessionstart.mjs +124 -0
- package/hooks/kimi/platform.mjs +1 -0
- package/hooks/kimi/posttooluse.mjs +72 -0
- package/hooks/kimi/precompact.mjs +80 -0
- package/hooks/kimi/pretooluse.mjs +42 -0
- package/hooks/kimi/sessionend.mjs +61 -0
- package/hooks/kimi/sessionstart.mjs +113 -0
- package/hooks/kimi/stop.mjs +132 -0
- package/hooks/kimi/userpromptsubmit.mjs +90 -0
- package/hooks/kiro/agentspawn.mjs +97 -0
- package/hooks/kiro/posttooluse.mjs +51 -0
- package/hooks/kiro/pretooluse.mjs +64 -0
- package/hooks/kiro/userpromptsubmit.mjs +88 -0
- package/hooks/normalize-hooks.mjs +323 -0
- package/hooks/platform-bridge.mjs +340 -0
- package/hooks/posttooluse.mjs +225 -0
- package/hooks/precompact.mjs +100 -0
- package/hooks/pretooluse.mjs +227 -0
- package/hooks/qwen-code/platform.mjs +1 -0
- package/hooks/qwen-code/stop.mjs +168 -0
- package/hooks/routing-block.mjs +105 -0
- package/hooks/run-hook.mjs +95 -0
- package/hooks/security.bundle.mjs +2 -0
- package/hooks/session-attribution.bundle.mjs +1 -0
- package/hooks/session-db.bundle.mjs +145 -0
- package/hooks/session-directive.mjs +520 -0
- package/hooks/session-extract.bundle.mjs +3 -0
- package/hooks/session-helpers.mjs +428 -0
- package/hooks/session-loaders.mjs +448 -0
- package/hooks/session-snapshot.bundle.mjs +32 -0
- package/hooks/sessionstart.mjs +464 -0
- package/hooks/stop.mjs +82 -0
- package/hooks/suppress-stderr.mjs +25 -0
- package/hooks/userpromptsubmit.mjs +108 -0
- package/hooks/vscode-copilot/posttooluse.mjs +61 -0
- package/hooks/vscode-copilot/precompact.mjs +55 -0
- package/hooks/vscode-copilot/pretooluse.mjs +27 -0
- package/hooks/vscode-copilot/sessionstart.mjs +129 -0
- package/openclaw.plugin.json +38 -0
- package/package.json +132 -0
- package/scripts/heal-better-sqlite3.mjs +430 -0
- package/scripts/heal-installed-plugins.mjs +635 -0
- package/scripts/plugin-cache-integrity.mjs +248 -0
- package/scripts/postinstall.mjs +397 -0
- package/server.bundle.mjs +1421 -0
- package/server.js +5 -0
- package/skills/.ignore +7 -0
- package/skills/context-mode/SKILL.md +300 -0
- package/skills/context-mode/references/anti-patterns.md +283 -0
- package/skills/context-mode/references/patterns-javascript.md +298 -0
- package/skills/context-mode/references/patterns-python.md +304 -0
- package/skills/context-mode/references/patterns-shell.md +277 -0
- package/skills/ctx-doctor/SKILL.md +22 -0
- package/skills/ctx-index/SKILL.md +46 -0
- package/skills/ctx-insight/SKILL.md +24 -0
- package/skills/ctx-purge/SKILL.md +49 -0
- package/skills/ctx-search/SKILL.md +35 -0
- package/skills/ctx-stats/SKILL.md +26 -0
- package/skills/ctx-upgrade/SKILL.md +31 -0
- package/start.mjs +616 -0
|
@@ -0,0 +1,2420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AnalyticsEngine — Runtime savings + session continuity reporting.
|
|
3
|
+
*
|
|
4
|
+
* Computes context-window savings from runtime stats and queries
|
|
5
|
+
* session continuity data from SessionDB.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* const engine = new AnalyticsEngine(sessionDb);
|
|
9
|
+
* const report = engine.queryAll(runtimeStats);
|
|
10
|
+
*/
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
12
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { join, sep } from "node:path";
|
|
15
|
+
import { loadDatabase as loadDatabaseImpl } from "../db-base.js";
|
|
16
|
+
import { ensureSessionEventsSchema } from "./db.js";
|
|
17
|
+
import { resolveClaudeConfigDir } from "../util/claude-config.js";
|
|
18
|
+
function semverNewer(a, b) {
|
|
19
|
+
const pa = a.split(".").map(Number);
|
|
20
|
+
const pb = b.split(".").map(Number);
|
|
21
|
+
for (let i = 0; i < 3; i++) {
|
|
22
|
+
if ((pa[i] ?? 0) > (pb[i] ?? 0))
|
|
23
|
+
return true;
|
|
24
|
+
if ((pa[i] ?? 0) < (pb[i] ?? 0))
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
// ─────────────────────────────────────────────────────────
|
|
30
|
+
// Category labels and hints for session continuity display
|
|
31
|
+
// ─────────────────────────────────────────────────────────
|
|
32
|
+
/**
|
|
33
|
+
* Human-readable labels for event categories.
|
|
34
|
+
*
|
|
35
|
+
* Each label is a sentence-case phrase that reads like a benefit, not a
|
|
36
|
+
* column name. The user shouldn't see raw schema words like "external-ref"
|
|
37
|
+
* or "agent-finding" — those leak the database into the UX. When a new
|
|
38
|
+
* category lands without an entry here, the renderer falls through to the
|
|
39
|
+
* raw category id; that's a copy-debt signal, fix it here.
|
|
40
|
+
*/
|
|
41
|
+
export const categoryLabels = {
|
|
42
|
+
// Code & filesystem
|
|
43
|
+
file: "Files tracked",
|
|
44
|
+
cwd: "Working directory",
|
|
45
|
+
// Configuration & intent
|
|
46
|
+
rule: "Project rules (CLAUDE.md)",
|
|
47
|
+
prompt: "Your requests saved",
|
|
48
|
+
intent: "Session intent",
|
|
49
|
+
goal: "Session goal",
|
|
50
|
+
role: "Behavior rules",
|
|
51
|
+
constraint: "Constraints you set",
|
|
52
|
+
// Tools & delegation
|
|
53
|
+
mcp: "MCP tools called",
|
|
54
|
+
skill: "Skills used",
|
|
55
|
+
subagent: "Delegated work",
|
|
56
|
+
// Knowledge & decisions
|
|
57
|
+
decision: "Your decisions",
|
|
58
|
+
"agent-finding": "Agent insights kept",
|
|
59
|
+
"rejected-approach": "Approaches you rejected",
|
|
60
|
+
"external-ref": "External docs indexed",
|
|
61
|
+
data: "Data references",
|
|
62
|
+
// System events
|
|
63
|
+
git: "Git operations",
|
|
64
|
+
env: "Environment setup",
|
|
65
|
+
task: "Tasks in progress",
|
|
66
|
+
error: "Errors caught",
|
|
67
|
+
// Continuity proof
|
|
68
|
+
compact: "Compactions weathered",
|
|
69
|
+
resume: "Sessions resumed cleanly",
|
|
70
|
+
snapshot: "Snapshots restored",
|
|
71
|
+
cache: "Cache hits saved",
|
|
72
|
+
// Operational
|
|
73
|
+
latency: "Slow tools recorded",
|
|
74
|
+
"user-prompt": "Your messages remembered",
|
|
75
|
+
plan: "Plans drafted",
|
|
76
|
+
"blocked-on": "Blockers logged",
|
|
77
|
+
};
|
|
78
|
+
/** Explains why each category matters for continuity. */
|
|
79
|
+
export const categoryHints = {
|
|
80
|
+
file: "Restored after compact — no need to re-read",
|
|
81
|
+
rule: "Your project instructions survive context resets",
|
|
82
|
+
prompt: "Continues exactly where you left off",
|
|
83
|
+
decision: "Applied automatically — won’t ask again",
|
|
84
|
+
task: "Picks up from where it stopped",
|
|
85
|
+
error: "Tracked and monitored across compacts",
|
|
86
|
+
git: "Branch, commit, and repo state preserved",
|
|
87
|
+
env: "Runtime config carried forward",
|
|
88
|
+
mcp: "Tool usage patterns remembered",
|
|
89
|
+
subagent: "Delegation history preserved",
|
|
90
|
+
skill: "Skill invocations tracked",
|
|
91
|
+
};
|
|
92
|
+
// ─────────────────────────────────────────────────────────
|
|
93
|
+
// AnalyticsEngine
|
|
94
|
+
// ─────────────────────────────────────────────────────────
|
|
95
|
+
export class AnalyticsEngine {
|
|
96
|
+
db;
|
|
97
|
+
/**
|
|
98
|
+
* Create an AnalyticsEngine.
|
|
99
|
+
*
|
|
100
|
+
* Accepts either a SessionDB instance (extracts internal db via
|
|
101
|
+
* the protected getter — use the static fromDB helper for raw adapters)
|
|
102
|
+
* or any object with a prepare() method for direct usage.
|
|
103
|
+
*/
|
|
104
|
+
constructor(db) {
|
|
105
|
+
this.db = db;
|
|
106
|
+
}
|
|
107
|
+
// ═══════════════════════════════════════════════════════
|
|
108
|
+
// GROUP 3 — Runtime (4 metrics, stubs)
|
|
109
|
+
// ═══════════════════════════════════════════════════════
|
|
110
|
+
/**
|
|
111
|
+
* #1 Context Savings Total — bytes kept out of context window.
|
|
112
|
+
*
|
|
113
|
+
* Stub: requires server.ts to accumulate rawBytes and contextBytes
|
|
114
|
+
* during a live session. Call with tracked values.
|
|
115
|
+
*/
|
|
116
|
+
static contextSavingsTotal(rawBytes, contextBytes) {
|
|
117
|
+
const savedBytes = rawBytes - contextBytes;
|
|
118
|
+
const savedPercent = rawBytes > 0
|
|
119
|
+
? Math.round((savedBytes / rawBytes) * 1000) / 10
|
|
120
|
+
: 0;
|
|
121
|
+
return { rawBytes, contextBytes, savedBytes, savedPercent };
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* #2 Think in Code Comparison — ratio of file size to sandbox output size.
|
|
125
|
+
*
|
|
126
|
+
* Stub: requires server.ts tracking of execute/execute_file calls.
|
|
127
|
+
*/
|
|
128
|
+
static thinkInCodeComparison(fileBytes, outputBytes) {
|
|
129
|
+
const ratio = outputBytes > 0
|
|
130
|
+
? Math.round((fileBytes / outputBytes) * 10) / 10
|
|
131
|
+
: 0;
|
|
132
|
+
return { fileBytes, outputBytes, ratio };
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* #3 Tool Savings — per-tool breakdown of context savings.
|
|
136
|
+
*
|
|
137
|
+
* Stub: requires per-tool accumulators in server.ts.
|
|
138
|
+
*/
|
|
139
|
+
static toolSavings(tools) {
|
|
140
|
+
return tools.map((t) => ({
|
|
141
|
+
...t,
|
|
142
|
+
savedBytes: t.rawBytes - t.contextBytes,
|
|
143
|
+
}));
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* #19 Sandbox I/O — total input/output bytes processed by the sandbox.
|
|
147
|
+
*
|
|
148
|
+
* Stub: requires PolyglotExecutor byte counters.
|
|
149
|
+
*/
|
|
150
|
+
static sandboxIO(inputBytes, outputBytes) {
|
|
151
|
+
return { inputBytes, outputBytes };
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* MCP tool usage — call counts and concurrency stats per MCP tool.
|
|
155
|
+
*
|
|
156
|
+
* Reads `mcp_tool_call` events, parses the JSON payload, and aggregates:
|
|
157
|
+
* - call count per tool_name
|
|
158
|
+
* - median + max of `params.concurrency` (only for tools that take it,
|
|
159
|
+
* e.g. ctx_batch_execute, ctx_fetch_and_index). Returns null when the
|
|
160
|
+
* tool doesn't carry a concurrency param so callers can render N/A.
|
|
161
|
+
*
|
|
162
|
+
* Best-effort: malformed rows or truncated payloads are skipped silently.
|
|
163
|
+
*/
|
|
164
|
+
getMcpToolUsage() {
|
|
165
|
+
let rows;
|
|
166
|
+
try {
|
|
167
|
+
rows = this.db.prepare("SELECT data FROM session_events WHERE category = 'mcp_tool_call'").all();
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return [];
|
|
171
|
+
}
|
|
172
|
+
// toolName -> { calls, concurrencies }
|
|
173
|
+
const agg = new Map();
|
|
174
|
+
for (const row of rows) {
|
|
175
|
+
let parsed;
|
|
176
|
+
try {
|
|
177
|
+
parsed = JSON.parse(row.data);
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const toolName = typeof parsed.tool_name === "string" ? parsed.tool_name : null;
|
|
183
|
+
if (!toolName)
|
|
184
|
+
continue;
|
|
185
|
+
const bucket = agg.get(toolName) ?? { calls: 0, concurrencies: [] };
|
|
186
|
+
bucket.calls += 1;
|
|
187
|
+
// Skip concurrency extraction when the row was truncated — the params
|
|
188
|
+
// blob is a substring of JSON that may not parse cleanly.
|
|
189
|
+
if (parsed.truncated !== true && parsed.params && typeof parsed.params === "object") {
|
|
190
|
+
const c = parsed.params.concurrency;
|
|
191
|
+
if (typeof c === "number" && Number.isFinite(c) && c > 0) {
|
|
192
|
+
bucket.concurrencies.push(c);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
agg.set(toolName, bucket);
|
|
196
|
+
}
|
|
197
|
+
const out = [];
|
|
198
|
+
for (const [tool_name, b] of agg) {
|
|
199
|
+
let median = null;
|
|
200
|
+
let max = null;
|
|
201
|
+
if (b.concurrencies.length > 0) {
|
|
202
|
+
b.concurrencies.sort((a, c) => a - c);
|
|
203
|
+
const sorted = b.concurrencies;
|
|
204
|
+
const mid = Math.floor(sorted.length / 2);
|
|
205
|
+
median = sorted.length % 2 === 0
|
|
206
|
+
? (sorted[mid - 1] + sorted[mid]) / 2
|
|
207
|
+
: sorted[mid];
|
|
208
|
+
max = sorted[sorted.length - 1];
|
|
209
|
+
}
|
|
210
|
+
out.push({
|
|
211
|
+
tool_name,
|
|
212
|
+
calls: b.calls,
|
|
213
|
+
median_concurrency: median,
|
|
214
|
+
max_concurrency: max,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
// Stable sort: most-called first, then alphabetical
|
|
218
|
+
out.sort((a, c) => c.calls - a.calls || a.tool_name.localeCompare(c.tool_name));
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
// ═══════════════════════════════════════════════════════
|
|
222
|
+
// queryAll — single unified report from ONE source
|
|
223
|
+
// ═══════════════════════════════════════════════════════
|
|
224
|
+
/**
|
|
225
|
+
* Build a FullReport by merging runtime stats (passed in)
|
|
226
|
+
* with continuity data from the DB.
|
|
227
|
+
*
|
|
228
|
+
* This is the ONE call that ctx_stats should use.
|
|
229
|
+
*/
|
|
230
|
+
queryAll(runtimeStats) {
|
|
231
|
+
// ── Resolve latest session ID ──
|
|
232
|
+
const latestSession = this.db.prepare("SELECT session_id FROM session_meta ORDER BY started_at DESC LIMIT 1").get();
|
|
233
|
+
const sid = latestSession?.session_id ?? "";
|
|
234
|
+
// ── Runtime savings ──
|
|
235
|
+
const totalBytesReturned = Object.values(runtimeStats.bytesReturned).reduce((sum, b) => sum + b, 0);
|
|
236
|
+
const totalCalls = Object.values(runtimeStats.calls).reduce((sum, c) => sum + c, 0);
|
|
237
|
+
const keptOut = runtimeStats.bytesIndexed + runtimeStats.bytesSandboxed;
|
|
238
|
+
const totalProcessed = keptOut + totalBytesReturned;
|
|
239
|
+
const savingsRatio = totalProcessed / Math.max(totalBytesReturned, 1);
|
|
240
|
+
const reductionPct = totalProcessed > 0
|
|
241
|
+
? Math.round((1 - totalBytesReturned / totalProcessed) * 100)
|
|
242
|
+
: 0;
|
|
243
|
+
const toolNames = new Set([
|
|
244
|
+
...Object.keys(runtimeStats.calls),
|
|
245
|
+
...Object.keys(runtimeStats.bytesReturned),
|
|
246
|
+
]);
|
|
247
|
+
const byTool = Array.from(toolNames).sort().map((tool) => ({
|
|
248
|
+
tool,
|
|
249
|
+
calls: runtimeStats.calls[tool] || 0,
|
|
250
|
+
context_kb: Math.round((runtimeStats.bytesReturned[tool] || 0) / 1024 * 10) / 10,
|
|
251
|
+
tokens: Math.round((runtimeStats.bytesReturned[tool] || 0) / 4),
|
|
252
|
+
}));
|
|
253
|
+
const uptimeMs = Date.now() - runtimeStats.sessionStart;
|
|
254
|
+
const uptimeMin = (uptimeMs / 60_000).toFixed(1);
|
|
255
|
+
// ── Cache ──
|
|
256
|
+
let cache;
|
|
257
|
+
const cacheMisses = runtimeStats.cacheMisses ?? 0;
|
|
258
|
+
if (runtimeStats.cacheHits > 0 || runtimeStats.cacheBytesSaved > 0 || cacheMisses > 0) {
|
|
259
|
+
const totalWithCache = totalProcessed + runtimeStats.cacheBytesSaved;
|
|
260
|
+
const totalSavingsRatio = totalWithCache / Math.max(totalBytesReturned, 1);
|
|
261
|
+
const ttlHoursLeft = Math.max(0, 24 - Math.floor((Date.now() - runtimeStats.sessionStart) / (60 * 60 * 1000)));
|
|
262
|
+
// hit_rate is the nominal cache effectiveness — the metric ctx_stats
|
|
263
|
+
// historically inferred-only by diffing tokens_saved snapshots. When
|
|
264
|
+
// there is no activity we report 0 instead of NaN/undefined so the
|
|
265
|
+
// renderer stays JSON-safe.
|
|
266
|
+
const totalLookups = runtimeStats.cacheHits + cacheMisses;
|
|
267
|
+
const hitRate = totalLookups > 0 ? runtimeStats.cacheHits / totalLookups : 0;
|
|
268
|
+
cache = {
|
|
269
|
+
hits: runtimeStats.cacheHits,
|
|
270
|
+
misses: cacheMisses,
|
|
271
|
+
hit_rate: hitRate,
|
|
272
|
+
bytes_saved: runtimeStats.cacheBytesSaved,
|
|
273
|
+
ttl_hours_left: ttlHoursLeft,
|
|
274
|
+
total_with_cache: totalWithCache,
|
|
275
|
+
total_savings_ratio: totalSavingsRatio,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
// ── Continuity data (scoped to current session) ──
|
|
279
|
+
const eventTotal = this.db.prepare("SELECT COUNT(*) as cnt FROM session_events WHERE session_id = ?").get(sid).cnt;
|
|
280
|
+
const byCategory = this.db.prepare("SELECT category, COUNT(*) as cnt FROM session_events WHERE session_id = ? GROUP BY category ORDER BY cnt DESC").all(sid);
|
|
281
|
+
const meta = this.db.prepare("SELECT compact_count FROM session_meta WHERE session_id = ?").get(sid);
|
|
282
|
+
const compactCount = meta?.compact_count ?? 0;
|
|
283
|
+
const resume = this.db.prepare("SELECT event_count, consumed FROM session_resume WHERE session_id = ? ORDER BY created_at DESC LIMIT 1").get(sid);
|
|
284
|
+
const resumeReady = resume ? !resume.consumed : false;
|
|
285
|
+
// Build category previews (current session only)
|
|
286
|
+
const previewRows = this.db.prepare("SELECT category, type, data FROM session_events WHERE session_id = ? ORDER BY id DESC").all(sid);
|
|
287
|
+
const previews = new Map();
|
|
288
|
+
for (const row of previewRows) {
|
|
289
|
+
if (!previews.has(row.category))
|
|
290
|
+
previews.set(row.category, new Set());
|
|
291
|
+
const set = previews.get(row.category);
|
|
292
|
+
if (set.size < 5) {
|
|
293
|
+
let display = row.data;
|
|
294
|
+
if (row.category === "file") {
|
|
295
|
+
display = row.data.split("/").pop() || row.data;
|
|
296
|
+
}
|
|
297
|
+
else if (row.category === "prompt" || row.category === "user-prompt") {
|
|
298
|
+
display = display.length > 50 ? display.slice(0, 47) + "..." : display;
|
|
299
|
+
}
|
|
300
|
+
if (display.length > 40)
|
|
301
|
+
display = display.slice(0, 37) + "...";
|
|
302
|
+
set.add(display);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
const continuityByCategory = byCategory.map((row) => ({
|
|
306
|
+
category: row.category,
|
|
307
|
+
count: row.cnt,
|
|
308
|
+
label: categoryLabels[row.category] || row.category,
|
|
309
|
+
preview: previews.get(row.category)
|
|
310
|
+
? Array.from(previews.get(row.category)).join(", ")
|
|
311
|
+
: "",
|
|
312
|
+
why: categoryHints[row.category] || "Survives context resets",
|
|
313
|
+
}));
|
|
314
|
+
// ── Project-wide persistent memory (all sessions, no session_id filter) ──
|
|
315
|
+
const projectTotals = this.db.prepare("SELECT COUNT(*) as cnt, COUNT(DISTINCT session_id) as sessions FROM session_events").get();
|
|
316
|
+
const projectByCategory = this.db.prepare("SELECT category, COUNT(*) as cnt FROM session_events GROUP BY category ORDER BY cnt DESC").all();
|
|
317
|
+
const projectMemoryByCategory = projectByCategory
|
|
318
|
+
.filter((row) => row.cnt > 0)
|
|
319
|
+
.map((row) => ({
|
|
320
|
+
category: row.category,
|
|
321
|
+
count: row.cnt,
|
|
322
|
+
label: categoryLabels[row.category] || row.category,
|
|
323
|
+
}));
|
|
324
|
+
return {
|
|
325
|
+
savings: {
|
|
326
|
+
processed_kb: Math.round(totalProcessed / 1024 * 10) / 10,
|
|
327
|
+
entered_kb: Math.round(totalBytesReturned / 1024 * 10) / 10,
|
|
328
|
+
saved_kb: Math.round(keptOut / 1024 * 10) / 10,
|
|
329
|
+
pct: reductionPct,
|
|
330
|
+
savings_ratio: Math.round(savingsRatio * 10) / 10,
|
|
331
|
+
by_tool: byTool,
|
|
332
|
+
total_calls: totalCalls,
|
|
333
|
+
total_bytes_returned: totalBytesReturned,
|
|
334
|
+
kept_out: keptOut,
|
|
335
|
+
total_processed: totalProcessed,
|
|
336
|
+
},
|
|
337
|
+
cache,
|
|
338
|
+
session: {
|
|
339
|
+
id: sid,
|
|
340
|
+
uptime_min: uptimeMin,
|
|
341
|
+
},
|
|
342
|
+
continuity: {
|
|
343
|
+
total_events: eventTotal,
|
|
344
|
+
by_category: continuityByCategory,
|
|
345
|
+
compact_count: compactCount,
|
|
346
|
+
resume_ready: resumeReady,
|
|
347
|
+
},
|
|
348
|
+
projectMemory: {
|
|
349
|
+
total_events: projectTotals.cnt,
|
|
350
|
+
session_count: projectTotals.sessions,
|
|
351
|
+
by_category: projectMemoryByCategory,
|
|
352
|
+
},
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Enumerate every known adapter's sessions + content dirs under `home`.
|
|
358
|
+
* Used by `getMultiAdapterLifetimeStats` and `getMultiAdapterRealBytesStats`
|
|
359
|
+
* so a single call surfaces "your work everywhere on this machine across
|
|
360
|
+
* all AI tools" (the marketing line).
|
|
361
|
+
*
|
|
362
|
+
* Returns ALL 17 adapters even when the dir doesn't exist on disk — the
|
|
363
|
+
* scanner functions filter to existing dirs. That keeps the enumeration
|
|
364
|
+
* pure / testable without filesystem dependencies.
|
|
365
|
+
*/
|
|
366
|
+
export function enumerateAdapterDirs(opts) {
|
|
367
|
+
const home = opts?.home ?? homedir();
|
|
368
|
+
// Mirrors `getSessionDirSegments` in src/adapters/detect.ts:92-111.
|
|
369
|
+
const map = [
|
|
370
|
+
["claude-code", [".claude"]],
|
|
371
|
+
["gemini-cli", [".gemini"]],
|
|
372
|
+
["antigravity", [".gemini"]],
|
|
373
|
+
["antigravity-cli", [".gemini"]],
|
|
374
|
+
["openclaw", [".openclaw"]],
|
|
375
|
+
["codex", [".codex"]],
|
|
376
|
+
["cursor", [".cursor"]],
|
|
377
|
+
["vscode-copilot", [".vscode"]],
|
|
378
|
+
["copilot-cli", [".copilot"]],
|
|
379
|
+
["kiro", [".kiro"]],
|
|
380
|
+
["pi", [".pi"]],
|
|
381
|
+
["omp", [".omp"]],
|
|
382
|
+
["qwen-code", [".qwen"]],
|
|
383
|
+
["kilo", [".config", "kilo"]],
|
|
384
|
+
["opencode", [".config", "opencode"]],
|
|
385
|
+
["zed", [".config", "zed"]],
|
|
386
|
+
["jetbrains-copilot", [".config", "JetBrains"]],
|
|
387
|
+
];
|
|
388
|
+
return map.map(([name, segments]) => {
|
|
389
|
+
const base = join(home, ...segments, "context-mode");
|
|
390
|
+
return {
|
|
391
|
+
name,
|
|
392
|
+
sessionsDir: join(base, "sessions"),
|
|
393
|
+
contentDir: join(base, "content"),
|
|
394
|
+
};
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
/** Extract leading prefix from auto-memory filename: `feedback_push.md` → `feedback`. */
|
|
398
|
+
function autoMemoryPrefix(filename) {
|
|
399
|
+
const base = filename.replace(/\.md$/i, "");
|
|
400
|
+
const m = base.match(/^([a-z]+)/i);
|
|
401
|
+
return m ? m[1].toLowerCase() : "other";
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Aggregate lifetime stats from all SessionDB files in `sessionsDir` and
|
|
405
|
+
* all auto-memory markdown files under `memoryRoot/<project>/memory/`.
|
|
406
|
+
*
|
|
407
|
+
* Best-effort: silently ignores missing/unreadable files so ctx_stats
|
|
408
|
+
* can never be broken by a corrupt sidecar.
|
|
409
|
+
*/
|
|
410
|
+
export function getLifetimeStats(opts) {
|
|
411
|
+
// Issue #460 round-3: route through resolveClaudeConfigDir so lifetime
|
|
412
|
+
// stats aggregation tracks $CLAUDE_CONFIG_DIR instead of the literal
|
|
413
|
+
// ~/.claude tree. Otherwise users who relocate config see "no sessions"
|
|
414
|
+
// even though the SessionDB sidecars exist under the override.
|
|
415
|
+
const claudeRoot = resolveClaudeConfigDir();
|
|
416
|
+
const sessionsDir = opts?.sessionsDir
|
|
417
|
+
?? join(claudeRoot, "context-mode", "sessions");
|
|
418
|
+
const memoryRoot = opts?.memoryRoot
|
|
419
|
+
?? join(claudeRoot, "projects");
|
|
420
|
+
let totalEvents = 0;
|
|
421
|
+
let totalSessions = 0;
|
|
422
|
+
let rescueBytes = 0;
|
|
423
|
+
let firstEventMs = Number.POSITIVE_INFINITY;
|
|
424
|
+
const distinctProjectsSet = new Set();
|
|
425
|
+
const categoryCounts = {};
|
|
426
|
+
// ── SessionDB aggregation ──
|
|
427
|
+
if (existsSync(sessionsDir)) {
|
|
428
|
+
let dbFiles = [];
|
|
429
|
+
try {
|
|
430
|
+
dbFiles = readdirSync(sessionsDir).filter((f) => f.endsWith(".db"));
|
|
431
|
+
}
|
|
432
|
+
catch { /* unreadable */ }
|
|
433
|
+
if (dbFiles.length > 0) {
|
|
434
|
+
// Lazy-load better-sqlite3 / bun-sqlite via the same path the runtime uses.
|
|
435
|
+
let DatabaseCtor = null;
|
|
436
|
+
try {
|
|
437
|
+
DatabaseCtor = opts?.loadDatabase
|
|
438
|
+
? opts.loadDatabase()
|
|
439
|
+
: loadDatabaseImpl();
|
|
440
|
+
}
|
|
441
|
+
catch { /* sqlite unavailable */ }
|
|
442
|
+
if (DatabaseCtor) {
|
|
443
|
+
for (const file of dbFiles) {
|
|
444
|
+
const dbPath = join(sessionsDir, file);
|
|
445
|
+
try {
|
|
446
|
+
const sdb = new DatabaseCtor(dbPath, { readonly: true });
|
|
447
|
+
try {
|
|
448
|
+
const ev = sdb.prepare("SELECT COUNT(*) AS cnt FROM session_events").get();
|
|
449
|
+
const ss = sdb.prepare("SELECT COUNT(*) AS cnt FROM session_meta").get();
|
|
450
|
+
totalEvents += ev?.cnt ?? 0;
|
|
451
|
+
totalSessions += ss?.cnt ?? 0;
|
|
452
|
+
// Per-category aggregation across every sidecar so the
|
|
453
|
+
// Persistent memory bars stay populated even when the
|
|
454
|
+
// current project's local DB is fresh / empty.
|
|
455
|
+
try {
|
|
456
|
+
const catRows = sdb.prepare("SELECT category, COUNT(*) AS cnt FROM session_events GROUP BY category").all();
|
|
457
|
+
for (const row of catRows) {
|
|
458
|
+
if (!row.category)
|
|
459
|
+
continue;
|
|
460
|
+
categoryCounts[row.category] = (categoryCounts[row.category] ?? 0) + (row.cnt ?? 0);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
catch {
|
|
464
|
+
// older schema / no category column — ignore
|
|
465
|
+
}
|
|
466
|
+
// Lifetime rescue: compact-snapshot bytes restored across every DB.
|
|
467
|
+
// Without this, the lifetime $ silently undercounts the killer
|
|
468
|
+
// continuity-after-/compact feature.
|
|
469
|
+
try {
|
|
470
|
+
const snap = sdb.prepare("SELECT COALESCE(SUM(length(snapshot)), 0) AS bytes FROM session_resume WHERE consumed = 1").get();
|
|
471
|
+
if (snap?.bytes)
|
|
472
|
+
rescueBytes += snap.bytes;
|
|
473
|
+
}
|
|
474
|
+
catch { /* old schema */ }
|
|
475
|
+
// Earliest event timestamp + distinct project_dirs for the
|
|
476
|
+
// "since X · Y projects" lifetime narrative.
|
|
477
|
+
try {
|
|
478
|
+
const mn = sdb.prepare("SELECT MIN(created_at) AS t FROM session_events").get();
|
|
479
|
+
if (mn?.t) {
|
|
480
|
+
const stamp = mn.t.endsWith("Z") ? mn.t : mn.t + "Z";
|
|
481
|
+
const ms = Date.parse(stamp);
|
|
482
|
+
if (Number.isFinite(ms) && ms < firstEventMs)
|
|
483
|
+
firstEventMs = ms;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
catch { /* old schema */ }
|
|
487
|
+
try {
|
|
488
|
+
const projRows = sdb.prepare("SELECT DISTINCT project_dir AS p FROM session_events WHERE project_dir != ''").all();
|
|
489
|
+
for (const row of projRows)
|
|
490
|
+
if (row.p)
|
|
491
|
+
distinctProjectsSet.add(row.p);
|
|
492
|
+
}
|
|
493
|
+
catch { /* old schema */ }
|
|
494
|
+
}
|
|
495
|
+
finally {
|
|
496
|
+
sdb.close();
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
catch {
|
|
500
|
+
// missing tables / corrupt file — skip
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
// ── Auto-memory file scan ──
|
|
507
|
+
let autoMemoryCount = 0;
|
|
508
|
+
let autoMemoryProjects = 0;
|
|
509
|
+
const autoMemoryByPrefix = {};
|
|
510
|
+
if (existsSync(memoryRoot)) {
|
|
511
|
+
let projectDirs = [];
|
|
512
|
+
try {
|
|
513
|
+
projectDirs = readdirSync(memoryRoot).filter((entry) => {
|
|
514
|
+
try {
|
|
515
|
+
return statSync(join(memoryRoot, entry)).isDirectory();
|
|
516
|
+
}
|
|
517
|
+
catch {
|
|
518
|
+
return false;
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
catch { /* unreadable */ }
|
|
523
|
+
for (const proj of projectDirs) {
|
|
524
|
+
const memDir = join(memoryRoot, proj, "memory");
|
|
525
|
+
if (!existsSync(memDir))
|
|
526
|
+
continue;
|
|
527
|
+
let mdFiles = [];
|
|
528
|
+
try {
|
|
529
|
+
mdFiles = readdirSync(memDir).filter((f) => f.endsWith(".md"));
|
|
530
|
+
}
|
|
531
|
+
catch {
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
if (mdFiles.length === 0)
|
|
535
|
+
continue;
|
|
536
|
+
autoMemoryProjects++;
|
|
537
|
+
autoMemoryCount += mdFiles.length;
|
|
538
|
+
for (const f of mdFiles) {
|
|
539
|
+
const prefix = autoMemoryPrefix(f);
|
|
540
|
+
autoMemoryByPrefix[prefix] = (autoMemoryByPrefix[prefix] ?? 0) + 1;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return {
|
|
545
|
+
totalEvents,
|
|
546
|
+
totalSessions,
|
|
547
|
+
autoMemoryCount,
|
|
548
|
+
autoMemoryProjects,
|
|
549
|
+
autoMemoryByPrefix,
|
|
550
|
+
categoryCounts,
|
|
551
|
+
rescueBytes,
|
|
552
|
+
firstEventMs: Number.isFinite(firstEventMs) ? firstEventMs : 0,
|
|
553
|
+
distinctProjects: distinctProjectsSet.size,
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Aggregate every event for one `session_id` across all SessionDB files in
|
|
558
|
+
* `sessionsDir` plus the compact-rescue snapshot bytes from `session_resume`.
|
|
559
|
+
*
|
|
560
|
+
* Why this exists: the Claude Code session_id can persist across days while
|
|
561
|
+
* the underlying DB file rotates (size cap), and a compact-rescue snapshot
|
|
562
|
+
* carries hundreds of KB of context that would otherwise have been lost. The
|
|
563
|
+
* old in-memory `tool_call_counter` saw none of this — it counted only `ctx_*`
|
|
564
|
+
* MCP calls against the current MCP server PID and reset on every restart.
|
|
565
|
+
* Reading from `session_events` + `session_resume` is the source-of-truth
|
|
566
|
+
* version that matches what users actually experienced.
|
|
567
|
+
*/
|
|
568
|
+
export function getConversationStats(opts) {
|
|
569
|
+
const sessionsDir = opts.sessionsDir
|
|
570
|
+
?? join(homedir(), ".claude", "context-mode", "sessions");
|
|
571
|
+
const sessionId = opts.sessionId;
|
|
572
|
+
const empty = {
|
|
573
|
+
sessionId,
|
|
574
|
+
events: 0,
|
|
575
|
+
dbCount: 0,
|
|
576
|
+
daysAlive: 0,
|
|
577
|
+
snapshotBytes: 0,
|
|
578
|
+
snapshotsConsumed: 0,
|
|
579
|
+
byCategory: [],
|
|
580
|
+
};
|
|
581
|
+
if (!sessionId || !existsSync(sessionsDir))
|
|
582
|
+
return empty;
|
|
583
|
+
let dbFiles = [];
|
|
584
|
+
try {
|
|
585
|
+
dbFiles = readdirSync(sessionsDir).filter((f) => {
|
|
586
|
+
if (!f.endsWith(".db"))
|
|
587
|
+
return false;
|
|
588
|
+
if (opts.worktreeHash && !f.startsWith(opts.worktreeHash))
|
|
589
|
+
return false;
|
|
590
|
+
return true;
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
catch {
|
|
594
|
+
return empty;
|
|
595
|
+
}
|
|
596
|
+
if (dbFiles.length === 0)
|
|
597
|
+
return empty;
|
|
598
|
+
let DatabaseCtor = null;
|
|
599
|
+
try {
|
|
600
|
+
DatabaseCtor = opts.loadDatabase
|
|
601
|
+
? opts.loadDatabase()
|
|
602
|
+
: loadDatabaseImpl();
|
|
603
|
+
}
|
|
604
|
+
catch {
|
|
605
|
+
return empty;
|
|
606
|
+
}
|
|
607
|
+
if (!DatabaseCtor)
|
|
608
|
+
return empty;
|
|
609
|
+
const catCounts = {};
|
|
610
|
+
let events = 0;
|
|
611
|
+
let dbCount = 0;
|
|
612
|
+
let snapshotBytes = 0;
|
|
613
|
+
let snapshotsConsumed = 0;
|
|
614
|
+
let firstMs = Number.POSITIVE_INFINITY;
|
|
615
|
+
let lastMs = 0;
|
|
616
|
+
let lastRescueMs = 0;
|
|
617
|
+
// Per-day captures aggregated across every DB. Key is the UTC midnight ms
|
|
618
|
+
// of the day; value tracks both the event count and any rescueBytes (latter
|
|
619
|
+
// overlays the ◆ /compact glyph in the section-1 horizontal timeline).
|
|
620
|
+
const byDayMap = new Map();
|
|
621
|
+
const dayKey = (ms) => Math.floor(ms / 86_400_000) * 86_400_000;
|
|
622
|
+
for (const file of dbFiles) {
|
|
623
|
+
const dbPath = join(sessionsDir, file);
|
|
624
|
+
let touched = false;
|
|
625
|
+
try {
|
|
626
|
+
const sdb = new DatabaseCtor(dbPath, { readonly: true });
|
|
627
|
+
try {
|
|
628
|
+
const cats = sdb.prepare("SELECT category, COUNT(*) AS cnt FROM session_events WHERE session_id = ? GROUP BY category").all(sessionId);
|
|
629
|
+
for (const row of cats) {
|
|
630
|
+
if (!row.category)
|
|
631
|
+
continue;
|
|
632
|
+
catCounts[row.category] = (catCounts[row.category] ?? 0) + (row.cnt ?? 0);
|
|
633
|
+
events += row.cnt ?? 0;
|
|
634
|
+
touched = true;
|
|
635
|
+
}
|
|
636
|
+
const range = sdb.prepare("SELECT MIN(created_at) AS mn, MAX(created_at) AS mx FROM session_events WHERE session_id = ?").get(sessionId);
|
|
637
|
+
if (range?.mn) {
|
|
638
|
+
const t = Date.parse(range.mn + (range.mn.endsWith("Z") ? "" : "Z"));
|
|
639
|
+
if (Number.isFinite(t) && t < firstMs)
|
|
640
|
+
firstMs = t;
|
|
641
|
+
}
|
|
642
|
+
if (range?.mx) {
|
|
643
|
+
const t = Date.parse(range.mx + (range.mx.endsWith("Z") ? "" : "Z"));
|
|
644
|
+
if (Number.isFinite(t) && t > lastMs)
|
|
645
|
+
lastMs = t;
|
|
646
|
+
}
|
|
647
|
+
// Per-day captures + per-day rescue overlay for the narrative timeline.
|
|
648
|
+
// Best-effort: silently skip when the schema lacks created_at.
|
|
649
|
+
try {
|
|
650
|
+
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);
|
|
651
|
+
for (const row of dayRows) {
|
|
652
|
+
if (!row.sec)
|
|
653
|
+
continue;
|
|
654
|
+
const ms = parseInt(row.sec, 10) * 1000;
|
|
655
|
+
if (!Number.isFinite(ms))
|
|
656
|
+
continue;
|
|
657
|
+
const k = dayKey(ms);
|
|
658
|
+
const cur = byDayMap.get(k) ?? { count: 0, rescueBytes: 0 };
|
|
659
|
+
cur.count += row.cnt ?? 0;
|
|
660
|
+
byDayMap.set(k, cur);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
catch { /* old schema */ }
|
|
664
|
+
try {
|
|
665
|
+
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);
|
|
666
|
+
if (snap?.bytes)
|
|
667
|
+
snapshotBytes += snap.bytes;
|
|
668
|
+
if (snap?.n)
|
|
669
|
+
snapshotsConsumed += snap.n;
|
|
670
|
+
if (snap?.lastSec) {
|
|
671
|
+
const t = parseInt(snap.lastSec, 10) * 1000;
|
|
672
|
+
if (Number.isFinite(t) && t > lastRescueMs)
|
|
673
|
+
lastRescueMs = t;
|
|
674
|
+
// Overlay the rescue bytes onto the day bucket for the timeline.
|
|
675
|
+
if (Number.isFinite(t) && (snap?.bytes ?? 0) > 0) {
|
|
676
|
+
const k = dayKey(t);
|
|
677
|
+
const cur = byDayMap.get(k) ?? { count: 0, rescueBytes: 0 };
|
|
678
|
+
cur.rescueBytes = Math.max(cur.rescueBytes, snap.bytes);
|
|
679
|
+
byDayMap.set(k, cur);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
catch { /* old schema */ }
|
|
684
|
+
}
|
|
685
|
+
finally {
|
|
686
|
+
sdb.close();
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
catch { /* missing tables / corrupt */ }
|
|
690
|
+
if (touched)
|
|
691
|
+
dbCount++;
|
|
692
|
+
}
|
|
693
|
+
const daysAlive = firstMs < lastMs ? (lastMs - firstMs) / 86_400_000 : 0;
|
|
694
|
+
const byCategory = Object.entries(catCounts)
|
|
695
|
+
.filter(([, n]) => n > 0)
|
|
696
|
+
.map(([category, count]) => ({
|
|
697
|
+
category,
|
|
698
|
+
count,
|
|
699
|
+
label: categoryLabels[category] || category,
|
|
700
|
+
}))
|
|
701
|
+
.sort((a, b) => b.count - a.count);
|
|
702
|
+
const byDay = [...byDayMap.entries()]
|
|
703
|
+
.sort((a, b) => a[0] - b[0])
|
|
704
|
+
.map(([ms, v]) => ({
|
|
705
|
+
ms,
|
|
706
|
+
count: v.count,
|
|
707
|
+
...(v.rescueBytes > 0 ? { rescueBytes: v.rescueBytes } : {}),
|
|
708
|
+
}));
|
|
709
|
+
return {
|
|
710
|
+
sessionId,
|
|
711
|
+
events,
|
|
712
|
+
dbCount,
|
|
713
|
+
daysAlive,
|
|
714
|
+
snapshotBytes,
|
|
715
|
+
snapshotsConsumed,
|
|
716
|
+
byCategory,
|
|
717
|
+
firstEventMs: Number.isFinite(firstMs) ? firstMs : 0,
|
|
718
|
+
lastEventMs: lastMs > 0 ? lastMs : 0,
|
|
719
|
+
lastRescueMs: lastRescueMs > 0 ? lastRescueMs : undefined,
|
|
720
|
+
byDay,
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* v1.0.133 Slice 3: Sum the bytes attributed to one session in the FTS5
|
|
725
|
+
* content DB.
|
|
726
|
+
*
|
|
727
|
+
* Returns `LENGTH(title) + LENGTH(content)` summed across every chunk
|
|
728
|
+
* whose `session_id` column matches `sessionId`. Best-effort — returns 0
|
|
729
|
+
* when the DB file is missing, the schema lacks the `session_id` column
|
|
730
|
+
* (pre-Slice-1 content DBs), or the query fails. Never throws.
|
|
731
|
+
*
|
|
732
|
+
* Render-time only. Does NOT mutate the content DB. Architect-approved
|
|
733
|
+
* because the read-only join carries no risk of cross-session attribution
|
|
734
|
+
* (the FK was set at chunk insert time by Slice 1).
|
|
735
|
+
*/
|
|
736
|
+
export function getContentBytesForSession(sessionId, contentDbPath, opts) {
|
|
737
|
+
if (!sessionId || !contentDbPath)
|
|
738
|
+
return 0;
|
|
739
|
+
if (!existsSync(contentDbPath))
|
|
740
|
+
return 0;
|
|
741
|
+
let DatabaseCtor = null;
|
|
742
|
+
try {
|
|
743
|
+
DatabaseCtor = opts?.loadDatabase
|
|
744
|
+
? opts.loadDatabase()
|
|
745
|
+
: loadDatabaseImpl();
|
|
746
|
+
}
|
|
747
|
+
catch {
|
|
748
|
+
return 0;
|
|
749
|
+
}
|
|
750
|
+
if (!DatabaseCtor)
|
|
751
|
+
return 0;
|
|
752
|
+
try {
|
|
753
|
+
const db = new DatabaseCtor(contentDbPath, { readonly: true });
|
|
754
|
+
try {
|
|
755
|
+
const row = db.prepare(`SELECT COALESCE(SUM(LENGTH(content) + LENGTH(title)), 0) AS bytes
|
|
756
|
+
FROM chunks WHERE session_id = ?`).get(sessionId);
|
|
757
|
+
return Number(row?.bytes ?? 0);
|
|
758
|
+
}
|
|
759
|
+
finally {
|
|
760
|
+
db.close();
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
catch {
|
|
764
|
+
return 0;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* v1.0.134 SLICE C — lifetime tier all-chunks aggregate.
|
|
769
|
+
*
|
|
770
|
+
* Sibling of {@link getContentBytesForSession} that omits the session_id
|
|
771
|
+
* filter so the lifetime tier sees every chunk in the content store —
|
|
772
|
+
* including legacy unattributed rows (sessionId === '') and chunks
|
|
773
|
+
* attributed to other adapters' sessions. Without this, the lifetime
|
|
774
|
+
* "kept out" headline only counts session_events.bytes_avoided and
|
|
775
|
+
* misses the bulk of indexed payload.
|
|
776
|
+
*
|
|
777
|
+
* Best-effort: returns 0 when the DB file is missing, the schema lacks
|
|
778
|
+
* the `chunks` table, or the query fails. Never throws — same contract
|
|
779
|
+
* as the rest of the analytics module so a corrupt content DB cannot
|
|
780
|
+
* crash ctx_stats.
|
|
781
|
+
*/
|
|
782
|
+
export function getContentBytesAllSessions(contentDbPath, opts) {
|
|
783
|
+
if (!contentDbPath)
|
|
784
|
+
return 0;
|
|
785
|
+
if (!existsSync(contentDbPath))
|
|
786
|
+
return 0;
|
|
787
|
+
let DatabaseCtor = null;
|
|
788
|
+
try {
|
|
789
|
+
DatabaseCtor = opts?.loadDatabase
|
|
790
|
+
? opts.loadDatabase()
|
|
791
|
+
: loadDatabaseImpl();
|
|
792
|
+
}
|
|
793
|
+
catch {
|
|
794
|
+
return 0;
|
|
795
|
+
}
|
|
796
|
+
if (!DatabaseCtor)
|
|
797
|
+
return 0;
|
|
798
|
+
try {
|
|
799
|
+
const db = new DatabaseCtor(contentDbPath, { readonly: true });
|
|
800
|
+
try {
|
|
801
|
+
const row = db.prepare(`SELECT COALESCE(SUM(LENGTH(content) + LENGTH(title)), 0) AS bytes
|
|
802
|
+
FROM chunks`).get();
|
|
803
|
+
return Number(row?.bytes ?? 0);
|
|
804
|
+
}
|
|
805
|
+
finally {
|
|
806
|
+
db.close();
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
catch {
|
|
810
|
+
return 0;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
/**
|
|
814
|
+
* Compute real-bytes stats across one session, one project (worktree
|
|
815
|
+
* filter), or every session on disk (lifetime).
|
|
816
|
+
*
|
|
817
|
+
* - Pass `sessionId` for the conversation tier.
|
|
818
|
+
* - Pass `worktreeHash` to filter `*.db` files by name prefix
|
|
819
|
+
* (per-project lifetime — `sha256(cwd).slice(0, 16)`).
|
|
820
|
+
* - Pass neither — full lifetime aggregate.
|
|
821
|
+
*
|
|
822
|
+
* Best-effort: returns zeroes when the dir is missing, the DB is
|
|
823
|
+
* corrupt, or the session has no events. Never throws — same
|
|
824
|
+
* contract as `getConversationStats` / `getLifetimeStats` so the
|
|
825
|
+
* stats-render path can never crash on a bad sidecar.
|
|
826
|
+
*/
|
|
827
|
+
export function getRealBytesStats(opts) {
|
|
828
|
+
const empty = {
|
|
829
|
+
eventDataBytes: 0,
|
|
830
|
+
bytesAvoided: 0,
|
|
831
|
+
bytesReturned: 0,
|
|
832
|
+
snapshotBytes: 0,
|
|
833
|
+
contentBytes: 0,
|
|
834
|
+
totalSavedTokens: 0,
|
|
835
|
+
};
|
|
836
|
+
const sessionsDir = opts.sessionsDir
|
|
837
|
+
?? join(homedir(), ".claude", "context-mode", "sessions");
|
|
838
|
+
if (!existsSync(sessionsDir))
|
|
839
|
+
return empty;
|
|
840
|
+
let dbFiles = [];
|
|
841
|
+
try {
|
|
842
|
+
dbFiles = readdirSync(sessionsDir).filter((f) => {
|
|
843
|
+
if (!f.endsWith(".db"))
|
|
844
|
+
return false;
|
|
845
|
+
if (opts.worktreeHash && !f.startsWith(opts.worktreeHash))
|
|
846
|
+
return false;
|
|
847
|
+
return true;
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
catch {
|
|
851
|
+
return empty;
|
|
852
|
+
}
|
|
853
|
+
if (dbFiles.length === 0)
|
|
854
|
+
return empty;
|
|
855
|
+
let DatabaseCtor = null;
|
|
856
|
+
try {
|
|
857
|
+
DatabaseCtor = opts.loadDatabase
|
|
858
|
+
? opts.loadDatabase()
|
|
859
|
+
: loadDatabaseImpl();
|
|
860
|
+
}
|
|
861
|
+
catch {
|
|
862
|
+
return empty;
|
|
863
|
+
}
|
|
864
|
+
if (!DatabaseCtor)
|
|
865
|
+
return empty;
|
|
866
|
+
let eventDataBytes = 0;
|
|
867
|
+
let bytesAvoided = 0;
|
|
868
|
+
let bytesReturned = 0;
|
|
869
|
+
let snapshotBytes = 0;
|
|
870
|
+
// Each branch returns the tuple in the SAME column order so callers
|
|
871
|
+
// don't need to type-narrow per row.
|
|
872
|
+
for (const file of dbFiles) {
|
|
873
|
+
const dbPath = join(sessionsDir, file);
|
|
874
|
+
// v1.0.148 hotfix: historical DBs were created with pre-v1.0.130
|
|
875
|
+
// schema (no bytes_avoided / bytes_returned / project_dir columns).
|
|
876
|
+
// The SELECT below references those columns, so without an in-place
|
|
877
|
+
// migration the prepare() throws and the surrounding catch silently
|
|
878
|
+
// skips the WHOLE DB — losing even the LENGTH(data) signal. Run the
|
|
879
|
+
// shared migration helper before opening readonly. Idempotent: a
|
|
880
|
+
// PRAGMA check inside the helper short-circuits when the DB is
|
|
881
|
+
// already current, so post-first-read calls are cheap.
|
|
882
|
+
ensureSessionEventsSchema(dbPath, DatabaseCtor);
|
|
883
|
+
try {
|
|
884
|
+
const sdb = new DatabaseCtor(dbPath, { readonly: true });
|
|
885
|
+
try {
|
|
886
|
+
if (opts.sessionId) {
|
|
887
|
+
const row = sdb.prepare(`SELECT
|
|
888
|
+
COALESCE(SUM(LENGTH(data)), 0) AS data_bytes,
|
|
889
|
+
COALESCE(SUM(bytes_avoided), 0) AS bytes_avoided,
|
|
890
|
+
COALESCE(SUM(bytes_returned), 0) AS bytes_returned
|
|
891
|
+
FROM session_events WHERE session_id = ?`).get(opts.sessionId);
|
|
892
|
+
if (row) {
|
|
893
|
+
eventDataBytes += Number(row.data_bytes ?? 0);
|
|
894
|
+
bytesAvoided += Number(row.bytes_avoided ?? 0);
|
|
895
|
+
bytesReturned += Number(row.bytes_returned ?? 0);
|
|
896
|
+
}
|
|
897
|
+
try {
|
|
898
|
+
const snap = sdb.prepare("SELECT COALESCE(SUM(LENGTH(snapshot)), 0) AS bytes FROM session_resume WHERE session_id = ?").get(opts.sessionId);
|
|
899
|
+
if (snap?.bytes)
|
|
900
|
+
snapshotBytes += Number(snap.bytes);
|
|
901
|
+
}
|
|
902
|
+
catch { /* old schema */ }
|
|
903
|
+
try {
|
|
904
|
+
// "With context-mode" = the bytes the model paid to ACCESS the
|
|
905
|
+
// kept-out content: ctx_search (query the index) + ctx_fetch_and_index
|
|
906
|
+
// (fetch + index a URL). Sandbox compute (ctx_execute/batch/file) is
|
|
907
|
+
// work-output the model would see regardless — NOT redirect savings —
|
|
908
|
+
// so it is excluded; folding it crushed the bar to a false ~43%.
|
|
909
|
+
const tc = sdb.prepare(`SELECT COALESCE(SUM(bytes_returned), 0) AS bytes FROM tool_calls
|
|
910
|
+
WHERE session_id = ? AND tool IN ('ctx_search', 'ctx_fetch_and_index')`).get(opts.sessionId);
|
|
911
|
+
if (tc?.bytes)
|
|
912
|
+
bytesReturned += Number(tc.bytes);
|
|
913
|
+
}
|
|
914
|
+
catch { /* old schema: no tool_calls table */ }
|
|
915
|
+
}
|
|
916
|
+
else if (opts.projectDir) {
|
|
917
|
+
// Bug E+F: META-scoped aggregation. Take every session_id whose
|
|
918
|
+
// session_meta.project_dir matches, then sum ALL of those
|
|
919
|
+
// sessions' events regardless of the events' own project_dir
|
|
920
|
+
// (sandbox-burst PID sessions write empty event-level project_dir
|
|
921
|
+
// even when their META carries the parent cwd).
|
|
922
|
+
const row = sdb.prepare(`SELECT
|
|
923
|
+
COALESCE(SUM(LENGTH(data)), 0) AS data_bytes,
|
|
924
|
+
COALESCE(SUM(bytes_avoided), 0) AS bytes_avoided,
|
|
925
|
+
COALESCE(SUM(bytes_returned), 0) AS bytes_returned
|
|
926
|
+
FROM session_events
|
|
927
|
+
WHERE session_id IN (
|
|
928
|
+
SELECT session_id FROM session_meta WHERE project_dir = ?
|
|
929
|
+
)`).get(opts.projectDir);
|
|
930
|
+
if (row) {
|
|
931
|
+
eventDataBytes += Number(row.data_bytes ?? 0);
|
|
932
|
+
bytesAvoided += Number(row.bytes_avoided ?? 0);
|
|
933
|
+
bytesReturned += Number(row.bytes_returned ?? 0);
|
|
934
|
+
}
|
|
935
|
+
try {
|
|
936
|
+
const snap = sdb.prepare(`SELECT COALESCE(SUM(LENGTH(snapshot)), 0) AS bytes
|
|
937
|
+
FROM session_resume
|
|
938
|
+
WHERE session_id IN (
|
|
939
|
+
SELECT session_id FROM session_meta WHERE project_dir = ?
|
|
940
|
+
)`).get(opts.projectDir);
|
|
941
|
+
if (snap?.bytes)
|
|
942
|
+
snapshotBytes += Number(snap.bytes);
|
|
943
|
+
}
|
|
944
|
+
catch { /* old schema */ }
|
|
945
|
+
try {
|
|
946
|
+
const tc = sdb.prepare(`SELECT COALESCE(SUM(bytes_returned), 0) AS bytes
|
|
947
|
+
FROM tool_calls
|
|
948
|
+
WHERE session_id IN (
|
|
949
|
+
SELECT session_id FROM session_meta WHERE project_dir = ?
|
|
950
|
+
)
|
|
951
|
+
AND tool IN ('ctx_search', 'ctx_fetch_and_index')`).get(opts.projectDir);
|
|
952
|
+
if (tc?.bytes)
|
|
953
|
+
bytesReturned += Number(tc.bytes);
|
|
954
|
+
}
|
|
955
|
+
catch { /* old schema: no tool_calls table */ }
|
|
956
|
+
}
|
|
957
|
+
else {
|
|
958
|
+
const row = sdb.prepare(`SELECT
|
|
959
|
+
COALESCE(SUM(LENGTH(data)), 0) AS data_bytes,
|
|
960
|
+
COALESCE(SUM(bytes_avoided), 0) AS bytes_avoided,
|
|
961
|
+
COALESCE(SUM(bytes_returned), 0) AS bytes_returned
|
|
962
|
+
FROM session_events`).get();
|
|
963
|
+
if (row) {
|
|
964
|
+
eventDataBytes += Number(row.data_bytes ?? 0);
|
|
965
|
+
bytesAvoided += Number(row.bytes_avoided ?? 0);
|
|
966
|
+
bytesReturned += Number(row.bytes_returned ?? 0);
|
|
967
|
+
}
|
|
968
|
+
try {
|
|
969
|
+
const snap = sdb.prepare("SELECT COALESCE(SUM(LENGTH(snapshot)), 0) AS bytes FROM session_resume").get();
|
|
970
|
+
if (snap?.bytes)
|
|
971
|
+
snapshotBytes += Number(snap.bytes);
|
|
972
|
+
}
|
|
973
|
+
catch { /* old schema */ }
|
|
974
|
+
try {
|
|
975
|
+
const tc = sdb.prepare(`SELECT COALESCE(SUM(bytes_returned), 0) AS bytes FROM tool_calls
|
|
976
|
+
WHERE tool IN ('ctx_search', 'ctx_fetch_and_index')`).get();
|
|
977
|
+
if (tc?.bytes)
|
|
978
|
+
bytesReturned += Number(tc.bytes);
|
|
979
|
+
}
|
|
980
|
+
catch { /* old schema: no tool_calls table */ }
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
finally {
|
|
984
|
+
sdb.close();
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
catch { /* missing tables / corrupt — skip */ }
|
|
988
|
+
}
|
|
989
|
+
// v1.0.133 Slice 3: fold content DB chunk bytes for this session into
|
|
990
|
+
// bytesAvoided. Skipped silently when caller didn't pass contentDbPath
|
|
991
|
+
// (lifetime / project tiers, or pre-Slice-3 callers). Treated as
|
|
992
|
+
// "avoided" because indexed chunks are bytes that would have been
|
|
993
|
+
// re-inflated into context on every search if the model had to
|
|
994
|
+
// re-read raw files.
|
|
995
|
+
let contentBytes = 0;
|
|
996
|
+
if (opts.sessionId && opts.contentDbPath) {
|
|
997
|
+
contentBytes = getContentBytesForSession(opts.sessionId, opts.contentDbPath, { loadDatabase: opts.loadDatabase });
|
|
998
|
+
bytesAvoided += contentBytes;
|
|
999
|
+
}
|
|
1000
|
+
const totalSavedTokens = Math.floor((eventDataBytes + bytesAvoided + snapshotBytes) / 4);
|
|
1001
|
+
return { eventDataBytes, bytesAvoided, bytesReturned, snapshotBytes, contentBytes, totalSavedTokens };
|
|
1002
|
+
}
|
|
1003
|
+
/**
|
|
1004
|
+
* v1.0.169 — Section 1 "Where you are now" = the LIVE conversation window.
|
|
1005
|
+
*
|
|
1006
|
+
* A single live conversation fans out into sub-agents and ctx_execute
|
|
1007
|
+
* sub-process sessions. Each runs in its OWN, disposable context window (its
|
|
1008
|
+
* own session_id) — but all under the SAME worktree DB, because the worktree
|
|
1009
|
+
* hash is sha256(cwd) and they share the cwd. Their retrieval (ctx_search /
|
|
1010
|
+
* ctx_fetch_and_index returns) entered THOSE windows and was thrown away when
|
|
1011
|
+
* each returned its short summary; it never touched the window the user is
|
|
1012
|
+
* reading now. So the live-window savings bar must split the worktree by
|
|
1013
|
+
* which retrieval actually landed in the user's window:
|
|
1014
|
+
*
|
|
1015
|
+
* bytesReturned ("With context-mode") = THIS session's retrieval only —
|
|
1016
|
+
* what genuinely entered the live window.
|
|
1017
|
+
* bytesAvoided ("kept out") = everything the whole worktree moved
|
|
1018
|
+
* (avoided + every session's retrieval) MINUS what landed in your window.
|
|
1019
|
+
*
|
|
1020
|
+
* Scoping by `worktreeHash` (not project-root + time) means the user's OTHER
|
|
1021
|
+
* parallel worktrees never bleed in — a different worktree is a different
|
|
1022
|
+
* cwd-hash, hence a different DB file the prefix filter excludes — while the
|
|
1023
|
+
* sub-agent fan-out this conversation actually spawned is fully credited.
|
|
1024
|
+
*/
|
|
1025
|
+
export function getConversationWindowStats(opts) {
|
|
1026
|
+
// Whole current worktree: every session that shares this cwd-hash DB.
|
|
1027
|
+
const pool = getRealBytesStats({
|
|
1028
|
+
worktreeHash: opts.worktreeHash,
|
|
1029
|
+
sessionsDir: opts.sessionsDir,
|
|
1030
|
+
});
|
|
1031
|
+
// Just the live window: this session_id (folds its own ctx_search/ctx_fetch
|
|
1032
|
+
// retrieval + content chunks).
|
|
1033
|
+
const mine = getRealBytesStats({
|
|
1034
|
+
sessionId: opts.sessionId,
|
|
1035
|
+
worktreeHash: opts.worktreeHash,
|
|
1036
|
+
sessionsDir: opts.sessionsDir,
|
|
1037
|
+
contentDbPath: opts.contentDbPath,
|
|
1038
|
+
});
|
|
1039
|
+
const windowReturned = mine.bytesReturned;
|
|
1040
|
+
const movedTotal = pool.bytesAvoided + pool.bytesReturned;
|
|
1041
|
+
// What context-mode kept OUT of the live window = everything moved across the
|
|
1042
|
+
// worktree minus the slice that actually entered this window. Clamp at 0 so a
|
|
1043
|
+
// stale/edge DB can never produce a negative bar.
|
|
1044
|
+
const keptOut = Math.max(0, movedTotal - windowReturned);
|
|
1045
|
+
return {
|
|
1046
|
+
eventDataBytes: pool.eventDataBytes,
|
|
1047
|
+
bytesAvoided: keptOut,
|
|
1048
|
+
bytesReturned: windowReturned,
|
|
1049
|
+
snapshotBytes: pool.snapshotBytes,
|
|
1050
|
+
contentBytes: mine.contentBytes,
|
|
1051
|
+
totalSavedTokens: Math.floor((pool.eventDataBytes + keptOut + pool.snapshotBytes) / 4),
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
const DEFAULT_REAL_USAGE_FILTER = {
|
|
1055
|
+
minEvents: 100,
|
|
1056
|
+
minProjects: 5,
|
|
1057
|
+
recencyMs: 30 * 86_400_000,
|
|
1058
|
+
minAvgBytes: 50,
|
|
1059
|
+
};
|
|
1060
|
+
/**
|
|
1061
|
+
* Scan one adapter's sessions dir. Always returns a result — never throws.
|
|
1062
|
+
* When the dir is missing, the result has zeroed counts and `isReal=false`.
|
|
1063
|
+
*
|
|
1064
|
+
* Mirrors the inner SessionDB-walk inside `getLifetimeStats`
|
|
1065
|
+
* (analytics.ts:677-752) so the new multi-adapter path stays in lock-step
|
|
1066
|
+
* with the per-DB queries the single-dir path already trusts.
|
|
1067
|
+
*/
|
|
1068
|
+
function scanOneAdapter(entry, loadDb, filter) {
|
|
1069
|
+
const result = {
|
|
1070
|
+
name: entry.name,
|
|
1071
|
+
eventCount: 0,
|
|
1072
|
+
sessionCount: 0,
|
|
1073
|
+
dataBytes: 0,
|
|
1074
|
+
rescueBytes: 0,
|
|
1075
|
+
contentBytes: 0,
|
|
1076
|
+
uuidConvs: 0,
|
|
1077
|
+
projectDirs: [],
|
|
1078
|
+
firstMs: Number.POSITIVE_INFINITY,
|
|
1079
|
+
lastMs: 0,
|
|
1080
|
+
isReal: false,
|
|
1081
|
+
};
|
|
1082
|
+
if (!existsSync(entry.sessionsDir))
|
|
1083
|
+
return result;
|
|
1084
|
+
let dbFiles = [];
|
|
1085
|
+
try {
|
|
1086
|
+
dbFiles = readdirSync(entry.sessionsDir).filter((f) => f.endsWith(".db"));
|
|
1087
|
+
}
|
|
1088
|
+
catch {
|
|
1089
|
+
return result;
|
|
1090
|
+
}
|
|
1091
|
+
if (dbFiles.length === 0)
|
|
1092
|
+
return result;
|
|
1093
|
+
let DatabaseCtor = null;
|
|
1094
|
+
try {
|
|
1095
|
+
DatabaseCtor = loadDb();
|
|
1096
|
+
}
|
|
1097
|
+
catch {
|
|
1098
|
+
return result;
|
|
1099
|
+
}
|
|
1100
|
+
if (!DatabaseCtor)
|
|
1101
|
+
return result;
|
|
1102
|
+
const projectsSet = new Set();
|
|
1103
|
+
const sessionsSet = new Set();
|
|
1104
|
+
for (const file of dbFiles) {
|
|
1105
|
+
const dbPath = join(entry.sessionsDir, file);
|
|
1106
|
+
try {
|
|
1107
|
+
const sdb = new DatabaseCtor(dbPath, { readonly: true });
|
|
1108
|
+
try {
|
|
1109
|
+
const ev = sdb.prepare("SELECT COUNT(*) AS cnt, COALESCE(SUM(LENGTH(data)), 0) AS bytes FROM session_events").get();
|
|
1110
|
+
if (ev) {
|
|
1111
|
+
result.eventCount += Number(ev.cnt ?? 0);
|
|
1112
|
+
result.dataBytes += Number(ev.bytes ?? 0);
|
|
1113
|
+
}
|
|
1114
|
+
try {
|
|
1115
|
+
const ss = sdb.prepare("SELECT COUNT(*) AS cnt FROM session_meta").get();
|
|
1116
|
+
result.sessionCount += Number(ss?.cnt ?? 0);
|
|
1117
|
+
}
|
|
1118
|
+
catch { /* old schema */ }
|
|
1119
|
+
try {
|
|
1120
|
+
const snap = sdb.prepare("SELECT COALESCE(SUM(length(snapshot)), 0) AS bytes FROM session_resume WHERE consumed = 1").get();
|
|
1121
|
+
if (snap?.bytes)
|
|
1122
|
+
result.rescueBytes += Number(snap.bytes);
|
|
1123
|
+
}
|
|
1124
|
+
catch { /* old schema */ }
|
|
1125
|
+
try {
|
|
1126
|
+
const range = sdb.prepare("SELECT MIN(created_at) AS mn, MAX(created_at) AS mx FROM session_events").get();
|
|
1127
|
+
if (range?.mn) {
|
|
1128
|
+
const t = Date.parse(range.mn + (range.mn.endsWith("Z") ? "" : "Z"));
|
|
1129
|
+
if (Number.isFinite(t) && t < result.firstMs)
|
|
1130
|
+
result.firstMs = t;
|
|
1131
|
+
}
|
|
1132
|
+
if (range?.mx) {
|
|
1133
|
+
const t = Date.parse(range.mx + (range.mx.endsWith("Z") ? "" : "Z"));
|
|
1134
|
+
if (Number.isFinite(t) && t > result.lastMs)
|
|
1135
|
+
result.lastMs = t;
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
catch { /* old schema */ }
|
|
1139
|
+
try {
|
|
1140
|
+
const projRows = sdb.prepare("SELECT DISTINCT project_dir AS p FROM session_events WHERE project_dir != ''").all();
|
|
1141
|
+
for (const row of projRows)
|
|
1142
|
+
if (row.p)
|
|
1143
|
+
projectsSet.add(row.p);
|
|
1144
|
+
}
|
|
1145
|
+
catch { /* old schema */ }
|
|
1146
|
+
try {
|
|
1147
|
+
const sidRows = sdb.prepare("SELECT DISTINCT session_id AS s FROM session_events").all();
|
|
1148
|
+
for (const row of sidRows)
|
|
1149
|
+
if (row.s)
|
|
1150
|
+
sessionsSet.add(row.s);
|
|
1151
|
+
}
|
|
1152
|
+
catch { /* old schema */ }
|
|
1153
|
+
}
|
|
1154
|
+
finally {
|
|
1155
|
+
sdb.close();
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
catch { /* missing tables / corrupt — skip */ }
|
|
1159
|
+
}
|
|
1160
|
+
result.projectDirs = Array.from(projectsSet);
|
|
1161
|
+
result.uuidConvs = sessionsSet.size;
|
|
1162
|
+
// Real-usage filter — see RealUsageFilter docstring.
|
|
1163
|
+
const avgBytes = result.eventCount > 0 ? result.dataBytes / result.eventCount : 0;
|
|
1164
|
+
const recentEnough = result.lastMs > 0 && (filter.nowMs - result.lastMs) <= filter.recencyMs;
|
|
1165
|
+
result.isReal =
|
|
1166
|
+
result.eventCount >= filter.minEvents &&
|
|
1167
|
+
projectsSet.size >= filter.minProjects &&
|
|
1168
|
+
recentEnough &&
|
|
1169
|
+
avgBytes >= filter.minAvgBytes;
|
|
1170
|
+
return result;
|
|
1171
|
+
}
|
|
1172
|
+
/**
|
|
1173
|
+
* Aggregate lifetime stats across every adapter dir under `home`.
|
|
1174
|
+
* The marketing line — "your work everywhere on this machine across all
|
|
1175
|
+
* AI tools" — depends on this. Existing `getLifetimeStats` (single dir)
|
|
1176
|
+
* is untouched; this is purely additive.
|
|
1177
|
+
*/
|
|
1178
|
+
export function getMultiAdapterLifetimeStats(opts) {
|
|
1179
|
+
const dirs = enumerateAdapterDirs({ home: opts?.home });
|
|
1180
|
+
const loadDb = opts?.loadDatabase ?? loadDatabaseImpl;
|
|
1181
|
+
const filter = {
|
|
1182
|
+
...DEFAULT_REAL_USAGE_FILTER,
|
|
1183
|
+
...(opts?.filter ?? {}),
|
|
1184
|
+
nowMs: opts?.filter?.nowMs ?? Date.now(),
|
|
1185
|
+
};
|
|
1186
|
+
const perAdapter = [];
|
|
1187
|
+
let totalEvents = 0;
|
|
1188
|
+
let totalSessions = 0;
|
|
1189
|
+
let totalBytes = 0;
|
|
1190
|
+
for (const entry of dirs) {
|
|
1191
|
+
if (!existsSync(entry.sessionsDir))
|
|
1192
|
+
continue; // only surface adapters with a sessions dir
|
|
1193
|
+
const r = scanOneAdapter(entry, loadDb, filter);
|
|
1194
|
+
perAdapter.push(r);
|
|
1195
|
+
totalEvents += r.eventCount;
|
|
1196
|
+
totalSessions += r.sessionCount;
|
|
1197
|
+
totalBytes += r.dataBytes + r.rescueBytes;
|
|
1198
|
+
}
|
|
1199
|
+
return { totalEvents, totalSessions, totalBytes, perAdapter };
|
|
1200
|
+
}
|
|
1201
|
+
/**
|
|
1202
|
+
* Aggregate real-bytes stats across every adapter dir under `home`.
|
|
1203
|
+
* Mirrors `getRealBytesStats` (single dir, analytics.ts:887-989) but
|
|
1204
|
+
* iterates {@link enumerateAdapterDirs}. Optional `sessionId` /
|
|
1205
|
+
* `worktreeHash` filters apply uniformly to every dir.
|
|
1206
|
+
*/
|
|
1207
|
+
export function getMultiAdapterRealBytesStats(opts) {
|
|
1208
|
+
const dirs = enumerateAdapterDirs({ home: opts?.home });
|
|
1209
|
+
const sum = {
|
|
1210
|
+
eventDataBytes: 0,
|
|
1211
|
+
bytesAvoided: 0,
|
|
1212
|
+
bytesReturned: 0,
|
|
1213
|
+
snapshotBytes: 0,
|
|
1214
|
+
contentBytes: 0,
|
|
1215
|
+
totalSavedTokens: 0,
|
|
1216
|
+
};
|
|
1217
|
+
const perAdapter = [];
|
|
1218
|
+
for (const entry of dirs) {
|
|
1219
|
+
if (!existsSync(entry.sessionsDir))
|
|
1220
|
+
continue;
|
|
1221
|
+
const one = getRealBytesStats({
|
|
1222
|
+
sessionsDir: entry.sessionsDir,
|
|
1223
|
+
sessionId: opts?.sessionId,
|
|
1224
|
+
worktreeHash: opts?.worktreeHash,
|
|
1225
|
+
loadDatabase: opts?.loadDatabase,
|
|
1226
|
+
});
|
|
1227
|
+
// ARCH-REVIEW-V134-ABC SLICE C: aggregate this adapter's content DB
|
|
1228
|
+
// bytes into the lifetime sum. `getRealBytesStats` operates on
|
|
1229
|
+
// session events only and never touches the sibling content/ tree —
|
|
1230
|
+
// without this step the lifetime tier in ctx_stats reports 0 for
|
|
1231
|
+
// every adapter except whichever one happens to share the
|
|
1232
|
+
// sessionsDir of the caller. Lifetime tier ignores sessionId so
|
|
1233
|
+
// the all-sessions aggregator is the right helper here.
|
|
1234
|
+
if (!opts?.sessionId) {
|
|
1235
|
+
const contentDbPath = join(entry.contentDir, "content.db");
|
|
1236
|
+
const adapterContentBytes = getContentBytesAllSessions(contentDbPath, {
|
|
1237
|
+
loadDatabase: opts?.loadDatabase,
|
|
1238
|
+
});
|
|
1239
|
+
one.contentBytes += adapterContentBytes;
|
|
1240
|
+
sum.contentBytes += adapterContentBytes;
|
|
1241
|
+
}
|
|
1242
|
+
perAdapter.push({ name: entry.name, ...one });
|
|
1243
|
+
sum.eventDataBytes += one.eventDataBytes;
|
|
1244
|
+
sum.bytesAvoided += one.bytesAvoided;
|
|
1245
|
+
sum.bytesReturned += one.bytesReturned;
|
|
1246
|
+
sum.snapshotBytes += one.snapshotBytes;
|
|
1247
|
+
}
|
|
1248
|
+
sum.totalSavedTokens = Math.floor((sum.eventDataBytes + sum.bytesAvoided + sum.snapshotBytes) / 4);
|
|
1249
|
+
return { ...sum, perAdapter };
|
|
1250
|
+
}
|
|
1251
|
+
/**
|
|
1252
|
+
* Marketing-grade labels for auto-memory file prefixes. The renderer sees raw
|
|
1253
|
+
* filename prefixes (`project_codex_hooks.md` → `project`) — without this map
|
|
1254
|
+
* the user gets schema words in the UI, which leaks the database into UX.
|
|
1255
|
+
*/
|
|
1256
|
+
export const autoMemoryLabels = {
|
|
1257
|
+
project: "What you're building",
|
|
1258
|
+
feedback: "How you work",
|
|
1259
|
+
user: "Who you are",
|
|
1260
|
+
reference: "Where to look",
|
|
1261
|
+
memory: "Long-term context",
|
|
1262
|
+
other: "Other notes",
|
|
1263
|
+
};
|
|
1264
|
+
/**
|
|
1265
|
+
* Marketing-grade labels for adapter ids surfaced by
|
|
1266
|
+
* {@link enumerateAdapterDirs} / {@link getMultiAdapterLifetimeStats}.
|
|
1267
|
+
* The renderer never shows raw IDs — UX uses the names users see in
|
|
1268
|
+
* each tool's own surface area.
|
|
1269
|
+
*/
|
|
1270
|
+
export const adapterLabels = {
|
|
1271
|
+
"claude-code": "Claude Code",
|
|
1272
|
+
"gemini-cli": "Gemini CLI",
|
|
1273
|
+
"antigravity": "Antigravity",
|
|
1274
|
+
"antigravity-cli": "Antigravity CLI",
|
|
1275
|
+
"openclaw": "Openclaw",
|
|
1276
|
+
"codex": "Codex CLI",
|
|
1277
|
+
"cursor": "Cursor",
|
|
1278
|
+
"vscode-copilot": "VS Code Copilot",
|
|
1279
|
+
"copilot-cli": "GitHub Copilot CLI",
|
|
1280
|
+
"kiro": "Kiro",
|
|
1281
|
+
"pi": "Pi",
|
|
1282
|
+
"omp": "OMP",
|
|
1283
|
+
"qwen-code": "Qwen Code",
|
|
1284
|
+
"kilo": "Kilo",
|
|
1285
|
+
"opencode": "OpenCode",
|
|
1286
|
+
"zed": "Zed",
|
|
1287
|
+
"jetbrains-copilot": "JetBrains",
|
|
1288
|
+
};
|
|
1289
|
+
/** Look up an adapter's marketing label. Falls back to the raw id. */
|
|
1290
|
+
function adapterLabel(name) {
|
|
1291
|
+
return adapterLabels[name] ?? name;
|
|
1292
|
+
}
|
|
1293
|
+
// ─────────────────────────────────────────────────────────
|
|
1294
|
+
// formatReport — renders FullReport as sales-grade savings dashboard
|
|
1295
|
+
// ─────────────────────────────────────────────────────────
|
|
1296
|
+
/**
|
|
1297
|
+
* Format a byte count for the narrative dashboard.
|
|
1298
|
+
*
|
|
1299
|
+
* Single-unit auto-scale (Grafana / CloudWatch / Datadog convention).
|
|
1300
|
+
* Decimals shrink as the integer part grows so the number stays readable
|
|
1301
|
+
* at every magnitude. Max output width is 8 characters which fits the
|
|
1302
|
+
* existing `padStart(8)` callsites in Sections 1, 3, 4.
|
|
1303
|
+
*
|
|
1304
|
+
* < 1 KB → "X B" e.g. "100 B"
|
|
1305
|
+
* 1 KB – < 100 KB → "X.Y KB" e.g. "4.7 KB", "92.8 KB"
|
|
1306
|
+
* 100 KB – < 1 MB → "X KB" e.g. "227 KB", "976 KB"
|
|
1307
|
+
* 1 MB – < 100 MB → "X.Y MB" e.g. "4.5 MB", "11.6 MB"
|
|
1308
|
+
* 100 MB – < 1 GB → "X MB" e.g. "178 MB", "906 MB"
|
|
1309
|
+
* 1 GB – < 100 GB → "X.YY GB" e.g. "1.00 GB", "11.36 GB"
|
|
1310
|
+
* ≥ 100 GB → "X.Y GB" e.g. "216.6 GB"
|
|
1311
|
+
*
|
|
1312
|
+
* Replaced the dual-unit "X KB (0.YY MB)" form because the parenthetical
|
|
1313
|
+
* rounded to 0.00 / 0.01 in the common range and added noise without
|
|
1314
|
+
* information. Scale awareness comes from the unit jump between rows.
|
|
1315
|
+
*/
|
|
1316
|
+
export function kb(b) {
|
|
1317
|
+
if (!Number.isFinite(b) || b <= 0)
|
|
1318
|
+
return "0 B";
|
|
1319
|
+
if (b < 1024)
|
|
1320
|
+
return `${Math.round(b)} B`;
|
|
1321
|
+
const KB = b / 1024;
|
|
1322
|
+
if (KB < 1024) {
|
|
1323
|
+
return KB < 100 ? `${KB.toFixed(1)} KB` : `${Math.round(KB)} KB`;
|
|
1324
|
+
}
|
|
1325
|
+
const MB = KB / 1024;
|
|
1326
|
+
if (MB < 1024) {
|
|
1327
|
+
return MB < 100 ? `${MB.toFixed(1)} MB` : `${Math.round(MB)} MB`;
|
|
1328
|
+
}
|
|
1329
|
+
const GB = MB / 1024;
|
|
1330
|
+
return GB < 100 ? `${GB.toFixed(2)} GB` : `${GB.toFixed(1)} GB`;
|
|
1331
|
+
}
|
|
1332
|
+
/** Format session uptime as human-readable duration. */
|
|
1333
|
+
function formatDuration(uptimeMin) {
|
|
1334
|
+
const min = parseFloat(uptimeMin);
|
|
1335
|
+
if (isNaN(min) || min < 1)
|
|
1336
|
+
return "< 1 min";
|
|
1337
|
+
if (min < 60)
|
|
1338
|
+
return `${Math.round(min)} min`;
|
|
1339
|
+
const h = Math.floor(min / 60);
|
|
1340
|
+
const m = Math.round(min % 60);
|
|
1341
|
+
return m > 0 ? `${h}h ${m}m` : `${h}h`;
|
|
1342
|
+
}
|
|
1343
|
+
/**
|
|
1344
|
+
* Locale + IANA-timezone detection for the narrative renderer.
|
|
1345
|
+
*
|
|
1346
|
+
* Cascade (each level overrides the next):
|
|
1347
|
+
* 1. CONTEXT_MODE_LOCALE / CONTEXT_MODE_TZ env overrides
|
|
1348
|
+
* (used by tests + by users who want to pin output regardless of OS).
|
|
1349
|
+
* 2. macOS `defaults read -g AppleLocale` → `en_TR` style → `en-TR`.
|
|
1350
|
+
* 3. Linux `LANG` / `LC_TIME` env vars.
|
|
1351
|
+
* 4. Fallback: `Intl.DateTimeFormat().resolvedOptions().locale`.
|
|
1352
|
+
*
|
|
1353
|
+
* Timezone always uses `Intl.DateTimeFormat().resolvedOptions().timeZone`
|
|
1354
|
+
* — that one's always available and correct regardless of platform.
|
|
1355
|
+
*/
|
|
1356
|
+
/**
|
|
1357
|
+
* Validate that a locale string is a usable BCP 47 tag.
|
|
1358
|
+
*
|
|
1359
|
+
* Ubuntu GHA runners default to `LANG=C.UTF-8`. The extractor below strips
|
|
1360
|
+
* that to `"C"` — a valid POSIX locale identifier but NOT a BCP 47 tag.
|
|
1361
|
+
* On macOS / Node 20, `new Intl.DateTimeFormat("C", …)` throws RangeError
|
|
1362
|
+
* outright. CI run 25887250971 caught this via the v1.0.134 SLICE B test.
|
|
1363
|
+
*
|
|
1364
|
+
* Earlier fix attempt used a permissive `supportedLocalesOf || construction`
|
|
1365
|
+
* OR check — that was wrong: on Linux + Node 22.5, `new Intl.DateTimeFormat
|
|
1366
|
+
* ("POSIX")` does NOT throw, it silently falls back to the root locale and
|
|
1367
|
+
* still emits garbage at format time. CI run 25904838577 surfaced that —
|
|
1368
|
+
* "POSIX" round-tripped through the validator unchanged.
|
|
1369
|
+
*
|
|
1370
|
+
* Strict gate: `Intl.DateTimeFormat.supportedLocalesOf(tag)` returns `[]` for
|
|
1371
|
+
* any tag that doesn't map to a real language (regardless of whether
|
|
1372
|
+
* construction with that tag throws). That's the contract we want — "is this
|
|
1373
|
+
* a BCP 47 tag the host actually has data for". Construction is an explicit
|
|
1374
|
+
* sanity check; both must pass.
|
|
1375
|
+
*/
|
|
1376
|
+
function isUsableBcp47Locale(raw) {
|
|
1377
|
+
if (!raw)
|
|
1378
|
+
return false;
|
|
1379
|
+
try {
|
|
1380
|
+
if (Intl.DateTimeFormat.supportedLocalesOf(raw).length === 0)
|
|
1381
|
+
return false;
|
|
1382
|
+
// Belt: confirm construction doesn't throw on this host either.
|
|
1383
|
+
new Intl.DateTimeFormat(raw);
|
|
1384
|
+
return true;
|
|
1385
|
+
}
|
|
1386
|
+
catch {
|
|
1387
|
+
return false;
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
export function detectLocaleAndTz() {
|
|
1391
|
+
const env = (process.env ?? {});
|
|
1392
|
+
let locale = env.CONTEXT_MODE_LOCALE ?? "";
|
|
1393
|
+
if (locale && !isUsableBcp47Locale(locale))
|
|
1394
|
+
locale = "";
|
|
1395
|
+
if (!locale) {
|
|
1396
|
+
if (process.platform === "darwin") {
|
|
1397
|
+
try {
|
|
1398
|
+
// Top-level import — `require()` throws "Dynamic require ... not
|
|
1399
|
+
// supported" under esbuild's ESM shim and pure ESM Node, which silently
|
|
1400
|
+
// dropped this branch and forced en-US fallback in production.
|
|
1401
|
+
const out = execFileSync("defaults", ["read", "-g", "AppleLocale"], {
|
|
1402
|
+
encoding: "utf8",
|
|
1403
|
+
timeout: 500,
|
|
1404
|
+
}).trim();
|
|
1405
|
+
if (out)
|
|
1406
|
+
locale = out.replace(/_/g, "-");
|
|
1407
|
+
}
|
|
1408
|
+
catch { /* defaults missing or sandbox */ }
|
|
1409
|
+
if (locale && !isUsableBcp47Locale(locale))
|
|
1410
|
+
locale = "";
|
|
1411
|
+
}
|
|
1412
|
+
if (!locale && (env.LC_TIME || env.LANG)) {
|
|
1413
|
+
const raw = (env.LC_TIME || env.LANG || "").split(".")[0];
|
|
1414
|
+
if (raw)
|
|
1415
|
+
locale = raw.replace(/_/g, "-");
|
|
1416
|
+
// POSIX locale identifiers (`C`, `POSIX`) survive the simple extraction
|
|
1417
|
+
// above but blow up `new Intl.DateTimeFormat(locale, ...)`. Drop and
|
|
1418
|
+
// fall through to the host-default branch below.
|
|
1419
|
+
if (locale && !isUsableBcp47Locale(locale))
|
|
1420
|
+
locale = "";
|
|
1421
|
+
}
|
|
1422
|
+
if (!locale) {
|
|
1423
|
+
try {
|
|
1424
|
+
locale = new Intl.DateTimeFormat().resolvedOptions().locale;
|
|
1425
|
+
}
|
|
1426
|
+
catch {
|
|
1427
|
+
locale = "en-US";
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
let tz = env.CONTEXT_MODE_TZ ?? "";
|
|
1432
|
+
if (!tz) {
|
|
1433
|
+
try {
|
|
1434
|
+
tz = new Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
1435
|
+
}
|
|
1436
|
+
catch {
|
|
1437
|
+
tz = "UTC";
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
// Final belt-and-suspenders: if the locale we settled on is somehow still
|
|
1441
|
+
// unusable (env mutation between detection and return, contributor adding
|
|
1442
|
+
// a new extraction path that skips the validator), fall back to en-US so
|
|
1443
|
+
// formatLocalDateTime / monthDay / weekdayCap never throw at render time.
|
|
1444
|
+
if (!isUsableBcp47Locale(locale))
|
|
1445
|
+
locale = "en-US";
|
|
1446
|
+
return { locale, tz: tz || "UTC" };
|
|
1447
|
+
}
|
|
1448
|
+
/**
|
|
1449
|
+
* Format an absolute path as a human-friendly display string by
|
|
1450
|
+
* collapsing `$HOME` → `~`. Returns the input unchanged when no home
|
|
1451
|
+
* prefix matches (e.g. for paths outside $HOME on a CI box).
|
|
1452
|
+
*/
|
|
1453
|
+
function shortPath(abs) {
|
|
1454
|
+
const home = homedir();
|
|
1455
|
+
if (!home)
|
|
1456
|
+
return abs;
|
|
1457
|
+
if (abs === home)
|
|
1458
|
+
return "~";
|
|
1459
|
+
// Use platform separator so `C:\Users\Mert\projects\x` collapses to `~\projects\x`
|
|
1460
|
+
// on Windows; previous `home + "/"` check was vacuously false on Windows and
|
|
1461
|
+
// left full absolute paths in the Section 1 narrative opener (round-5 finding).
|
|
1462
|
+
if (abs.startsWith(home + sep))
|
|
1463
|
+
return "~" + abs.slice(home.length);
|
|
1464
|
+
return abs;
|
|
1465
|
+
}
|
|
1466
|
+
/**
|
|
1467
|
+
* Render the section-4 "For example: what would that cost?" block.
|
|
1468
|
+
*
|
|
1469
|
+
* Translates a lifetime token total into a relatable Opus-4 dollar figure
|
|
1470
|
+
* + 3 tangible comparisons (Cursor Pro / Claude Max / weekends of API
|
|
1471
|
+
* coding) + 10-dev team scale projection + alternate-model scale row,
|
|
1472
|
+
* capped with an EXAMPLES disclaimer. The renderer is intentionally
|
|
1473
|
+
* liberal with rounding (whole-month Cursor counts, integer weekends)
|
|
1474
|
+
* because this section is illustrative — the EXAMPLES line tells users
|
|
1475
|
+
* not to confuse it for a bill.
|
|
1476
|
+
*
|
|
1477
|
+
* Returns [] when there's nothing to scale (lifetimeTokens === 0) so
|
|
1478
|
+
* the section disappears cleanly on a fresh install.
|
|
1479
|
+
*
|
|
1480
|
+
* Math constants:
|
|
1481
|
+
* Opus 4.7/4.8 = $5.00 per 1M input tokens (fallback when PI_CONTEXT_MODE_PRICE_OUTPUT_PER_TOKEN not set)
|
|
1482
|
+
* Sonnet 4.6 = $3.00 per 1M input tokens
|
|
1483
|
+
* GPT-4o = $2.50 per 1M input tokens
|
|
1484
|
+
* Gemini 2 = $1.25 per 1M input tokens
|
|
1485
|
+
* Haiku 4.5 = $1.00 per 1M input tokens
|
|
1486
|
+
* Cursor Pro = $20 / month → "X months of Cursor Pro"
|
|
1487
|
+
* Claude Max = $200 / month → "X.X months of Claude Max"
|
|
1488
|
+
* Weekend coding ≈ $73.67 → "X weekends of nonstop API coding"
|
|
1489
|
+
* Team multiplier = 10× → "At a 10-dev team scale: ~$X over Y days, or ~$Z/year"
|
|
1490
|
+
*/
|
|
1491
|
+
export function renderCostExample(lifetimeBytes, lifetimeTokens, lifetimeDays) {
|
|
1492
|
+
if (!Number.isFinite(lifetimeTokens) || lifetimeTokens <= 0)
|
|
1493
|
+
return [];
|
|
1494
|
+
const lifetimeUsd = lifetimeTokens * pricePerToken();
|
|
1495
|
+
const usdStr = (n, dp = 2) => n.toFixed(dp);
|
|
1496
|
+
// Comparison units — kept locally so they're easy to tune without touching
|
|
1497
|
+
// the renderer logic. Cursor Pro & Claude Max are public list prices; the
|
|
1498
|
+
// weekend constant is an intentional approximation calibrated to make
|
|
1499
|
+
// $1399.73 → "19 weekends" line up with the demo target.
|
|
1500
|
+
const cursorMonths = Math.round(lifetimeUsd / 20);
|
|
1501
|
+
const claudeMaxMonths = (lifetimeUsd / 200).toFixed(1);
|
|
1502
|
+
const weekendCount = Math.round(lifetimeUsd / 73.67);
|
|
1503
|
+
const teamUsd = Math.round(lifetimeUsd * 10);
|
|
1504
|
+
const teamYearUsd = lifetimeDays > 0
|
|
1505
|
+
? Math.round((lifetimeUsd * 10) / lifetimeDays * 365)
|
|
1506
|
+
: 0;
|
|
1507
|
+
// Alternate-model scale row — same token count, different per-1M rates.
|
|
1508
|
+
// (Kept for internal reference but unreachable per Mert directive.)
|
|
1509
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
1510
|
+
const _sonnetUsd = ((lifetimeTokens * 3.0) / 1_000_000).toFixed(2);
|
|
1511
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
1512
|
+
const _gpt4oUsd = ((lifetimeTokens * 2.5) / 1_000_000).toFixed(2);
|
|
1513
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
1514
|
+
const _geminiUsd = ((lifetimeTokens * 1.25) / 1_000_000).toFixed(2);
|
|
1515
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
1516
|
+
const _haikuUsd = ((lifetimeTokens * 1.0) / 1_000_000).toFixed(2);
|
|
1517
|
+
const usingDynamicPrice = process.env.PI_CONTEXT_MODE_PRICE_OUTPUT_PER_TOKEN !== undefined;
|
|
1518
|
+
const modelId = process.env.PI_CONTEXT_MODE_MODEL_ID;
|
|
1519
|
+
// Mert: "daha marketing ve business value e vermeli, math hesaplamalari ile
|
|
1520
|
+
// kalabalik yapma" — collapse the old 4-block render into ONE headline
|
|
1521
|
+
// number, ONE relatable comparison, ONE team-scale callout.
|
|
1522
|
+
const out = [];
|
|
1523
|
+
if (usingDynamicPrice && modelId) {
|
|
1524
|
+
out.push(` $${usdStr(lifetimeUsd)} of ${modelId} tokens your team didn't burn.`);
|
|
1525
|
+
}
|
|
1526
|
+
else if (usingDynamicPrice) {
|
|
1527
|
+
out.push(` $${usdStr(lifetimeUsd)} of tokens your team didn't burn.`);
|
|
1528
|
+
}
|
|
1529
|
+
else {
|
|
1530
|
+
out.push(` $${usdStr(lifetimeUsd)} of Opus 4.7 tokens your team didn't burn.`);
|
|
1531
|
+
}
|
|
1532
|
+
out.push(` context-mode kept ${kb(lifetimeBytes)} out of context — that's ${cursorMonths} months of Cursor Pro paid for itself.`);
|
|
1533
|
+
if (teamUsd > 0 && teamYearUsd > 0) {
|
|
1534
|
+
out.push("");
|
|
1535
|
+
out.push(` Scale across a 10-dev team and that's ~$${teamYearUsd.toLocaleString("en-US")}/year saved.`);
|
|
1536
|
+
}
|
|
1537
|
+
if (!usingDynamicPrice) {
|
|
1538
|
+
out.push("");
|
|
1539
|
+
out.push(` (Opus rates shown for context. On cheaper models the dollar number drops; the savings ratio holds.)`);
|
|
1540
|
+
}
|
|
1541
|
+
return out;
|
|
1542
|
+
}
|
|
1543
|
+
/**
|
|
1544
|
+
* Render the full 5-section narrative ("kitap gibi") layout — the
|
|
1545
|
+
* Mert-approved screenshot format the production ctx_stats handler
|
|
1546
|
+
* produces for users with conversation + lifetime + multi-adapter data.
|
|
1547
|
+
*
|
|
1548
|
+
* Order:
|
|
1549
|
+
* Opener
|
|
1550
|
+
* Section 1 — Where you are now (datetime, /compact, timeline)
|
|
1551
|
+
* Section 2 — What this chat captured (per-category bars)
|
|
1552
|
+
* Section 3 — The receipt — getting wider (this conv vs all-work)
|
|
1553
|
+
* Section 4 — For example: what would that cost?
|
|
1554
|
+
* Section 5 — What context-mode learned about how you work (auto-memory)
|
|
1555
|
+
* Footer
|
|
1556
|
+
*
|
|
1557
|
+
* Pure renderer: every input arrives via the args object so this
|
|
1558
|
+
* function is trivially testable end-to-end without mocking process or
|
|
1559
|
+
* Date. The caller (formatReport) is responsible for choosing a `now`
|
|
1560
|
+
* value that matches the conversation's age math and a `cwd` that
|
|
1561
|
+
* matches the user's project — defaults are sensible for production.
|
|
1562
|
+
*/
|
|
1563
|
+
function renderNarrative5Section(args) {
|
|
1564
|
+
const { conversation, lifetime, multiAdapter, realBytes, cwd, locale, tz, now, version, latestVersion } = args;
|
|
1565
|
+
const out = [];
|
|
1566
|
+
// ── Token math (same monotonic-growth invariant as the legacy branch).
|
|
1567
|
+
const convEventsTokens = conversation.events * TOKENS_PER_EVENT;
|
|
1568
|
+
const convRescueTokens = Math.round((conversation.snapshotBytes ?? 0) / 4);
|
|
1569
|
+
const convLegacyTokens = convEventsTokens + convRescueTokens;
|
|
1570
|
+
const convRealTokens = realBytes?.conversation?.totalSavedTokens ?? 0;
|
|
1571
|
+
const conversationTokens = Math.max(convLegacyTokens, convRealTokens);
|
|
1572
|
+
const lifetimeEventsTokens = (lifetime?.totalEvents ?? 0) * TOKENS_PER_EVENT;
|
|
1573
|
+
const lifetimeRescueTokens = Math.round((lifetime?.rescueBytes ?? 0) / 4);
|
|
1574
|
+
const lifetimeLegacyTokens = lifetimeEventsTokens + lifetimeRescueTokens;
|
|
1575
|
+
const lifetimeRealTokens = realBytes?.lifetime?.totalSavedTokens ?? 0;
|
|
1576
|
+
const lifetimeTokensWithout = Math.max(lifetimeLegacyTokens, lifetimeRealTokens);
|
|
1577
|
+
// Lifetime "with" — measured when available, else legacy 0.02 fallback.
|
|
1578
|
+
// Honest definition (matches conversation bar below):
|
|
1579
|
+
// "with" = bytes_returned (what the model actually re-saw)
|
|
1580
|
+
// "without" = bytes_returned + bytes_avoided
|
|
1581
|
+
// When the schema has measurement, derive `with` from `bytes_returned/4`.
|
|
1582
|
+
const lifeRet = realBytes?.lifetime?.bytesReturned ?? 0;
|
|
1583
|
+
const lifeAv = realBytes?.lifetime?.bytesAvoided ?? 0;
|
|
1584
|
+
const lifetimeTokensWith = (lifeRet + lifeAv) > 0
|
|
1585
|
+
? Math.max(1, Math.floor(lifeRet / 4))
|
|
1586
|
+
: Math.max(1, Math.round(lifetimeTokensWithout * 0.02));
|
|
1587
|
+
// Bytes from realBytes when present, else derive from tokens (×4 — same
|
|
1588
|
+
// ratio Phase 8 uses everywhere). All-work bytes drives the opener tally
|
|
1589
|
+
// + the section-3 receipt + section-4 cost example.
|
|
1590
|
+
const lifetimeBytes = (multiAdapter?.totalBytes && multiAdapter.totalBytes > 0)
|
|
1591
|
+
? multiAdapter.totalBytes
|
|
1592
|
+
: lifetimeTokensWithout * 4;
|
|
1593
|
+
const convBytes = realBytes?.conversation
|
|
1594
|
+
? (realBytes.conversation.eventDataBytes + realBytes.conversation.bytesAvoided + realBytes.conversation.snapshotBytes)
|
|
1595
|
+
: conversationTokens * 4;
|
|
1596
|
+
// ── Days alive of THE CONVERSATION (section 1).
|
|
1597
|
+
const convDays = conversation.daysAlive >= 1
|
|
1598
|
+
? `${conversation.daysAlive.toFixed(1)} days alive · still going`
|
|
1599
|
+
: `${Math.max(1, Math.round(conversation.daysAlive * 24))} hr alive · still going`;
|
|
1600
|
+
// ── Lifetime span (opener + receipt) — across every adapter / DB on disk.
|
|
1601
|
+
const sinceMs = lifetime?.firstEventMs ?? multiAdapter?.perAdapter?.[0]?.firstMs ?? 0;
|
|
1602
|
+
const lifetimeDays = sinceMs > 0
|
|
1603
|
+
? Math.max(1, Math.round((now - sinceMs) / 86_400_000))
|
|
1604
|
+
: 0;
|
|
1605
|
+
const totalConversations = multiAdapter?.totalSessions ?? lifetime?.totalSessions ?? 1;
|
|
1606
|
+
const realAdapterCount = multiAdapter?.perAdapter.filter((a) => a.isReal).length ?? 0;
|
|
1607
|
+
let where;
|
|
1608
|
+
if (multiAdapter && realAdapterCount >= 2) {
|
|
1609
|
+
where = `across ${realAdapterCount} AI tools`;
|
|
1610
|
+
}
|
|
1611
|
+
else if (multiAdapter && realAdapterCount === 1) {
|
|
1612
|
+
const onlyReal = multiAdapter.perAdapter.find((a) => a.isReal);
|
|
1613
|
+
where = `in ${onlyReal ? adapterLabel(onlyReal.name) : "Claude Code"}`;
|
|
1614
|
+
}
|
|
1615
|
+
else {
|
|
1616
|
+
where = "in Claude Code";
|
|
1617
|
+
}
|
|
1618
|
+
// ── Opener.
|
|
1619
|
+
if (lifetimeDays > 0) {
|
|
1620
|
+
out.push(` Across ${lifetimeDays} days you ran ${fmtNum(totalConversations)} conversations ${where}.`);
|
|
1621
|
+
}
|
|
1622
|
+
else {
|
|
1623
|
+
out.push(` You ran ${fmtNum(totalConversations)} conversations ${where}.`);
|
|
1624
|
+
}
|
|
1625
|
+
// Daily-average sub-line — never tease users with a tiny number when the
|
|
1626
|
+
// average is sub-MB (still informative); fall back to KB display.
|
|
1627
|
+
const dailyBytes = lifetimeDays > 0 ? lifetimeBytes / lifetimeDays : 0;
|
|
1628
|
+
out.push(` context-mode kept ${kb(lifetimeBytes)} out of your context window — about ${kb(dailyBytes)} every single day.`);
|
|
1629
|
+
out.push("");
|
|
1630
|
+
out.push("");
|
|
1631
|
+
// ── Section 1 — Where you are now.
|
|
1632
|
+
out.push(" ─── 1. Where you are now ───");
|
|
1633
|
+
out.push("");
|
|
1634
|
+
const startedStr = conversation.firstEventMs && conversation.firstEventMs > 0
|
|
1635
|
+
? formatLocalDateTime(conversation.firstEventMs, locale, tz)
|
|
1636
|
+
: "";
|
|
1637
|
+
if (startedStr) {
|
|
1638
|
+
out.push(` This conversation started ${startedStr} in ${shortPath(cwd)}.`);
|
|
1639
|
+
}
|
|
1640
|
+
else {
|
|
1641
|
+
out.push(` This conversation lives in ${shortPath(cwd)}.`);
|
|
1642
|
+
}
|
|
1643
|
+
out.push(` ${convDays}.`);
|
|
1644
|
+
if (conversation.snapshotsConsumed > 0 && conversation.snapshotBytes > 0) {
|
|
1645
|
+
const rescueAt = conversation.lastRescueMs && conversation.lastRescueMs > 0
|
|
1646
|
+
? formatLocalDateTime(conversation.lastRescueMs, locale, tz)
|
|
1647
|
+
: "";
|
|
1648
|
+
const rescueKb = Math.round(conversation.snapshotBytes / 1024);
|
|
1649
|
+
if (rescueAt) {
|
|
1650
|
+
out.push(` On ${rescueAt}, /compact fired — ${rescueKb} KB rescued from snapshot.`);
|
|
1651
|
+
}
|
|
1652
|
+
else {
|
|
1653
|
+
out.push(` /compact fired — ${rescueKb} KB rescued from snapshot.`);
|
|
1654
|
+
}
|
|
1655
|
+
out.push(` Without that, you'd be re-explaining everything to a blank model right now.`);
|
|
1656
|
+
}
|
|
1657
|
+
out.push("");
|
|
1658
|
+
// Without/With bars — strict compression (v1.0.148, Bug G / ADR-0004).
|
|
1659
|
+
//
|
|
1660
|
+
// Honest definitions:
|
|
1661
|
+
// Without = bytes the model WOULD have re-seen if context-mode
|
|
1662
|
+
// had not diverted them
|
|
1663
|
+
// = bytesAvoided + bytesReturned
|
|
1664
|
+
// With = bytes the model ACTUALLY re-saw after context-mode
|
|
1665
|
+
// = max(1, bytesReturned)
|
|
1666
|
+
//
|
|
1667
|
+
// Why eventDataBytes is excluded from this ratio:
|
|
1668
|
+
// `eventDataBytes` is the raw hook payload (tool args, prompt
|
|
1669
|
+
// body) we captured for the knowledge base. Those bytes are
|
|
1670
|
+
// analytics infrastructure — they NEVER enter the model context
|
|
1671
|
+
// window. Including them on either side (as v1.0.134 SLICE B did
|
|
1672
|
+
// to dodge a degenerate 100% bar) misrepresents context cost.
|
|
1673
|
+
// SLICE B was an incidental fix that crushed the displayed
|
|
1674
|
+
// percentage from ~95% (the true compression ratio) to ~56% on
|
|
1675
|
+
// live conversations. eventDataBytes is rendered in Section 2
|
|
1676
|
+
// (captures count), not in this Section 1 Without/With bar.
|
|
1677
|
+
//
|
|
1678
|
+
// Empty-state branch:
|
|
1679
|
+
// If neither bytesAvoided nor bytesReturned has been measured yet
|
|
1680
|
+
// (early in a session, schema-migration recovery in progress, or
|
|
1681
|
+
// tool-heavy work that hasn't re-hit the index), we do NOT draw
|
|
1682
|
+
// a degenerate 0% / 100% bar. We emit one honest hint line and
|
|
1683
|
+
// skip the bar — honesty over decoration.
|
|
1684
|
+
const realConv = realBytes?.conversation;
|
|
1685
|
+
const measuredAvoided = realConv?.bytesAvoided ?? 0;
|
|
1686
|
+
const measuredReturned = realConv?.bytesReturned ?? 0;
|
|
1687
|
+
if (measuredAvoided + measuredReturned === 0) {
|
|
1688
|
+
// No measurable redirect activity yet — captures may exist, but
|
|
1689
|
+
// nothing has been diverted from the model context window.
|
|
1690
|
+
out.push(" No measurable redirect activity captured yet — bars will appear once context-mode diverts its first payload.");
|
|
1691
|
+
out.push("");
|
|
1692
|
+
}
|
|
1693
|
+
else {
|
|
1694
|
+
const convBytesWithout = measuredAvoided + measuredReturned;
|
|
1695
|
+
const convBytesWith = Math.max(1, measuredReturned);
|
|
1696
|
+
const convTokensWithout = Math.max(1, Math.floor(convBytesWithout / 4));
|
|
1697
|
+
const convTokensWith = Math.max(1, Math.floor(convBytesWith / 4));
|
|
1698
|
+
const withoutBar = dataBar(convTokensWithout, convTokensWithout, 32);
|
|
1699
|
+
const withBar = dataBar(convTokensWith, convTokensWithout, 32);
|
|
1700
|
+
const convPct = (1 - convTokensWith / convTokensWithout) * 100;
|
|
1701
|
+
const convMult = Math.max(1, Math.round(convTokensWithout / convTokensWith));
|
|
1702
|
+
out.push(` Without context-mode ${kb(convBytesWithout).padStart(8)} ${withoutBar} ${fmtNum(convTokensWithout).padStart(7)} tokens`);
|
|
1703
|
+
out.push(` With context-mode ${kb(convBytesWith).padStart(8)} ${withBar} ${fmtNum(convTokensWith).padStart(7)} tokens`);
|
|
1704
|
+
out.push(` ${convPct.toFixed(1)}% kept out of context · your AI ran ${convMult}× longer before /compact fired`);
|
|
1705
|
+
out.push("");
|
|
1706
|
+
}
|
|
1707
|
+
// Timeline — drop-in if conversation has byDay.
|
|
1708
|
+
if (conversation.byDay && conversation.byDay.length > 0) {
|
|
1709
|
+
const totalConvDays = conversation.lastEventMs && conversation.firstEventMs
|
|
1710
|
+
? Math.max(1, Math.round((conversation.lastEventMs - conversation.firstEventMs) / 86_400_000) + 1)
|
|
1711
|
+
: conversation.byDay.length;
|
|
1712
|
+
out.push(` How that ${kb(convBytes)} built up — ${totalConvDays} days, ${conversation.byDay.length} active:`);
|
|
1713
|
+
out.push("");
|
|
1714
|
+
out.push(...renderHorizontalTimeline(conversation.byDay, locale, tz));
|
|
1715
|
+
}
|
|
1716
|
+
out.push("");
|
|
1717
|
+
out.push("");
|
|
1718
|
+
// ── Section 2 — What this chat captured.
|
|
1719
|
+
out.push(" ─── 2. What this chat captured (used when you --continue or /resume here) ───");
|
|
1720
|
+
out.push("");
|
|
1721
|
+
const capturedTotal = conversation.byCategory.reduce((s, c) => s + c.count, 0);
|
|
1722
|
+
// Format with locale separator (en-* → "1,277"; en-TR → "1.277").
|
|
1723
|
+
const totalStr = capturedTotal.toLocaleString(locale);
|
|
1724
|
+
out.push(` ${totalStr} things — files, errors, decisions, agent runs:`);
|
|
1725
|
+
out.push("");
|
|
1726
|
+
// ALL categories, no truncation (Slice 5).
|
|
1727
|
+
const max = conversation.byCategory[0]?.count ?? 1;
|
|
1728
|
+
for (const cat of conversation.byCategory) {
|
|
1729
|
+
out.push(` ${cat.label.padEnd(26)} ${String(cat.count).padStart(5)} ${dataBar(cat.count, max, 28)}`);
|
|
1730
|
+
}
|
|
1731
|
+
out.push("");
|
|
1732
|
+
out.push("");
|
|
1733
|
+
// ── Section 3 — Scope ladder, prose form (Mert: "cok daginik" → drop columns).
|
|
1734
|
+
// Two short sentences instead of a 4-column table — the same numbers framed
|
|
1735
|
+
// as "this chat" → "all your work" so the reader sees the scope getting wider
|
|
1736
|
+
// without being asked to scan a wide grid.
|
|
1737
|
+
out.push(" ─── 3. The scope, getting wider ───");
|
|
1738
|
+
out.push("");
|
|
1739
|
+
const convStartedYMD = conversation.firstEventMs && conversation.firstEventMs > 0
|
|
1740
|
+
? new Intl.DateTimeFormat(locale, { timeZone: tz, year: "numeric", month: "short", day: "numeric" })
|
|
1741
|
+
.format(new Date(conversation.firstEventMs))
|
|
1742
|
+
: "";
|
|
1743
|
+
const lifeStartedYMD = sinceMs > 0
|
|
1744
|
+
? new Intl.DateTimeFormat(locale, { timeZone: tz, year: "numeric", month: "short", day: "numeric" })
|
|
1745
|
+
.format(new Date(sinceMs))
|
|
1746
|
+
: "";
|
|
1747
|
+
const distinctProj = lifetime?.distinctProjects ?? 0;
|
|
1748
|
+
const allCaps = lifetime?.totalEvents ?? multiAdapter?.totalEvents ?? 0;
|
|
1749
|
+
out.push(` This chat: ${kb(convBytes)} kept out · ${conversation.events.toLocaleString(locale)} captures${convStartedYMD ? ` · started ${convStartedYMD}` : ""}.`);
|
|
1750
|
+
out.push(` All your work: ${kb(lifetimeBytes)} kept out · ${allCaps.toLocaleString(locale)} captures across ${distinctProj} project${distinctProj === 1 ? "" : "s"}${lifeStartedYMD ? ` · since ${lifeStartedYMD}` : ""}.`);
|
|
1751
|
+
out.push("");
|
|
1752
|
+
out.push("");
|
|
1753
|
+
// ── Section 4 — Marketing-grade cost framing (Mert: "math hesaplamalari ile
|
|
1754
|
+
// kalabalik yapma" → less math, more business value). One headline, one
|
|
1755
|
+
// optional team-scale callout, no scaling table, no math footnotes.
|
|
1756
|
+
out.push(" ─── 4. The bottom line ───");
|
|
1757
|
+
out.push("");
|
|
1758
|
+
out.push(...renderCostExample(lifetimeBytes, lifetimeTokensWithout, lifetimeDays));
|
|
1759
|
+
out.push("");
|
|
1760
|
+
out.push("");
|
|
1761
|
+
// ── Section 5 — What context-mode learned about how you work.
|
|
1762
|
+
out.push(" ─── 5. What context-mode learned about how you work ───");
|
|
1763
|
+
out.push("");
|
|
1764
|
+
if (lifetime && lifetime.autoMemoryCount > 0) {
|
|
1765
|
+
out.push(` ${lifetime.autoMemoryCount} preferences picked up across ${lifetime.autoMemoryProjects} project${lifetime.autoMemoryProjects === 1 ? "" : "s"}:`);
|
|
1766
|
+
const entries = Object.entries(lifetime.autoMemoryByPrefix).sort((a, b) => b[1] - a[1]);
|
|
1767
|
+
const maxAm = entries.length > 0 ? entries[0][1] : 1;
|
|
1768
|
+
for (const [prefix, count] of entries) {
|
|
1769
|
+
const label = autoMemoryLabels[prefix] ?? prefix;
|
|
1770
|
+
out.push(` ${label.padEnd(26)} ${String(count).padStart(2)} ${dataBar(count, maxAm, 20)}`);
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
else {
|
|
1774
|
+
out.push(" No preferences learned yet — context-mode picks them up automatically.");
|
|
1775
|
+
}
|
|
1776
|
+
out.push("");
|
|
1777
|
+
out.push("");
|
|
1778
|
+
// ── Footer.
|
|
1779
|
+
out.push(" Your AI talks less, remembers more, costs less.");
|
|
1780
|
+
out.push(` Locale ${locale} · timezone ${tz} · pricing examples for illustration only.`);
|
|
1781
|
+
out.push("");
|
|
1782
|
+
const versionStr = version ? `v${version}` : "context-mode";
|
|
1783
|
+
out.push(` ${versionStr}`);
|
|
1784
|
+
if (version && latestVersion && latestVersion !== "unknown" && semverNewer(latestVersion, version)) {
|
|
1785
|
+
out.push(` Update available: v${version} -> v${latestVersion} | ctx_upgrade`);
|
|
1786
|
+
}
|
|
1787
|
+
// Suppress consecutive blank lines / leading blanks for tidier output —
|
|
1788
|
+
// we use `push("")` liberally above as paragraph separators, easier to
|
|
1789
|
+
// collapse here than to track flag state inline.
|
|
1790
|
+
return collapseBlanks(out);
|
|
1791
|
+
}
|
|
1792
|
+
/** Drop runs of >2 consecutive blank strings so the renderer never emits visual gaps. */
|
|
1793
|
+
function collapseBlanks(lines) {
|
|
1794
|
+
const out = [];
|
|
1795
|
+
let blankRun = 0;
|
|
1796
|
+
for (const ln of lines) {
|
|
1797
|
+
if (ln === "") {
|
|
1798
|
+
blankRun++;
|
|
1799
|
+
if (blankRun <= 2)
|
|
1800
|
+
out.push(ln);
|
|
1801
|
+
}
|
|
1802
|
+
else {
|
|
1803
|
+
blankRun = 0;
|
|
1804
|
+
out.push(ln);
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
// Trim trailing blanks.
|
|
1808
|
+
while (out.length > 0 && out[out.length - 1] === "")
|
|
1809
|
+
out.pop();
|
|
1810
|
+
return out;
|
|
1811
|
+
}
|
|
1812
|
+
/**
|
|
1813
|
+
* Render the proportional-spacing horizontal day strip used in section 1
|
|
1814
|
+
* of the 5-section narrative. Returns the lines verbatim ready to splice
|
|
1815
|
+
* into the formatReport line buffer:
|
|
1816
|
+
*
|
|
1817
|
+
* apr 28 ●──────────────────────●────█──────────────────────◆────● may 10
|
|
1818
|
+
*
|
|
1819
|
+
* apr 28 277 captures
|
|
1820
|
+
* may 4 438 captures ← peak
|
|
1821
|
+
* may 9 261 captures ◆ /compact rescued 1552 KB
|
|
1822
|
+
* may 10 100 captures
|
|
1823
|
+
*
|
|
1824
|
+
* ● active day █ peak day ◆ /compact rescue
|
|
1825
|
+
*
|
|
1826
|
+
* The strip body is exactly 56 chars wide. Day positions are computed as
|
|
1827
|
+
* `round((day - first) / (last - first) * 55)`. Glyph priority for a
|
|
1828
|
+
* column: rescue (◆) > peak (█) > active (●). Filler is the box-drawing
|
|
1829
|
+
* `─` character so the strip reads cleanly in monospace terminals.
|
|
1830
|
+
*/
|
|
1831
|
+
export function renderHorizontalTimeline(days, locale, tz) {
|
|
1832
|
+
if (days.length === 0)
|
|
1833
|
+
return [];
|
|
1834
|
+
// Sort ascending so first/last bookends + bar positions are stable.
|
|
1835
|
+
const sorted = [...days].sort((a, b) => a.ms - b.ms);
|
|
1836
|
+
const first = sorted[0];
|
|
1837
|
+
const last = sorted[sorted.length - 1];
|
|
1838
|
+
const span = Math.max(1, last.ms - first.ms);
|
|
1839
|
+
// Locate the peak day (max count). Ties: earliest wins so the visual
|
|
1840
|
+
// pin matches the chronologically first big day.
|
|
1841
|
+
let peak = sorted[0];
|
|
1842
|
+
for (const d of sorted)
|
|
1843
|
+
if (d.count > peak.count)
|
|
1844
|
+
peak = d;
|
|
1845
|
+
// Build the 56-char strip body.
|
|
1846
|
+
const WIDTH = 56;
|
|
1847
|
+
const body = Array.from({ length: WIDTH }, () => "─");
|
|
1848
|
+
for (const d of sorted) {
|
|
1849
|
+
const col = Math.round(((d.ms - first.ms) / span) * (WIDTH - 1));
|
|
1850
|
+
let glyph = "●";
|
|
1851
|
+
if (d === peak)
|
|
1852
|
+
glyph = "█";
|
|
1853
|
+
if ((d.rescueBytes ?? 0) > 0)
|
|
1854
|
+
glyph = "◆"; // rescue beats peak
|
|
1855
|
+
body[col] = glyph;
|
|
1856
|
+
}
|
|
1857
|
+
// Lowercase short month names ("apr"/"may"/"jan") matching the target.
|
|
1858
|
+
const monthDay = (ms) => {
|
|
1859
|
+
const dt = new Intl.DateTimeFormat(locale, {
|
|
1860
|
+
timeZone: tz,
|
|
1861
|
+
month: "short",
|
|
1862
|
+
day: "numeric",
|
|
1863
|
+
}).formatToParts(new Date(ms));
|
|
1864
|
+
const month = (dt.find((p) => p.type === "month")?.value ?? "").toLowerCase();
|
|
1865
|
+
const day = dt.find((p) => p.type === "day")?.value ?? "";
|
|
1866
|
+
return `${month} ${day}`;
|
|
1867
|
+
};
|
|
1868
|
+
const out = [];
|
|
1869
|
+
out.push(` ${monthDay(first.ms)} ${body.join("")} ${monthDay(last.ms)}`);
|
|
1870
|
+
out.push("");
|
|
1871
|
+
// Daily detail rows — count + " ← peak" + "◆ /compact rescued N KB".
|
|
1872
|
+
for (const d of sorted) {
|
|
1873
|
+
const label = monthDay(d.ms).padEnd(7);
|
|
1874
|
+
const captures = `${d.count} captures`;
|
|
1875
|
+
const peakStr = d === peak ? " ← peak" : "";
|
|
1876
|
+
const rescue = (d.rescueBytes ?? 0) > 0
|
|
1877
|
+
? ` ◆ /compact rescued ${Math.round((d.rescueBytes ?? 0) / 1024)} KB`
|
|
1878
|
+
: "";
|
|
1879
|
+
out.push(` ${label} ${captures}${peakStr}${rescue}`);
|
|
1880
|
+
}
|
|
1881
|
+
out.push("");
|
|
1882
|
+
out.push(" ● active day █ peak day ◆ /compact rescue");
|
|
1883
|
+
return out;
|
|
1884
|
+
}
|
|
1885
|
+
/**
|
|
1886
|
+
* Render a UTC ms timestamp as a human-readable local datetime string in
|
|
1887
|
+
* the canonical Mert-approved format:
|
|
1888
|
+
*
|
|
1889
|
+
* "28 Apr 2026 at 12:16 (Europe/Istanbul)"
|
|
1890
|
+
*
|
|
1891
|
+
* Used by the 5-section narrative renderer (formatReport) so users see
|
|
1892
|
+
* exactly when their conversation started + when /compact rescues fired
|
|
1893
|
+
* in their wall-clock timezone — never UTC, never ambiguous.
|
|
1894
|
+
*
|
|
1895
|
+
* - 24-hour clock with zero-padded minutes ("20:54", not "8:54 PM").
|
|
1896
|
+
* - Day is NOT zero-padded ("9 May", not "09 May") to match the target.
|
|
1897
|
+
* - IANA timezone is appended verbatim in parentheses regardless of
|
|
1898
|
+
* locale so users never misread Istanbul-time as UTC.
|
|
1899
|
+
* - Returns "" for ms === 0 or NaN so callers can guard the rendered
|
|
1900
|
+
* line ("started …") without an extra timestamp-validity check.
|
|
1901
|
+
*/
|
|
1902
|
+
export function formatLocalDateTime(ms, locale, tz) {
|
|
1903
|
+
if (!Number.isFinite(ms) || ms <= 0)
|
|
1904
|
+
return "";
|
|
1905
|
+
const date = new Date(ms);
|
|
1906
|
+
if (Number.isNaN(date.getTime()))
|
|
1907
|
+
return "";
|
|
1908
|
+
// Intl.DateTimeFormat's "day"/"month"/"year" parts give us the locale's
|
|
1909
|
+
// ordering (en-* → "DD MMM YYYY"), and the explicit numeric hour/minute
|
|
1910
|
+
// forces 24-hour with leading zero on minute when in en-* with hour12=false.
|
|
1911
|
+
const dt = new Intl.DateTimeFormat(locale, {
|
|
1912
|
+
timeZone: tz,
|
|
1913
|
+
year: "numeric",
|
|
1914
|
+
month: "short",
|
|
1915
|
+
day: "numeric",
|
|
1916
|
+
hour: "2-digit",
|
|
1917
|
+
minute: "2-digit",
|
|
1918
|
+
hour12: false,
|
|
1919
|
+
}).formatToParts(date);
|
|
1920
|
+
const get = (type) => dt.find((p) => p.type === type)?.value ?? "";
|
|
1921
|
+
const day = get("day");
|
|
1922
|
+
const month = get("month");
|
|
1923
|
+
const year = get("year");
|
|
1924
|
+
let hour = get("hour");
|
|
1925
|
+
const min = get("minute");
|
|
1926
|
+
// Some locales / some Node versions emit "24" for midnight under hour12=false.
|
|
1927
|
+
// Coerce back to "00" so the displayed time is always wall-clock-correct.
|
|
1928
|
+
if (hour === "24")
|
|
1929
|
+
hour = "00";
|
|
1930
|
+
return `${day} ${month} ${year} at ${hour}:${min} (${tz})`;
|
|
1931
|
+
}
|
|
1932
|
+
/** Format large numbers with K/M suffixes */
|
|
1933
|
+
function fmtNum(n) {
|
|
1934
|
+
if (n >= 1_000_000)
|
|
1935
|
+
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
1936
|
+
if (n >= 1_000)
|
|
1937
|
+
return `${(n / 1_000).toFixed(1)}K`;
|
|
1938
|
+
return String(n);
|
|
1939
|
+
}
|
|
1940
|
+
// ─────────────────────────────────────────────────────────
|
|
1941
|
+
// Pricing (Bug #6) — Anthropic Opus input rate
|
|
1942
|
+
// ─────────────────────────────────────────────────────────
|
|
1943
|
+
// ── Pricing (Bug #6) — per-token USD rate ─────────────────
|
|
1944
|
+
// Reads PI_CONTEXT_MODE_PRICE_OUTPUT_PER_TOKEN when set by a Pi host;
|
|
1945
|
+
// falls back to the Opus 4.7/4.8 input rate ($5/1M) for all other adapters.
|
|
1946
|
+
// Verified against platform.claude.com/docs/en/about-claude/pricing 2026-06.
|
|
1947
|
+
//
|
|
1948
|
+
// IMPORTANT: this is a FUNCTION, not a const. Pi sets the env var
|
|
1949
|
+
// AFTER the MCP server has been imported (the bridge spawns the server
|
|
1950
|
+
// child, then the child reads its own env on every render). A
|
|
1951
|
+
// module-load-time const would freeze to the fallback because
|
|
1952
|
+
// process.env.PI_CONTEXT_MODE_PRICE_OUTPUT_PER_TOKEN is unset at
|
|
1953
|
+
// import time. Resolving on every call keeps the dynamic-pricing
|
|
1954
|
+
// contract honest — the env var works without an MCP restart.
|
|
1955
|
+
// (Reverted module-load const semantics, PR #741 follow-up.)
|
|
1956
|
+
/**
|
|
1957
|
+
* Per-token USD rate — resolves on every call.
|
|
1958
|
+
* Dynamic when PI_CONTEXT_MODE_PRICE_OUTPUT_PER_TOKEN is set, Opus 4.7/4.8 input
|
|
1959
|
+
* ($5 per 1M tokens) otherwise.
|
|
1960
|
+
*/
|
|
1961
|
+
export function pricePerToken() {
|
|
1962
|
+
const env = process.env.PI_CONTEXT_MODE_PRICE_OUTPUT_PER_TOKEN;
|
|
1963
|
+
if (env !== undefined && env !== "") {
|
|
1964
|
+
const parsed = Number(env);
|
|
1965
|
+
if (Number.isFinite(parsed) && parsed > 0)
|
|
1966
|
+
return parsed;
|
|
1967
|
+
}
|
|
1968
|
+
return 5 / 1_000_000; // Opus 4.7/4.8 input fallback
|
|
1969
|
+
}
|
|
1970
|
+
/**
|
|
1971
|
+
* Back-compat alias for the original Opus-rate const (PR #401 architect
|
|
1972
|
+
* P1.1 — single source of truth). Kept as a literal so any third-party
|
|
1973
|
+
* consumer importing the named constant still resolves to the same
|
|
1974
|
+
* fallback rate. New code should call pricePerToken() to pick up the
|
|
1975
|
+
* dynamic Pi env override.
|
|
1976
|
+
*
|
|
1977
|
+
* @deprecated Use pricePerToken() to honor PI_CONTEXT_MODE_PRICE_OUTPUT_PER_TOKEN.
|
|
1978
|
+
*/
|
|
1979
|
+
export const OPUS_INPUT_PRICE_PER_TOKEN = 5 / 1_000_000;
|
|
1980
|
+
/** Convert a token count to a USD string at the current per-token rate. */
|
|
1981
|
+
export function tokensToUsd(tokens) {
|
|
1982
|
+
const safe = Number.isFinite(tokens) && tokens > 0 ? tokens : 0;
|
|
1983
|
+
return `$${(safe * pricePerToken()).toFixed(2)}`;
|
|
1984
|
+
}
|
|
1985
|
+
/**
|
|
1986
|
+
* Build a proportional bar using █ chars, scaled to a fixed width.
|
|
1987
|
+
* Returns e.g. "████████████████████████████████████████" for full width.
|
|
1988
|
+
*/
|
|
1989
|
+
function dataBar(bytes, maxBytes, width = 40) {
|
|
1990
|
+
if (maxBytes <= 0)
|
|
1991
|
+
return "░".repeat(width);
|
|
1992
|
+
const filled = Math.max(1, Math.round((bytes / maxBytes) * width));
|
|
1993
|
+
return "█".repeat(Math.min(filled, width)) + "░".repeat(Math.max(0, width - filled));
|
|
1994
|
+
}
|
|
1995
|
+
/**
|
|
1996
|
+
* Render project memory section with category bars.
|
|
1997
|
+
*
|
|
1998
|
+
* Shows persistent event data, and — when supplied — lifetime totals
|
|
1999
|
+
* across every project's SessionDB so users see the cumulative value
|
|
2000
|
+
* (Bug #3).
|
|
2001
|
+
*
|
|
2002
|
+
* Caps the category list at `topN` and prints "N more categories" with the
|
|
2003
|
+
* actual remaining count (Bug #5 — was hardcoded "9 more").
|
|
2004
|
+
*/
|
|
2005
|
+
function renderProjectMemory(pm, opts) {
|
|
2006
|
+
const sessionTokensSaved = opts?.sessionTokensSaved ?? 0;
|
|
2007
|
+
// Render when EITHER disk has data OR current session has earnings.
|
|
2008
|
+
if (pm.total_events === 0 &&
|
|
2009
|
+
(opts?.lifetime?.totalEvents ?? 0) === 0 &&
|
|
2010
|
+
sessionTokensSaved === 0 &&
|
|
2011
|
+
(opts?.multiAdapter?.totalEvents ?? 0) === 0) {
|
|
2012
|
+
return [];
|
|
2013
|
+
}
|
|
2014
|
+
// Slice 5 — Mert: "honest, no tease". Show ALL categories. The legacy
|
|
2015
|
+
// topN cap silently hid real data; users would screenshot a stats card
|
|
2016
|
+
// missing half their work. The opts.topN parameter stays in the signature
|
|
2017
|
+
// for back-compat with any external caller that explicitly passes a cap.
|
|
2018
|
+
const topN = opts?.topN ?? Number.POSITIVE_INFINITY;
|
|
2019
|
+
const out = [];
|
|
2020
|
+
out.push("");
|
|
2021
|
+
// Header switches based on whether we have rich lifetime data from the new
|
|
2022
|
+
// pipeline. With it: forward-leaning "All your work" framing. Without it:
|
|
2023
|
+
// legacy "Persistent memory" line for back-compat with older fixtures + tests.
|
|
2024
|
+
// Slice 3.6: promote to "All your work everywhere" when multi-adapter
|
|
2025
|
+
// aggregation is supplied so the receipt scope matches the rendered totals.
|
|
2026
|
+
const ma = opts?.multiAdapter;
|
|
2027
|
+
const realAdapters = ma?.perAdapter.filter((a) => a.isReal).length ?? 0;
|
|
2028
|
+
const lifeEvents = ma?.totalEvents
|
|
2029
|
+
?? opts?.lifetime?.totalEvents
|
|
2030
|
+
?? pm.total_events;
|
|
2031
|
+
const lifeSessions = ma?.totalSessions
|
|
2032
|
+
?? opts?.lifetime?.totalSessions
|
|
2033
|
+
?? pm.session_count;
|
|
2034
|
+
const distinctProj = opts?.lifetime?.distinctProjects;
|
|
2035
|
+
if (lifeEvents > 0 && distinctProj && distinctProj > 0) {
|
|
2036
|
+
const everywhere = realAdapters >= 2 ? " everywhere" : "";
|
|
2037
|
+
out.push(` All your work${everywhere} · ${fmtNum(lifeEvents)} events captured across ${distinctProj} project${distinctProj === 1 ? "" : "s"} · ${fmtNum(lifeSessions)} conversations`);
|
|
2038
|
+
}
|
|
2039
|
+
else {
|
|
2040
|
+
out.push("Persistent memory ✓ preserved across compact, restart & upgrade");
|
|
2041
|
+
// Current session counts as 1 when no prior session has been recorded yet.
|
|
2042
|
+
const effectiveSessions = lifeSessions === 0 && sessionTokensSaved > 0 ? 1 : lifeSessions;
|
|
2043
|
+
const sessionLabel = effectiveSessions === 1 ? "1 session" : `${fmtNum(effectiveSessions)} sessions`;
|
|
2044
|
+
// Estimate lifetime savings: ~1KB per event → ~256 tokens/event at Opus rates,
|
|
2045
|
+
// plus current session's already-tracked token savings (in-memory).
|
|
2046
|
+
const lifetimeTokens = lifeEvents * 256 + sessionTokensSaved;
|
|
2047
|
+
out.push(` ${fmtNum(lifeEvents)} events · ${sessionLabel} · ~${tokensToUsd(lifetimeTokens)} saved lifetime`);
|
|
2048
|
+
}
|
|
2049
|
+
out.push("");
|
|
2050
|
+
// Prefer lifetime categoryCounts (aggregated across every SessionDB) so
|
|
2051
|
+
// the bar block matches the lifetime header above. Falls back to the
|
|
2052
|
+
// project-local pm.by_category when lifetime data is absent (tests, older
|
|
2053
|
+
// callers) or when no sidecar has any events yet.
|
|
2054
|
+
const lifetimeCats = opts?.lifetime?.categoryCounts;
|
|
2055
|
+
let cats;
|
|
2056
|
+
if (lifetimeCats && Object.keys(lifetimeCats).length > 0) {
|
|
2057
|
+
cats = Object.entries(lifetimeCats)
|
|
2058
|
+
.filter(([, c]) => c > 0)
|
|
2059
|
+
.map(([category, count]) => ({
|
|
2060
|
+
category,
|
|
2061
|
+
count,
|
|
2062
|
+
label: categoryLabels[category] || category,
|
|
2063
|
+
}))
|
|
2064
|
+
.sort((a, b) => b.count - a.count);
|
|
2065
|
+
}
|
|
2066
|
+
else {
|
|
2067
|
+
// Defensive: filter zero/null counts on the fallback path too — bumping
|
|
2068
|
+
// topN to 15 made any leaked empty rows visible as "label 0 ░░░░░░".
|
|
2069
|
+
cats = (pm.by_category ?? []).filter((c) => c && c.count > 0);
|
|
2070
|
+
}
|
|
2071
|
+
const visible = cats.slice(0, topN);
|
|
2072
|
+
const maxCount = visible.length > 0 ? visible[0].count : 1;
|
|
2073
|
+
for (const cat of visible) {
|
|
2074
|
+
out.push(` ${cat.label.padEnd(26)} ${String(cat.count).padStart(5)} ${dataBar(cat.count, maxCount, 30)}`);
|
|
2075
|
+
}
|
|
2076
|
+
// Bug #5: real overflow count, not hardcoded.
|
|
2077
|
+
const remaining = Math.max(0, cats.length - topN);
|
|
2078
|
+
if (remaining > 0) {
|
|
2079
|
+
out.push(` ... ${remaining} more categor${remaining === 1 ? "y" : "ies"}`);
|
|
2080
|
+
}
|
|
2081
|
+
return out;
|
|
2082
|
+
}
|
|
2083
|
+
/**
|
|
2084
|
+
* Render the auto-memory section (Bug #4) — files Claude Code captured
|
|
2085
|
+
* under ~/.claude/projects/<project>/memory/ across the user's machine.
|
|
2086
|
+
*/
|
|
2087
|
+
function renderAutoMemory(lifetime) {
|
|
2088
|
+
if (!lifetime || lifetime.autoMemoryCount === 0)
|
|
2089
|
+
return [];
|
|
2090
|
+
const out = [];
|
|
2091
|
+
out.push("");
|
|
2092
|
+
out.push(` Preferences learned · ${lifetime.autoMemoryCount} across ${lifetime.autoMemoryProjects} project${lifetime.autoMemoryProjects === 1 ? "" : "s"}`);
|
|
2093
|
+
const entries = Object.entries(lifetime.autoMemoryByPrefix)
|
|
2094
|
+
.sort((a, b) => b[1] - a[1])
|
|
2095
|
+
.slice(0, 6);
|
|
2096
|
+
// Top entry sets the bar scale so the visual stays proportional even when
|
|
2097
|
+
// the absolute counts are tiny. Entries are pre-sorted desc.
|
|
2098
|
+
const maxCount = entries.length > 0 ? entries[0][1] : 1;
|
|
2099
|
+
for (const [prefix, count] of entries) {
|
|
2100
|
+
const label = autoMemoryLabels[prefix] ?? prefix;
|
|
2101
|
+
out.push(` ${label.padEnd(26)} ${String(count).padStart(2)} ${dataBar(count, maxCount, 20)}`);
|
|
2102
|
+
}
|
|
2103
|
+
return out;
|
|
2104
|
+
}
|
|
2105
|
+
/** Render the closing "Bottom line" footer (Bug #8). */
|
|
2106
|
+
function renderBottomLine(sessionTokensSaved, lifetime) {
|
|
2107
|
+
const out = [];
|
|
2108
|
+
const sessionUsd = tokensToUsd(sessionTokensSaved);
|
|
2109
|
+
// Lifetime = disk-aggregated events × 256 tokens + current session's
|
|
2110
|
+
// in-memory token savings. Two pipelines unified at the render edge so
|
|
2111
|
+
// lifetime ≥ session always (never the surprising "$X session · $0 lifetime"
|
|
2112
|
+
// a fresh user sees pre-flush).
|
|
2113
|
+
const lifetimeTokens = (lifetime?.totalEvents ?? 0) * 256 + sessionTokensSaved;
|
|
2114
|
+
const lifetimeUsd = tokensToUsd(lifetimeTokens);
|
|
2115
|
+
out.push("");
|
|
2116
|
+
out.push("─".repeat(65));
|
|
2117
|
+
out.push("Your AI talks less, remembers more, costs less.");
|
|
2118
|
+
out.push(`${sessionUsd} this session · ${lifetimeUsd} lifetime`);
|
|
2119
|
+
out.push("─".repeat(65));
|
|
2120
|
+
return out;
|
|
2121
|
+
}
|
|
2122
|
+
/**
|
|
2123
|
+
* Constant token-per-event used everywhere we estimate session/lifetime $.
|
|
2124
|
+
* Kept in lockstep with `bin/statusline.mjs`'s persisted lifetime conversion.
|
|
2125
|
+
*/
|
|
2126
|
+
const TOKENS_PER_EVENT = 256;
|
|
2127
|
+
/**
|
|
2128
|
+
* Render the LIFETIME Without/With hero — the screenshottable receipt.
|
|
2129
|
+
*
|
|
2130
|
+
* Why lifetime and not session: the "$X saved this session" framing is
|
|
2131
|
+
* arbitrary (a fresh PID can show $0 even while the user has weeks of work
|
|
2132
|
+
* banked). Lifetime is real, accumulating, and the number worth screenshotting.
|
|
2133
|
+
* The current conversation's contribution still shows below as a sub-block.
|
|
2134
|
+
*/
|
|
2135
|
+
function renderHero(args) {
|
|
2136
|
+
const { lifetimeTokensWithout, lifetimeTokensWith, lifetimeUsd, lifetimeWithUsd, savedPct, totalConversations, firstDate } = args;
|
|
2137
|
+
const out = [];
|
|
2138
|
+
const since = firstDate ? ` · since ${firstDate}` : "";
|
|
2139
|
+
out.push(` ${lifetimeUsd} saved with context-mode · ${savedPct.toFixed(1)}% reduction${since}`);
|
|
2140
|
+
out.push("");
|
|
2141
|
+
const withoutBar = dataBar(lifetimeTokensWithout, lifetimeTokensWithout, 32);
|
|
2142
|
+
const withBar = dataBar(lifetimeTokensWith, lifetimeTokensWithout, 32);
|
|
2143
|
+
out.push(` Without context-mode ${fmtNum(lifetimeTokensWithout).padStart(7)} tokens ${withoutBar} ${lifetimeUsd}`);
|
|
2144
|
+
out.push(` With context-mode ${fmtNum(lifetimeTokensWith).padStart(7)} tokens ${withBar} ${lifetimeWithUsd}`);
|
|
2145
|
+
const kept = lifetimeTokensWithout - lifetimeTokensWith;
|
|
2146
|
+
out.push(` ${fmtNum(kept).padStart(7)} tokens kept out · across ${totalConversations.toLocaleString("en-US")} conversations`);
|
|
2147
|
+
return out;
|
|
2148
|
+
}
|
|
2149
|
+
/**
|
|
2150
|
+
* Render the current conversation as a contribution narrative — not a hero.
|
|
2151
|
+
* Highlights the slice of lifetime savings this chat earned + concrete proof
|
|
2152
|
+
* (events, days alive, compact rescues).
|
|
2153
|
+
*/
|
|
2154
|
+
function renderConversation(c, conversationUsd, contribPct) {
|
|
2155
|
+
const out = [];
|
|
2156
|
+
const daysStr = c.daysAlive >= 1 ? `${c.daysAlive.toFixed(1)} days` : `${Math.max(1, Math.round(c.daysAlive * 24))} hr`;
|
|
2157
|
+
const pctStr = contribPct >= 1 ? `${contribPct.toFixed(0)}% of all-time` : `<1% of all-time`;
|
|
2158
|
+
out.push(` This conversation contributed ${conversationUsd} · ${pctStr}`);
|
|
2159
|
+
out.push(` ${c.events.toLocaleString("en-US")} events · ${daysStr} alive`);
|
|
2160
|
+
if (c.snapshotsConsumed > 0 && c.snapshotBytes > 0) {
|
|
2161
|
+
const rescuedTokens = Math.round(c.snapshotBytes / 4);
|
|
2162
|
+
out.push(` ${c.snapshotsConsumed} compact weathered · ${fmtNum(rescuedTokens)} tokens rescued from a ${(c.snapshotBytes / 1024).toFixed(0)} KB snapshot`);
|
|
2163
|
+
}
|
|
2164
|
+
out.push("");
|
|
2165
|
+
if (c.byCategory.length === 0)
|
|
2166
|
+
return out;
|
|
2167
|
+
const max = c.byCategory[0].count || 1;
|
|
2168
|
+
for (const cat of c.byCategory) {
|
|
2169
|
+
out.push(` ${cat.label.padEnd(26)} ${String(cat.count).padStart(5)} ${dataBar(cat.count, max, 28)}`);
|
|
2170
|
+
}
|
|
2171
|
+
return out;
|
|
2172
|
+
}
|
|
2173
|
+
/**
|
|
2174
|
+
* B3b Slice 3.2/3.3 — render the "Where it came from" sub-block from a
|
|
2175
|
+
* `MultiAdapterLifetimeStats` (analytics.ts:1231-1240). Two layers:
|
|
2176
|
+
*
|
|
2177
|
+
* 1. Real adapters (`isReal=true`) become a table row each:
|
|
2178
|
+
* Tool Captures Indexed Total kept out
|
|
2179
|
+
* Claude Code 17.4K 276.7 MB 291.1 MB
|
|
2180
|
+
* JetBrains — 8.6 MB 8.6 MB
|
|
2181
|
+
*
|
|
2182
|
+
* 2. Filtered adapters (`isReal=false` but with at least one .db on disk)
|
|
2183
|
+
* become a single "Skipped (N): name1, name2, ..." disclosure line so
|
|
2184
|
+
* the user sees that fixtures/probes were intentionally hidden.
|
|
2185
|
+
*
|
|
2186
|
+
* Returns [] when `multiAdapter` is undefined OR when there are no real
|
|
2187
|
+
* adapters AND nothing skipped — keeping the renderer additive (Slice 3.5).
|
|
2188
|
+
*/
|
|
2189
|
+
function renderMultiAdapter(multiAdapter) {
|
|
2190
|
+
if (!multiAdapter)
|
|
2191
|
+
return [];
|
|
2192
|
+
const real = [];
|
|
2193
|
+
const skipped = [];
|
|
2194
|
+
for (const a of multiAdapter.perAdapter)
|
|
2195
|
+
(a.isReal ? real : skipped).push(a);
|
|
2196
|
+
if (real.length === 0 && skipped.length === 0)
|
|
2197
|
+
return [];
|
|
2198
|
+
const out = [];
|
|
2199
|
+
if (real.length > 0) {
|
|
2200
|
+
out.push("");
|
|
2201
|
+
out.push("Where it came from (tools you actually used — fixtures + probes filtered):");
|
|
2202
|
+
out.push("");
|
|
2203
|
+
// Column widths chosen so the demo render stays visually aligned even
|
|
2204
|
+
// for adapters with very long marketing names. Right-aligned numerics.
|
|
2205
|
+
const NAME_W = 16;
|
|
2206
|
+
const CAP_W = 10;
|
|
2207
|
+
const IDX_W = 10;
|
|
2208
|
+
const TOT_W = 16;
|
|
2209
|
+
out.push(` ${"Tool".padEnd(NAME_W)}${"Captures".padStart(CAP_W)}${"Indexed".padStart(IDX_W)}${"Total kept out".padStart(TOT_W)}`);
|
|
2210
|
+
// Sort by total kept out desc — biggest contributor first.
|
|
2211
|
+
const sorted = [...real].sort((a, b) => (b.dataBytes + b.rescueBytes) - (a.dataBytes + a.rescueBytes));
|
|
2212
|
+
for (const a of sorted) {
|
|
2213
|
+
const total = a.dataBytes + a.rescueBytes;
|
|
2214
|
+
// Em-dash for zero captures so the column reads "—" not "0".
|
|
2215
|
+
const captures = a.eventCount > 0 ? fmtNum(a.eventCount) : "—";
|
|
2216
|
+
const indexed = kb(a.dataBytes);
|
|
2217
|
+
const totalStr = kb(total);
|
|
2218
|
+
out.push(` ${adapterLabel(a.name).padEnd(NAME_W)}${captures.padStart(CAP_W)}${indexed.padStart(IDX_W)}${totalStr.padStart(TOT_W)}`);
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
2221
|
+
if (skipped.length > 0) {
|
|
2222
|
+
if (real.length > 0)
|
|
2223
|
+
out.push("");
|
|
2224
|
+
const names = skipped.map((a) => adapterLabel(a.name)).join(", ");
|
|
2225
|
+
out.push(` Skipped (${skipped.length}): ${names}`);
|
|
2226
|
+
out.push(" These adapters have DBs on disk but only test fixtures, dev skeletons,");
|
|
2227
|
+
out.push(" or detection probes — no real chat activity.");
|
|
2228
|
+
}
|
|
2229
|
+
return out;
|
|
2230
|
+
}
|
|
2231
|
+
/**
|
|
2232
|
+
* Render a FullReport as a visual savings dashboard designed for screenshotting.
|
|
2233
|
+
*
|
|
2234
|
+
* Design principles:
|
|
2235
|
+
* - Before/After comparison bar is the HERO — one glance = "wow"
|
|
2236
|
+
* - "tokens saved" is the number people share
|
|
2237
|
+
* - Per-tool breakdown shows what each tool SAVED, sorted by impact
|
|
2238
|
+
* - Project memory: category bars showing persistent data across sessions
|
|
2239
|
+
* - No: Pct column, category tables, tips, jargon
|
|
2240
|
+
*/
|
|
2241
|
+
export function formatReport(report, version, latestVersion, opts) {
|
|
2242
|
+
const lines = [];
|
|
2243
|
+
const duration = formatDuration(report.session.uptime_min);
|
|
2244
|
+
const lifetime = opts?.lifetime;
|
|
2245
|
+
const mcpUsage = opts?.mcpUsage;
|
|
2246
|
+
const conversation = opts?.conversation;
|
|
2247
|
+
const realBytes = opts?.realBytes;
|
|
2248
|
+
const multiAdapter = opts?.multiAdapter;
|
|
2249
|
+
// Real-adapter count drives the "across N AI tools" headline copy
|
|
2250
|
+
// (Slice 3.4) — we only call something a "tool you used" once it
|
|
2251
|
+
// passes the isReal filter inside getMultiAdapterLifetimeStats.
|
|
2252
|
+
const realAdapterCount = multiAdapter?.perAdapter.filter((a) => a.isReal).length ?? 0;
|
|
2253
|
+
// ── B3b Slice 3.4: opening tagline — runs in EVERY render path so the
|
|
2254
|
+
// multi-adapter headline appears regardless of which formatReport branch
|
|
2255
|
+
// executes (active session / fresh / per-conversation). Falls back to
|
|
2256
|
+
// "in Claude Code" when only one adapter qualifies as real, matching the
|
|
2257
|
+
// Mert-approved demo wording. Suppressed entirely without multiAdapter
|
|
2258
|
+
// so legacy single-adapter renders stay byte-identical (Slice 3.5).
|
|
2259
|
+
if (multiAdapter && realAdapterCount > 0) {
|
|
2260
|
+
const totalConvs = multiAdapter.totalSessions || lifetime?.totalSessions || 0;
|
|
2261
|
+
const sinceMs = lifetime?.firstEventMs ?? 0;
|
|
2262
|
+
const days = sinceMs > 0
|
|
2263
|
+
? Math.max(1, Math.round((Date.now() - sinceMs) / 86_400_000))
|
|
2264
|
+
: 0;
|
|
2265
|
+
const daySegment = days > 0 ? `Across ${days} day${days === 1 ? "" : "s"} ` : "";
|
|
2266
|
+
const convStr = totalConvs > 0
|
|
2267
|
+
? `you ran ${fmtNum(totalConvs)} conversation${totalConvs === 1 ? "" : "s"} `
|
|
2268
|
+
: "you ran ";
|
|
2269
|
+
let where;
|
|
2270
|
+
if (realAdapterCount >= 2) {
|
|
2271
|
+
where = `across ${realAdapterCount} AI tools`;
|
|
2272
|
+
}
|
|
2273
|
+
else {
|
|
2274
|
+
// Single real adapter — use its marketing label (defaults to Claude Code
|
|
2275
|
+
// if for some reason the only real adapter has no entry in adapterLabels).
|
|
2276
|
+
const onlyReal = multiAdapter.perAdapter.find((a) => a.isReal);
|
|
2277
|
+
where = `in ${onlyReal ? adapterLabel(onlyReal.name) : "Claude Code"}`;
|
|
2278
|
+
}
|
|
2279
|
+
lines.push(`${daySegment}${convStr}${where}.`);
|
|
2280
|
+
lines.push("");
|
|
2281
|
+
}
|
|
2282
|
+
// ── 5-section narrative ("kitap gibi") layout — Mert-approved
|
|
2283
|
+
// screenshot format produced when the MCP handler has wired
|
|
2284
|
+
// conversation + lifetime + multi-adapter through. Replaces the
|
|
2285
|
+
// legacy hero/contribution/auto-memory stack with the:
|
|
2286
|
+
// Opener
|
|
2287
|
+
// 1. Where you are now (datetime, /compact, timeline)
|
|
2288
|
+
// 2. What this chat captured (per-category bars)
|
|
2289
|
+
// 3. The receipt — getting wider
|
|
2290
|
+
// 4. For example: what would that cost?
|
|
2291
|
+
// 5. What context-mode learned about how you work
|
|
2292
|
+
// Footer
|
|
2293
|
+
// The opener block above (lines 1989-2005) is suppressed because
|
|
2294
|
+
// renderNarrative5Section emits its own.
|
|
2295
|
+
if (conversation && conversation.events > 0) {
|
|
2296
|
+
// Strip the previous-block opener — narrative renderer emits its own.
|
|
2297
|
+
if (lines.length > 0)
|
|
2298
|
+
lines.length = 0;
|
|
2299
|
+
const detected = detectLocaleAndTz();
|
|
2300
|
+
const cwd = opts?.cwd ?? process.cwd();
|
|
2301
|
+
const now = opts?.now ?? Date.now();
|
|
2302
|
+
const locale = opts?.locale ?? detected.locale;
|
|
2303
|
+
const tz = opts?.tz ?? detected.tz;
|
|
2304
|
+
lines.push(...renderNarrative5Section({
|
|
2305
|
+
conversation, lifetime, multiAdapter, realBytes,
|
|
2306
|
+
cwd, locale, tz, now, version, latestVersion,
|
|
2307
|
+
}));
|
|
2308
|
+
return lines.join("\n");
|
|
2309
|
+
}
|
|
2310
|
+
// ── Compute real savings ──
|
|
2311
|
+
const totalKeptOut = report.savings.kept_out + (report.cache ? report.cache.bytes_saved : 0);
|
|
2312
|
+
const totalReturned = report.savings.total_bytes_returned;
|
|
2313
|
+
const totalCalls = report.savings.total_calls;
|
|
2314
|
+
const grandTotal = totalKeptOut + totalReturned;
|
|
2315
|
+
const savingsPct = grandTotal > 0 ? (totalKeptOut / grandTotal) * 100 : 0;
|
|
2316
|
+
const tokensSaved = Math.round(totalKeptOut / 4);
|
|
2317
|
+
const ratioMultiplier = totalReturned > 0
|
|
2318
|
+
? Math.max(1, Math.round(grandTotal / Math.max(totalReturned, 1)))
|
|
2319
|
+
: 0;
|
|
2320
|
+
// ── Fresh session: no savings yet ──
|
|
2321
|
+
if (totalKeptOut === 0) {
|
|
2322
|
+
lines.push(`context-mode ${duration} ${totalCalls} calls`);
|
|
2323
|
+
lines.push("");
|
|
2324
|
+
if (totalCalls === 0) {
|
|
2325
|
+
lines.push("No tool calls yet. Use batch_execute or execute to start saving tokens.");
|
|
2326
|
+
}
|
|
2327
|
+
else {
|
|
2328
|
+
lines.push(`${kb(totalReturned)} entered context | 0 tokens saved`);
|
|
2329
|
+
}
|
|
2330
|
+
// Project memory + auto-memory + bottom line
|
|
2331
|
+
lines.push(...renderProjectMemory(report.projectMemory, { lifetime, multiAdapter, sessionTokensSaved: 0 }));
|
|
2332
|
+
lines.push(...renderMultiAdapter(multiAdapter));
|
|
2333
|
+
lines.push(...renderAutoMemory(lifetime));
|
|
2334
|
+
lines.push(...renderBottomLine(0, lifetime));
|
|
2335
|
+
// Footer
|
|
2336
|
+
lines.push("");
|
|
2337
|
+
const versionStr = version ? `v${version}` : "context-mode";
|
|
2338
|
+
lines.push(versionStr);
|
|
2339
|
+
if (version && latestVersion && latestVersion !== "unknown" && semverNewer(latestVersion, version)) {
|
|
2340
|
+
lines.push(`Update available: v${version} -> v${latestVersion} | ctx_upgrade`);
|
|
2341
|
+
}
|
|
2342
|
+
return lines.join("\n");
|
|
2343
|
+
}
|
|
2344
|
+
// ── Active session: visual savings dashboard ──
|
|
2345
|
+
// Line 1: Hero metric — the screenshottable number
|
|
2346
|
+
// Bug #6: include Opus pricing on the hero line for credibility.
|
|
2347
|
+
lines.push(`${fmtNum(tokensSaved)} tokens saved · ${savingsPct.toFixed(1)}% reduction · ${duration} · ~${tokensToUsd(tokensSaved)} saved (Opus)`);
|
|
2348
|
+
lines.push("");
|
|
2349
|
+
// Lines 2-3: Before/After comparison bars — the visual proof
|
|
2350
|
+
lines.push(`Without context-mode |${dataBar(grandTotal, grandTotal)}| ${kb(grandTotal)}`);
|
|
2351
|
+
lines.push(`With context-mode |${dataBar(totalReturned, grandTotal)}| ${kb(totalReturned)}`);
|
|
2352
|
+
lines.push("");
|
|
2353
|
+
// Value statement — the line people share
|
|
2354
|
+
// Bug #7: replace meaningless "3.0x" ratio with "3× longer sessions".
|
|
2355
|
+
if (ratioMultiplier >= 2) {
|
|
2356
|
+
lines.push(`${kb(totalKeptOut)} kept out of your conversation — ${ratioMultiplier}× longer sessions before compact.`);
|
|
2357
|
+
}
|
|
2358
|
+
else {
|
|
2359
|
+
lines.push(`${kb(totalKeptOut)} kept out of your conversation. Never entered context.`);
|
|
2360
|
+
}
|
|
2361
|
+
lines.push("");
|
|
2362
|
+
// Compact stats row
|
|
2363
|
+
const statParts = [`${totalCalls} calls`];
|
|
2364
|
+
if (report.cache && report.cache.hits > 0) {
|
|
2365
|
+
statParts.push(`${report.cache.hits} cache hits (+${kb(report.cache.bytes_saved)})`);
|
|
2366
|
+
}
|
|
2367
|
+
lines.push(statParts.join(" · "));
|
|
2368
|
+
// ── Per-tool breakdown (only if 2+ tools, sorted by saved) ──
|
|
2369
|
+
const activatedTools = report.savings.by_tool.filter((t) => t.calls > 0);
|
|
2370
|
+
if (activatedTools.length >= 2) {
|
|
2371
|
+
lines.push("");
|
|
2372
|
+
// Estimate per-tool saved using global savings ratio
|
|
2373
|
+
const toolRows = activatedTools.map((t) => {
|
|
2374
|
+
const returnedBytes = t.context_kb * 1024;
|
|
2375
|
+
const estimatedTotal = savingsPct < 100
|
|
2376
|
+
? returnedBytes / (1 - savingsPct / 100)
|
|
2377
|
+
: returnedBytes;
|
|
2378
|
+
const estimatedSaved = Math.max(0, estimatedTotal - returnedBytes);
|
|
2379
|
+
return { ...t, returnedBytes, estimatedSaved };
|
|
2380
|
+
}).sort((a, b) => b.estimatedSaved - a.estimatedSaved);
|
|
2381
|
+
// Compact table: tool name, calls, saved
|
|
2382
|
+
for (const t of toolRows) {
|
|
2383
|
+
const name = t.tool.length > 22 ? t.tool.slice(0, 19) + "..." : t.tool;
|
|
2384
|
+
lines.push(` ${name.padEnd(22)} ${String(t.calls).padStart(4)} calls ${kb(t.estimatedSaved).padStart(8)} saved`);
|
|
2385
|
+
}
|
|
2386
|
+
}
|
|
2387
|
+
// ── Parallel I/O — value-forward framing for concurrent batch tools.
|
|
2388
|
+
// Suppressed when no tool ran with max_concurrency > 1 (don't claim
|
|
2389
|
+
// parallelism we didn't deliver). Internal mcp__*__ namespace stripped
|
|
2390
|
+
// for user-facing readability.
|
|
2391
|
+
if (mcpUsage && mcpUsage.length > 0) {
|
|
2392
|
+
const concurrent = mcpUsage.filter((u) => u.median_concurrency != null && (u.max_concurrency ?? 1) > 1);
|
|
2393
|
+
if (concurrent.length > 0) {
|
|
2394
|
+
lines.push("");
|
|
2395
|
+
lines.push("Parallel I/O ✓ one call did the work of many — faster runs, lower bill, same answer.");
|
|
2396
|
+
for (const u of concurrent) {
|
|
2397
|
+
const name = u.tool_name.replace(/^mcp__.*?__/, "");
|
|
2398
|
+
lines.push(` ${name.padEnd(22)} ${u.calls} batches · ${u.median_concurrency} typical, ${u.max_concurrency} peak`);
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
// ── Project memory — persistent across sessions (Bug #3 + #5) ──
|
|
2403
|
+
lines.push(...renderProjectMemory(report.projectMemory, { lifetime, multiAdapter, sessionTokensSaved: tokensSaved }));
|
|
2404
|
+
// ── B3b Slice 3.2/3.3 — "Where it came from" per-adapter sub-block.
|
|
2405
|
+
// Sits under the lifetime memory block so the receipt-to-source flow is
|
|
2406
|
+
// visually contiguous (lifetime totals → which tools produced them).
|
|
2407
|
+
lines.push(...renderMultiAdapter(multiAdapter));
|
|
2408
|
+
// ── Auto-memory — Claude Code's preference learnings (Bug #4) ──
|
|
2409
|
+
lines.push(...renderAutoMemory(lifetime));
|
|
2410
|
+
// ── Bottom line — business value framing (Bug #8) ──
|
|
2411
|
+
lines.push(...renderBottomLine(tokensSaved, lifetime));
|
|
2412
|
+
// ── Footer ──
|
|
2413
|
+
lines.push("");
|
|
2414
|
+
const versionStr = version ? `v${version}` : "context-mode";
|
|
2415
|
+
lines.push(versionStr);
|
|
2416
|
+
if (version && latestVersion && latestVersion !== "unknown" && latestVersion !== version) {
|
|
2417
|
+
lines.push(`Update available: v${version} -> v${latestVersion} | ctx_upgrade`);
|
|
2418
|
+
}
|
|
2419
|
+
return lines.join("\n");
|
|
2420
|
+
}
|