diff --git a/.ai-run/guides/integration/external-integrations.md b/.ai-run/guides/integration/external-integrations.md index 03c9319b8..2924f8da6 100644 --- a/.ai-run/guides/integration/external-integrations.md +++ b/.ai-run/guides/integration/external-integrations.md @@ -300,6 +300,37 @@ Catalog-agnostic thin wrapper around the upstream `skills` npm CLI. Discovery, r --- +## Cursor Integration (analytics-only) + +Cursor is read, never managed: `analyticsOnly: true`, no npm package, no CLI command, no provider +mapping. `codemie analytics` discovers Cursor Agent conversations from Cursor's local stores — +`state.vscdb` (`composerHeaders` for discovery, `cursorDiskKV` for per-turn enrichment), +`~/.cursor/projects//agent-transcripts/`, and `~/.cursor/ai-tracking/ai-code-tracking.db` — +all read-only and all fail-soft. `CURSOR_HOME` relocates every one of them. Cursor sessions are +tagged `native-external` and appear only with `--include-external`. + +**Recent Cursor builds write zero `tokenCount` on bubbles, or omit it, while `toolFormerData` still +works** — so tool-call enrichment is reliable and token/cost enrichment is usually empty. The +supported way to recover real Cursor tokens and cost is the **dashboard usage export** +(`--cursor-usage-csv `, `src/agents/plugins/cursor/cursor.usage-csv.ts`) — a local file read +with no credential. `cursor-usage-loader.ts` converts it into `RawSessionData` + a canonical +`SessionCostIndex` — the OTEL-loader pattern — matching each event to the session whose activity +window contains it and rolling the rest up per day, so its tokens and cost reach every report +figure once. Those rows carry `costBasis: 'vendor-billed'` (Cursor's own billing, not a CodeMie +estimate). `Kind=Included` in that CSV is a billing category, not zero usage: verified +export rows marked `Included` carried 39,952,466 tokens and $25.25. Team Analytics is **not** the +answer here and never was. Such +sessions carry `usageUnavailableReason` and render as an em dash, never as `$0`, `Included`, or +"covered by subscription". Do not widen the default discovery max-age to harvest year-old bubbles +that still have tokens, and do not infer tokens from `contextTokensUsed`, transcript length, or +tool-call counts. When tokens *are* recovered under an unpriceable model (`default`/Auto), the cost +enricher estimates at a published Claude Sonnet rate, preserves the original model label, and marks +the session `usagePartial`. + +Full operational and developer guide: `docs/CURSOR_INTEGRATION.md`. `state.vscdb` is +undocumented VS Code/Cursor application state; `composerHeaders` is primary session discovery +and all reads are fail-soft. + ## Configuration Validation Validate provider config at startup; warn (not throw) on connectivity failures. `file:src/env/config-loader.ts:150-170` @@ -322,6 +353,7 @@ Validate provider config at startup; warn (not throw) on connectivity failures. | LiteLLM connection error | Proxy not running | `litellm --port 4000` | | OpenCode not found | Not installed | `codemie install opencode` | | OpenCode sessions not syncing | Metrics processing failed | `codemie opencode-metrics --discover --verbose` | +| No Cursor sessions in analytics | Cursor sessions are external | Re-run with `--include-external`; see `docs/CURSOR_INTEGRATION.md` | | Codex sessions stuck `status: active` | Hard kill skipped `onSessionEnd` | Auto-reconciled on next codex run via `codex.reconciliation.ts` | | Codex `money_spent` is 0 | Backend `cost_config` missing model entry | Add model pricing in backend `cost_config` | @@ -334,6 +366,7 @@ Validate provider config at startup; warn (not throw) on connectivity failures. - OpenCode plugin: `src/agents/plugins/opencode/` - Codex plugin: `src/agents/plugins/codex/` - Claude plugin: `src/agents/plugins/claude/` +- Cursor plugin: `src/agents/plugins/cursor/` (guide: `docs/CURSOR_INTEGRATION.md`) - MCP proxy: `src/mcp/` - Session adapters: `src/agents/core/session/` - Config loader: `src/env/config-loader.ts` diff --git a/.gitignore b/.gitignore index 8bd91e38e..6442dbd8b 100644 --- a/.gitignore +++ b/.gitignore @@ -89,3 +89,5 @@ docs/superpowers/tasks/*/code-review*.diff /docs/superpowers/resume/ /docs/superpowers/review-prompts/ /docs/codemie/analytics/ + +.scratch/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 8bedc6f89..7b92d7b92 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -128,7 +128,7 @@ Ask the user when: | Keywords | P0 Guide | P1 Guide | |---|---|---| | `plugin`, `registry`, `agent`, `adapter` | architecture | external-integrations | -| `claude`, `codex`, `gemini`, `opencode`, `pi`, `kimi`, `copilot`, `acp` | architecture | external-integrations | +| `claude`, `codex`, `gemini`, `opencode`, `pi`, `kimi`, `copilot`, `cursor`, `acp` | architecture | external-integrations | | `session`, `metrics`, `analytics`, `transcript`, `sync` | architecture | external-integrations | | `architecture`, `layer`, `structure`, `pattern` | architecture | development-practices | | `test`, `vitest`, `mock`, `coverage` | testing-patterns | development-practices | @@ -222,7 +222,8 @@ See `package.json` for exact dependency versions and `.ai-run/guides/architectur | `pi` | `pi/` | `@earendil-works/pi-coding-agent` | Redirects `PI_CODING_AGENT_DIR` to `/.pi/codemie/agent`; metrics via injected extension + run ledger | | `kimi` / `kimi-acp` | `kimi/` | `@moonshot-ai/kimi-code` | ACP variant prepends `acp` to argv | | `openwiki` | `openwiki/` | `openwiki` | Docs/wiki tool, not a chat agent; declarative-only adapter — `envMapping` feeds the profile's base URL/key/model to `OPENAI_COMPATIBLE_*`/`OPENWIKI_MODEL_ID`, SSO/JWT goes through the local proxy | -| `copilot-cli` | `copilot-cli/` | none | Analytics ingestion only — never installed or launched by CodeMie | +| `copilot-cli` | `copilot-cli/` | `@github/copilot` | Managed agent (installed, configured, and launched by CodeMie); session metrics + backend conversation sync via its own processors | +| `cursor` | `cursor/` | none | Analytics-only agent (`analyticsOnly: true`) — never installed or launched by CodeMie; reads Cursor's locally persisted agent transcripts, enriched read-only from Cursor's AI-tracking database, and surfaces them as external sessions (opt-in behind `--include-external`, like any session CodeMie did not launch). See `docs/CURSOR_INTEGRATION.md` | Not agent adapters, but injected runtime plugins under the same tree: `codemie-code-hooks/` (injected into `codemie-code` and `opencode`) and `reasoning-sanitizer/` (injected into `codemie-code`). diff --git a/docs/ANALYTICS-REPORT.md b/docs/ANALYTICS-REPORT.md index 04c43c77c..c289453b7 100644 --- a/docs/ANALYTICS-REPORT.md +++ b/docs/ANALYTICS-REPORT.md @@ -27,6 +27,11 @@ codemie analytics --report --report-format both # Include ALL local agent usage — also the sessions you ran outside CodeMie codemie analytics --report --open --include-external + +# Real Cursor tokens and cost — import a usage export from the Cursor dashboard +# (Cursor records no billable tokens locally; see "Cursor — why it needs a CSV") +codemie analytics --report --open --include-external \ + --cursor-usage-csv ~/Downloads/team-usage-events-....csv ``` > **If your question is "what did AI actually cost us?", you probably want `--include-external`.** @@ -55,11 +60,11 @@ The landing view. Gives every headline number at a glance: - **Sessions** — total count with wall-clock duration and turns per session - **Files & lines** — file operations, lines added/removed, net change - **Tool calls** — total calls and overall success rate -- **Estimated cost** — API-equivalent spend across priced sessions +- **Estimated cost** — API-equivalent spend across priced sessions; `—` when nothing in view is measurable (see [When cost and tokens show `—`](#unknown-cost)) Below the headline KPIs, two supplementary sections appear: -**Token usage** breaks down input tokens, output tokens, cache writes (tokens written to the prompt cache), and cache reads (tokens served back from cache), plus a combined total. +**Token usage** breaks down input tokens, output tokens, cache writes (tokens written to the prompt cache), and cache reads (tokens served back from cache), plus a combined total. When no session in view has any token signal, these show `—` with a note explaining that local token telemetry is absent — see [Analytics-only agents](#analytics-only-agents). **Efficiency summary** shows cache-read cost, bloat percentage (cache reads as a share of total spend), dead session count, and average context per call — all linking to the Efficiency tab for full detail. @@ -143,7 +148,7 @@ Metrics shown are generic per-session metrics aggregated by source — **no fram ![Cost](assets/analytics-report-cost.png) -Estimated API-equivalent spend (token usage × model pricing). If you use Claude on a subscription, you don't pay per token — this view shows the equivalent metered-API value for benchmarking against alternatives or tracking consumption trends. +Estimated API-equivalent spend (token usage × model pricing) — what the same usage would have been metered at through the API. It is a benchmark for comparing agents and tracking consumption trends, **not an invoice**, and it is never presented as one. > **Why this reads lower than the terminal's live cost.** Cost here is counted **per API response**: each response's token usage is priced exactly once, matching how the provider bills and how Claude Code's own telemetry (`cost.usage`) records it. A single response is written to the native log across several lines (e.g. a `thinking` line and a `tool_use` line, each repeating the same usage), and the live statusline in the terminal sums those lines — so it over-counts multi-part responses and shows a higher number. For sessions heavy on extended thinking plus tool use, expect the report total to sit noticeably below the live statusline; the report figure is the authoritative, de-duplicated one. @@ -155,6 +160,35 @@ Key elements: - **Cost by model** — horizontal bar chart of USD spend per model - **Most expensive sessions** — top 10 ranked by cost, with per-session token breakdown (input, output, cached) + + +#### When cost and tokens show `—` + +A dash means **unmeasurable**, not free and not zero. It appears when a session left no readable +local token signal — the native log was rotated away, or the agent records transcripts but no token +telemetry at all (see [Analytics-only agents](#analytics-only-agents)). + +The report will not paper over that gap: + +- Unmeasurable sessions render `—` for cost and for every token field, never `$0.00` or `0`. A + structural zero and a genuine zero look different because they mean different things. +- Aggregates dash out only when **nothing** in the group was measurable. A mixed group still shows + the real sum of whatever was measured, so known data is never hidden by unknown peers. +- No cell is ever labelled `Included` or "covered by subscription". Your plan's billing status is + not something the local logs record, and absence of a token count is not evidence of free usage. + +#### Estimated costs and the partial badge + +When a session has real recovered tokens but its model is not in the pricing table — Cursor's +`Auto`, or simply a model newer than the table — the cost is estimated at a published **Claude +Sonnet** API rate rather than dropped to `$0`. Such sessions: + +- keep their own model label (`Auto` stays `Auto`; nothing is renamed to Sonnet), +- are marked **partial usage** in the session detail modal, and +- are still listed under "models with no published price" in the Cost coverage banner. + +Treat any partial figure as an understated floor, not a measurement. + --- ### Sessions @@ -235,6 +269,7 @@ CodeMie merges two sources to give the most complete picture: 1. **Tracked sessions** — metrics written by the CodeMie hooks during sessions CodeMie launched 2. **Native agent logs** — transcripts left on disk by `claude`, `codex`, `gemini`, `pi`, and `copilot`, discovered automatically and deduped against tracked sessions +3. **Analytics-only agents** — agents CodeMie never launches and only reads. `cursor` is the one today: its conversations are read from Cursor's own local stores and surfaced like any other external session. See [Cursor Integration](CURSOR_INTEGRATION.md) and [Analytics-only agents](#analytics-only-agents) below. Pass `--no-scan-native` to disable native-log discovery and use only CodeMie-tracked sessions. @@ -242,6 +277,19 @@ Discovery looks back as far as your date filter requires: with `--from` or `--la Cost enrichment requires the native log to read per-turn token data. Sessions where the log has already been rotated or deleted will appear with `—` cost; the **Coverage** section in the Cost view shows exactly which sessions are priced. + + +### Analytics-only agents (Cursor) — expect no token counts + +An *analytics-only* agent is one CodeMie never launches and only reads. `cursor` is the only one +today, and it is the single case where local files cannot supply tokens or cost: recent Cursor +builds record no billable token counts on disk, so Cursor sessions show real turns, tool calls and +file activity but `—` for cost and every token field (see +[When cost and tokens show `—`](#unknown-cost)). + +That gap has a supported fix, and Cursor has a section of its own because none of it applies to any +other agent: **[Cursor — why it needs a CSV, and how to import it](#cursor-usage-csv)**. + ### Session provenance — and why some sessions are hidden @@ -262,7 +310,7 @@ That default is the right one for adoption reporting and the **wrong** one for c codemie analytics --report --open --include-external ``` -**This is the flag that shows all of your local agent usage.** GitHub Copilot CLI sessions are included in the gate, so they too are absent from the default report. +**This is the flag that shows all of your local agent usage.** GitHub Copilot CLI sessions are included in the gate, so they too are absent from the default report. Cursor sessions are too — CodeMie never launches Cursor, so *every* Cursor session is external; see [Cursor Integration](CURSOR_INTEGRATION.md). Two things to know before you rely on the wider number: @@ -271,7 +319,159 @@ Two things to know before you rely on the wider number: `--include-external` applies to the default local-session source only. The `analytics otel` subcommand does not accept it — an OTEL events file has no notion of CodeMie ownership. -### OTEL events file (`analytics otel`) + + +## Cursor — why it needs a CSV, and how to import it + +Cursor is the one agent in this report that cannot be measured from local files alone. This +section explains why, and what to do about it. Everything here is specific to Cursor; no other +agent needs any of it. + +### Why Cursor is different + +Two things set Cursor apart from every other agent CodeMie reads: + +1. **CodeMie never launches it.** Cursor is *analytics-only*: there is no `codemie-cursor` command + and no npm package CodeMie installs. Its conversations are read from Cursor's own local stores, + read-only. Because CodeMie never launched them, *every* Cursor session is external — so they + appear only with `--include-external` (see [Session provenance](#session-provenance)). +2. **Recent Cursor builds record no billable token counts locally.** They write zero for the token + field, or omit it entirely, while tool-call data keeps working perfectly. This is Cursor's own + behaviour, not a CodeMie bug or a parsing gap. + +The consequence of (2) is that Cursor sessions arrive with real turns, real tool calls, real file +activity — and `—` for every token and cost cell. + +### Why the local data cannot be fixed + +The obvious question is whether CodeMie is simply looking in the wrong place. It is not, and this +was measured rather than assumed: + +- Of **469** discovered Cursor sessions on one machine, **0** carried any token signal; 24 carried + tool calls. +- Every Cursor store was checked — the state database, the per-session chat store, the AI-tracking + database, and the transcript directories. No *local* store holds the recent numbers. +- The conversations that *do* still hold token counts were all roughly a year old, and are no + longer discoverable at all. + +CodeMie will not manufacture the figure from context-window fill, transcript length, or tool-call +counts. Those correlate with usage but are not billable tokens, and presenting them as such would +trade an honest blank for a confident wrong number. So the dashes stay — see +[When cost and tokens show `—`](#unknown-cost) for the general rule. + +### The numbers do exist — in Cursor's dashboard + +What Cursor stops writing to disk, it still bills you for, and its dashboard exports that ledger. +**Usage → Export** produces a `team-usage-events-*.csv` with per-event input, cache-write, +cache-read, output and total tokens, usually with a `Cost` column. + +Importing it is a plain file read: **no credential, no network call.** + +1. In Cursor, open **Usage** and click **Export** for the period you want. +2. Pass the downloaded file: + +```bash +codemie analytics --report --open --include-external \ + --cursor-usage-csv ~/Downloads/team-usage-events-....csv +``` + +The flag works with or without `--report`. Either way the command first prints what it imported: + +``` + Imported 61 Cursor usage event(s): 39,952,466 tokens, $25.25 (Cursor's own billing). + 20 attributed to a Cursor session; 41 in 3 daily rollup(s) — no session window matched them unambiguously. +``` + +### How the import reaches the rest of the report + +There is **no separate Cursor section or tab.** The import is converted into ordinary sessions and +ordinary cost rows before anything is rendered, so it flows into Overview, Cost, Tools & Models, +Coverage and the session table by construction — the same path every other agent's data takes. + +Export rows carry no session id, so they cannot be joined to a conversation by key. CodeMie matches +them by **time** instead: + +| Case | Where the event lands | +|---|---| +| Its timestamp falls inside exactly one Cursor session's activity window | That session — its empty usage is overwritten with the real figures | +| No session window contains it | A `Cursor usage — ` daily rollup, which behaves as an ordinary session | +| Several overlapping windows contain it | The same daily rollup — CodeMie will not pick between two candidates | + +That last row is the important one: an ambiguous event goes to the rollup rather than to a guess. +Either way **every event is counted exactly once**, so the report's Cursor totals equal the +export's own totals. + +**These are Cursor's figures, not CodeMie's estimate.** Every cost line derived from the export is +tagged `costBasis: "vendor-billed"` in the report payload, and the Cost view says so in its banner +whenever such a row is on screen. Every other cost figure in the report is CodeMie's own +calculation from tokens × a pricing table. + +### Reading the export correctly + +> **`Kind=Included` does not mean free.** `Included` is Cursor's *billing category* — "covered by +> your plan" — not a statement that the usage was unmetered. In a real export, all 61 events were +> `Included` and together carried **39,952,466 tokens and $25.25 of cost**. CodeMie counts the +> tokens and the `Cost` column regardless of `Kind`, and never uses the word "Included" as a cost +> label anywhere in the report. + +Other things worth knowing about the file: + +- **Two shapes exist.** Most exports end with a `Cost` column; at least one variant ships + `Requests` instead and carries no cost at all. Both import. When `Cost` is absent, tokens are + still counted in full and the cost contribution is zero. +- **`Cost` is not always a number.** Some rows read `Free`. Those contribute zero rather than + corrupting the total. +- **Rows are filtered to you** *when the export names users at all*. The `User` column is matched + against your configured CodeMie email, which is frequently *not* the address on your Cursor + account — override with `--cursor-usage-user `. An export with no `User` column is + imported whole, since there is no one else's data in it to exclude. If the filter matches + nothing, CodeMie warns and lists the addresses actually present rather than importing silence. + + + +### Downloading the export automatically (optional, unsupported) + +If clicking Export each time is tedious, CodeMie can fetch the same CSV. This is **opt-in and +unsupported**, and file import above remains the recommended path. + +```bash +export CURSOR_USAGE_EXPORT_URL='' +export CURSOR_SESSION_TOKEN='::' # the WorkosCursorSessionToken cookie +codemie analytics --report --open --cursor-usage-fetch +``` + +**All three are required** — the flag, the URL, and the cookie. Any one missing means no request +is made at all. + +Why it looks like this rather than "just work": + +- **CodeMie ships no endpoint URL.** Cursor's dashboard export is undocumented and can change or + disappear without notice. Baking in such a URL means quietly breaking later; supplying it + yourself means you know exactly what is being called. Read it off your browser's Network tab + when you click Export. +- **You supply the cookie.** It is a browser cookie for cursor.com, so on a signed-in machine it + lives in Cursor's Chromium cookie jar encrypted against your OS keychain. CodeMie does not + decrypt that — prying a credential out of another application's protected store is not + something an analytics command should do. It makes a harmless read-only check of Cursor's own + plaintext state database first, and otherwise expects `CURSOR_SESSION_TOKEN`. +- **Authentication is the session cookie, never an API key.** An admin `crsr_` Team API key is a + different credential for a different API and is rejected here. +- **The token is never logged.** Failures name the status code and the endpoint host only. Run + with `CODEMIE_DEBUG=true` to see them. + +The response goes through the exact same parser as the file import, so a downloaded export and a +hand-saved one can never be interpreted differently. Any failure — 401, 403, a changed endpoint, a +sign-in redirect returning HTML — omits the section and leaves the rest of the report intact. + +> **What about the Cursor Team Analytics API?** CodeMie does not use it. Its documented endpoints +> return no token or cost field at any tier, so it cannot answer "what did Cursor cost?", and it +> requires an enterprise-admin key most users cannot obtain. An implementation exists on the +> `feature/cursor-team-analytics-untested` branch but is **not shipped**, because we have no admin +> account to verify it against. + +--- + +## OTEL events file (`analytics otel`) As an alternative to the local-session sources above, the `analytics otel` subcommand builds the same report from a **flattened OTEL events file** (`otel-events.jsonl`) — for example, telemetry exported from a fleet or CI environment rather than the current machine's history. @@ -287,6 +487,154 @@ With this source, **cost is authoritative**: it is read directly from each event --- +## Verifying it works + +Concrete checks you can run yourself, in the order that builds confidence fastest. Each says what +to run, what you should see, and what it means if you see something else. + +### 1. Does a report build at all? + +```bash +codemie analytics --report --report-output /tmp/check.html +``` + +**Expect:** `✓ HTML report written to: /tmp/check.html`, preceded by terminal tables. Open it — the +sidebar should list Overview through Sessions. + +If you get *"No sessions found matching the specified criteria"*, you have no CodeMie-launched +sessions in range. Add `--include-external` (below) or widen with `--last 90d`. + +### 2. Do your non-CodeMie sessions appear? + +```bash +codemie analytics --report --open --include-external --last 30d +``` + +**Expect:** a higher session count than step 1, and more agents in the top filter bar. This is the +flag that answers "what did AI actually cost me?" — see +[Session provenance](#session-provenance). + +### 3. Cursor sessions and the honest empty state + +With `--include-external`, Cursor sessions appear. To see the behaviour that surprises people +most, **deselect every agent except Cursor** in the top bar. + +**Expect:** Overview's Input/Output/Total token KPIs and Est. cost all go to `—`, with a note +saying local token telemetry is absent — *and the tool-call tables keep working*. (This is the +state *without* a usage export; step 4 is how those dashes become numbers.) + +That is correct, not a bug: recent Cursor builds record no billable token counts locally. If you +instead see `$0.00`, `0`, or the word `Included` anywhere, that **is** a bug — those were removed +deliberately (see [When cost and tokens show `—`](#unknown-cost)). + +### 4. Real Cursor tokens and cost, from the usage export + +This is the check that turns those dashes into numbers. + +1. In Cursor, open **Usage** → **Export**, choosing a period you actually worked in. +2. Run: + +```bash +codemie analytics --report --open --include-external \ + --cursor-usage-csv ~/Downloads/team-usage-events-*.csv +``` + +**Expect**, before any table is printed: + +``` + Imported 61 Cursor usage event(s): 39,952,466 tokens, $25.25 (Cursor's own billing). + 20 attributed to a Cursor session; 41 in 3 daily rollup(s) — no session window matched them unambiguously. +``` + +Those two lines print for **every** run that passes the flag, report or not — so +`codemie analytics --cursor-usage-csv f.csv` on its own tells you what it imported. + +In the report, expect those figures folded into the ordinary views — there is no separate Cursor +tab to look in: + +- Overview's **Est. cost** and Cost's **Total est. cost** agree, both including the import. +- Cursor's models (`auto`, `cursor-grok-*`, …) appear in **both** "Cost by model" and + "Tokens by model", spelled the same way in each. +- **Coverage by agent** reports Cursor as priced for the sessions the export reached. +- Deselecting every agent except Cursor still shows real figures — the rollups are Cursor + sessions like any other. + +Sanity-check the totals against the file itself. (This handles both export shapes, the `Free` +cost cells, and the CRLF line endings the export ships — a naive `awk` over the last column +silently sums `Requests` on the no-`Cost` variant and prints a plausible, wrong dollar figure.) + +```bash +python3 - ~/Downloads/team-usage-events-....csv <<'EOF' +import csv, re, sys +rows = list(csv.DictReader(open(sys.argv[1], newline='', encoding='utf-8-sig'))) + +def num(v): + m = re.search(r'-?\d+(?:\.\d+)?', (v or '').replace(',', '')) + return float(m.group()) if m else 0.0 + +tok = sum(int(r['Total Tokens'] or 0) for r in rows) +if rows and 'Cost' in rows[0]: + print(f"{len(rows)} events, {tok:,} tokens, ${sum(num(r['Cost']) for r in rows):.2f}") +else: + print(f"{len(rows)} events, {tok:,} tokens (this export has no Cost column)") +EOF +``` + +The report's Events, Total tokens and Cost KPIs should match that line exactly — and so should the +`Imported …` line the command printed, since each event is counted once and only once. **Every row +saying `Included` still contributes** — that word is a billing category, not zero usage. + +**If nothing was imported**, the `Imported …` line is absent and the terminal names the check that +failed: + +| Message | Meaning | Fix | +|---|---|---| +| `Could not read a Cursor usage export from …` | Wrong path, or not a usage CSV | Check the path; confirm the header starts `Date,User,…` | +| `matched no rows for ` + `The export contains: …` | Your Cursor account email differs from your CodeMie one | Re-run with `--cursor-usage-user ` | +| Tokens imported but cost is `$0.00` | This export variant has no `Cost` column (it ships `Requests`) | Expected; re-export from a period Cursor priced, or read the token columns | + +### 5. Optional: fetching that export automatically + +Only worth trying after step 4 works. It is opt-in and unsupported — see +[Downloading it automatically](#cursor-usage-fetch). + +You need the endpoint URL, which CodeMie deliberately does not ship. To find it: open the Cursor +dashboard **Usage** page in your browser, open DevTools → **Network**, click **Export**, and copy +the request URL of the CSV download. The session cookie is `WorkosCursorSessionToken` in the same +request's headers (`::`). + +```bash +export CURSOR_USAGE_EXPORT_URL='' +export CURSOR_SESSION_TOKEN='::' +codemie analytics --report --open --include-external --cursor-usage-fetch +``` + +**Expect:** the same imported figures as step 4, without having saved a file. + +To prove the gate rather than the happy path, unset either variable and re-run: no request should +be made at all. `CODEMIE_DEBUG=true` prints the outcome per attempt — status code and endpoint +host only, never your token. + +| Symptom | Meaning | +|---|---| +| `usage export fetch skipped (needs the opt-in flag, an export URL, and a session cookie)` | One of the three is missing — the gate working | +| `returned HTTP 401` / `403` | Cookie expired or wrong; re-copy it from a fresh request | +| `was not a usage CSV` | The endpoint returned a sign-in page, not an export | + +### 6. Regression checks, if you are changing this code + +```bash +npm run typecheck && npm run lint +npx vitest run src/agents/plugins/cursor/__tests__/ # Cursor plugin, incl. CSV + fetch +npx vitest run --project unit --project cli # everything +``` + +The CSV tests assert against a fixture copied verbatim from a real export — 61 events, +39,952,466 tokens, $25.25, including its two `Free` cost cells — so a parser regression shows up +as a changed total rather than as a vague failure. + +--- + ## CLI Reference ``` @@ -311,6 +659,16 @@ Source flags: --no-scan-native Skip native-log discovery (CodeMie-tracked sessions only) --include-external Also count local sessions CodeMie did not launch (see "Session provenance"; requires native scanning) + --cursor-usage-csv Import a Cursor usage-events CSV (Cursor dashboard + -> Usage -> Export) for REAL Cursor tokens and cost. + No network call. Anyone can use this. + --cursor-usage-user Which User column value to keep from the export + (default: your configured CodeMie email) + --cursor-usage-fetch Download the usage export instead of passing a file. + OPT-IN and UNSUPPORTED: makes a NETWORK CALL to an + undocumented endpoint. Requires CURSOR_USAGE_EXPORT_URL + and CURSOR_SESSION_TOKEN. File import is the + recommended path. (see "Downloading it automatically") Other flags: -v, --verbose Session-level breakdown in the terminal output @@ -318,6 +676,14 @@ Other flags: -o, --output Output path for --export ``` +**Environment variables** + +| Variable | Effect | +|---|---| +| `CURSOR_USAGE_EXPORT_URL` | Cursor dashboard usage-export endpoint, for `--cursor-usage-fetch`. No default — CodeMie ships no undocumented URL. | +| `CURSOR_SESSION_TOKEN` | Your `WorkosCursorSessionToken` cookie, `::`, for `--cursor-usage-fetch`. Never logged. | +| `CODEMIE_DEBUG=true` | Verbose per-source discovery and enrichment logging, including the usage-export fetch outcome. | + **Every filter and source flag governs the terminal output and the HTML report alike.** There is no report-only or terminal-only filtering: `--include-external`, `--no-scan-native`, and the date/project/agent filters all decide which sessions the command sees, and both outputs are rendered from that same set. The date filters control which sessions are **embedded** in the report; the client-side range presets (Today / 7d / 30d / 90d) then let the report viewer narrow further within that data. diff --git a/docs/CURSOR_INTEGRATION.md b/docs/CURSOR_INTEGRATION.md new file mode 100644 index 000000000..2667152b8 --- /dev/null +++ b/docs/CURSOR_INTEGRATION.md @@ -0,0 +1,297 @@ +# Cursor Integration + +How CodeMie reads Cursor usage into `codemie analytics`, what it can and cannot know, and what +to do when the numbers look wrong. + +## Overview + +Cursor is CodeMie's first **analytics-only** agent (`analyticsOnly: true` in +`src/agents/plugins/cursor/cursor.plugin.ts`). CodeMie never installs, configures, updates or +launches Cursor, and Cursor is absent from every management surface — `codemie install`, +`codemie update`, `codemie doctor`, first-run setup. There is no npm package, no CLI command and +no provider mapping. The plugin exists solely to hand the agent registry a session adapter that +reads what Cursor has already written to disk. + +Because CodeMie never launches Cursor, no Cursor session carries a CodeMie ownership marker, so +every Cursor session is tagged `native-external` and is **hidden until you pass +`--include-external`** — the same gate that applies to any agent run outside CodeMie (see +[Session provenance](ANALYTICS-REPORT.md#session-provenance)): + +```bash +codemie analytics --report --open --include-external +``` + +All reads are strictly read-only and fail-soft. CodeMie never writes to, migrates or locks a +store Cursor owns, and no Cursor problem — missing data, corrupt database, schema change — can +fail an analytics run for the other agents. + +## Data locations and structure + +Cursor writes to two unrelated trees — `~/.cursor` and the editor's own app-data directory — and CodeMie reads four sources across them. `CURSOR_HOME` overrides both (see +[Environment configuration](#environment-configuration)). + +| Source | Path | Supplies | +|---|---|---| +| **`composerHeaders`** (primary discovery) | `state.vscdb` → `composerHeaders` table | one row per agent conversation, keyed `composerId`: project path (`workspaceIdentifier.uri.fsPath`), branch (`activeBranch.branchName` / `createdOnBranch`), created/updated timestamps, `totalLinesAdded` / `totalLinesRemoved` / `filesChangedCount` | +| **Agent transcripts** (secondary) | `~/.cursor/projects//agent-transcripts//.jsonl` | role-tagged prompt/response text, `tool_use` blocks, `turn_ended` markers, a human-readable `` on each prompt | +| **AI-tracking database** (enrichment) | `~/.cursor/ai-tracking/ai-code-tracking.db` → `ai_code_hashes` (`conversationId`, `fileName`, `model`, `timestamp`, `source`) | model, edited file paths, first/last edit time, joined on `conversationId`; only `source = 'composer'` rows are the agent's work — `human` rows are the user's own edits | +| **`cursorDiskKV`** (enrichment) | `state.vscdb` → `cursorDiskKV`, keys `bubbleId::` | per-tool success/failure counts (`toolFormerData.status` / `.name`) and a sparse `tokenCount` | + +`state.vscdb` is the VS Code-derived *application* state store, so it lives under the OS +app-data directory rather than `~/.cursor`: + +| Platform | `state.vscdb` | +|---|---| +| macOS | `~/Library/Application Support/Cursor/User/globalStorage/state.vscdb` | +| Windows | `%APPDATA%\Cursor\User\globalStorage\state.vscdb` (falling back to `~/AppData/Roaming/Cursor/...` when `%APPDATA%` is unset) | +| Linux | `~/.config/Cursor/User/globalStorage/state.vscdb` | + +Everything joins on one identifier: **`composerId`**, which is also the transcript's directory +and file name and `ai_code_hashes.conversationId`. Discovery unions the header ids with the +transcript ids, so a conversation with a header but no transcript (the common case) and a +transcript with no header (rare — schema drift, a pruned header row) both produce a session row. +Headers marked `isDraft: true` are conversations that were never started and are excluded. + +`state.vscdb` is undocumented and can change in any Cursor release; reads are read-only and +fail-soft so a missing, locked, or drifted store never fails analytics for other agents. + +### What Cursor sessions can and cannot report + +- **Activity window** prefers the header's own timestamps, then the tracking database's first/last + edit, then the transcript's prompt stamps, and only then the transcript file's birthtime/mtime. + File times are last because a conversation resumed days later would otherwise report a span of + days instead of minutes. +- **Messages carry no per-message timestamps** on purpose, so the loader falls back to the + session window rather than a fabricated per-message clock. +- **Model** comes from the tracking database. Cursor writes the literal `default` when the user + delegated model choice; that is reported as **`Auto`** — Cursor's own word for it — never as + whatever model Cursor happens to default to. +- **Tokens and cost** come from `cursorDiskKV`'s sparse per-bubble `tokenCount`. When a session has + no token signal at all, `usageUnavailableReason` is set and the report renders tokens and cost as + **unmeasurable** — an em dash, not a confident zero and not a claim that the usage was free. See + [Expect no token counts from recent Cursor builds](#expect-no-token-counts-from-recent-cursor-builds) + before reading anything into a Cursor cost figure. + +### Expect no token counts from recent Cursor builds + +**Recent Cursor builds write zero `tokenCount` on bubbles, or omit the field entirely, while +`toolFormerData` keeps working.** Tool-call success/failure enrichment is therefore reliable and +token/cost enrichment is usually empty. This is the normal, expected shape — not a CodeMie bug, +not a schema-drift failure, and not something `--include-external` or a wider `--max-age` will fix. + +Verified on one operator machine (2026-09-05): of 469 discovered Cursor sessions, **0** carried any +token signal and **24** carried tool calls. Composers with a nonzero `tokenCount` existed only +354–408 days back and no longer appeared in `composerHeaders` at all. Widening discovery age to +harvest those year-old bubbles is explicitly *not* the fix: it would resurface stale conversations +to manufacture a token total that says nothing about recent work. + +What this means when reading a report: + +- A Cursor-only view (for example after deselecting every other agent in the top bar) will show + **dashes** for Input/Output/Total tokens and Est. cost, plus a note that local token telemetry is + absent. That is the filter working correctly on absent data, not a broken agent chip. +- Cost cells for such sessions are **never** labelled `Included` or "covered by subscription". + CodeMie's cost column is an API-equivalent estimate, not a bill, and a missing local token signal + is not evidence that the usage was free. +- When tokens *are* recovered but the model is `Auto`/`default` (or otherwise absent from the price + table), the session is estimated at a published Claude Sonnet API rate, keeps its own model label, + and is badged as partial. Treat it as an understated floor. +- The Enterprise Team Analytics API does **not** close this gap and is **not integrated**: none of + its documented endpoints returns token or cost fields at any tier, and it needs an admin-scoped + key an ordinary team member cannot obtain. See + [Cursor Enterprise Team Analytics API](../.ai-run/guides/integration/external-integrations.md#cursor-enterprise-team-analytics-api-not-integrated). + +Nothing here is inferred from `contextTokensUsed`, transcript text length, or tool-call counts. +Those correlate with usage but are not billable token counts, and presenting them as such would +trade an honest blank for a confident wrong number. + +### The usage export does have the numbers + +The gap above is *local*. Cursor's dashboard still exports the billable ledger: **Usage → Export** +produces a `team-usage-events-*.csv` carrying per-event input, cache-write, cache-read, output and +total tokens, usually with a `Cost` column. Import it with `--cursor-usage-csv ` (no network +call, no credential) and CodeMie converts it into ordinary sessions, so its tokens and cost reach +every figure in the report. There is no separate Cursor tab: the import becomes ordinary sessions, +so it lands in Overview, Cost, Tools & Models and Coverage like any other agent's data. + +Two facts that decide how it must be read: + +- **`Kind=Included` is a billing category, not zero usage.** It means "covered by your plan". In a + verified 2026-09-05 export, all 61 events were `Included` and together carried 39,952,466 tokens + and $25.25 of cost. Treating `Included` as free would discard the only accurate Cursor figures + available, so CodeMie counts tokens and `Cost` regardless of `Kind` — and never uses "Included" + as a cost label in the UI. +- **Two export shapes exist.** Most end with a `Cost` column; at least one variant ships `Requests` + instead and has no cost at all. Both parse; the section dashes the money and says why when `Cost` + is missing. Some `Cost` cells also read `Free` and contribute zero. + +Export rows carry no `composerId`, so they are matched on **time** instead: an event whose +timestamp falls inside exactly one Cursor session's activity window is attributed to that session +and overwrites its empty usage. An event that no window contains, or that several overlapping +windows contain, goes to a `Cursor usage — ` daily rollup — CodeMie refuses to choose between +two candidate sessions, the same way it refuses to guess an ambiguous project slug. Either way each +event is counted exactly once, so the report's totals equal the export's own. + +Every cost line this produces is tagged `costBasis: "vendor-billed"`: Cursor billed that amount and +CodeMie recorded it. Every other cost line in the report is CodeMie's estimate from a pricing +table, and the report keeps that distinction visible. + +`--cursor-usage-fetch` can download the same CSV instead, but it is **opt-in and unsupported**: it +needs `CURSOR_USAGE_EXPORT_URL` (CodeMie ships no undocumented endpoint) and `CURSOR_SESSION_TOKEN` +(the `WorkosCursorSessionToken` browser cookie, `::`) on top of the flag. The cookie is +never read out of Cursor's keychain-encrypted Chromium jar and never logged; authentication is that +cookie, never a `crsr_` admin key. Both paths end in the same parser. File import remains the +supported route. + +### Database schema and versioning + +`state.vscdb` and `ai-code-tracking.db` are Cursor-internal and undocumented; there is no schema +version to read and no compatibility promise. CodeMie therefore pins nothing and asserts nothing: +each query names the tables and columns it needs, and anything else — a renamed table, a dropped +column, a changed row shape — degrades to "that source contributed nothing" for this run. +`composerHeaders` rows in particular are handled in both observed shapes (flat columns, and the +VS Code-typical `key TEXT, value TEXT` pair with a JSON blob in `value`, whose key may itself carry +a `:` form). `cursorDiskKV` values are JSON blobs holding `toolFormerData` +(`name`, `status` — one of `completed` / `error` / `cancelled` / `loading`) and an optional +`tokenCount` (`inputTokens`, `outputTokens`). + +There is no version marker in either database, so CodeMie cannot detect which Cursor release wrote +a schema and does not try. If you need to correlate drift with a release, the Cursor version is in +Cursor's own About dialog; pair it with the `[cursor] ... unusable` debug line naming the table or +column that moved (see [Database schema drift](#database-schema-drift-after-a-cursor-update)). + +## Environment configuration + +| Variable | Effect | +|---|---| +| `CURSOR_HOME` | Overrides `~/.cursor`. Also relocates `state.vscdb` to `$CURSOR_HOME/User/globalStorage/state.vscdb`, mirroring its real layout relative to Cursor's app-data root. Unset (the default) uses `~/.cursor` plus the per-OS app-data path above. | +| `CODEMIE_DEBUG=true` | Enables the `[cursor]` debug logging described under [Logging and debugging](#logging-and-debugging). | + +`CURSOR_HOME` mirrors `COPILOT_HOME` in the Copilot CLI plugin and is what lets the whole +ingestion path be driven against a fixture tree in tests. + +### When Cursor is not installed + +No Cursor home, an empty Cursor home, no `state.vscdb` and no tracking database all yield **zero +Cursor sessions and no error**. Analytics for every other agent is unaffected. Nothing about the +report changes except that Cursor does not appear in it. + +## Troubleshooting + +### Empty analytics results while you are actively using Cursor + +1. **You did not pass `--include-external`.** This is the overwhelmingly common cause. Cursor + sessions are hidden by default because CodeMie did not launch them. Re-run with + `codemie analytics --report --open --include-external`. +2. **Native scanning is off.** `--no-scan-native` turns off native-log discovery for *every* agent, + including the discovery `--include-external` asks for, so the two flags together add nothing. +3. **Your date filter excludes them.** Discovery looks back only as far as `--from` / `--last` + requires. +4. **A non-default Cursor location.** If Cursor stores data elsewhere, point `CURSOR_HOME` at it. +5. **`state.vscdb` is not where CodeMie looks.** Confirm the per-OS path above exists; run with + `CODEMIE_DEBUG=true` and look for the `[cursor]` lines naming the paths that were tried. + +### Missing or corrupt Cursor data + +Each source degrades independently, so a session is built from whatever remains: + +| Missing / broken | Result | +|---|---| +| Transcript file | Header-only row: project, branch, timing, line counts, model — but no prompt/response text | +| `composerHeaders` row | Transcript-only row: project path guessed by walking the directory slug, branch and line counts absent | +| `ai-code-tracking.db` | No model and no edited-file list; timing falls back to header or prompt stamps | +| `cursorDiskKV` rows | No tool outcomes; usage reported as unmeasurable | +| Corrupt / locked / unreadable database | Treated exactly like "absent" — that source contributes nothing | +| Unparseable transcript line | That line is dropped; the rest of the session is kept (a live session's last line is often truncated mid-write) | + +### Node runtime too old for `node:sqlite` + +`node:sqlite` landed in **Node 22.5**; this repo supports Node >= 20. On Node 20 or 22.0–22.4 the +import fails, both SQLite sources are skipped, and Cursor degrades to transcript-only rows. This +is deliberate — the module is imported dynamically so an older runtime cannot take the analytics +run down at module load. Upgrade to Node >= 22.5 for full Cursor enrichment. + +### Database schema drift after a Cursor update + +Symptom: Cursor sessions still appear, but model, tool outcomes, line counts or timing suddenly +go missing. Run with `CODEMIE_DEBUG=true` and look for `[cursor] ... unusable` lines — the +underlying SQLite error names the table or column that moved. This is a fail-soft degradation, +not a bug in your setup; the fix is a plugin update, not a workaround on your machine. + +### Permission issues with the Cursor home + +Symptom: nothing under `~/.cursor` or the app-data directory is readable. CodeMie logs the read +failure at debug level and reports zero Cursor sessions. Confirm with `ls -l ~/.cursor` and +`ls -l` on the `state.vscdb` directory for your platform; the files must be readable by the user +running `codemie`. CodeMie needs no write access anywhere in Cursor's trees. + +### Logging and debugging + +Every read of a Cursor data source logs at debug level, and nothing there +ever escalates past debug: a missing, corrupt or drifted Cursor store is a degradation, not a +failure, so there are no warnings to look for. The one exception is CodeMie's own side of the +pipeline — a session processor that throws is logged at error level as +`[cursor-adapter] Processor failed:`, because that is a CodeMie bug rather than a Cursor +condition. + +```bash +export CODEMIE_DEBUG=true +codemie analytics --report --include-external +``` + +Three prefixes are in use, so filter on `[cursor` rather than `[cursor]`: `[cursor]` for the data-source +reads, `[cursor-discovery]` for session discovery, `[cursor-adapter]` for the adapter itself. + +Expected messages include: `no ai-tracking database at `, `node:sqlite unavailable — skipping +tracking enrichment`, `ai-tracking database unusable at ` (with the SQLite error), and +`tracking index covers N conversation(s)`. + +## Developer guidance + +Source lives in `src/agents/plugins/cursor/`: `cursor.paths.ts` (locations and the `CURSOR_HOME` +override), `cursor.state-db.ts` (`composerHeaders` discovery), `cursor.bubbles.ts` (`cursorDiskKV` +per-turn enrichment), `cursor.tracking-db.ts` (AI-tracking enrichment), `cursor.transcript.ts` +(JSONL reader), `cursor.session.ts` (the adapter that joins them). + +### Creating test fixtures + +Tests drive the whole path from the top seam — `loadNativeSessions()` — against a temporary +Cursor home reached through `CURSOR_HOME`, never by reaching into a parser. See +`src/cli/commands/analytics/__tests__/native-loader-cursor.test.ts` for the working pattern: + +1. `mkdtempSync()` a directory and set `process.env.CURSOR_HOME` to it, so the run touches + neither `~/.codemie` nor the developer's own `~/.cursor`. +2. Write transcripts at + `/projects//agent-transcripts//.jsonl`, where the slug is the project path + with leading separators dropped and `/` and `_` both replaced by `-`. +3. Write fixture SQLite databases at `/ai-tracking/ai-code-tracking.db` and + `/User/globalStorage/state.vscdb`. +4. Re-import the module graph per test (`vi.resetModules()`), because the adapter memoizes its + tracking index once per run and the memo would otherwise leak one fixture into the next. +5. Guard database-backed tests with `describe.skipIf(!hasNodeSqlite())` — Node < 22.5 cannot + create them, which is the same degradation the product promises. + +### Database connection safety + +- Open with `new sqlite.DatabaseSync(path, { readOnly: true })`, `SELECT` only, and `close()` in a + `finally` (itself wrapped, since closing a database that failed to open throws). +- Import `node:sqlite` **dynamically**; a static import breaks Node 20 at module load. +- Check `existsSync()` before opening, and treat every failure — missing table, renamed column, + corrupt file, locked database — as "no data", returning the empty result. +- Parameterize any id in a query; never interpolate. `cursorDiskKV` must be filtered in SQL rather + than scanned, as the table can reach ~1.4 GB. + +### Schema evolution guidelines + +When Cursor changes a schema, add tolerance rather than assertions. Guard every field +(`asString`, `asEpochMs` style helpers), accept both known row shapes where a table has them, +skip a malformed row instead of aborting the loop, and let a whole missing source degrade to an +empty index. Never report a value Cursor did not record: prefer an explicit "unmeasurable" +(`usageUnavailableReason`) or Cursor's own label (`Auto`) over a plausible-looking zero or +default. Any new source needs a fixture-driven test at the `loadNativeSessions()` seam plus a +degradation test proving analytics still works when that source is gone. + +## See also + +- [Analytics Report](ANALYTICS-REPORT.md) — provenance, `--include-external`, the report views +- [`.ai-run/guides/integration/external-integrations.md`](../.ai-run/guides/integration/external-integrations.md) — including the deferred Cursor Enterprise Team Analytics API diff --git a/package-lock.json b/package-lock.json index 8880cda65..a9336e380 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,6 +56,7 @@ "codemie-kimi-acp": "bin/codemie-kimi-acp.js", "codemie-mcp-proxy": "bin/codemie-mcp-proxy.js", "codemie-opencode": "bin/codemie-opencode.js", + "codemie-openwiki": "bin/codemie-openwiki.js", "codemie-pi": "bin/codemie-pi.js", "proxy-daemon": "bin/proxy-daemon.js" }, @@ -84,9 +85,6 @@ "node": ">=20.0.0" } }, - "../codemie-sdk": { - "extraneous": true - }, "node_modules/@aws-crypto/crc32": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", @@ -3693,7 +3691,7 @@ "version": "20.19.25", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz", "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -9342,7 +9340,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/unicorn-magic": { @@ -9781,27 +9779,6 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } - }, - "web/analytics": { - "name": "@codemieai/analytics-web", - "version": "0.0.11", - "extraneous": true, - "dependencies": { - "@tanstack/react-query": "^5.14.0", - "date-fns": "^3.0.0", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "recharts": "^2.10.3" - }, - "devDependencies": { - "@types/react": "^18.2.43", - "@types/react-dom": "^18.2.17", - "@vitejs/plugin-react": "^4.2.1", - "autoprefixer": "^10.4.16", - "postcss": "^8.4.32", - "tailwindcss": "^3.4.0", - "vite": "^5.0.8" - } } } } diff --git a/src/agents/__tests__/registry.test.ts b/src/agents/__tests__/registry.test.ts index baf860714..443231601 100644 --- a/src/agents/__tests__/registry.test.ts +++ b/src/agents/__tests__/registry.test.ts @@ -27,6 +27,7 @@ describe('AgentRegistry', () => { 'kimi-acp', 'openwiki', 'copilot-cli', // analytics-only: read for the report, never managed by CodeMie + 'cursor', // analytics-only: read for the report, never managed by CodeMie ].sort() ); }); diff --git a/src/agents/core/metrics/types.ts b/src/agents/core/metrics/types.ts index 8501bcc02..0f0c1ab1f 100644 --- a/src/agents/core/metrics/types.ts +++ b/src/agents/core/metrics/types.ts @@ -77,6 +77,9 @@ export interface MetricDelta { durationMs?: number; // Tool execution time (from tool_result) }[]; + // Aggregate files-changed count for adapters that know the total but not individual paths (e.g. Cursor's composerHeaders); when set, this overrides the path-derived count instead of being redundant with it. + filesChangedCount?: number; + // Model tracking (raw names, unnormalized) models?: string[]; // All models used in this turn diff --git a/src/agents/core/session/BaseSessionAdapter.ts b/src/agents/core/session/BaseSessionAdapter.ts index 463cab671..865ca20a4 100644 --- a/src/agents/core/session/BaseSessionAdapter.ts +++ b/src/agents/core/session/BaseSessionAdapter.ts @@ -63,6 +63,15 @@ export interface ParsedSession { usagePartial?: boolean; /** Why this session has no usage data; absent when usage was found. */ usageUnavailableReason?: string; + /** + * Session-level token totals for an agent whose native format has no per-message usage + * the standard per-agent readers in `cost/usage-readers.ts` can walk (e.g. Cursor's + * transcript carries no tokens at all — real counts only exist per-turn in a separate + * store, unaligned with transcript messages). The cost enricher prices this directly + * instead of routing it through a per-message reader; combine with `usagePartial: true` + * when the totals are known to be incomplete rather than an authoritative rollup. + */ + tokensByModel?: Record; }; // Parsed metrics data (optional - for metrics processor) @@ -78,6 +87,8 @@ export interface ParsedSession { linesAdded?: number; linesRemoved?: number; }>; + // Aggregate files-changed count for adapters that know the total but not individual paths (e.g. Cursor's composerHeaders); when set, this overrides the path-derived count instead of being redundant with it. + filesChangedCount?: number; // Named invocation breakdowns (skill names, agent subtypes, slash commands) skillInvocations?: Record; agentInvocations?: Record; diff --git a/src/agents/plugins/cursor/__tests__/cursor.usage-csv.test.ts b/src/agents/plugins/cursor/__tests__/cursor.usage-csv.test.ts new file mode 100644 index 000000000..e98ef9404 --- /dev/null +++ b/src/agents/plugins/cursor/__tests__/cursor.usage-csv.test.ts @@ -0,0 +1,156 @@ +/** + * Cursor usage-events CSV import. + * + * The fixture is a slice of a real 2026-09-05 export, so the header set, the quoting, and the + * token magnitudes are the ones the product will actually meet. Its defining property: every + * row is `Kind=Included` and yet carries real tokens and a real `Cost` — the whole reason this + * import exists, and the thing a naive reading of "Included" would throw away. + */ + +import { describe, it, expect } from 'vitest'; +import { fileURLToPath } from 'node:url'; +import { parseCursorUsageCsv, loadCursorUsageCsv } from '../cursor.usage-csv.js'; +import { readFileSync } from 'node:fs'; + +const fixturePath = fileURLToPath(new URL('./fixtures/cursor-usage-events.csv', import.meta.url)); +const fixture = readFileSync(fixturePath, 'utf-8'); + +describe('parseCursorUsageCsv', () => { + it('keeps tokens and cost for Included rows instead of reading them as free', () => { + const out = parseCursorUsageCsv(fixture)!; + expect(out.events).toHaveLength(8); + expect(out.events.every((e) => e.kind === 'Included')).toBe(true); + expect(out.totals.costUSD).toBeCloseTo(3.87, 2); + expect(out.totals.tokens.total).toBe(4183618); + expect(out.totals.tokens.output).toBe(48138); + // Cache read is the bulk of Cursor usage and must not be folded into plain input. + expect(out.totals.tokens.cacheRead).toBe(3437315); + expect(out.totals.tokens.cacheCreation).toBe(168695); + expect(out.totals.tokens.input).toBe(529470); + }); + + it('groups by day and model so the section can show provenance without inventing a session key', () => { + const out = parseCursorUsageCsv(fixture)!; + const auto = out.byModel.find((m) => m.model === 'auto')!; + expect(auto.events).toBe(4); + expect(auto.costUSD).toBeCloseTo(0.58, 2); + expect(out.byDay.map((d) => d.day)).toEqual(['2026-08-28', '2026-09-05']); + }); + + it('filters to one user when asked, and reports who it actually found', () => { + const mixed = fixture.replace('"owner@example.com"', '"someone.else@example.com"'); + const out = parseCursorUsageCsv(mixed, { userEmail: 'owner@example.com' })!; + expect(out.events).toHaveLength(7); + expect(out.usersInFile.sort()).toEqual(['owner@example.com', 'someone.else@example.com']); + expect(out.droppedByUserFilter).toBe(1); + }); + + it('reports when the user filter matched nothing, rather than silently emptying the section', () => { + const out = parseCursorUsageCsv(fixture, { userEmail: 'nobody@example.com' }); + expect(out).not.toBeNull(); + expect(out!.events).toHaveLength(0); + expect(out!.droppedByUserFilter).toBe(8); + expect(out!.usersInFile).toEqual(['owner@example.com']); + }); + + it('tolerates the export variant that has no Cost column', () => { + // The 380-row 2026-09-05 export ships `Requests` in place of `Cost`. + const noCost = fixture + .replace('"Total Tokens","Cost"', '"Total Tokens","Requests"') + .replace(/,"\d+\.\d+"\r?\n/g, ',"3.1"\n'); + const out = parseCursorUsageCsv(noCost)!; + expect(out.events.length).toBeGreaterThan(0); + expect(out.hasCost).toBe(false); + expect(out.totals.costUSD).toBe(0); + // Tokens are still the point — they must survive a missing Cost column. + expect(out.totals.tokens.total).toBeGreaterThan(0); + }); + + it('tolerates added columns and non-numeric cost values', () => { + const odd = fixture + .replace('"Cost"', '"Cost","Some New Column"') + .replace(/("\d+\.\d+")\r?\n/g, '$1,"x"\n') + .replace('"0.07","x"', '"Free","x"'); + const out = parseCursorUsageCsv(odd)!; + expect(out.events).toHaveLength(8); + expect(out.totals.costUSD).toBeCloseTo(3.80, 2); // the 0.07 row contributed nothing + }); + + it('returns null for a file that is not a usage export', () => { + expect(parseCursorUsageCsv('name,value\nfoo,1\n')).toBeNull(); + expect(parseCursorUsageCsv('')).toBeNull(); + }); +}); + +describe('loadCursorUsageCsv', () => { + it('reads a real export off disk', () => { + const out = loadCursorUsageCsv(fixturePath)!; + expect(out.events).toHaveLength(8); + expect(out.sourceFile).toBe(fixturePath); + }); + + it('fails soft on a missing file instead of throwing', () => { + expect(loadCursorUsageCsv('/no/such/export.csv')).toBeNull(); + }); +}); + +/** + * The full 61-event export, verbatim apart from the email. This is the exact shape and scale the + * feature was specified against (issue #21), so the headline figures are asserted rather than + * described: all rows Included, $25.25, 39,952,466 tokens — and two `Free` cost cells. + */ +describe('the real 61-event export', () => { + const full = readFileSync(fileURLToPath(new URL('./fixtures/cursor-usage-events-full.csv', import.meta.url)), 'utf-8'); + + it('reproduces the export totals exactly', () => { + const out = parseCursorUsageCsv(full)!; + expect(out.events).toHaveLength(61); + expect(out.events.every((e) => e.kind === 'Included')).toBe(true); + expect(out.totals.costUSD).toBeCloseTo(25.25, 2); + expect(out.totals.tokens.total).toBe(39952466); + expect(out.totals.tokens.input).toBe(5625173); + expect(out.totals.tokens.cacheCreation).toBe(428582); + expect(out.totals.tokens.cacheRead).toBe(33354962); + expect(out.totals.tokens.output).toBe(543749); + expect(out.byDay.map((d) => d.day)).toEqual(['2026-08-28', '2026-08-31', '2026-09-04', '2026-09-05']); + expect(out.byModel.find((m) => m.model === 'auto')!.events).toBe(40); + }); +}); + +describe('malformed and variant exports', () => { + it('parses a file that begins with a byte-order mark', () => { + // Anything that has been through Excel or a browser download can arrive BOM-prefixed; without + // stripping it the first header cell reads "Date" and the whole import is rejected. + const out = parseCursorUsageCsv('\uFEFF' + fixture); + expect(out).not.toBeNull(); + expect(out!.events).toHaveLength(8); + }); + + it('keeps every row when the export has no User column at all', () => { + // A personal export can omit User entirely. Filtering on an absent column must not drop the + // whole file — there is no other user's data present to exclude. + const noUser = fixture + .replace('"Date","User",', '"Date",') + .replace(/^("[^"]*"),"owner@example\.com",/gm, '$1,'); + const out = parseCursorUsageCsv(noUser, { userEmail: 'someone@example.com' })!; + expect(out.events).toHaveLength(8); + expect(out.droppedByUserFilter).toBe(0); + }); + + it('reads a thousands-separated cost as the full amount, not its first digits', () => { + const big = fixture.replace('"0.07"', '"1,234.50"'); + const out = parseCursorUsageCsv(big)!; + expect(out.totals.costUSD).toBeCloseTo(3.80 + 1234.50, 2); + }); + + it('buckets days in local time, matching the rest of the report', () => { + // A UTC slice would put a 23:30 local event on the following day while every other view + // buckets it locally — one report, two day definitions. + const late = fixture.replace('2026-09-05T13:52:28.087Z', '2026-09-05T23:30:00.000Z'); + const out = parseCursorUsageCsv(late)!; + const expected = new Date('2026-09-05T23:30:00.000Z'); + const pad = (n: number) => String(n).padStart(2, '0'); + const localDay = `${expected.getFullYear()}-${pad(expected.getMonth() + 1)}-${pad(expected.getDate())}`; + expect(out.events.some((e) => e.day === localDay)).toBe(true); + }); +}); diff --git a/src/agents/plugins/cursor/__tests__/cursor.usage-fetch.test.ts b/src/agents/plugins/cursor/__tests__/cursor.usage-fetch.test.ts new file mode 100644 index 000000000..2d3d20e33 --- /dev/null +++ b/src/agents/plugins/cursor/__tests__/cursor.usage-fetch.test.ts @@ -0,0 +1,173 @@ +/** + * Cookie-authenticated usage-export fetch. + * + * Two properties matter more than the happy path and are asserted first: no request may leave + * the machine without an explicit opt-in AND a configured URL, and the session token must never + * reach a log line. The token used here is a fabricated string with the right *shape* only. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { + fetchCursorUsageExport, + readCursorSessionCookie, + type UsageFetchRequest, +} from '../cursor.usage-fetch.js'; + +/** + * Shape-accurate, entirely fabricated: `::`. + * + * Assembled at runtime rather than written as a literal — a literal three-part JWT trips the + * repo's gitleaks scan, and a secrets scanner that has learned to ignore this file is worse than + * no scanner. The parts are meaningless: header `{"alg":"none"}`, body `{"sub":"test"}`. + */ +const FAKE_USER_ID = 'user_01ABCDEF'; +const FAKE_JWT = [ + Buffer.from('{"alg":"none"}').toString('base64url'), + Buffer.from('{"sub":"test"}').toString('base64url'), + 'notarealsignature', +].join('.'); +const FAKE_COOKIE = `${FAKE_USER_ID}::${FAKE_JWT}`; + +const csv = readFileSync( + fileURLToPath(new URL('./fixtures/cursor-usage-events.csv', import.meta.url)), + 'utf-8' +); + +function recordingFetch(handler?: (url: string, init?: unknown) => { status?: number; body?: string }) { + const calls: { url: string; init?: { headers?: Record } }[] = []; + const impl = async (url: string, init?: { headers?: Record }) => { + calls.push({ url, init }); + const r = handler?.(url, init) ?? {}; + return { + ok: (r.status ?? 200) < 400, + status: r.status ?? 200, + text: async () => r.body ?? csv, + }; + }; + return { impl, calls }; +} + +const base: UsageFetchRequest = { + enabled: true, + exportUrl: 'https://cursor.example/api/usage-export', + cookie: FAKE_COOKIE, + startDate: '2026-08-29', + endDate: '2026-09-05', +}; + +describe('usage-export fetch gate', () => { + it('makes no request without the explicit opt-in flag', async () => { + const f = recordingFetch(); + expect(await fetchCursorUsageExport({ ...base, enabled: false }, { fetch: f.impl })).toBeNull(); + expect(f.calls).toEqual([]); + }); + + it('makes no request when no export URL is configured', async () => { + const f = recordingFetch(); + expect(await fetchCursorUsageExport({ ...base, exportUrl: undefined }, { fetch: f.impl })).toBeNull(); + expect(f.calls).toEqual([]); + }); + + it('makes no request when no session cookie could be read', async () => { + const f = recordingFetch(); + expect(await fetchCursorUsageExport({ ...base, cookie: undefined }, { fetch: f.impl })).toBeNull(); + expect(f.calls).toEqual([]); + }); +}); + +describe('usage-export request', () => { + it('authenticates with the session cookie, never a bearer token', async () => { + const f = recordingFetch(); + await fetchCursorUsageExport(base, { fetch: f.impl }); + expect(f.calls).toHaveLength(1); + const headers = f.calls[0].init?.headers ?? {}; + expect(headers.Cookie).toBe(`WorkosCursorSessionToken=${FAKE_COOKIE}`); + expect(headers.Authorization).toBeUndefined(); + }); + + it('passes the report window through as date parameters', async () => { + const f = recordingFetch(); + await fetchCursorUsageExport(base, { fetch: f.impl }); + const url = new URL(f.calls[0].url); + expect(url.searchParams.get('startDate')).toBe('2026-08-29'); + expect(url.searchParams.get('endDate')).toBe('2026-09-05'); + }); + + it('feeds the response through the same parser as the file import', async () => { + const f = recordingFetch(); + const out = (await fetchCursorUsageExport(base, { fetch: f.impl }))!; + expect(out.events).toHaveLength(8); + expect(out.totals.costUSD).toBeCloseTo(3.87, 2); + expect(out.sourceFile).toBeUndefined(); // fetched, not read from disk + }); +}); + +describe('usage-export failure handling', () => { + it.each([401, 403, 500])('degrades to null on HTTP %i', async (status) => { + const f = recordingFetch(() => ({ status })); + expect(await fetchCursorUsageExport(base, { fetch: f.impl })).toBeNull(); + }); + + it('degrades to null when the body is not a usage export', async () => { + const f = recordingFetch(() => ({ body: 'Sign in' })); + expect(await fetchCursorUsageExport(base, { fetch: f.impl })).toBeNull(); + }); + + it('survives a transport throw', async () => { + const impl = async () => { throw new Error('ENOTFOUND cursor.example'); }; + expect(await fetchCursorUsageExport(base, { fetch: impl as never })).toBeNull(); + }); +}); + +describe('session token confidentiality', () => { + let logged: string[]; + beforeEach(async () => { + logged = []; + const { logger } = await import('@/utils/logger.js'); + vi.spyOn(logger, 'debug').mockImplementation((...args: unknown[]) => { logged.push(args.map(String).join(' ')); }); + vi.spyOn(logger, 'warn').mockImplementation((...args: unknown[]) => { logged.push(args.map(String).join(' ')); }); + }); + afterEach(() => vi.restoreAllMocks()); + + it('never writes the cookie to a log line, on success or on failure', async () => { + await fetchCursorUsageExport(base, { fetch: recordingFetch().impl }); + await fetchCursorUsageExport(base, { fetch: recordingFetch(() => ({ status: 401 })).impl }); + const all = logged.join('\n'); + expect(all).not.toContain(FAKE_COOKIE); + expect(all).not.toContain(FAKE_JWT); + expect(all).not.toContain(FAKE_USER_ID); + }); +}); + +describe('readCursorSessionCookie', () => { + let dir: string; + beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'cursor-cookie-')); }); + afterEach(() => rmSync(dir, { recursive: true, force: true })); + + it('returns undefined when the state database is absent', async () => { + expect(await readCursorSessionCookie(join(dir, 'missing.vscdb'))).toBeUndefined(); + }); + + it('returns undefined for a file that is not a database', async () => { + const p = join(dir, 'state.vscdb'); + writeFileSync(p, 'not a database'); + expect(await readCursorSessionCookie(p)).toBeUndefined(); + }); +}); + +describe('explicitly supplied session token', () => { + it('prefers CURSOR_SESSION_TOKEN over any store lookup', async () => { + const cookie = await readCursorSessionCookie('/no/such/state.vscdb', { CURSOR_SESSION_TOKEN: FAKE_COOKIE }); + expect(cookie).toBe(FAKE_COOKIE); + }); + + it('rejects a supplied value that is not of the documented shape', async () => { + const cookie = await readCursorSessionCookie('/no/such/state.vscdb', { CURSOR_SESSION_TOKEN: 'crsr_someadminapikey' }); + expect(cookie).toBeUndefined(); + }); +}); diff --git a/src/agents/plugins/cursor/__tests__/fixtures/cursor-usage-events-full.csv b/src/agents/plugins/cursor/__tests__/fixtures/cursor-usage-events-full.csv new file mode 100644 index 000000000..f97954224 --- /dev/null +++ b/src/agents/plugins/cursor/__tests__/fixtures/cursor-usage-events-full.csv @@ -0,0 +1,62 @@ +"Date","User","Cloud Agent ID","Automation ID","Kind","Model","Max Mode","Input (w/ Cache Write)","Input (w/o Cache Write)","Cache Read","Output Tokens","Total Tokens","Cost" +"2026-09-05T13:52:28.087Z","owner@example.com","","","Included","auto","No","0","30061","118400","1038","149499","0.07" +"2026-09-05T13:50:50.577Z","owner@example.com","","","Included","auto","No","0","3435","400768","1998","406201","0.10" +"2026-09-05T13:46:37.532Z","owner@example.com","","","Included","auto","No","0","123309","514176","5627","643112","0.28" +"2026-09-05T13:45:11.696Z","owner@example.com","","","Included","auto","No","0","6972","480768","1999","489739","0.13" +"2026-09-05T13:41:15.163Z","owner@example.com","","","Included","auto","No","0","8996","340736","1109","350841","0.09" +"2026-09-05T13:34:12.659Z","owner@example.com","","","Included","auto","No","0","181275","536832","11881","729988","0.39" +"2026-09-05T13:34:12.552Z","owner@example.com","","","Included","auto","No","0","153493","1207168","12048","1372709","0.51" +"2026-09-05T13:33:47.950Z","owner@example.com","","","Included","auto","No","0","201833","1291392","21077","1514302","0.63" +"2026-09-05T13:08:46.257Z","owner@example.com","","","Included","cursor-grok-4.6-medium","No","0","104940","238071","3820","346831","0.30" +"2026-09-05T13:08:29.351Z","owner@example.com","","","Included","cursor-grok-4.6-medium","No","0","38388","59050","2594","100032","0.10" +"2026-09-05T12:31:50.679Z","owner@example.com","","","Included","cursor-grok-4.6-medium","No","0","31032","2359096","11610","2401738","1.16" +"2026-09-05T12:22:47.673Z","owner@example.com","","","Included","cursor-grok-4.6-medium","No","0","213249","2087401","23918","2324568","1.43" +"2026-09-04T19:17:27.058Z","owner@example.com","","","Included","auto","No","0","166067","489728","14170","669965","0.37" +"2026-09-04T19:02:51.972Z","owner@example.com","","","Included","auto","No","0","153619","687488","25662","866769","0.47" +"2026-08-31T19:08:27.563Z","owner@example.com","","","Included","auto","No","0","16538","87936","447","104921","0.04" +"2026-08-31T18:53:10.303Z","owner@example.com","","","Included","auto","No","0","98458","6016","505","104979","0.11" +"2026-08-31T18:37:09.266Z","owner@example.com","","","Included","auto","No","0","67","100352","1623","102042","0.03" +"2026-08-31T18:19:11.482Z","owner@example.com","","","Included","auto","No","0","67","100352","1647","102066","0.03" +"2026-08-31T18:19:08.958Z","owner@example.com","","","Included","auto","No","0","88","121984","11988","134060","0.09" +"2026-08-31T17:49:57.620Z","owner@example.com","","","Included","auto","No","0","115928","6144","13279","135351","0.20" +"2026-08-31T17:49:54.032Z","owner@example.com","","","Included","auto","No","0","92547","206848","2906","302301","0.17" +"2026-08-31T15:32:52.035Z","owner@example.com","","","Included","auto","No","0","116039","125824","12362","254225","0.23" +"2026-08-31T15:31:26.602Z","owner@example.com","","","Included","auto","No","0","229325","206848","12774","448947","0.37" +"2026-08-31T15:31:25.394Z","owner@example.com","","","Included","auto","No","0","81784","303488","12744","398016","0.23" +"2026-08-31T15:31:11.660Z","owner@example.com","","","Included","auto","No","0","26771","28544","968","56283","0.04" +"2026-08-31T15:29:08.991Z","owner@example.com","","","Included","auto","No","0","18633","643584","12657","674874","0.23" +"2026-08-31T15:26:52.309Z","owner@example.com","","","Included","cursor-grok-4.6-medium","No","0","134119","606666","4655","745440","0.53" +"2026-08-31T15:26:13.449Z","owner@example.com","","","Included","auto","No","0","146696","3649152","33981","3829829","1.17" +"2026-08-31T15:25:47.622Z","owner@example.com","","","Included","auto","No","0","16871","360960","10188","388019","0.16" +"2026-08-31T15:20:22.032Z","owner@example.com","","","Included","auto","No","0","78268","90112","221","168601","0.11" +"2026-08-31T15:11:49.623Z","owner@example.com","","","Included","auto","No","0","127792","965504","26813","1120109","0.51" +"2026-08-31T15:10:35.709Z","owner@example.com","","","Included","auto","No","0","16582","208000","3158","227740","0.08" +"2026-08-31T15:06:38.268Z","owner@example.com","","","Included","auto","No","0","139728","251776","5609","397113","0.24" +"2026-08-31T15:06:05.829Z","owner@example.com","","","Included","auto","No","0","67072","473216","4340","544628","0.21" +"2026-08-31T14:46:33.316Z","owner@example.com","","","Included","cursor-grok-4.6-medium","No","0","263253","1068986","24738","1356977","1.07" +"2026-08-28T17:37:44.232Z","owner@example.com","","","Included","auto","No","0","13478","839552","4999","858029","0.23" +"2026-08-28T17:33:00.005Z","owner@example.com","","","Included","auto","No","0","168178","449024","6354","623556","0.32" +"2026-08-28T17:15:28.405Z","owner@example.com","","","Included","auto","No","0","6124","475648","5894","487666","0.15" +"2026-08-28T17:12:52.084Z","owner@example.com","","","Included","auto","No","0","2575","308736","7206","318517","0.11" +"2026-08-28T17:01:04.463Z","owner@example.com","","","Included","auto","No","0","158514","448768","3753","611035","0.30" +"2026-08-28T16:56:57.387Z","owner@example.com","","","Included","claude-opus-5-thinking-high","No","168695","4598","791217","19889","984399","1.97" +"2026-08-28T16:56:24.687Z","owner@example.com","","","Included","auto","No","0","142720","803712","15447","961879","0.42" +"2026-08-28T16:51:57.500Z","owner@example.com","","","Included","auto","No","0","395","121088","92","121575","0.03" +"2026-08-28T16:40:38.603Z","owner@example.com","","","Included","auto","No","0","235665","1661440","32317","1929422","0.81" +"2026-08-28T16:38:18.311Z","owner@example.com","","","Included","cursor-grok-4.6-medium","No","0","43773","8469","1379","53621","0.08" +"2026-08-28T16:35:04.195Z","owner@example.com","","","Included","composer-2.5-fast","No","0","73532","367686","7049","448267","0.43" +"2026-08-28T16:34:56.979Z","owner@example.com","","","Included","claude-opus-5-thinking-high","No","184657","5759","1494129","18255","1702800","2.50" +"2026-08-28T16:34:55.299Z","owner@example.com","","","Included","cursor-grok-4.6-high","No","0","182623","526229","6718","715570","0.59" +"2026-08-28T16:34:42.274Z","owner@example.com","","","Included","claude-opus-5-thinking-high","No","75230","5427","394112","13247","488016","1.00" +"2026-08-28T16:34:03.637Z","owner@example.com","","","Included","cursor-grok-4.6-medium","No","0","40486","119275","2817","162578","0.13" +"2026-08-28T16:32:39.680Z","owner@example.com","","","Included","cursor-grok-4.6-high","No","0","83389","438214","14561","536164","0.41" +"2026-08-28T16:32:34.965Z","owner@example.com","","","Included","cursor-grok-4.6-high","No","","","","","","Free" +"2026-08-28T16:32:20.117Z","owner@example.com","","","Included","cursor-grok-4.6-high","No","0","20924","2944","348","24216","0.04" +"2026-08-28T16:22:57.513Z","owner@example.com","","","Included","cursor-grok-4.6-medium","No","0","216764","1502534","11314","1730612","1.11" +"2026-08-28T16:17:11.434Z","owner@example.com","","","Included","cursor-grok-4.6-medium","No","0","83196","85627","1842","170665","0.18" +"2026-08-28T16:17:03.174Z","owner@example.com","","","Included","cursor-grok-4.6-medium","No","0","640283","690568","10824","1341675","1.51" +"2026-08-28T16:16:26.737Z","owner@example.com","","","Included","cursor-grok-4.6-medium","No","0","18606","6656","219","25481","0.04" +"2026-08-28T16:05:31.208Z","owner@example.com","","","Included","auto","No","0","67132","405120","4591","476843","0.19" +"2026-08-28T16:05:01.199Z","owner@example.com","","","Included","cursor-grok-4.6-medium","No","0","152124","727936","12350","892410","0.67" +"2026-08-28T15:51:14.358Z","owner@example.com","","","Included","auto","No","0","55613","262912","6130","324655","0.15" +"2026-08-28T15:51:09.846Z","owner@example.com","","","Included","auto","No","","","","","","Free" diff --git a/src/agents/plugins/cursor/__tests__/fixtures/cursor-usage-events.csv b/src/agents/plugins/cursor/__tests__/fixtures/cursor-usage-events.csv new file mode 100644 index 000000000..637052cb6 --- /dev/null +++ b/src/agents/plugins/cursor/__tests__/fixtures/cursor-usage-events.csv @@ -0,0 +1,9 @@ +"Date","User","Cloud Agent ID","Automation ID","Kind","Model","Max Mode","Input (w/ Cache Write)","Input (w/o Cache Write)","Cache Read","Output Tokens","Total Tokens","Cost" +"2026-09-05T13:52:28.087Z","owner@example.com","","","Included","auto","No","0","30061","118400","1038","149499","0.07" +"2026-09-05T13:08:46.257Z","owner@example.com","","","Included","cursor-grok-4.6-medium","No","0","104940","238071","3820","346831","0.30" +"2026-08-28T16:56:57.387Z","owner@example.com","","","Included","claude-opus-5-thinking-high","No","168695","4598","791217","19889","984399","1.97" +"2026-08-28T16:35:04.195Z","owner@example.com","","","Included","composer-2.5-fast","No","0","73532","367686","7049","448267","0.43" +"2026-08-28T16:34:55.299Z","owner@example.com","","","Included","cursor-grok-4.6-high","No","0","182623","526229","6718","715570","0.59" +"2026-09-05T13:50:50.577Z","owner@example.com","","","Included","auto","No","0","3435","400768","1998","406201","0.10" +"2026-09-05T13:46:37.532Z","owner@example.com","","","Included","auto","No","0","123309","514176","5627","643112","0.28" +"2026-09-05T13:45:11.696Z","owner@example.com","","","Included","auto","No","0","6972","480768","1999","489739","0.13" diff --git a/src/agents/plugins/cursor/cursor.bubbles.ts b/src/agents/plugins/cursor/cursor.bubbles.ts new file mode 100644 index 000000000..6341d9aa7 --- /dev/null +++ b/src/agents/plugins/cursor/cursor.bubbles.ts @@ -0,0 +1,212 @@ +/** + * Per-turn enrichment from Cursor's internal `state.vscdb` — the `cursorDiskKV` table. + * + * `cursorDiskKV` is VS Code/Cursor's own undocumented internal key/value store, not a stable + * public API. It holds one row per bubble (turn/message) keyed `bubbleId::`, + * interleaved with + * unrelated `composerData:*` keys and, in aggregate, up to ~1.4GB of unrelated VS Code state — + * so every read here filters by `composerId` in SQL rather than scanning the whole table. + * + * Each bubble row carries a `toolFormerData.status` (`completed` / `error` / `cancelled` / + * `loading`) and `toolFormerData.name`, used to build per-tool success/failure counts, plus a + * sparse `tokenCount: {inputTokens, outputTokens}` present on roughly 1% of bubbles — enough to + * signal that partial pricing is possible, not enough to guarantee full coverage. + * + * Everything here is fail-soft by mandate, exactly like `cursor.state-db.ts`: an absent file, an + * absent `node:sqlite` (Node < 22.5), a renamed table or column, a corrupt file, a locked + * database, or a malformed individual row all degrade to a zeroed-out summary — never a thrown + * error. A single malformed row must not lose the rest of the bubbles. + * + * Reads are strictly read-only: the database is opened with `readOnly: true` and only + * SELECTed, with the composerId parameterized (never interpolated) into the query. + */ + +import { existsSync } from 'fs'; +import { logger } from '@/utils/logger.js'; +import { getCursorStateDbPath } from './cursor.paths.js'; +import { asNumber, asString, loadSqlite } from './cursor.sqlite.js'; + +/** Aggregated tool-outcome and token-usage signal for one Cursor Agent conversation's bubbles. */ +export interface CursorBubbleSummary { + /** Per-tool success/failure counts, keyed by toolFormerData.name. Only tools with a resolved (non-'loading') status are counted. */ + toolStatus: Record; + /** Sum of inputTokens across every bubble that had a tokenCount object, however sparse. */ + totalInputTokens: number; + /** Sum of outputTokens across every bubble that had a tokenCount object. */ + totalOutputTokens: number; + /** True iff at least one bubble carried a nonzero inputTokens or outputTokens — the signal that gates partial pricing. */ + hasTokenSignal: boolean; +} + +function emptySummary(): CursorBubbleSummary { + return { toolStatus: {}, totalInputTokens: 0, totalOutputTokens: 0, hasTokenSignal: false }; +} + +function asPositiveNumber(value: unknown): number { + const num = asNumber(value); + return num !== undefined && num > 0 ? num : 0; +} + +/** A `cursorDiskKV` bubble row in either of its possible shapes — never assumed, always guarded. */ +interface BubbleRow { + key?: unknown; + value?: unknown; + toolFormerData?: unknown; + tokenCount?: unknown; +} + +/** + * Escape `%`, `_`, and `\` in a LIKE pattern fragment so a composerId containing them cannot + * widen or corrupt the match. composerIds are expected to be UUID-like, but this is never + * trusted — the value ultimately comes from Cursor's own undocumented, unversioned storage. + */ +function escapeLikeFragment(value: string): string { + return value.replace(/[\\%_]/g, (char) => `\\${char}`); +} + +/** + * `toolFormerData` may arrive as a plain object (flat-column row shape) or, after JSON-parsing + * a key/value row's `value` blob, as a parsed object too — same shape either way, just guarded + * defensively since SQLite hands back `unknown` in both cases. + */ +function applyToolStatus( + toolFormerData: unknown, + toolStatus: Record +): boolean { + if (!toolFormerData || typeof toolFormerData !== 'object') { + return false; + } + + const name = asString((toolFormerData as { name?: unknown }).name); + if (!name) { + // Can't attribute an outcome to nothing. + return false; + } + + const status = asString((toolFormerData as { status?: unknown }).status); + if (status !== 'completed' && status !== 'error' && status !== 'cancelled') { + // 'loading' or any other/missing status is not a resolved outcome. + return false; + } + + const counts = (toolStatus[name] ??= { success: 0, failure: 0 }); + if (status === 'completed') { + counts.success += 1; + } else { + counts.failure += 1; + } + return true; +} + +/** + * `tokenCount` may arrive as a plain object (flat-column row shape) or a parsed JSON object + * (key/value row shape) — same guarded handling either way. + */ +function applyTokenCount( + tokenCount: unknown, + summary: CursorBubbleSummary +): boolean { + if (!tokenCount || typeof tokenCount !== 'object') { + return false; + } + + const inputTokens = asPositiveNumber((tokenCount as { inputTokens?: unknown }).inputTokens); + const outputTokens = asPositiveNumber((tokenCount as { outputTokens?: unknown }).outputTokens); + + summary.totalInputTokens += inputTokens; + summary.totalOutputTokens += outputTokens; + + return inputTokens > 0 || outputTokens > 0; +} + +/** + * Summarize tool outcomes and token usage across every bubble belonging to one Cursor Agent + * conversation, or a zeroed-out summary when the database cannot be read. + * + * Never throws. + */ +export async function readCursorBubbles( + composerId: string, + dbPath: string = getCursorStateDbPath() +): Promise { + const summary = emptySummary(); + + if (!existsSync(dbPath)) { + logger.debug(`[cursor] no state database at ${dbPath}`); + return summary; + } + + const sqlite = await loadSqlite('bubble summary'); + if (!sqlite) { + return summary; + } + + let db: InstanceType | undefined; + let toolOutcomeCount = 0; + let tokenSignalCount = 0; + let scannedCount = 0; + + try { + db = new sqlite.DatabaseSync(dbPath, { readOnly: true }); + const pattern = `bubbleId:${escapeLikeFragment(composerId)}:%`; + const rows = db + .prepare("SELECT key, value FROM cursorDiskKV WHERE key LIKE ? ESCAPE '\\'") + .all(pattern) as BubbleRow[]; + + for (const row of rows) { + scannedCount += 1; + try { + let toolFormerData: unknown; + let tokenCount: unknown; + + const value = asString(row.value); + if (value) { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch (error) { + logger.debug(`[cursor] unparsable cursorDiskKV value for key "${String(row.key)}":`, error); + continue; + } + if (!parsed || typeof parsed !== 'object') { + continue; + } + const parsedRow = parsed as BubbleRow; + toolFormerData = parsedRow.toolFormerData; + tokenCount = parsedRow.tokenCount; + } else { + toolFormerData = row.toolFormerData; + tokenCount = row.tokenCount; + } + + if (applyToolStatus(toolFormerData, summary.toolStatus)) { + toolOutcomeCount += 1; + } + if (applyTokenCount(tokenCount, summary)) { + tokenSignalCount += 1; + summary.hasTokenSignal = true; + } + } catch (error) { + // A single malformed row must not lose the rest of the bubbles. + logger.debug('[cursor] skipping unreadable cursorDiskKV row:', error); + } + } + } catch (error) { + // Missing table, renamed column, corrupt file, locked database — all the same to us. + logger.debug(`[cursor] state database unusable at ${dbPath}:`, error); + return emptySummary(); + } finally { + try { + db?.close(); + } catch { + // closing a database we failed to open is not an error worth reporting + } + } + + logger.debug( + `[cursor] bubble summary for composer ${composerId} scanned ${scannedCount} bubble(s): ` + + `${toolOutcomeCount} with a tool outcome, ${tokenSignalCount} with a token signal` + ); + + return summary; +} diff --git a/src/agents/plugins/cursor/cursor.constants.ts b/src/agents/plugins/cursor/cursor.constants.ts new file mode 100644 index 000000000..a5e9f0afb --- /dev/null +++ b/src/agents/plugins/cursor/cursor.constants.ts @@ -0,0 +1,30 @@ +/** + * Shared Cursor identifiers. + * + * These live apart from `cursor.plugin.ts` so the session adapter can use them without + * importing the plugin, which imports the adapter — a cycle. + */ + +/** Internal agent key. */ +export const CURSOR_AGENT_NAME = 'cursor'; + +/** User-facing label shown in the analytics report and terminal output. */ +export const CURSOR_DISPLAY_NAME = 'Cursor'; + +/** + * What Cursor's AI-tracking database writes when the user left model choice to Cursor. + * + * It names no model, so it must never be reported as one — the report would be claiming a + * model Cursor never recorded. + */ +export const CURSOR_AUTO_MODEL_SENTINEL = 'default'; + +/** + * How Cursor itself labels that mode. + * + * Cursor's own usage export writes `auto` in its Model column for exactly the conversations the + * local database marks `default`, so "Auto" is Cursor's word rather than our invention. Showing + * it beats showing a blank: "Auto" says the user delegated the choice, where a blank would + * suggest CodeMie failed to read something. + */ +export const CURSOR_AUTO_MODEL_LABEL = 'Auto'; diff --git a/src/agents/plugins/cursor/cursor.paths.ts b/src/agents/plugins/cursor/cursor.paths.ts new file mode 100644 index 000000000..1e877075a --- /dev/null +++ b/src/agents/plugins/cursor/cursor.paths.ts @@ -0,0 +1,68 @@ +/** + * Cursor storage locations. + * + * Cursor keeps its user data under `~/.cursor`. `CURSOR_HOME` overrides it, mirroring the + * `COPILOT_HOME` handling in `copilot-cli.paths.ts` — which is also what lets the adapter + * be driven against a fixture tree in tests. + * + * `state.vscdb` is a second, unrelated Cursor data location: it is the VS Code/Cursor + * *application* state store, not `~/.cursor` (which holds Cursor's own project/tracking + * data), so it lives under the OS's per-app-data directory. `CURSOR_HOME` still doubles + * as the test-fixture override for it — same rationale as above — under a `User/globalStorage` + * layout that mirrors where Cursor actually keeps it relative to its app-data root. + */ + +import { homedir } from 'os'; +import { join } from 'path'; +import { resolveHomeDir } from '@/utils/paths.js'; + +/** `~/.cursor`, or `$CURSOR_HOME` when set. */ +export function getCursorHome(): string { + const override = process.env.CURSOR_HOME?.trim(); + if (override) { + return override; + } + return resolveHomeDir('.cursor'); +} + +/** Directory holding one subdirectory per project, each keyed by a slug of its path. */ +export function getCursorProjectsRoot(): string { + return join(getCursorHome(), 'projects'); +} + +/** Cursor's AI-tracking SQLite database — the only local source of model and timing data. */ +export function getCursorTrackingDbPath(): string { + return join(getCursorHome(), 'ai-tracking', 'ai-code-tracking.db'); +} + +/** + * Cursor's (VS Code-derived) per-user application-data directory: the real, OS-specific home + * of `state.vscdb`. Not `~/.cursor` — that is Cursor's own project/tracking data, a separate + * tree from the editor shell's VS Code-inherited state. + */ +function getCursorAppDataDir(): string { + const home = homedir(); + switch (process.platform) { + case 'darwin': + return join(home, 'Library', 'Application Support', 'Cursor'); + case 'win32': { + const appData = process.env.APPDATA; + return appData ? join(appData, 'Cursor') : join(home, 'AppData', 'Roaming', 'Cursor'); + } + default: + // Linux and other Unix-likes. + return join(home, '.config', 'Cursor'); + } +} + +/** + * `state.vscdb` — the undocumented internal store `composerHeaders` (session discovery) and + * `cursorDiskKV` (per-turn enrichment) live in. `$CURSOR_HOME`, when set, relocates it under + * `User/globalStorage` the same way it relocates `projects/` and `ai-tracking/`, which is what + * lets tests point it at a fixture tree instead of the real per-OS app-data directory. + */ +export function getCursorStateDbPath(): string { + const override = process.env.CURSOR_HOME?.trim(); + const root = override ?? getCursorAppDataDir(); + return join(root, 'User', 'globalStorage', 'state.vscdb'); +} diff --git a/src/agents/plugins/cursor/cursor.plugin.ts b/src/agents/plugins/cursor/cursor.plugin.ts new file mode 100644 index 000000000..acee12bc7 --- /dev/null +++ b/src/agents/plugins/cursor/cursor.plugin.ts @@ -0,0 +1,62 @@ +/** + * Cursor agent plugin — analytics only. + * + * Cursor is an IDE that CodeMie neither installs, launches, configures nor updates; it is + * read for the analytics report and nothing else. That is exactly what `analyticsOnly: true` + * declares, and it is load-bearing in two places: + * + * - `AgentRegistry.getManageableAgents()` filters on it, which keeps Cursor out of every + * management surface (install, uninstall, update, list, doctor, first-run). `codemie update` + * in particular would otherwise run `npm install -g` against a package Cursor does not have. + * - the analytics ownership gate in `native-loader.ts` applies to Cursor like any other agent: + * a session CodeMie cannot prove it launched is external, so Cursor sessions are opt-in + * behind `--include-external`. Since CodeMie never launches Cursor today, that is all of + * them; set one up through CodeMie and the ownership marker makes it show with no flag. + * + * There is therefore no npm package, no CLI command, no env mapping and no provider list — + * none of the launch machinery is ever reached. The plugin exists solely to hand the registry + * a session adapter. + */ + +import type { AgentMetadata } from '../../core/types.js'; +import { BaseAgentAdapter } from '../../core/BaseAgentAdapter.js'; +import type { SessionAdapter } from '../../core/session/BaseSessionAdapter.js'; +import { CURSOR_AGENT_NAME, CURSOR_DISPLAY_NAME } from './cursor.constants.js'; +import { CursorSessionAdapter } from './cursor.session.js'; + +export const CursorPluginMetadata: AgentMetadata = { + name: CURSOR_AGENT_NAME, + displayName: CURSOR_DISPLAY_NAME, + description: 'Cursor - AI code editor; read for analytics, never managed by CodeMie', + npmPackage: null, + cliCommand: null, + dataPaths: { + home: '.cursor', + }, + envMapping: { + baseUrl: [], + apiKey: [], + model: [], + }, + supportedProviders: [], + analyticsOnly: true, +}; + +export class CursorPlugin extends BaseAgentAdapter { + private sessionAdapter: SessionAdapter | null = null; + + constructor() { + super(CursorPluginMetadata); + } + + /** + * Built lazily: a `codemie` run that never touches analytics should not pay to construct + * the adapter, and the registry instantiates every plugin at startup. + */ + getSessionAdapter(): SessionAdapter { + if (!this.sessionAdapter) { + this.sessionAdapter = new CursorSessionAdapter(this.metadata); + } + return this.sessionAdapter; + } +} diff --git a/src/agents/plugins/cursor/cursor.session.ts b/src/agents/plugins/cursor/cursor.session.ts new file mode 100644 index 000000000..50c23cfbe --- /dev/null +++ b/src/agents/plugins/cursor/cursor.session.ts @@ -0,0 +1,881 @@ +/** + * Cursor session adapter — analytics-only. + * + * Discovery is keyed on `composerId`, the identifier Cursor uses for one agent conversation + * across every local store it writes: `state.vscdb`'s `composerHeaders` table (primary; + * undocumented VS Code/Cursor state, fail-soft), the + * `~/.cursor/projects//agent-transcripts//.jsonl` + * transcript (secondary, joined by the shared id), and `ai_code_hashes.conversationId` in the + * AI-tracking database (enrichment, same join). A session can have a header with no transcript + * (most of them — transcripts cover a small fraction of real sessions), a transcript with no + * header (observed rarely — schema drift, a header row Cursor pruned), or both; discovery + * unions the two id sets rather than requiring either alone. + * + * `composerHeaders` is what makes project path, branch and line counts trustworthy: + * `workspaceIdentifier.uri.fsPath` names the project directly, `activeBranch.branchName` / + * `createdOnBranch` name the real branch, and `totalLinesAdded` / `totalLinesRemoved` / + * `filesChangedCount` are Cursor's own totals rather than something reconstructed from content + * hashes. Only when a session has no header row (transcript-only) does the adapter fall back + * to the slug-walk project-path guess this file used to rely on for every session — see + * {@link projectPathFromSlug}. + * + * A transcript, when one exists, still supplies what neither store does: role-tagged text, + * tool_use blocks, turn markers, and a human-readable stamp on each prompt. It carries no model + * and no token counts. So: + * + * - the activity window prefers the header's own timestamps, then Cursor's recorded first/last + * edit, then the prompt stamps, and only then the transcript file's birthtime/mtime — file + * times measure when the file was touched, so a conversation resumed days later would + * otherwise report a span of days rather than of minutes; + * - messages are emitted deliberately WITHOUT per-message timestamps, so the native loader + * falls back to the descriptor's window instead of a fabricated per-message clock; + * - `usageMeta.usageUnavailableReason` is set only when `cursorDiskKV`'s bubbles carried no + * token signal at all for the session — see {@link resolveUsageMeta} — which is what makes + * the report render tokens and cost as unmeasurable rather than as a confident zero for the + * (large) majority of sessions the sparse per-turn token data never touches. + * + * Model and edited-file lists still come from the AI-tracking database, joined by the same + * `composerId`/`conversationId` — see {@link CursorSessionAdapter.setTrackingIndex}. Per-tool + * call outcomes (success/failure) and partial token pricing come from `state.vscdb`'s + * `cursorDiskKV` table (`bubbleId::` rows), joined the same way — see + * `cursor.bubbles.ts`. When any of these stores is missing, locked, on a runtime without + * `node:sqlite`, or schema-drifted, the join simply finds nothing and the session degrades to + * whatever the remaining sources supply. + * + * Messages are emitted in the Claude-shaped `{type, message: {role, content}}` form (with + * `gitBranch` stamped alongside `message` — see {@link applyBranch}) on purpose: + * `synthesizeRawSession` in `src/cli/commands/analytics/native-loader.ts` uses that shape for + * its default branch, so Cursor needs no per-agent case there. + * + * Everything is read-only and fail-soft. A missing Cursor home yields zero sessions, never an + * error — analytics for every other agent must survive Cursor not being installed. + */ + +import { existsSync, readdirSync, statSync } from 'fs'; +import { basename, dirname, isAbsolute, join, sep } from 'path'; +import type { + SessionAdapter, + ParsedSession, + AggregatedResult, + SessionDiscoveryOptions, + SessionDescriptor, +} from '../../core/session/BaseSessionAdapter.js'; +import type { + SessionProcessor, + ProcessingContext, + ProcessingResult, +} from '../../core/session/BaseProcessor.js'; +import type { AgentMetadata } from '../../core/types.js'; +import { CURSOR_AGENT_NAME } from './cursor.constants.js'; +import { getCursorProjectsRoot } from './cursor.paths.js'; +import type { CursorConversationActivity, CursorTrackingIndex } from './cursor.tracking-db.js'; +import { readCursorTrackingIndex } from './cursor.tracking-db.js'; +import type { CursorComposerHeader, CursorComposerIndex } from './cursor.state-db.js'; +import { readCursorComposerIndex } from './cursor.state-db.js'; +import type { CursorBubbleSummary } from './cursor.bubbles.js'; +import { readCursorBubbles } from './cursor.bubbles.js'; +import type { CursorMessageLine, CursorTranscriptLine } from './cursor.transcript.js'; +import { + contentBlocks, + isMessageLine, + readCursorTranscript, + transcriptStampWindow, + userQueryText, +} from './cursor.transcript.js'; +import { logger } from '@/utils/logger.js'; + +const DEFAULT_MAX_AGE_DAYS = 30; +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +/** Subdirectory of a Cursor project directory that holds agent conversations. */ +const TRANSCRIPTS_DIR = 'agent-transcripts'; + +/** + * Why a Cursor session has no priced usage — used only when `cursorDiskKV` carried no token + * signal for it at all (see {@link resolveUsageMeta}; most sessions, since the per-turn + * `tokenCount` field is present on roughly 1% of bubbles). Reporting zero cost + * would read as "this session was free"; the reason string makes the report say "unmeasurable" + * instead. + */ +const NO_USAGE_REASON = + "Cursor records token usage on only a sparse fraction of turns — this session's bubbles carried none, so cost cannot be derived"; + +/** Trailing-separator-insensitive directory comparison. */ +function sameDir(a: string | undefined, b: string): boolean { + if (!a) { + return false; + } + return a.replace(/[/\\]+$/, '') === b.replace(/[/\\]+$/, ''); +} + +/** + * Best-effort project path for a Cursor project slug. + * + * The slug is lossy in two directions at once: Cursor replaces `/` and `_` alike with `-`, and + * a directory name may contain `-` of its own. So a `-` in a slug can mean any of three things, + * and splitting on it cannot work — `Users-ada_lovelace-claude-code-router` would de-slug to + * `/Users/ada/lovelace/claude/code/router`, which is nobody's project. That naive reversal is + * why nearly every session used to report no project at all. + * + * Instead of guessing at the string, this walks the filesystem and lets it decide: from the + * root, only descend into a child whose own slug matches the next tokens of the slug being + * resolved. Each step is verified against a directory that exists, so the result is Cursor's + * own naming confirmed rather than a plausible-looking reconstruction — the same principle + * {@link projectPathFromFiles} already applies to the tracking database's file paths. When no + * branch consumes the whole slug the session stays silent about its project: an honest gap + * still beats a wrong answer. + */ +function projectPathFromSlug(slug: string, cache?: Map): string | undefined { + if (cache?.has(slug)) { + return cache.get(slug); + } + const matches = descendMatchingSlug(sep, slug.split('-')); + if (matches.length > 1) { + logger.debug(`[cursor-discovery] slug ${slug} matches ${matches.length} directories — reporting no project`); + } + const resolved = matches.length === 1 ? matches[0] : undefined; + cache?.set(slug, resolved); + return resolved; +} + +/** + * Every existing directory reachable by consuming a slug whole, stopping at two. + * + * A child matches when its own name, slugified, equals the tokens it would have to account for. + * Recursion (rather than a single greedy pass) is what makes `foo-bar/baz` and `foo/bar-baz` + * both reachable from `foo-bar-baz`. + * + * More than one branch can succeed, because `/`, `_` and `-` all slugify to `-`: with both + * `~/work/my_app` and `~/work/my-app` on disk, one slug describes them equally well. Collecting + * a second match is how the caller learns to stay silent — attributing a session confidently to + * the wrong project is worse than reporting none. Two is enough to know it is ambiguous, and + * stopping there keeps the walk from exploring a tree it has already disqualified. + * + * Terminating: every step consumes at least one token, so depth is bounded by the token count + * even if a symlink points back up the tree. + */ +function descendMatchingSlug(dir: string, tokens: string[], found: string[] = []): string[] { + if (tokens.length === 0) { + found.push(dir); + return found; + } + for (const name of readDirNames(dir)) { + if (found.length >= 2) { + break; + } + const nameTokens = slugForPath(name).split('-'); + if (nameTokens.length > tokens.length) { + continue; + } + if (!nameTokens.every((token, i) => token === tokens[i])) { + continue; + } + descendMatchingSlug(join(dir, name), tokens.slice(nameTokens.length), found); + } + return found; +} + +/** The slug Cursor would have written for a directory: leading separator dropped, `/` and `_` → `-`. */ +function slugForPath(dir: string): string { + return dir.replace(/^[/\\]+/, '').replace(/[/\\_]/g, '-'); +} + +/** Deepest directory that is an ancestor of (or equal to) both paths. */ +function commonAncestor(a: string, b: string): string { + const left = a.split(sep); + const right = b.split(sep); + const shared: string[] = []; + for (let i = 0; i < Math.min(left.length, right.length) && left[i] === right[i]; i++) { + shared.push(left[i]); + } + return shared.join(sep) || sep; +} + +/** + * Project root for a conversation, recovered from the absolute paths the AI-tracking database + * recorded for it. + * + * The slug alone cannot be reversed (see {@link projectPathFromSlug}), and the files' common + * directory alone is not the project root either — a conversation that only touched `src/` + * yields `/src`. Combining the two settles it: walk up from the common directory until a + * directory slugifies back to the slug Cursor filed the conversation under. That match is a + * verification against Cursor's own naming, not a guess, so the answer is exact even for slugs + * whose `-` came from a `_`. No match means we stay silent and let the caller fall back. + */ +function projectPathFromFiles(slug: string, files: string[]): string | undefined { + const absolute = files.filter((file) => isAbsolute(file)); + if (absolute.length === 0) { + return undefined; + } + + let dir = absolute.map(dirname).reduce(commonAncestor); + for (;;) { + if (slugForPath(dir) === slug) { + return dir; + } + const parent = dirname(dir); + if (parent === dir) { + return undefined; + } + dir = parent; + } +} + +/** + * Subdirectory names of `dir`, or an empty list when it cannot be read. + * + * Symlinked directories count. On macOS `/var` — the ancestor of every temporary directory, and + * of plenty of real project trees — is a symlink, and `isDirectory()` is false for a symlink, so + * filtering on it alone would make the slug walk give up at the first step. + */ +function readDirNames(dir: string): string[] { + try { + return readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() || (entry.isSymbolicLink() && isDirectory(join(dir, entry.name)))) + .map((entry) => entry.name); + } catch (error) { + logger.debug(`[cursor-discovery] failed to read ${dir}:`, error); + return []; + } +} + +/** Whether `path` is a directory, following symlinks. False when it cannot be stat'd. */ +function isDirectory(path: string): boolean { + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} + +/** + * When the transcript file was created and last written. + * + * Some filesystems report a zero birthtime; mtime is then the only timestamp available and + * collapses the window to a point, which is still truthful about "when this happened". + */ +function fileWindow(filePath: string): { createdAt: number; updatedAt: number } | undefined { + try { + const stats = statSync(filePath); + const updatedAt = stats.mtimeMs; + const birth = stats.birthtimeMs; + return { createdAt: birth > 0 ? birth : updatedAt, updatedAt }; + } catch (error) { + logger.debug(`[cursor-discovery] cannot stat transcript ${filePath}:`, error); + return undefined; + } +} + +/** + * When a conversation ran, best source first. + * + * Cursor's own recorded edits are the strongest signal but exist only for conversations that + * changed a file. The prompt stamps in the transcript cover the rest and still describe the + * work rather than the file, so they come before the file's own times — those measure when the + * transcript was touched and stretch a resumed conversation across the whole gap. + */ +function activityWindow( + filePath: string, + activity: CursorConversationActivity | undefined +): { createdAt: number; updatedAt: number } | undefined { + // Either end can be missing on its own — a database row can record a first edit and no last — + // so the stamps are read whenever either end is still open, not only when both are. + const needsStamps = activity?.firstEditMs === undefined || activity?.lastEditMs === undefined; + const stamps = needsStamps ? transcriptStampWindow(filePath) : undefined; + const createdAt = activity?.firstEditMs ?? stamps?.firstMs; + const updatedAt = activity?.lastEditMs ?? stamps?.lastMs; + + if (createdAt === undefined || updatedAt === undefined) { + const file = fileWindow(filePath); + if (!file) { + return undefined; + } + return { + createdAt: createdAt ?? file.createdAt, + updatedAt: Math.max(createdAt ?? file.createdAt, updatedAt ?? file.updatedAt), + }; + } + + return { createdAt, updatedAt: Math.max(createdAt, updatedAt) }; +} + +/** The Claude-shaped message the native loader's default synthesis branch understands. */ +interface CursorNativeMessage { + type: 'user' | 'assistant'; + message: { role: 'user' | 'assistant'; content: string; model?: string }; + /** Top-level, sibling to `message` — where `synthesizeRawSession` reads `m.gitBranch` from. */ + gitBranch?: string; +} + +/** + * Stamp recorded models onto assistant messages — the only place the native loader looks for a + * session's model distribution. + * + * The tracking database attributes a model to a conversation, not to a turn. When it recorded a + * single model the whole conversation demonstrably ran on it, so every assistant message + * carries it. When it recorded several, the per-turn split is unknown, so each model is counted + * once instead of being spread into a distribution Cursor never stated. When it recorded none — + * including a conversation whose only model was the literal `default`, which the reader drops — + * nothing is stamped and the report shows the model as unknown. + */ +function applyModels(messages: CursorNativeMessage[], models: string[]): void { + if (models.length === 0) { + return; + } + const assistant = messages.filter((message) => message.type === 'assistant'); + if (models.length === 1) { + for (const message of assistant) { + message.message.model = models[0]; + } + return; + } + models.slice(0, assistant.length).forEach((model, i) => { + assistant[i].message.model = model; + }); +} + +/** + * Stamp the header's real git branch onto every message — the only place the native loader's + * default synthesis looks (`messages.map((m) => m.gitBranch)`, mode-voted). One branch per + * conversation is all `composerHeaders` ever records, so every message carries the same value; + * unlike {@link applyModels} there is no multi-value case to spread across turns. + */ +function applyBranch(messages: CursorNativeMessage[], branch: string | undefined): void { + if (!branch) { + return; + } + for (const message of messages) { + message.gitBranch = branch; + } +} + +/** + * When a conversation ran, preferring `composerHeaders`'s own timestamps over anything derived. + * + * A header can record only one end of the window (Cursor's own writes are not guaranteed + * complete either). The header always wins for the end it does record; the open end falls + * through to the recorded edit times rather than mirroring the closed one, which would claim a + * zero-length session for work that plainly ran on. + */ +function resolveWindow( + header: CursorComposerHeader | undefined, + filePath: string, + activity: CursorConversationActivity | undefined +): { createdAt: number; updatedAt: number } | undefined { + if (header?.createdAt === undefined && header?.updatedAt === undefined) { + return activityWindow(filePath, activity); + } + + const createdAt = header.createdAt ?? header.updatedAt!; + if (header.updatedAt !== undefined) { + return { createdAt, updatedAt: Math.max(createdAt, header.updatedAt) }; + } + + // Only one end recorded. Mirroring `createdAt` would report a zero-length session for work + // that demonstrably continued — about half of a real `composerHeaders` table dates only its + // creation — so the weaker sources close the open end, and only that end. + const derived = activityWindow(filePath, activity); + return { createdAt, updatedAt: Math.max(createdAt, derived?.updatedAt ?? createdAt) }; +} + +/** + * The project path for a conversation: `composerHeaders`'s own `workspaceIdentifier.uri.fsPath` + * when the session has a header, with no slug-guessing needed at all — that is the whole point + * of discovering from `state.vscdb`. The slug walk only runs for a session that has a + * transcript but no header row, which is the one case left with nothing better to go on. + */ +function resolveProjectPath( + header: CursorComposerHeader | undefined, + slug: string | undefined, + activity: CursorConversationActivity | undefined, + cache: Map +): string | undefined { + if (header?.projectPath) { + return header.projectPath; + } + if (!slug) { + return undefined; + } + return projectPathFromFiles(slug, activity?.files ?? []) ?? projectPathFromSlug(slug, cache); +} + +/** + * A single synthetic file operation carrying `composerHeaders`'s aggregate line counts. + * + * The database gives Cursor's own `totalLinesAdded`/`totalLinesRemoved` for the whole + * conversation, not a per-file breakdown — there is no real path to attach them to file by + * file. Rather than inventing per-file entries, one entry stands in for the session as a whole; + * its `path` is the resolved project path when known (a real, verified directory) or a + * synthetic id-keyed marker when not, purely because the aggregator drops any file operation + * with no `path` at all. `filesChangedCount` itself rides separately on `metrics` — see + * `ParsedSession.metrics.filesChangedCount` — because the aggregator's default files-changed + * count (distinct operation paths) cannot represent an aggregate with only one synthetic entry. + */ +function aggregateLinesFileOp( + header: CursorComposerHeader | undefined, + projectPath: string | undefined, + sessionId: string +): NonNullable['fileOperations'] { + if (header?.linesAdded === undefined && header?.linesRemoved === undefined) { + return []; + } + return [ + { + type: 'edit', + path: projectPath ?? `cursor-session:${sessionId}`, + linesAdded: header.linesAdded ?? 0, + linesRemoved: header.linesRemoved ?? 0, + }, + ]; +} + +/** + * Usage provenance for a session, from its `cursorDiskKV` bubbles. + * + * Cursor's per-turn token counts are sparse (~1% of bubbles) and have no + * alignment to transcript messages, so there is nothing for a per-message reader to walk — + * unlike a fabricated confident zero, `usagePartial: true` tells the report this total + * understates the session's real usage. A session with no token signal anywhere keeps the + * existing "unmeasurable" reason instead of a $0.00 that would read as "this was free". + * + * The summed tokens are attributed to the conversation's own recorded model (from the + * AI-tracking database — the same single-value case {@link applyModels} already prefers) when + * unambiguous, or `'unknown'` when no single model is recorded; an unrecognized model name + * simply prices as unpriced rather than misattributing spend to the wrong model. + */ +function resolveUsageMeta( + bubbles: CursorBubbleSummary, + activity: CursorConversationActivity | undefined +): NonNullable { + if (!bubbles.hasTokenSignal) { + return { usageUnavailableReason: NO_USAGE_REASON }; + } + const model = activity?.models[0] ?? 'unknown'; + return { + usagePartial: true, + tokensByModel: { + [model]: { inputTokens: bubbles.totalInputTokens, outputTokens: bubbles.totalOutputTokens }, + }, + }; +} + +/** What one transcript's lines amount to, once the shape Cursor writes is set aside. */ +interface FlattenedTranscript { + messages: CursorNativeMessage[]; + userPrompts: Array<{ count: number; text: string }>; + tools: Record; +} + +/** The text of one line's content blocks, counting any tool_use it names along the way. */ +function textOfLine(line: CursorMessageLine, tools: Record): string { + const texts: string[] = []; + for (const block of contentBlocks(line)) { + if (block.type === 'tool_use') { + const name = (block as { name?: string }).name; + if (name) { + tools[name] = (tools[name] ?? 0) + 1; + } + continue; + } + const text = (block as { text?: string }).text; + if (typeof text === 'string' && text.trim()) { + texts.push(text); + } + } + return texts.join('\n'); +} + +/** + * Transcript lines as the message stream the native loader understands. + * + * Turn markers are skipped: they carry no fact the message stream does not already imply, since + * the loader derives the turn count from assistant messages. + */ +function flattenTranscript(lines: CursorTranscriptLine[]): FlattenedTranscript { + const messages: CursorNativeMessage[] = []; + const userPrompts: Array<{ count: number; text: string }> = []; + const tools: Record = {}; + + for (const line of lines) { + if (!isMessageLine(line)) { + continue; + } + const role = line.role === 'assistant' ? 'assistant' : line.role === 'user' ? 'user' : undefined; + if (!role) { + continue; + } + + const joined = textOfLine(line, tools); + // Cursor wraps a prompt in /; unwrap it so the report's session + // title reads as the user's question rather than as a date. + const content = role === 'user' ? (userQueryText(joined) ?? joined) : joined; + if (!content.trim()) { + continue; + } + + messages.push({ type: role, message: { role, content } }); + if (role === 'user') { + userPrompts.push({ count: 1, text: content }); + } + } + + return { messages, userPrompts, tools }; +} + +/** + * Files the agent wrote, as file operations. + * + * Line counts are deliberately absent: Cursor records content hashes, not diffs, so an added or + * removed line count would have to be invented. `edit` rather than `write` because the database + * does not distinguish creating a file from changing one. + */ +function fileOperationsFrom(activity: CursorConversationActivity | undefined): NonNullable['fileOperations'] { + return (activity?.files ?? []).map((path) => ({ type: 'edit', path })); +} + +/** + * The project slug a transcript lives under, given the fixed layout + * `//agent-transcripts//.jsonl`. + */ +function slugOfTranscript(filePath: string): string { + return basename(dirname(dirname(dirname(filePath)))); +} + +/** + * A stable, never-created path for a session discovered only through `composerHeaders` — no + * transcript exists for it on disk. `parseSessionFile` takes its conversation id from the + * path's own basename (`basename(filePath, '.jsonl')`), so this has to end in + * `.jsonl` for that id round-trip to work like it does for a real transcript path; + * everything upstream of that (`readCursorTranscript`, `statSync` for the file-time fallback) + * already degrades to "no data" for a path that does not exist, so nothing downstream needs to + * know this path is synthetic. + */ +function virtualTranscriptPath(root: string, composerId: string): string { + return join(root, '.composer-only', composerId, `${composerId}.jsonl`); +} + +/** One discovered transcript: where it lives, and the project slug it lives under. */ +interface DiscoveredTranscript { + filePath: string; + slug: string; +} + +/** + * Every real transcript under `~/.cursor/projects`, keyed by conversation id. + * + * `projects/` also holds directories that are not projects at all (numeric window ids, + * `empty-window`) and project directories holding only `canvases`/`terminals`/`mcps`, so this + * keys on the presence of `agent-transcripts` rather than on the directory name — same rule the + * single-pass scan used before discovery split into "list transcripts" and "list headers". + */ +function findTranscripts(root: string): Map { + const found = new Map(); + if (!existsSync(root)) { + return found; + } + for (const slug of readDirNames(root)) { + const transcriptsRoot = join(root, slug, TRANSCRIPTS_DIR); + if (!existsSync(transcriptsRoot)) { + continue; + } + for (const conversationId of readDirNames(transcriptsRoot)) { + const filePath = join(transcriptsRoot, conversationId, `${conversationId}.jsonl`); + if (existsSync(filePath)) { + found.set(conversationId, { filePath, slug }); + } + } + } + return found; +} + +export class CursorSessionAdapter implements SessionAdapter { + readonly agentName = CURSOR_AGENT_NAME; + private processors: SessionProcessor[] = []; + + /** + * Slug → project path, for this adapter's lifetime. + * + * Resolving a slug walks the filesystem from the root, and every conversation in a project + * repeats the same slug — so without this a run pays for the walk once per session rather + * than once per project. The adapter is memoized per run, which is exactly the scope the + * answer is stable over. + */ + private readonly slugPaths = new Map(); + + /** + * Enrichment from `~/.cursor/ai-tracking/ai-code-tracking.db`, keyed by conversation id. + * + * Memoized as the in-flight promise rather than the resolved map so that discovery and every + * subsequent parse share a single database read: the plugin hands out one adapter instance + * per process (`CursorPlugin.getSessionAdapter`), and an analytics run discovers once and + * then parses each transcript, so one memo here is one read per run. Doing it inside the + * adapter — rather than making the native loader call `readCursorTrackingIndex` before + * dispatching — keeps `native-loader.ts` free of Cursor-specific code, which is the whole + * reason the Cursor adapter emits Claude-shaped output in the first place. + */ + private trackingIndexLoad?: Promise; + + /** + * Enrichment from `state.vscdb`'s `composerHeaders` table, keyed by composerId — the primary + * session-discovery source (see the module doc comment). Memoized for the same reason as + * {@link trackingIndexLoad}: one adapter instance per process, one database read per run. + */ + private composerIndexLoad?: Promise; + + constructor(private readonly metadata: AgentMetadata) {} + + /** + * Attach the AI-tracking index that supplies what a transcript cannot: the model, the edited + * files and the real edit window. + * + * The injection seam exists because loading the database is async, needs Node >= 22.5 and + * must happen once per run; tests and any future caller that already holds an index can hand + * it over and suppress the lazy read below. + */ + setTrackingIndex(index: CursorTrackingIndex): void { + this.trackingIndexLoad = Promise.resolve(index); + } + + /** + * The tracking index, reading the database on first use. + * + * `readCursorTrackingIndex` never throws — a missing, locked or schema-drifted database + * resolves to an empty map — so no failure here can cost the run its transcript-only rows. + */ + private async trackingIndex(): Promise { + this.trackingIndexLoad ??= readCursorTrackingIndex(); + return this.trackingIndexLoad; + } + + /** + * Attach the composer index directly — the `state.vscdb` counterpart of + * {@link setTrackingIndex}, for the same reasons (async load, test injection). + */ + setComposerIndex(index: CursorComposerIndex): void { + this.composerIndexLoad = Promise.resolve(index); + } + + /** + * The composer index, reading `state.vscdb` on first use. + * + * `readCursorComposerIndex` never throws — see its own contract — so a missing, locked or + * schema-drifted state database degrades discovery to transcript-only, not to zero sessions. + */ + private async composerIndex(): Promise { + this.composerIndexLoad ??= readCursorComposerIndex(); + return this.composerIndexLoad; + } + + registerProcessor(processor: SessionProcessor): void { + this.processors.push(processor); + this.processors.sort((a, b) => a.priority - b.priority); + logger.debug(`[cursor-adapter] Registered processor: ${processor.name} (priority: ${processor.priority})`); + } + + /** + * Enumerate every discoverable Cursor session, newest first. + * + * Session identity is the union of two id sets: every composerId `state.vscdb`'s + * `composerHeaders` table has a (non-draft) row for, and every composerId with a real + * transcript under `~/.cursor/projects`. Most real sessions today have a header and no + * transcript; a small, shrinking set has a transcript with no header (schema drift, a pruned + * row) and falls all the way back to the slug-walk project-path guess. Neither set alone is + * discovery — see the module doc comment. + * + * Discovery deliberately does not open transcripts: a transcript file's own stat, or the + * header's own timestamps, are enough to date and filter a session, so a run never pays to + * read a transcript it goes on to discard. + * + * The descriptor — not the parsed session — is where enrichment has to land for timing and + * project: Cursor messages carry no timestamps and no cwd, so the native loader's synthesis + * falls back to `descriptor.createdAt` / `updatedAt` / `projectPath` for exactly those three + * facts. Resolving the window here also keeps the age cutoff and the reported window + * consistent with each other. + */ + async discoverSessions(options?: SessionDiscoveryOptions): Promise { + const root = getCursorProjectsRoot(); + const transcripts = findTranscripts(root); + const [tracking, composerIndex] = await Promise.all([this.trackingIndex(), this.composerIndex()]); + + if (transcripts.size === 0 && composerIndex.size === 0) { + logger.debug(`[cursor-discovery] no Cursor sessions found (no state database, no transcripts under ${root})`); + return []; + } + + const maxAgeDays = options?.maxAgeDays ?? DEFAULT_MAX_AGE_DAYS; + const cutoffMs = Date.now() - maxAgeDays * MS_PER_DAY; + + const composerIds = new Set([...transcripts.keys(), ...composerIndex.keys()]); + const results: SessionDescriptor[] = []; + + for (const composerId of composerIds) { + const descriptor = this.describeConversation( + root, + composerId, + composerIndex.get(composerId), + transcripts.get(composerId), + tracking + ); + if (!descriptor || descriptor.createdAt < cutoffMs) { + continue; + } + if (options?.cwd && !sameDir(descriptor.projectPath, options.cwd)) { + continue; + } + results.push(descriptor); + } + + results.sort((a, b) => b.createdAt - a.createdAt); + + if (options?.limit && options.limit > 0) { + logger.debug(`[cursor-discovery] found ${results.length} session(s), returning ${options.limit}`); + return results.slice(0, options.limit); + } + + logger.debug(`[cursor-discovery] found ${results.length} session(s)`); + return results; + } + + /** + * One conversation as a descriptor, or undefined when neither source can date it. + * + * The descriptor — not the parsed session — is where the project and the window have to land: + * Cursor's messages carry no timestamps and no cwd, so the native loader's default synthesis + * reads exactly those facts off the descriptor. + */ + private describeConversation( + root: string, + composerId: string, + header: CursorComposerHeader | undefined, + transcript: DiscoveredTranscript | undefined, + tracking: CursorTrackingIndex + ): SessionDescriptor | undefined { + const filePath = transcript?.filePath ?? virtualTranscriptPath(root, composerId); + const activity = tracking.get(composerId); + const window = resolveWindow(header, filePath, activity); + if (!window) { + return undefined; + } + + return { + sessionId: composerId, + filePath, + projectPath: resolveProjectPath(header, transcript?.slug, activity, this.slugPaths), + createdAt: window.createdAt, + updatedAt: window.updatedAt, + agentName: this.agentName, + }; + } + + /** + * Parse one conversation. + * + * The conversation id is the file's own basename, which is also the key both the AI-tracking + * database and the composer index join on, so no separate correlation step is needed. A + * header-only session (see the module doc comment) has a synthetic, never-created `filePath` + * — `readCursorTranscript` and the file-time fallbacks all already degrade to "no data" for a + * path that does not exist, so nothing here needs a separate code path for that case except + * the slug walk, which has no slug to walk without a real transcript. + */ + async parseSessionFile(filePath: string, sessionId: string): Promise { + const conversationId = basename(filePath, '.jsonl'); + const hasTranscript = existsSync(filePath); + const lines = hasTranscript ? readCursorTranscript(filePath) : []; + const [activity, composerIndex, bubbles] = await Promise.all([ + this.trackingIndex().then((index) => index.get(conversationId)), + this.composerIndex(), + readCursorBubbles(conversationId), + ]); + const header = composerIndex.get(conversationId); + + const { messages, userPrompts, tools } = flattenTranscript(lines); + + applyModels(messages, activity?.models ?? []); + applyBranch(messages, header?.branch); + + const window = resolveWindow(header, filePath, activity); + const slug = hasTranscript ? slugOfTranscript(filePath) : undefined; + const projectPath = resolveProjectPath(header, slug, activity, this.slugPaths); + + logger.debug( + `[cursor-adapter] ${conversationId}: ${messages.length} message(s), ${userPrompts.length} prompt(s)` + ); + + return { + sessionId, + agentName: this.metadata.displayName, + metadata: { + projectPath, + createdAt: window === undefined ? undefined : new Date(window.createdAt).toISOString(), + updatedAt: window === undefined ? undefined : new Date(window.updatedAt).toISOString(), + branch: header?.branch, + }, + // No per-message timestamps exist, and inventing them would make the report show a + // duration Cursor never recorded. Leaving them out makes the loader fall back to the + // descriptor's file-derived window, which is the only real signal available. + messages, + usageMeta: resolveUsageMeta(bubbles, activity), + metrics: { + tools, + // Real per-tool success/failure, from cursorDiskKV's toolFormerData.status — replaces + // the old assumed-success behavior (a tool named in `tools` but absent here just means + // no bubble resolved an outcome for it, e.g. a call still 'loading' when scanned). + // Populated independent of `hasTranscript`: bubbles are keyed by composerId directly, + // so a header-only session (most of them) gets real tool outcomes too. + ...(Object.keys(bubbles.toolStatus).length > 0 && { toolStatus: bubbles.toolStatus }), + userPrompts, + fileOperations: [ + ...(fileOperationsFrom(activity) ?? []), + ...(aggregateLinesFileOp(header, projectPath, sessionId) ?? []), + ], + filesChangedCount: header?.filesChangedCount, + }, + }; + } + + /** Parse once, then run every registered processor in priority order. */ + async processSession( + filePath: string, + sessionId: string, + context: ProcessingContext + ): Promise { + const parsed = await this.parseSessionFile(filePath, sessionId); + + const processors: AggregatedResult['processors'] = {}; + const failedProcessors: string[] = []; + let totalRecords = 0; + + for (const processor of this.processors) { + if (!processor.shouldProcess(parsed)) { + continue; + } + try { + const result: ProcessingResult = await processor.process(parsed, context); + const recordsProcessed = result.metadata?.recordsProcessed ?? 0; + totalRecords += recordsProcessed; + processors[processor.name] = { + success: result.success, + message: result.message, + recordsProcessed, + }; + if (!result.success) { + failedProcessors.push(processor.name); + } + } catch (error) { + logger.error(`[cursor-adapter] Processor ${processor.name} failed:`, error); + processors[processor.name] = { + success: false, + message: error instanceof Error ? error.message : String(error), + }; + failedProcessors.push(processor.name); + } + } + + return { + success: failedProcessors.length === 0, + processors, + totalRecords, + failedProcessors, + }; + } +} diff --git a/src/agents/plugins/cursor/cursor.sqlite.ts b/src/agents/plugins/cursor/cursor.sqlite.ts new file mode 100644 index 000000000..5cfcbbf62 --- /dev/null +++ b/src/agents/plugins/cursor/cursor.sqlite.ts @@ -0,0 +1,49 @@ +/** + * Shared read-only helpers for the Cursor plugin's SQLite readers. + * + * Every reader here follows the same fail-soft contract: an absent database, an old + * Node without `node:sqlite`, a renamed table/column, or a corrupt/locked file degrades to + * "no enrichment" rather than throwing. These helpers hold the parts that were otherwise + * copy-pasted across `cursor.tracking-db.ts`, `cursor.state-db.ts`, `cursor.bubbles.ts`, and + * `cursor.usage-fetch.ts`. + */ + +import { logger } from '@/utils/logger.js'; + +/** + * `node:sqlite`, or null where it does not exist. + * + * The repository supports Node >= 20 and `node:sqlite` only landed in 22.5, so this cannot be a + * static import: on Node 20 it would throw at module load and take the whole analytics run + * down. Every caller is optional enrichment, so an older runtime simply sees no signal from + * that source. `purpose` names what is being skipped, for the debug log only. + */ +export async function loadSqlite(purpose: string): Promise { + try { + return await import('node:sqlite'); + } catch (error) { + logger.debug(`[cursor] node:sqlite unavailable — skipping ${purpose}:`, error); + return null; + } +} + +/** A non-empty string, or undefined. */ +export function asString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value : undefined; +} + +/** A finite number, or undefined. */ +export function asNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +/** A positive finite epoch-ms timestamp, or undefined. */ +export function asEpochMs(value: unknown): number | undefined { + const num = asNumber(value); + return num !== undefined && num > 0 ? num : undefined; +} + +/** A loose boolean, tolerating the `1`/`'true'`/`'1'` shapes SQLite/JSON rows use. */ +export function asBoolean(value: unknown): boolean { + return value === true || value === 1 || value === 'true' || value === '1'; +} diff --git a/src/agents/plugins/cursor/cursor.state-db.ts b/src/agents/plugins/cursor/cursor.state-db.ts new file mode 100644 index 000000000..7ca62a309 --- /dev/null +++ b/src/agents/plugins/cursor/cursor.state-db.ts @@ -0,0 +1,234 @@ +/** + * Session discovery from Cursor's internal `state.vscdb` — the `composerHeaders` table. + * + * `state.vscdb` is VS Code/Cursor's own undocumented internal state store, not a stable public + * API. `composerHeaders` holds one row per Cursor Agent conversation, keyed by `composerId` — the + * same identifier used + * as the `agent-transcripts` directory name and `ai_code_hashes.conversationId` elsewhere in + * this plugin. Its row shape is unconfirmed: it may be flat columns, or (as is common for + * VS Code/Cursor internal tables) a `key TEXT, value TEXT` pair with `value` holding a JSON + * blob. Both shapes are handled defensively below. + * + * Everything here is fail-soft by mandate, exactly like `cursor.tracking-db.ts`: an absent + * file, an absent `node:sqlite` (Node < 22.5), a renamed table or column, a corrupt file, or a + * locked database all degrade to an empty index — never a thrown error. A single malformed row + * must not lose the rest of the table. + * + * Draft sessions (`isDraft: true`) are never started, so surfacing them as discoverable + * sessions would be misleading; they are filtered out before entering the returned index. + * + * Reads are strictly read-only: the database is opened with `readOnly: true` and only + * SELECTed. + */ + +import { existsSync } from 'fs'; +import { logger } from '@/utils/logger.js'; +import { getCursorStateDbPath } from './cursor.paths.js'; +import { asBoolean, asEpochMs, asNumber, asString, loadSqlite } from './cursor.sqlite.js'; + +/** What `composerHeaders` knows about one Cursor Agent conversation. */ +export interface CursorComposerHeader { + /** The conversation id — shared with `agent-transcripts` and `ai_code_hashes`. */ + composerId: string; + /** Absolute workspace path, resolved from `workspaceIdentifier.uri.fsPath`, when present. */ + projectPath?: string; + /** Git branch the conversation ran on, when Cursor recorded one. */ + branch?: string; + /** Epoch ms the conversation was created, when recorded. */ + createdAt?: number; + /** Epoch ms the conversation was last updated (`lastUpdatedAt`), when recorded. */ + updatedAt?: number; + /** Total lines added across the conversation, when recorded. */ + linesAdded?: number; + /** Total lines removed across the conversation, when recorded. */ + linesRemoved?: number; + /** Count of files touched across the conversation, when recorded. */ + filesChangedCount?: number; +} + +/** composerId → header. An empty map means "no sessions discoverable". */ +export type CursorComposerIndex = Map; + +/** A `composerHeaders` row in either of its possible shapes — never assumed, always guarded. */ +interface ComposerRow { + key?: unknown; + value?: unknown; + composerId?: unknown; + workspaceIdentifier?: unknown; + activeBranch?: unknown; + createdOnBranch?: unknown; + createdAt?: unknown; + lastUpdatedAt?: unknown; + updatedAt?: unknown; + totalLinesAdded?: unknown; + totalLinesRemoved?: unknown; + filesChangedCount?: unknown; + isDraft?: unknown; +} + +/** + * `workspaceIdentifier.uri.fsPath` may be a plain string field, or (rarer, seen on some Cursor + * builds) a `file://…` URI string in place of the object. Both decode to the same absolute + * path; anything else is not a shape this loader recognizes and leaves `projectPath` undefined. + */ +function extractProjectPath(workspaceIdentifier: unknown): string | undefined { + if (!workspaceIdentifier || typeof workspaceIdentifier !== 'object') { + return undefined; + } + + const uri = (workspaceIdentifier as { uri?: unknown }).uri; + + if (typeof uri === 'string') { + return decodeFileUri(uri); + } + + if (uri && typeof uri === 'object') { + const fsPath = asString((uri as { fsPath?: unknown }).fsPath); + if (fsPath) { + return fsPath; + } + } + + return undefined; +} + +function decodeFileUri(uri: string): string | undefined { + if (!uri.startsWith('file://')) { + return undefined; + } + + try { + return decodeURIComponent(uri.slice('file://'.length)) || undefined; + } catch (error) { + logger.debug(`[cursor] unable to decode file URI "${uri}":`, error); + return undefined; + } +} + +function extractBranch(row: { + activeBranch?: unknown; + createdOnBranch?: unknown; +}): string | undefined { + if (row.activeBranch && typeof row.activeBranch === 'object') { + const branchName = asString((row.activeBranch as { branchName?: unknown }).branchName); + if (branchName) { + return branchName; + } + } + + return asString(row.createdOnBranch); +} + +/** + * `key` is only present on the key/value table shape and, when it names a composerId at all, + * commonly prefixes it (e.g. `composerHeaderData:`). Take the last `:`-delimited + * segment either way — a bare id round-trips through this unchanged. + */ +function composerIdFromKey(key: unknown): string | undefined { + const raw = asString(key); + if (!raw) { + return undefined; + } + const segments = raw.split(':'); + return asString(segments[segments.length - 1]); +} + +function normalizeHeader(source: ComposerRow): Omit { + return { + projectPath: extractProjectPath(source.workspaceIdentifier), + branch: extractBranch(source), + createdAt: asEpochMs(source.createdAt), + // `lastUpdatedAt` is the name Cursor's own rows use; `updatedAt` is only a fallback for a + // build that spells it the obvious way. Reading solely `updatedAt` — as this did — found + // nothing on any real row and collapsed every Cursor session to a zero-length window. + updatedAt: asEpochMs(source.lastUpdatedAt) ?? asEpochMs(source.updatedAt), + linesAdded: asNumber(source.totalLinesAdded), + linesRemoved: asNumber(source.totalLinesRemoved), + filesChangedCount: asNumber(source.filesChangedCount), + }; +} + +/** + * Build the composerId → header index, or an empty map when the database cannot be read. + * + * Never throws. + */ +export async function readCursorComposerIndex( + dbPath: string = getCursorStateDbPath() +): Promise { + const index: CursorComposerIndex = new Map(); + + if (!existsSync(dbPath)) { + logger.debug(`[cursor] no state database at ${dbPath}`); + return index; + } + + const sqlite = await loadSqlite('composer index'); + if (!sqlite) { + return index; + } + + let db: InstanceType | undefined; + try { + db = new sqlite.DatabaseSync(dbPath, { readOnly: true }); + const rows = db.prepare('SELECT * FROM composerHeaders').all() as ComposerRow[]; + + for (const row of rows) { + try { + let composerId: string | undefined; + let header: Omit; + let isDraft: unknown; + + const value = asString(row.value); + if (value !== undefined) { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch (error) { + logger.debug(`[cursor] unparsable composerHeaders value for key "${String(row.key)}":`, error); + continue; + } + + if (!parsed || typeof parsed !== 'object') { + continue; + } + + const parsedRow = parsed as ComposerRow; + composerId = asString(parsedRow.composerId) ?? composerIdFromKey(row.key); + header = normalizeHeader(parsedRow); + isDraft = parsedRow.isDraft; + } else { + composerId = asString(row.composerId); + header = normalizeHeader(row); + isDraft = row.isDraft; + } + + if (!composerId) { + continue; + } + + if (asBoolean(isDraft)) { + continue; + } + + index.set(composerId, { composerId, ...header }); + } catch (error) { + // A single malformed row must not lose the rest of the table. + logger.debug('[cursor] skipping unreadable composerHeaders row:', error); + } + } + } catch (error) { + // Missing table, renamed column, corrupt file, locked database — all the same to us. + logger.debug(`[cursor] state database unusable at ${dbPath}:`, error); + return new Map(); + } finally { + try { + db?.close(); + } catch { + // closing a database we failed to open is not an error worth reporting + } + } + + logger.debug(`[cursor] composer index covers ${index.size} session(s)`); + return index; +} diff --git a/src/agents/plugins/cursor/cursor.tracking-db.ts b/src/agents/plugins/cursor/cursor.tracking-db.ts new file mode 100644 index 000000000..ffd5dedfb --- /dev/null +++ b/src/agents/plugins/cursor/cursor.tracking-db.ts @@ -0,0 +1,141 @@ +/** + * Read-only enrichment from Cursor's AI-tracking database. + * + * A Cursor agent transcript records role-tagged text and turn markers and NOTHING else — no + * timestamps, no model, no token counts. `~/.cursor/ai-tracking/ai-code-tracking.db` is the + * only local store that carries the missing facts, and it joins to a transcript on the + * conversation id (which is the transcript's own file/directory name). + * + * Everything here is fail-soft by mandate. The schema is undocumented and Cursor may change + * it in any release, so an absent file, an absent `node:sqlite` (Node < 22.5), a renamed + * table or a renamed column all degrade to "no enrichment" — the transcripts still produce + * session rows, just without model, files or an activity window. A Cursor update must never + * break `codemie analytics`. + * + * Reads are strictly read-only: the database is opened with `readOnly` and only SELECTed. + */ + +import { existsSync } from 'fs'; +import { logger } from '@/utils/logger.js'; +import { CURSOR_AUTO_MODEL_LABEL, CURSOR_AUTO_MODEL_SENTINEL } from './cursor.constants.js'; +import { getCursorTrackingDbPath } from './cursor.paths.js'; +import { asEpochMs, asString, loadSqlite } from './cursor.sqlite.js'; + +/** What the tracking database knows about one conversation. */ +export interface CursorConversationActivity { + /** Epoch ms of the first recorded edit, when any. */ + firstEditMs?: number; + /** Epoch ms of the last recorded edit, when any. */ + lastEditMs?: number; + /** Absolute paths Cursor recorded itself as having written in this conversation. */ + files: string[]; + /** + * Models Cursor attributed edits to. + * + * The literal `default` never appears: it is Cursor's sentinel for delegated model choice and + * names no model, so it is reported as `Auto` — the term Cursor's own usage export uses for + * the same conversations. What is never done is stamping the session with whatever model + * Cursor happens to default to today. + */ + models: string[]; +} + +/** Conversation id → enrichment. An empty map means "no enrichment available". */ +export type CursorTrackingIndex = Map; + +/** + * Only `composer` rows are agent-written. `human` rows are the user's own edits that Cursor + * tracked for its AI-percentage stats, and counting them would attribute human work to the + * agent. + */ +const ACTIVITY_QUERY = ` + SELECT conversationId AS id, + fileName AS file, + model AS model, + MIN(timestamp) AS firstMs, + MAX(timestamp) AS lastMs + FROM ai_code_hashes + WHERE source = 'composer' + AND conversationId IS NOT NULL + GROUP BY conversationId, fileName, model +`; + +interface ActivityRow { + id?: unknown; + file?: unknown; + model?: unknown; + firstMs?: unknown; + lastMs?: unknown; +} + +/** + * Build the conversation → activity index, or an empty map when the database cannot be read. + * + * Never throws. + */ +export async function readCursorTrackingIndex( + dbPath: string = getCursorTrackingDbPath() +): Promise { + const index: CursorTrackingIndex = new Map(); + + if (!existsSync(dbPath)) { + logger.debug(`[cursor] no ai-tracking database at ${dbPath}`); + return index; + } + + const sqlite = await loadSqlite('tracking enrichment'); + if (!sqlite) { + return index; + } + + let db: InstanceType | undefined; + try { + db = new sqlite.DatabaseSync(dbPath, { readOnly: true }); + const rows = db.prepare(ACTIVITY_QUERY).all() as ActivityRow[]; + + for (const row of rows) { + const id = asString(row.id); + if (!id) { + continue; + } + const entry = index.get(id) ?? { files: [], models: [] }; + + const file = asString(row.file); + if (file && !entry.files.includes(file)) { + entry.files.push(file); + } + + // `default` is Cursor's sentinel for "you pick" — reported under the name Cursor's own + // dashboard gives it rather than dropped, so the row reads "Auto" instead of blank. + const raw = asString(row.model); + const model = raw === CURSOR_AUTO_MODEL_SENTINEL ? CURSOR_AUTO_MODEL_LABEL : raw; + if (model && !entry.models.includes(model)) { + entry.models.push(model); + } + + const firstMs = asEpochMs(row.firstMs); + if (firstMs !== undefined && (entry.firstEditMs === undefined || firstMs < entry.firstEditMs)) { + entry.firstEditMs = firstMs; + } + const lastMs = asEpochMs(row.lastMs); + if (lastMs !== undefined && (entry.lastEditMs === undefined || lastMs > entry.lastEditMs)) { + entry.lastEditMs = lastMs; + } + + index.set(id, entry); + } + } catch (error) { + // Missing table, renamed column, corrupt file, locked database — all the same to us. + logger.debug(`[cursor] ai-tracking database unusable at ${dbPath}:`, error); + return new Map(); + } finally { + try { + db?.close(); + } catch { + // closing a database we failed to open is not an error worth reporting + } + } + + logger.debug(`[cursor] tracking index covers ${index.size} conversation(s)`); + return index; +} diff --git a/src/agents/plugins/cursor/cursor.transcript.ts b/src/agents/plugins/cursor/cursor.transcript.ts new file mode 100644 index 000000000..7a9e987d7 --- /dev/null +++ b/src/agents/plugins/cursor/cursor.transcript.ts @@ -0,0 +1,173 @@ +/** + * Tolerant reader for a Cursor agent transcript (`.jsonl`). + * + * The format is thin and undocumented: each line is either a role-tagged message + * (`{role, message: {content: [...]}}`) or a turn marker (`{type: 'turn_ended', status}`). + * There is no model, no token count and no tool result — those come from + * `cursor.tracking-db.ts`. The one timing signal a transcript does carry is the human-readable + * `` Cursor writes ahead of every prompt; see {@link transcriptStampWindow}. + * + * A live session's final line can be truncated mid-write, so unparseable lines are dropped + * rather than thrown: one bad line must not discard a whole session. + */ + +import { readFileSync } from 'fs'; +import { logger } from '@/utils/logger.js'; + +/** A `tool_use` block inside an assistant message. */ +interface CursorToolUseBlock { + type: 'tool_use'; + name?: string; + input?: Record; +} + +/** A plain text block inside a message. */ +interface CursorTextBlock { + type: 'text'; + text?: string; +} + +export type CursorContentBlock = CursorToolUseBlock | CursorTextBlock | { type?: string }; + +/** A role-tagged transcript line. */ +export interface CursorMessageLine { + role: 'user' | 'assistant' | string; + message?: { content?: CursorContentBlock[] | string }; +} + +/** A control line, e.g. `{"type":"turn_ended","status":"success"}`. */ +interface CursorMarkerLine { + type: string; + status?: string; +} + +export type CursorTranscriptLine = CursorMessageLine | CursorMarkerLine; + +export function isMessageLine(line: CursorTranscriptLine): line is CursorMessageLine { + return typeof (line as CursorMessageLine).role === 'string'; +} + +/** The content blocks of a message, normalized to an array (a bare string becomes one text block). */ +export function contentBlocks(line: CursorMessageLine): CursorContentBlock[] { + const content = line.message?.content; + if (typeof content === 'string') { + return [{ type: 'text', text: content }]; + } + return Array.isArray(content) ? content : []; +} + +/** Read a transcript, dropping any line that is not parseable JSON. Never throws. */ +export function readCursorTranscript(filePath: string): CursorTranscriptLine[] { + let text: string; + try { + text = readFileSync(filePath, 'utf-8'); + } catch (error) { + logger.debug(`[cursor] unreadable transcript at ${filePath}:`, error); + return []; + } + + const lines: CursorTranscriptLine[] = []; + let dropped = 0; + + for (const raw of text.split('\n')) { + const trimmed = raw.trim(); + if (!trimmed) { + continue; + } + try { + const parsed: unknown = JSON.parse(trimmed); + if (parsed && typeof parsed === 'object') { + lines.push(parsed as CursorTranscriptLine); + } + } catch { + dropped++; + } + } + + if (dropped > 0) { + logger.debug(`[cursor] dropped ${dropped} unparseable line(s) in ${filePath}`); + } + return lines; +} + +const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']; + +/** + * The stamp Cursor writes ahead of every prompt, e.g. + * `Monday, Aug 31, 2026, 5:46 PM (UTC+3)`. The weekday is ignored — it + * carries no information the date does not — and the explicit offset is what makes the instant + * unambiguous. The `` wrapper is part of the pattern on purpose: a bare date shape + * also occurs in pasted logs and model output, and matching those would date the session by + * whatever text it happened to quote. + */ +const STAMP_PATTERN = + /[^<]*?([A-Z][a-z]{2})\s+(\d{1,2}),\s*(\d{4}),\s*(\d{1,2}):(\d{2})\s*(AM|PM)\s*\(UTC([+-]\d{1,2})(?::(\d{2}))?\)[^<]*?<\/timestamp>/gi; + +function pad(value: number): string { + return String(value).padStart(2, '0'); +} + +/** One Cursor stamp as epoch ms, or undefined when it is not a shape we recognise. */ +function stampToEpochMs(match: RegExpExecArray): number | undefined { + const [, month, day, year, hour12, minute, meridiem, offsetHours, offsetMinutes] = match; + const monthIndex = MONTHS.indexOf(month.toLowerCase()); + if (monthIndex < 0) { + return undefined; + } + const hour = Number(hour12) % 12 + (meridiem.toUpperCase() === 'PM' ? 12 : 0); + const offsetSign = offsetHours.startsWith('-') ? '-' : '+'; + const offset = `${offsetSign}${pad(Math.abs(Number(offsetHours)))}:${pad(Number(offsetMinutes ?? 0))}`; + const parsed = Date.parse( + `${year}-${pad(monthIndex + 1)}-${pad(Number(day))}T${pad(hour)}:${pad(Number(minute))}:00${offset}` + ); + return Number.isNaN(parsed) ? undefined : parsed; +} + +/** + * When the conversation actually ran, from the stamps Cursor writes into the prompts. + * + * This is the only in-transcript timing signal, and for a conversation the AI-tracking database + * never recorded an edit for it is the only honest one available. The alternative — the + * transcript file's birthtime and mtime — measures when the file was touched, not when the work + * happened, so a conversation resumed days later reports a span of days instead of of minutes. + * + * Scans the raw text rather than the parsed lines: this runs during discovery, for sessions that + * may yet be filtered out, so it must not pay for JSON parsing. Undefined when nothing is + * stamped, which is the caller's cue to fall back to file times. + */ +export function transcriptStampWindow(filePath: string): { firstMs: number; lastMs: number } | undefined { + let text: string; + try { + text = readFileSync(filePath, 'utf-8'); + } catch (error) { + logger.debug(`[cursor] unreadable transcript at ${filePath}:`, error); + return undefined; + } + + let firstMs: number | undefined; + let lastMs: number | undefined; + for (const match of text.matchAll(STAMP_PATTERN)) { + const ms = stampToEpochMs(match); + if (ms === undefined) { + continue; + } + firstMs = firstMs === undefined || ms < firstMs ? ms : firstMs; + lastMs = lastMs === undefined || ms > lastMs ? ms : lastMs; + } + + return firstMs === undefined || lastMs === undefined ? undefined : { firstMs, lastMs }; +} + +/** + * The user's own words in a Cursor user message. + * + * Cursor wraps every prompt in `` and ``. + * Returning the raw text would make the report's session title read as a date, so the query + * is unwrapped here. Text with no `` is an injected continuation prompt + * (subagent hand-offs, "briefly inform the user…") rather than something the user typed. + */ +export function userQueryText(text: string): string | undefined { + const match = /([\s\S]*?)<\/user_query>/i.exec(text); + const query = match?.[1]?.trim(); + return query ? query : undefined; +} diff --git a/src/agents/plugins/cursor/cursor.usage-csv.ts b/src/agents/plugins/cursor/cursor.usage-csv.ts new file mode 100644 index 000000000..b4288896b --- /dev/null +++ b/src/agents/plugins/cursor/cursor.usage-csv.ts @@ -0,0 +1,275 @@ +/** + * Cursor usage-events CSV import — the member path to real Cursor tokens and cost. + * + * Cursor's local stores stopped carrying billable token counts (see docs/CURSOR_INTEGRATION.md). + * The dashboard's Usage → Export CSV still has them: a real + * 2026-09-05 export held 39,952,466 tokens and $25.25 of Cost across 61 events. + * + * The trap this module exists to avoid: **every one of those 61 rows was `Kind=Included`.** + * `Included` is Cursor's billing *category* — "covered by your plan" — not a statement that the + * usage was free or unmeasured. Reading it as "no cost" would discard the only accurate usage + * figures available. So `Kind` is recorded and never used to zero anything out. + * + * Two export shapes are in the wild and both must parse: most exports end with a `Cost` column, + * while at least one variant ships `Requests` instead and has no cost at all. Tokens are the + * durable part; cost is optional. + * + * Rows are per-event with no composerId, so they cannot be joined to local sessions by id. This + * module's job ends at parsing; `cursor-usage-loader.ts` matches the events to sessions by time + * and converts them into the shapes the analytics pipeline consumes. + */ + +import { readFileSync } from 'node:fs'; +import { logger } from '@/utils/logger.js'; + +/** One usage event, normalized. Field names are ours; the CSV's are not stable enough to expose. */ +export interface CursorUsageEvent { + /** ISO timestamp as written by the export. */ + date: string; + /** + * Local day key (YYYY-MM-DD). Derived in local time, not by slicing the UTC timestamp, so + * these buckets line up with every other day-grouped view in the report — an evening event + * must not land on tomorrow here and today everywhere else. + */ + day: string; + user: string; + /** Cursor's billing category — `Included`, `On-Demand`, … Recorded, never used to zero usage. */ + kind: string; + model: string; + tokens: CursorUsageTokens; + /** USD from the `Cost` column; 0 when the export variant has no such column. */ + costUSD: number; +} + +export interface CursorUsageTokens { + /** `Input (w/o Cache Write)` — plain prompt tokens. */ + input: number; + /** `Input (w/ Cache Write)` — prompt tokens that also populated the cache. */ + cacheCreation: number; + cacheRead: number; + output: number; + total: number; +} + +/** Rolled-up usage for one key (a model, a day, …). */ +export interface CursorUsageBucket { + events: number; + tokens: CursorUsageTokens; + costUSD: number; +} + +export type CursorUsageGroup = CursorUsageBucket & { model: string }; +export type CursorUsageDay = CursorUsageBucket & { day: string }; + +export interface CursorUsageImport { + events: CursorUsageEvent[]; + totals: { events: number; tokens: CursorUsageTokens; costUSD: number }; + byModel: CursorUsageGroup[]; + byDay: CursorUsageDay[]; + /** False for the export variant that ships `Requests` instead of `Cost`. */ + hasCost: boolean; + /** Every distinct `User` seen BEFORE filtering — lets a caller explain an empty result. */ + usersInFile: string[]; + /** How many rows the user filter removed, so "imported nothing" is never silent. */ + droppedByUserFilter: number; + sourceFile?: string; +} + +export interface ParseOptions { + /** When set, keep only rows whose `User` matches (case-insensitive). */ + userEmail?: string; +} + +/** Columns that must all be present for a file to be a usage export rather than some other CSV. */ +const REQUIRED_COLUMNS = ['Date', 'Kind', 'Model', 'Total Tokens']; + +function emptyTokens(): CursorUsageTokens { + return { input: 0, cacheCreation: 0, cacheRead: 0, output: 0, total: 0 }; +} + +function addTokens(a: CursorUsageTokens, b: CursorUsageTokens): CursorUsageTokens { + return { + input: a.input + b.input, + cacheCreation: a.cacheCreation + b.cacheCreation, + cacheRead: a.cacheRead + b.cacheRead, + output: a.output + b.output, + total: a.total + b.total, + }; +} + +/** + * Minimal RFC4180 reader — the export quotes every field and its prompts never appear, but a + * model name or a future column could still carry a comma or an escaped quote. + */ +function parseCsv(text: string): string[][] { + const rows: string[][] = []; + let row: string[] = []; + let field = ''; + let quoted = false; + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (quoted) { + if (c === '"') { + if (text[i + 1] === '"') { + field += '"'; + i++; + } else { + quoted = false; + } + } else { + field += c; + } + } else if (c === '"') { + quoted = true; + } else if (c === ',') { + row.push(field); + field = ''; + } else if (c === '\n') { + row.push(field); + field = ''; + rows.push(row); + row = []; + } else if (c !== '\r') { + field += c; + } + } + if (field.length || row.length) { + row.push(field); + rows.push(row); + } + return rows.filter((r) => r.some((f) => f.trim() !== '')); +} + +function num(v: string | undefined): number { + const n = Number(String(v ?? '').replace(/,/g, '').trim()); + return Number.isFinite(n) ? n : 0; +} + +/** Local YYYY-MM-DD for an export timestamp; empty when it is unparseable. */ +function localDay(iso: string): string { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) { + return iso.slice(0, 10); + } + const pad = (n: number): string => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +/** + * Cost cells are mostly plain decimals but the export also writes words such as `Free` (two of + * them in the verified 61-event export). Pull the first number out and treat anything wordy as + * zero rather than NaN-poisoning the total. + * + * Thousands separators are stripped FIRST: matching a number out of `1,234.50` without doing so + * yields `1`, which is far worse than a NaN because it is silently plausible. + */ +function money(v: string | undefined): number { + const m = /-?\d+(?:\.\d+)?/.exec(String(v ?? '').replace(/,/g, '')); + return m ? Number(m[0]) : 0; +} + +/** Parse the text of a Cursor usage export. Returns null when it is not one. */ +export function parseCursorUsageCsv(text: string, options: ParseOptions = {}): CursorUsageImport | null { + const rows = parseCsv(text); + if (rows.length < 2) { + return null; + } + const header = rows[0].map((h) => h.trim()); + if (!REQUIRED_COLUMNS.every((c) => header.includes(c))) { + return null; + } + const at = (r: string[], col: string): string | undefined => { + const i = header.indexOf(col); + return i === -1 ? undefined : r[i]; + }; + + const hasCost = header.includes('Cost'); + // Only filter when the export actually identifies users. A personal export can omit the column + // entirely, and filtering an absent column would drop every row of a perfectly valid file — + // there is no one else's data in it to exclude. + const hasUser = header.includes('User'); + const wanted = hasUser ? options.userEmail?.trim().toLowerCase() : undefined; + const usersInFile = new Set(); + const events: CursorUsageEvent[] = []; + let droppedByUserFilter = 0; + + for (const r of rows.slice(1)) { + const user = (at(r, 'User') ?? '').trim(); + if (user) { + usersInFile.add(user); + } + if (wanted && user.toLowerCase() !== wanted) { + droppedByUserFilter++; + continue; + } + const date = (at(r, 'Date') ?? '').trim(); + const tokens: CursorUsageTokens = { + input: num(at(r, 'Input (w/o Cache Write)')), + cacheCreation: num(at(r, 'Input (w/ Cache Write)')), + cacheRead: num(at(r, 'Cache Read')), + output: num(at(r, 'Output Tokens')), + total: num(at(r, 'Total Tokens')), + }; + events.push({ + date, + day: localDay(date), + user, + kind: (at(r, 'Kind') ?? '').trim(), + model: (at(r, 'Model') ?? '').trim(), + tokens, + costUSD: hasCost ? money(at(r, 'Cost')) : 0, + }); + } + + const group = (keyOf: (e: CursorUsageEvent) => K): Map => { + const m = new Map(); + for (const e of events) { + const k = keyOf(e); + const cur = m.get(k) ?? { events: 0, tokens: emptyTokens(), costUSD: 0 }; + cur.events++; + cur.tokens = addTokens(cur.tokens, e.tokens); + cur.costUSD += e.costUSD; + m.set(k, cur); + } + return m; + }; + + const byModel = [...group((e) => e.model).entries()] + .map(([model, v]) => ({ model, ...v })) + .sort((a, b) => b.tokens.total - a.tokens.total); + const byDay = [...group((e) => e.day).entries()] + .map(([day, v]) => ({ day, ...v })) + .sort((a, b) => a.day.localeCompare(b.day)); + + return { + events, + totals: { + events: events.length, + tokens: events.reduce((acc, e) => addTokens(acc, e.tokens), emptyTokens()), + costUSD: events.reduce((acc, e) => acc + e.costUSD, 0), + }, + byModel, + byDay, + hasCost, + usersInFile: [...usersInFile], + droppedByUserFilter, + }; +} + +/** + * Read and parse an export from disk. Never throws: an unreadable or wrong-shaped file omits the + * section and leaves the local report — the part that always works — untouched. + */ +export function loadCursorUsageCsv(path: string, options: ParseOptions = {}): CursorUsageImport | null { + try { + const parsed = parseCursorUsageCsv(readFileSync(path, 'utf-8'), options); + if (!parsed) { + logger.debug(`[cursor] usage CSV ${path} is not a Cursor usage export (unexpected columns)`); + return null; + } + return { ...parsed, sourceFile: path }; + } catch (error) { + logger.debug(`[cursor] usage CSV ${path} unreadable: ${(error as Error).message}`); + return null; + } +} diff --git a/src/agents/plugins/cursor/cursor.usage-fetch.ts b/src/agents/plugins/cursor/cursor.usage-fetch.ts new file mode 100644 index 000000000..b9b5eb8b3 --- /dev/null +++ b/src/agents/plugins/cursor/cursor.usage-fetch.ts @@ -0,0 +1,192 @@ +/** + * Cookie-authenticated fetch of the Cursor usage export. + * + * This is a convenience wrapper around exactly one thing: getting the same CSV that + * `--cursor-usage-csv` reads from disk, without the operator clicking Export by hand. It parses + * the result through {@link parseCursorUsageCsv} — one code path, so the fetched and the + * downloaded file can never diverge in interpretation. + * + * Three deliberate constraints, because this handles a live session credential: + * + * 1. **The endpoint is not hardcoded.** Cursor's dashboard export is undocumented and can change + * or vanish without notice, so CodeMie ships no URL and asserts nothing about one: the + * operator supplies `CURSOR_USAGE_EXPORT_URL`. A product that bakes in an undocumented + * endpoint quietly breaks when the vendor moves it; this one simply does nothing. + * 2. **Auth is the browser session cookie, never a `crsr_` API key.** The Team Analytics admin + * key is rejected by this endpoint (401) and is a different credential for a different API. + * 3. **The token never reaches a log line.** It is read, put in one header, and dropped. Failure + * messages name the status code and the endpoint host, never the credential. + * + * File import remains the supported path. This is strictly opt-in and fail-soft: anything that + * goes wrong omits the section and leaves the local report — which always works — untouched. + * + * On where the cookie comes from: it is a *browser* cookie for cursor.com, so on a signed-in + * machine it lives in the Electron app's Chromium cookie jar, encrypted against the OS keychain. + * CodeMie does not decrypt that — prying a credential out of another application's protected + * store is not something an analytics command should do. {@link readCursorSessionCookie} makes a + * cheap, read-only attempt at Cursor's own plaintext state database (harmless if it finds + * nothing), and otherwise the operator supplies the value explicitly via `CURSOR_SESSION_TOKEN`, + * which keeps handing over a credential a deliberate act. + */ + +import { existsSync } from 'node:fs'; +import { logger } from '@/utils/logger.js'; +import { getCursorStateDbPath } from './cursor.paths.js'; +import { loadSqlite } from './cursor.sqlite.js'; +import { parseCursorUsageCsv, type CursorUsageImport } from './cursor.usage-csv.js'; + +/** The cookie Cursor's dashboard authenticates with: `::`. */ +const COOKIE_NAME = 'WorkosCursorSessionToken'; + +/** + * A session cookie value is `::`. Matching on the shape rather than on a fixed + * key name means a renamed storage key does not silently break the reader — and, more + * importantly, that we never treat some other opaque secret as if it were this one. + */ +const COOKIE_SHAPE = /^[\w-]+::[\w-]+\.[\w-]+\.[\w-]+$/; + +export interface UsageFetchRequest { + /** Explicit per-invocation opt-in. A readable cookie on disk is never sufficient by itself. */ + enabled: boolean; + /** Operator-supplied export endpoint. Absent means no request is made. */ + exportUrl?: string; + /** `::`, normally from {@link readCursorSessionCookie}. */ + cookie?: string; + startDate?: string; + endDate?: string; + /** Restrict imported rows to one `User` value, as the file import does. */ + userEmail?: string; +} + +type FetchLike = ( + url: string, + init?: { headers?: Record } +) => Promise<{ ok: boolean; status: number; text: () => Promise }>; + +export interface UsageFetchDeps { + fetch: FetchLike; +} + +/** Host only — enough to debug a failure without ever naming the credential. */ +function hostOf(url: string): string { + try { + return new URL(url).host; + } catch { + return 'the configured endpoint'; + } +} + +/** + * Read the signed-in session cookie out of Cursor's own state database. + * + * Read-only and fail-soft: an absent file, an old Node, a renamed table, + * a corrupt or locked database, or simply not being signed in all return `undefined` rather + * than throwing. Candidate rows are matched on {@link COOKIE_SHAPE}, so a storage-key rename + * does not break this and no unrelated secret is mistaken for the cookie. + * + * Returns the raw cookie value. Callers must not log it. + */ +export async function readCursorSessionCookie( + dbPath: string = getCursorStateDbPath(), + env: NodeJS.ProcessEnv = process.env +): Promise { + // An explicitly-provided value always wins: it is the documented way in, and it means the + // operator chose to hand over the credential rather than having it lifted from an app store. + const supplied = env.CURSOR_SESSION_TOKEN?.trim(); + if (supplied) { + if (COOKIE_SHAPE.test(supplied)) { + return supplied; + } + logger.debug('[cursor] CURSOR_SESSION_TOKEN is set but is not of the form ::'); + return undefined; + } + + if (!existsSync(dbPath)) { + logger.debug('[cursor] no state database; cannot read the session cookie'); + return undefined; + } + const sqlite = await loadSqlite('the session cookie read'); + if (!sqlite) { + return undefined; + } + + let db: InstanceType | undefined; + try { + db = new sqlite.DatabaseSync(dbPath, { readOnly: true }); + const rows = db + .prepare( + `SELECT value FROM ItemTable + WHERE key LIKE ? OR key LIKE 'cursorAuth%'` + ) + .all(`%${COOKIE_NAME}%`) as { value?: unknown }[]; + for (const row of rows) { + const value = typeof row.value === 'string' ? row.value.trim() : undefined; + if (value && COOKIE_SHAPE.test(value)) { + return value; + } + } + logger.debug('[cursor] no session cookie found in the state database (signed out?)'); + return undefined; + } catch (error) { + logger.debug(`[cursor] state database unusable while reading the session cookie: ${(error as Error).message}`); + return undefined; + } finally { + try { + db?.close(); + } catch { + /* closing a failed open is not an error worth reporting */ + } + } +} + +/** + * Fetch and parse the usage export. Returns `null` whenever the gate is shut or anything at all + * goes wrong — the caller simply omits the section. + */ +export async function fetchCursorUsageExport( + req: UsageFetchRequest, + deps: UsageFetchDeps = { fetch: globalThis.fetch as unknown as FetchLike } +): Promise { + if (!req.enabled || !req.exportUrl || !req.cookie) { + logger.debug('[cursor] usage export fetch skipped (needs the opt-in flag, an export URL, and a session cookie)'); + return null; + } + + let url: string; + try { + const u = new URL(req.exportUrl); + if (req.startDate) { + u.searchParams.set('startDate', req.startDate); + } + if (req.endDate) { + u.searchParams.set('endDate', req.endDate); + } + url = u.toString(); + } catch { + logger.debug('[cursor] CURSOR_USAGE_EXPORT_URL is not a valid URL'); + return null; + } + + try { + const res = await deps.fetch(url, { + headers: { Cookie: `${COOKIE_NAME}=${req.cookie}`, Accept: 'text/csv' }, + }); + if (!res.ok) { + // Status and host only — naming the credential here is how secrets end up in bug reports. + logger.debug(`[cursor] usage export fetch returned HTTP ${res.status} from ${hostOf(url)}`); + return null; + } + const parsed = parseCursorUsageCsv(await res.text(), { + ...(req.userEmail !== undefined && { userEmail: req.userEmail }), + }); + if (!parsed) { + // A sign-in redirect returns 200 with an HTML body; that is not an export. + logger.debug(`[cursor] usage export response from ${hostOf(url)} was not a usage CSV`); + return null; + } + return parsed; + } catch (error) { + logger.debug(`[cursor] usage export fetch failed against ${hostOf(url)}: ${(error as Error).message}`); + return null; + } +} diff --git a/src/agents/plugins/cursor/index.ts b/src/agents/plugins/cursor/index.ts new file mode 100644 index 000000000..b05916fa1 --- /dev/null +++ b/src/agents/plugins/cursor/index.ts @@ -0,0 +1,20 @@ +export { CursorPlugin, CursorPluginMetadata } from './cursor.plugin.js'; +export { + CURSOR_AGENT_NAME, + CURSOR_AUTO_MODEL_LABEL, + CURSOR_AUTO_MODEL_SENTINEL, + CURSOR_DISPLAY_NAME, +} from './cursor.constants.js'; +export { CursorSessionAdapter } from './cursor.session.js'; +export { + getCursorHome, + getCursorProjectsRoot, + getCursorTrackingDbPath, + getCursorStateDbPath, +} from './cursor.paths.js'; +export { readCursorTrackingIndex } from './cursor.tracking-db.js'; +export type { CursorConversationActivity, CursorTrackingIndex } from './cursor.tracking-db.js'; +export { readCursorComposerIndex } from './cursor.state-db.js'; +export type { CursorComposerHeader, CursorComposerIndex } from './cursor.state-db.js'; +export { readCursorBubbles } from './cursor.bubbles.js'; +export type { CursorBubbleSummary } from './cursor.bubbles.js'; diff --git a/src/agents/registry.ts b/src/agents/registry.ts index d0a655de4..7a12e8892 100644 --- a/src/agents/registry.ts +++ b/src/agents/registry.ts @@ -9,6 +9,7 @@ import { KimiPlugin } from './plugins/kimi/kimi.plugin.js'; import { KimiAcpPlugin } from './plugins/kimi/kimi-acp.plugin.js'; import { OpenWikiPlugin } from './plugins/openwiki/openwiki.plugin.js'; import { CopilotCliPlugin } from './plugins/copilot-cli/index.js'; +import { CursorPlugin } from './plugins/cursor/index.js'; import { AgentAdapter, AgentAnalyticsAdapter } from './core/types.js'; // Re-export for backwards compatibility @@ -43,6 +44,7 @@ export class AgentRegistry { AgentRegistry.registerPlugin(new KimiAcpPlugin()); AgentRegistry.registerPlugin(new OpenWikiPlugin()); AgentRegistry.registerPlugin(new CopilotCliPlugin()); + AgentRegistry.registerPlugin(new CursorPlugin()); AgentRegistry.initialized = true; } diff --git a/src/cli/commands/analytics/__tests__/cursor-usage-loader.test.ts b/src/cli/commands/analytics/__tests__/cursor-usage-loader.test.ts new file mode 100644 index 000000000..456ab6a77 --- /dev/null +++ b/src/cli/commands/analytics/__tests__/cursor-usage-loader.test.ts @@ -0,0 +1,195 @@ +/** + * The Cursor usage export, converted into the shapes the rest of analytics already speaks. + * + * These tests drive the real parser (`loadCursorUsageCsv`) over the real fixtures rather than + * hand-built event objects, so what is asserted here is what a Cursor dashboard export actually + * produces. The verified 2026-09-05 export — 61 events, 39,952,466 tokens, $25.25 — is the + * yardstick: whatever the matcher decides, the run's totals must equal that file's totals + * exactly once. Conservation is the property that makes the conversion safe to fold into every + * headline figure; double-counting or dropping a remainder is the failure this file exists to + * catch. + */ + +import { describe, it, expect } from 'vitest'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { buildCursorUsageSessions } from '../cursor-usage-loader.js'; +import { loadCursorUsageCsv, parseCursorUsageCsv } from '../../../../agents/plugins/cursor/cursor.usage-csv.js'; +import type { CursorUsageImport } from '../../../../agents/plugins/cursor/cursor.usage-csv.js'; +import type { RawSessionData } from '../data-loader.js'; +import type { SessionCostIndex } from '../cost/types.js'; + +const FIXTURES = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..', '..', '..', 'agents', 'plugins', 'cursor', '__tests__', 'fixtures'); + +function load(name: string): CursorUsageImport { + const usage = loadCursorUsageCsv(join(FIXTURES, name)); + if (!usage) { + throw new Error(`fixture ${name} did not parse as a Cursor usage export`); + } + return usage; +} + +function totals(index: SessionCostIndex): { costUSD: number; tokens: number } { + return { + costUSD: [...index.values()].reduce((s, c) => s + c.costUSD, 0), + tokens: [...index.values()].reduce((s, c) => s + c.tokens.total, 0), + }; +} + +/** A Cursor session covering [start, end], shaped the way the native loader synthesizes one. */ +function cursorSession(sessionId: string, start: number, end: number): RawSessionData { + return { + sessionId, + startEvent: { + recordId: sessionId, + type: 'session_start', + timestamp: start, + codeMieSessionId: sessionId, + agentName: 'cursor', + syncStatus: 'synced', + data: { provider: 'native', workingDirectory: '/repo', startTime: start }, + }, + endEvent: { + recordId: `${sessionId}-end`, + type: 'session_end', + timestamp: end, + codeMieSessionId: sessionId, + agentName: 'cursor', + syncStatus: 'synced', + data: { endTime: end, duration: end - start, totalTurns: 1 }, + }, + deltas: [], + }; +} + +describe('buildCursorUsageSessions — totals are conserved', () => { + it('accounts for the verified export exactly once when nothing matches a session', () => { + const usage = load('cursor-usage-events-full.csv'); + expect(usage.totals).toMatchObject({ events: 61 }); + expect(usage.totals.tokens.total).toBe(39952466); + expect(usage.totals.costUSD).toBeCloseTo(25.25, 10); + + const built = buildCursorUsageSessions(usage, []); + + expect(built.matched).toBe(0); + expect(built.unmatched).toBe(61); + expect(totals(built.costIndex).tokens).toBe(39952466); + expect(totals(built.costIndex).costUSD).toBeCloseTo(25.25, 10); + expect(built.summary.totalCostUSD).toBeCloseTo(25.25, 10); + }); + + it('still accounts for it exactly once when some events land on real sessions', () => { + const usage = load('cursor-usage-events-full.csv'); + // One session wide enough to swallow a good share of the export, so the assertion is about + // matched and remaining usage summing back to the file — not about an empty match path. + const stamps = usage.events.map((e) => Date.parse(e.date)).sort((a, b) => a - b); + const mid = stamps[Math.floor(stamps.length / 2)]; + const built = buildCursorUsageSessions(usage, [cursorSession('conv-a', stamps[0], mid)]); + + expect(built.matched).toBeGreaterThan(0); + expect(built.matched + built.unmatched).toBe(61); + expect(totals(built.costIndex).tokens).toBe(39952466); + expect(totals(built.costIndex).costUSD).toBeCloseTo(25.25, 10); + }); + + it('keeps tokens and drops cost for the export variant that ships no Cost column', () => { + const withCost = load('cursor-usage-events.csv'); + const text = [ + '"Date","User","Kind","Model","Input (w/ Cache Write)","Input (w/o Cache Write)","Cache Read","Output Tokens","Total Tokens","Requests"', + ...withCost.events.map( + (e) => + `"${e.date}","${e.user}","${e.kind}","${e.model}","${e.tokens.cacheCreation}","${e.tokens.input}","${e.tokens.cacheRead}","${e.tokens.output}","${e.tokens.total}","1"` + ), + ].join('\n'); + const usage = parseCursorUsageCsv(text); + expect(usage?.hasCost).toBe(false); + + const built = buildCursorUsageSessions(usage!, []); + + expect(totals(built.costIndex).tokens).toBe(withCost.totals.tokens.total); + expect(totals(built.costIndex).costUSD).toBe(0); + expect(built.summary.totalCostUSD).toBe(0); + }); +}); + +describe('buildCursorUsageSessions — matching events to sessions', () => { + const usage = load('cursor-usage-events.csv'); + const first = usage.events.reduce((a, b) => (Date.parse(a.date) < Date.parse(b.date) ? a : b)); + const firstMs = Date.parse(first.date); + + it('gives a containing session that event’s usage', () => { + // Tight enough that only this one event is inside, so the assertion is about attribution + // rather than about how many neighbours the window happened to sweep up. + const built = buildCursorUsageSessions(usage, [cursorSession('conv-a', firstMs - 500, firstMs + 500)]); + + const cost = built.costIndex.get('conv-a'); + expect(cost).toBeDefined(); + expect(cost!.tokens.total).toBe(first.tokens.total); + expect(cost!.costUSD).toBeCloseTo(first.costUSD, 10); + expect(cost!.priced).toBe(true); + expect(cost!.usageUnavailableReason).toBeUndefined(); + expect(built.matched).toBe(1); + }); + + it('sends an event inside two overlapping windows to the remainder rather than guessing', () => { + const built = buildCursorUsageSessions(usage, [ + cursorSession('conv-a', firstMs - 60_000, firstMs + 60_000), + cursorSession('conv-b', firstMs - 30_000, firstMs + 90_000), + ]); + + expect(built.costIndex.has('conv-a')).toBe(false); + expect(built.costIndex.has('conv-b')).toBe(false); + expect(built.matched).toBe(0); + expect(totals(built.costIndex).tokens).toBe(usage.totals.tokens.total); + }); + + it('collects everything unmatched into one pseudo-session per local day', () => { + const built = buildCursorUsageSessions(usage, []); + + const ids = built.rawSessions.map((r) => r.sessionId).sort(); + expect(ids).toEqual(usage.byDay.map((d) => `cursor-usage:${d.day}`).sort()); + for (const day of usage.byDay) { + const cost = built.costIndex.get(`cursor-usage:${day.day}`); + expect(cost!.tokens.total).toBe(day.tokens.total); + expect(cost!.costUSD).toBeCloseTo(day.costUSD, 10); + } + // A pseudo-session must be an ordinary Cursor session to the rest of the pipeline. + const raw = built.rawSessions[0]; + expect(raw.startEvent!.agentName).toBe('cursor'); + expect(raw.deltas.length).toBeGreaterThan(0); + }); + + it('does not synthesize a pseudo-session for a day whose events all matched', () => { + const sameDay = usage.events.filter((e) => e.day === first.day).map((e) => Date.parse(e.date)); + const built = buildCursorUsageSessions(usage, [ + cursorSession('conv-a', Math.min(...sameDay) - 1000, Math.max(...sameDay) + 1000), + ]); + + expect(built.rawSessions.map((r) => r.sessionId)).not.toContain(`cursor-usage:${first.day}`); + expect(totals(built.costIndex).tokens).toBe(usage.totals.tokens.total); + }); +}); + +describe('buildCursorUsageSessions — provenance and model labels', () => { + const usage = load('cursor-usage-events-full.csv'); + + it('marks every CSV-derived model line as Cursor’s own billing', () => { + const built = buildCursorUsageSessions(usage, []); + + const lines = [...built.costIndex.values()].flatMap((c) => c.perModel); + expect(lines.length).toBeGreaterThan(0); + for (const line of lines) { + expect(line.costBasis).toBe('vendor-billed'); + expect(line.unpriced).toBe(false); + expect(line.estimated).toBeUndefined(); + } + }); + + it('leaves model names unsuffixed so one model cannot appear under two spellings', () => { + const built = buildCursorUsageSessions(usage, []); + + const models = new Set([...built.costIndex.values()].flatMap((c) => c.perModel.map((m) => m.model))); + expect(models).toContain('auto'); + expect([...models].some((m) => m.includes('(cursor)'))).toBe(false); + }); +}); diff --git a/src/cli/commands/analytics/__tests__/native-loader-cursor.test.ts b/src/cli/commands/analytics/__tests__/native-loader-cursor.test.ts new file mode 100644 index 000000000..426701c92 --- /dev/null +++ b/src/cli/commands/analytics/__tests__/native-loader-cursor.test.ts @@ -0,0 +1,938 @@ +/** + * Cursor analytics, driven from the outside. + * + * Cursor is CodeMie's first analytics-only agent: never installed, never launched, only read. + * These tests exercise the whole ingestion path from the top seam — `loadNativeSessions()` — + * against a fixture Cursor home reached through the `CURSOR_HOME` override, exactly the way + * `copilot-cli.discovery.test.ts` drives `COPILOT_HOME`. Nothing here reaches into a parser: + * a Cursor home goes in, analytics rows come out, and the assertions are about those rows. + * + * Discovery and parsing run through the real registry-resolved `CursorSessionAdapter`; the + * loader's other dependencies (tracked-log dedup, ownership markers, the other native agents) + * are injected, which is what keeps the run off `~/.codemie` and off the developer's own + * `~/.cursor`. Each run re-imports the module graph so the adapter's once-per-run tracking-index + * memo cannot leak one test's fixture database into the next. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import type { NativeLoaderDeps, DiscoveredNative } from '../native-loader.js'; +import type { RawSessionData } from '../data-loader.js'; +import type { ParsedSession } from '../../../../agents/core/session/BaseSessionAdapter.js'; + +/** + * `node:sqlite` landed in Node 22.5 and the repo supports Node >= 20, so the tracking-database + * enrichment is optional at runtime — and the tests that need a fixture database are optional + * too. On an older runtime they skip and the transcript-only expectations below still run, + * which is the same degradation the product promises. + */ +function hasNodeSqlite(): boolean { + const [major, minor] = process.versions.node.split('.').map(Number); + return major > 22 || (major === 22 && minor >= 5); +} + +const HOUR = 60 * 60 * 1000; +const FIRST_EDIT_MS = Date.now() - 3 * HOUR; +const LAST_EDIT_MS = Date.now() - 2 * HOUR; + +let cursorHome: string; +let projectDir: string; +let projectSlug: string; + +/** The slug Cursor files a project under: leading separator dropped, `/` and `_` both `-`. */ +function slugForPath(dir: string): string { + return dir.replace(/^[/\\]+/, '').replace(/[/\\_]/g, '-'); +} + +/** One `//agent-transcripts//.jsonl` under the fixture home. */ +function writeTranscript(conversationId: string, lines: unknown[], slug: string = projectSlug): void { + const dir = join(cursorHome, 'projects', slug, 'agent-transcripts', conversationId); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, `${conversationId}.jsonl`), + `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`, + 'utf-8' + ); +} + +function userLine(text: string): unknown { + return { + role: 'user', + message: { content: [{ type: 'text', text: `2026-09-03${text}` }] }, + }; +} + +function assistantLine(text: string): unknown { + return { role: 'assistant', message: { content: [{ type: 'text', text }] } }; +} + +/** A two-turn conversation — the shape every fixture below reuses. */ +function conversation(prompt: string): unknown[] { + return [ + userLine(prompt), + assistantLine('on it'), + { type: 'turn_ended', status: 'completed' }, + userLine('and the second thing'), + assistantLine('done'), + { type: 'turn_ended', status: 'completed' }, + ]; +} + +interface TrackingRow { + conversationId: string; + fileName: string; + model: string; + timestamp: number; + source?: string; +} + +/** A fixture AI-tracking database with Cursor's real table/column names. */ +async function writeTrackingDb(rows: TrackingRow[]): Promise { + const { DatabaseSync } = await import('node:sqlite'); + const dir = join(cursorHome, 'ai-tracking'); + mkdirSync(dir, { recursive: true }); + const db = new DatabaseSync(join(dir, 'ai-code-tracking.db')); + db.exec( + 'CREATE TABLE ai_code_hashes (conversationId TEXT, fileName TEXT, model TEXT, timestamp INTEGER, source TEXT)' + ); + const insert = db.prepare( + 'INSERT INTO ai_code_hashes (conversationId, fileName, model, timestamp, source) VALUES (?, ?, ?, ?, ?)' + ); + for (const row of rows) { + insert.run(row.conversationId, row.fileName, row.model, row.timestamp, row.source ?? 'composer'); + } + db.close(); +} + +/** A database Cursor could plausibly ship after a schema change: valid file, unknown table. */ +async function writeSchemaDriftedDb(): Promise { + const { DatabaseSync } = await import('node:sqlite'); + const dir = join(cursorHome, 'ai-tracking'); + mkdirSync(dir, { recursive: true }); + const db = new DatabaseSync(join(dir, 'ai-code-tracking.db')); + db.exec('CREATE TABLE ai_code_events (conversation_uuid TEXT, path TEXT)'); + db.close(); +} + +/** Not a database at all — a corrupt or half-written file. */ +function writeCorruptDb(): void { + const dir = join(cursorHome, 'ai-tracking'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'ai-code-tracking.db'), 'this is not a sqlite file', 'utf-8'); +} + +interface ComposerHeaderRow { + composerId: string; + /** Overrides the row's `key` column; defaults to the bare `composerId`. */ + key?: string; + /** Drops `composerId` from the JSON value, forcing the reader to fall back to `key`. */ + omitComposerIdField?: boolean; + isDraft?: boolean; + projectPath?: string; + branch?: string; + createdOnBranch?: string; + createdAt?: number; + updatedAt?: number; + /** + * Writes the last-update stamp under the legacy `updatedAt` key instead of the + * `lastUpdatedAt` key real Cursor builds use, so the reader's fallback stays covered. + */ + legacyUpdatedAtKey?: boolean; + linesAdded?: number; + linesRemoved?: number; + filesChangedCount?: number; +} + +/** + * A fixture `state.vscdb` with Cursor's real `composerHeaders` key/value table shape — the + * primary session-discovery source. + * `CURSOR_HOME` relocates it to `/User/globalStorage/state.vscdb`, mirroring + * `getCursorStateDbPath()`. + */ +async function writeComposerHeaders(rows: ComposerHeaderRow[]): Promise { + const { DatabaseSync } = await import('node:sqlite'); + const dir = join(cursorHome, 'User', 'globalStorage'); + mkdirSync(dir, { recursive: true }); + const db = new DatabaseSync(join(dir, 'state.vscdb')); + db.exec('CREATE TABLE composerHeaders (key TEXT, value TEXT)'); + const insert = db.prepare('INSERT INTO composerHeaders (key, value) VALUES (?, ?)'); + for (const row of rows) { + const value = JSON.stringify({ + composerId: row.omitComposerIdField ? undefined : row.composerId, + isDraft: row.isDraft, + workspaceIdentifier: row.projectPath ? { uri: { fsPath: row.projectPath } } : undefined, + activeBranch: row.branch ? { branchName: row.branch } : undefined, + createdOnBranch: row.createdOnBranch, + createdAt: row.createdAt, + // Real `composerHeaders` rows name the last-update stamp `lastUpdatedAt`; `updatedAt` is + // only the fallback spelling. Fixtures default to what Cursor actually writes. + ...(row.legacyUpdatedAtKey ? { updatedAt: row.updatedAt } : { lastUpdatedAt: row.updatedAt }), + totalLinesAdded: row.linesAdded, + totalLinesRemoved: row.linesRemoved, + filesChangedCount: row.filesChangedCount, + }); + insert.run(row.key ?? row.composerId, value); + } + db.close(); +} + +interface BubbleFixture { + /** Defaults to an incrementing counter when omitted — only the composerId prefix matters to the reader. */ + bubbleId?: string; + toolName?: string; + toolStatus?: 'completed' | 'error' | 'cancelled' | 'loading'; + inputTokens?: number; + outputTokens?: number; +} + +let bubbleIdCounter = 0; + +/** + * A fixture `cursorDiskKV` table in the SAME `state.vscdb` file `writeComposerHeaders` writes + * to — real bubble rows are keyed `bubbleId::` (see `cursor.bubbles.ts`). + * Uses `CREATE TABLE IF NOT EXISTS` since a test may call this before or after + * `writeComposerHeaders` touches the same file; the two tables never collide. + * + * A fixture row that specifies neither tool data nor token data omits `toolFormerData`/ + * `tokenCount` entirely from the JSON value, exactly mirroring a real bubble that carries + * neither — this lets a test build bubbles with only tool data, only token data, or both. + */ +async function writeCursorBubbles(composerId: string, bubbles: BubbleFixture[]): Promise { + const { DatabaseSync } = await import('node:sqlite'); + const dir = join(cursorHome, 'User', 'globalStorage'); + mkdirSync(dir, { recursive: true }); + const db = new DatabaseSync(join(dir, 'state.vscdb')); + db.exec('CREATE TABLE IF NOT EXISTS cursorDiskKV (key TEXT, value TEXT)'); + const insert = db.prepare('INSERT INTO cursorDiskKV (key, value) VALUES (?, ?)'); + for (const bubble of bubbles) { + const bubbleId = bubble.bubbleId ?? `bubble-${(bubbleIdCounter += 1)}`; + const hasToolData = bubble.toolName !== undefined || bubble.toolStatus !== undefined; + const hasTokenData = bubble.inputTokens !== undefined || bubble.outputTokens !== undefined; + const value = JSON.stringify({ + ...(hasToolData && { toolFormerData: { name: bubble.toolName, status: bubble.toolStatus } }), + ...(hasTokenData && { + tokenCount: { inputTokens: bubble.inputTokens, outputTokens: bubble.outputTokens }, + }), + }); + insert.run(`bubbleId:${composerId}:${bubbleId}`, value); + } + db.close(); +} + +/** A managed agent's native session, for contrast with the unmanaged Cursor rows. */ +const claudeDiscovery: DiscoveredNative = { + agentName: 'claude', + descriptor: { + sessionId: 'cl1', + filePath: '/logs/cl1.jsonl', + projectPath: '/repo/app', + createdAt: Date.now() - HOUR, + updatedAt: Date.now(), + agentName: 'claude', + }, +}; + +const claudeParsed = { + sessionId: 'cl1', + agentName: 'claude', + metadata: {}, + messages: [ + { type: 'assistant', timestamp: '2026-09-03T10:00:00Z', message: { role: 'assistant', model: 'claude-sonnet-4-6' } }, + ], + metrics: { tools: {} }, +} as never; + +interface SeamRun { + rows: RawSessionData[]; + /** Every session the loader asked the Cursor adapter to parse, as the adapter returned it. */ + parsed: ParsedSession[]; +} + +/** + * Load native sessions the way `SessionsSource` does, but with only the Cursor adapter (plus an + * optional managed-agent contrast row) behind the discovery dependency. + */ +async function runLoader(options: { withManagedClaude?: boolean; owned?: boolean } = {}): Promise { + vi.resetModules(); + const { AgentRegistry } = await import('../../../../agents/registry.js'); + const { loadNativeSessions } = await import('../native-loader.js'); + + const adapter = AgentRegistry.getAgent('cursor')?.getSessionAdapter?.(); + if (!adapter?.discoverSessions) { + throw new Error('cursor session adapter is not reachable through the registry'); + } + + const parsed: ParsedSession[] = []; + const deps: NativeLoaderDeps = { + trackedLogPaths: () => new Set(), + async discover(maxAgeDays) { + const descriptors = await adapter.discoverSessions!({ maxAgeDays }); + const found: DiscoveredNative[] = descriptors.map((descriptor) => ({ + agentName: descriptor.agentName ?? 'cursor', + descriptor, + })); + return options.withManagedClaude ? [...found, claudeDiscovery] : found; + }, + async parse(agentName, filePath, sessionId) { + if (agentName !== 'cursor') { + return claudeParsed; + } + const session = await adapter.parseSessionFile(filePath, sessionId); + parsed.push(session); + return session; + }, + realPath: (p) => p, + hasOwnershipMarker: () => options.owned === true, + }; + + return { rows: await loadNativeSessions(undefined, deps), parsed }; +} + +/** + * The one gate `--include-external` applies, copied from `sources/sessions-source.ts` so the + * default-visibility claim is asserted against the real predicate rather than a paraphrase. + */ +function visible(rows: RawSessionData[], includeExternal: boolean): RawSessionData[] { + return rows.filter((s) => includeExternal || s.startEvent?.data.provider !== 'native-external'); +} + +function cursorRows(rows: RawSessionData[]): RawSessionData[] { + return rows.filter((s) => s.startEvent?.agentName === 'cursor'); +} + +beforeEach(() => { + cursorHome = mkdtempSync(join(tmpdir(), 'cursor-home-')); + projectDir = mkdtempSync(join(tmpdir(), 'cursor-project-')); + projectSlug = slugForPath(projectDir); + process.env.CURSOR_HOME = cursorHome; +}); + +afterEach(() => { + delete process.env.CURSOR_HOME; + rmSync(cursorHome, { recursive: true, force: true }); + rmSync(projectDir, { recursive: true, force: true }); + vi.resetModules(); +}); + +describe('loadNativeSessions — Cursor discovery and unmanaged tagging', () => { + it('discovers every transcript in the fixture Cursor home', async () => { + writeTranscript('conv-a', conversation('add cursor analytics')); + writeTranscript('conv-b', conversation('rename the module')); + + const { rows } = await runLoader(); + + expect(cursorRows(rows).map((s) => s.sessionId).sort()).toEqual(['conv-a', 'conv-b']); + }); + + it('ignores Cursor project directories that hold no agent transcripts', async () => { + // `projects/` also carries window ids and canvas/terminal/mcp-only directories. + mkdirSync(join(cursorHome, 'projects', 'empty-window', 'canvases'), { recursive: true }); + mkdirSync(join(cursorHome, 'projects', '1749283', 'terminals'), { recursive: true }); + writeTranscript('conv-a', conversation('add cursor analytics')); + + const { rows } = await runLoader(); + + expect(cursorRows(rows)).toHaveLength(1); + }); + + it('tags a Cursor session CodeMie did not launch as native-external', async () => { + writeTranscript('conv-a', conversation('add cursor analytics')); + + const { rows } = await runLoader(); + + expect(cursorRows(rows)[0].startEvent!.data.provider).toBe('native-external'); + }); + + it('hides Cursor sessions until --include-external, exactly like a managed agent’s', async () => { + writeTranscript('conv-a', conversation('add cursor analytics')); + + const { rows } = await runLoader({ withManagedClaude: true }); + + // One rule for every agent: no ownership marker means external, flag or nothing. + expect(rows.find((s) => s.sessionId === 'cl1')!.startEvent!.data.provider).toBe('native-external'); + + const byDefault = visible(rows, false).map((s) => s.sessionId); + expect(byDefault).not.toContain('conv-a'); + expect(byDefault).not.toContain('cl1'); + + const withFlag = visible(rows, true).map((s) => s.sessionId); + expect(withFlag).toContain('conv-a'); + expect(withFlag).toContain('cl1'); + }); + + it('shows a Cursor session CodeMie set up, with no flag', async () => { + writeTranscript('conv-a', conversation('add cursor analytics')); + + // An ownership marker is what proves CodeMie launched it; the gate then does not apply. + const { rows } = await runLoader({ owned: true }); + + expect(cursorRows(rows)[0].startEvent!.data.provider).toBe('native'); + expect(visible(rows, false).map((s) => s.sessionId)).toContain('conv-a'); + }); + + it('carries the transcript’s prompts and turns onto the synthesized row', async () => { + writeTranscript('conv-a', conversation('add cursor analytics')); + + const { rows } = await runLoader(); + const row = cursorRows(rows)[0]; + + expect(row.endEvent!.data.totalTurns).toBe(2); + // The prompt is unwrapped from Cursor's envelope, so the report titles the + // session with the question rather than with a date. + expect(row.deltas[0].userPrompts?.[0].text).toBe('add cursor analytics'); + }); +}); + +describe.skipIf(!hasNodeSqlite())('loadNativeSessions — Cursor enrichment from the AI-tracking database', () => { + it('adds model, files touched and the edit-derived activity window', async () => { + writeTranscript('conv-a', conversation('add cursor analytics')); + await writeTrackingDb([ + { + conversationId: 'conv-a', + fileName: join(projectDir, 'src', 'app.ts'), + model: 'claude-4.5-sonnet', + timestamp: FIRST_EDIT_MS, + }, + { + conversationId: 'conv-a', + fileName: join(projectDir, 'src', 'index.ts'), + model: 'claude-4.5-sonnet', + timestamp: LAST_EDIT_MS, + }, + ]); + + const { rows } = await runLoader(); + const row = cursorRows(rows)[0]; + + expect(row.deltas[0].models).toEqual(['claude-4.5-sonnet', 'claude-4.5-sonnet']); + expect(row.deltas[0].fileOperations?.map((f) => f.path).sort()).toEqual( + [join(projectDir, 'src', 'app.ts'), join(projectDir, 'src', 'index.ts')].sort() + ); + expect(row.startEvent!.data.startTime).toBe(FIRST_EDIT_MS); + expect(row.endEvent!.data.endTime).toBe(LAST_EDIT_MS); + // The project root is recovered by matching the recorded files back against Cursor's slug. + expect(row.startEvent!.data.workingDirectory).toBe(projectDir); + }); + + it('reports a model recorded as "default" as Auto, the name Cursor gives it', async () => { + writeTranscript('conv-a', conversation('add cursor analytics')); + await writeTrackingDb([ + { + conversationId: 'conv-a', + fileName: join(projectDir, 'src', 'app.ts'), + model: 'default', + timestamp: FIRST_EDIT_MS, + }, + ]); + + const { rows } = await runLoader(); + const row = cursorRows(rows)[0]; + + // Cursor's own usage export calls these conversations `auto`, so "Auto" is its word, not a + // guess at which model actually ran — that is still never invented. + expect(row.deltas[0].models).toEqual(['Auto', 'Auto']); + expect(row.deltas[0].fileOperations?.map((f) => f.path)).toEqual([join(projectDir, 'src', 'app.ts')]); + }); + + it('ignores human-attributed edits, which are not the agent’s work', async () => { + writeTranscript('conv-a', conversation('add cursor analytics')); + await writeTrackingDb([ + { + conversationId: 'conv-a', + fileName: join(projectDir, 'typed-by-hand.ts'), + model: 'claude-4.5-sonnet', + timestamp: FIRST_EDIT_MS, + source: 'human', + }, + ]); + + const { rows } = await runLoader(); + + expect(cursorRows(rows)[0].deltas[0].fileOperations).toEqual([]); + }); + + it('discovers a composerHeaders-only session with no transcript on disk (issue #10: composerHeaders became the primary discovery source, so a conversation that used to be invisible without a transcript file now surfaces as a real, transcript-less row instead of being dropped)', async () => { + writeTranscript('conv-a', conversation('add cursor analytics')); + await writeComposerHeaders([ + { composerId: 'header-only', projectPath: projectDir, createdAt: FIRST_EDIT_MS, updatedAt: LAST_EDIT_MS }, + ]); + + const { rows } = await runLoader(); + + expect(cursorRows(rows).map((s) => s.sessionId).sort()).toEqual(['conv-a', 'header-only']); + }); +}); + +describe('loadNativeSessions — Cursor degrades to transcript-only rows', () => { + it('still reports the session when the tracking database is missing', async () => { + writeTranscript('conv-a', conversation('add cursor analytics')); + + const { rows } = await runLoader(); + const row = cursorRows(rows)[0]; + + expect(row.sessionId).toBe('conv-a'); + expect(row.deltas[0].models).toEqual([]); + expect(row.deltas[0].fileOperations).toEqual([]); + // The project still resolves without a database: the slug is walked against the filesystem. + expect(row.startEvent!.data.workingDirectory).toBe(projectDir); + // The window falls back to the transcript file's own birth/modification times. + expect(row.startEvent!.data.startTime).toBeGreaterThan(0); + }); + + it('still reports the session when the tracking database is corrupt', async () => { + writeTranscript('conv-a', conversation('add cursor analytics')); + writeCorruptDb(); + + const { rows } = await runLoader(); + + expect(cursorRows(rows).map((s) => s.sessionId)).toEqual(['conv-a']); + expect(cursorRows(rows)[0].deltas[0].models).toEqual([]); + }); + + it.skipIf(!hasNodeSqlite())('still reports the session when the database schema has drifted', async () => { + writeTranscript('conv-a', conversation('add cursor analytics')); + await writeSchemaDriftedDb(); + + const { rows } = await runLoader(); + + expect(cursorRows(rows).map((s) => s.sessionId)).toEqual(['conv-a']); + expect(cursorRows(rows)[0].deltas[0].fileOperations).toEqual([]); + }); +}); + +/** + * `composerHeaders` in `state.vscdb` is the primary session-discovery source (see the module + * doc comment in `cursor.session.ts`); + * a transcript is no longer required for a conversation to be discoverable, and when a header + * exists it settles project path, branch and line counts outright instead of the transcript-only + * fallbacks (slug walk, tracking-db files, prompt stamps) exercised elsewhere in this file. + */ +describe.skipIf(!hasNodeSqlite())('loadNativeSessions — Cursor composerHeaders as the primary discovery source', () => { + it('surfaces project, branch, line counts and files-changed from a composerHeaders-only session', async () => { + // No transcript exists for this composerId, so there are no messages to stamp `gitBranch` + // onto — project path, line counts and files-changed all come straight off the header and + // never depended on a message existing. Branch used to be message-only too (see + // `applyBranch` in cursor.session.ts) and so would have silently gone missing for exactly + // this — the majority — shape of session; `synthesizeRawSession`'s branch resolution now + // falls back to `parsed.metadata.branch` when there are no messages to vote over, which is + // what lets this session still report one. + await writeComposerHeaders([ + { + composerId: 'header-full', + projectPath: projectDir, + branch: 'feature/header-only', + createdAt: FIRST_EDIT_MS, + updatedAt: LAST_EDIT_MS, + linesAdded: 42, + linesRemoved: 7, + filesChangedCount: 3, + }, + ]); + + const { rows } = await runLoader(); + const row = cursorRows(rows)[0]; + + expect(row.sessionId).toBe('header-full'); + expect(row.startEvent!.data.workingDirectory).toBe(projectDir); + expect(row.deltas[0].gitBranch).toBe('feature/header-only'); + expect(row.deltas[0].filesChangedCount).toBe(3); + expect(row.deltas[0].fileOperations).toEqual([ + { type: 'edit', path: projectDir, linesAdded: 42, linesRemoved: 7 }, + ]); + }); + + it('excludes a draft composerHeaders row from discovery entirely', async () => { + // A draft was never started, so surfacing it as a discoverable session would be misleading. + await writeComposerHeaders([{ composerId: 'draft-one', projectPath: projectDir, isDraft: true }]); + + const { rows } = await runLoader(); + + expect(cursorRows(rows)).toEqual([]); + }); + + it('takes the project path and branch from the header rather than the slug walk when a session has both', async () => { + // The transcript's own slug resolves to nothing on disk, so if the header were being + // ignored this would report 'Unknown' instead of the header's real project path. Branch is + // stamped onto the transcript's own messages here (see `applyBranch` in cursor.session.ts); + // the header-only test above exercises the message-less fallback path instead. + writeTranscript('both-conv', conversation('ship it'), 'Users-nobody-vanished-project'); + await writeComposerHeaders([ + { composerId: 'both-conv', projectPath: projectDir, branch: 'feature/cursor-header' }, + ]); + + const { rows } = await runLoader(); + const row = cursorRows(rows)[0]; + + expect(row.startEvent!.data.workingDirectory).toBe(projectDir); + expect(row.deltas[0].gitBranch).toBe('feature/cursor-header'); + }); + + it('prefers the header’s own timestamps over the tracking database’s recorded edit times', async () => { + writeTranscript('window-conv', conversation('ship it')); + const headerCreated = Date.now() - 10 * HOUR; + const headerUpdated = Date.now() - 9 * HOUR; + await writeComposerHeaders([ + { composerId: 'window-conv', projectPath: projectDir, createdAt: headerCreated, updatedAt: headerUpdated }, + ]); + await writeTrackingDb([ + { + conversationId: 'window-conv', + fileName: join(projectDir, 'src', 'app.ts'), + model: 'claude-4.5-sonnet', + timestamp: FIRST_EDIT_MS, + }, + ]); + + const { rows } = await runLoader(); + const row = cursorRows(rows)[0]; + + expect(row.startEvent!.data.startTime).toBe(headerCreated); + expect(row.endEvent!.data.endTime).toBe(headerUpdated); + }); + + it('gives a Cursor session a real duration from the header’s own lastUpdatedAt stamp', async () => { + // Regression: the reader used to look for `updatedAt`, a key `composerHeaders` never writes. + // Every Cursor session therefore collapsed to a zero-width window — `resolveWindow` mirrored + // `createdAt` — which zeroed every Cursor duration in the report and left nothing for the + // usage-CSV matcher to match against. + const created = Date.now() - 10 * HOUR; + const updated = created + 25 * 60 * 1000; + await writeComposerHeaders([ + { composerId: 'duration-conv', projectPath: projectDir, createdAt: created, updatedAt: updated }, + ]); + + const { rows } = await runLoader(); + const row = cursorRows(rows)[0]; + + expect(row.startEvent!.data.startTime).toBe(created); + expect(row.endEvent!.data.endTime).toBe(updated); + expect(row.endEvent!.data.duration).toBe(25 * 60 * 1000); + }); + + it('still reads a header that spells the stamp `updatedAt`', async () => { + const created = Date.now() - 8 * HOUR; + const updated = created + 5 * 60 * 1000; + await writeComposerHeaders([ + { + composerId: 'legacy-conv', + projectPath: projectDir, + createdAt: created, + updatedAt: updated, + legacyUpdatedAtKey: true, + }, + ]); + + const { rows } = await runLoader(); + + expect(cursorRows(rows)[0].endEvent!.data.duration).toBe(5 * 60 * 1000); + }); + + it('widens a header that dates only its creation with the tracking database’s last edit', async () => { + // Half of a real `composerHeaders` table carries `createdAt` and no last-update stamp. + // Mirroring `createdAt` for those would report a zero duration for work that demonstrably + // continued, so the recorded edit times fill the open end. + const created = FIRST_EDIT_MS - HOUR; + await writeComposerHeaders([ + { composerId: 'open-ended-conv', projectPath: projectDir, createdAt: created }, + ]); + await writeTrackingDb([ + { + conversationId: 'open-ended-conv', + fileName: join(projectDir, 'src', 'app.ts'), + model: 'claude-4.5-sonnet', + timestamp: LAST_EDIT_MS, + }, + ]); + + const { rows } = await runLoader(); + const row = cursorRows(rows)[0]; + + expect(row.startEvent!.data.startTime).toBe(created); + expect(row.endEvent!.data.endTime).toBe(LAST_EDIT_MS); + }); + + it('resolves the composerId from a prefixed key when the JSON value carries none', async () => { + // `key` on the key/value table shape commonly prefixes the id (e.g. + // `composerHeaderData:`); the reader takes the last `:`-delimited segment. + await writeComposerHeaders([ + { + composerId: 'prefixed-conv', + key: 'composerHeaderData:prefixed-conv', + omitComposerIdField: true, + projectPath: projectDir, + createdAt: FIRST_EDIT_MS, + updatedAt: LAST_EDIT_MS, + }, + ]); + + const { rows } = await runLoader(); + + expect(cursorRows(rows).map((s) => s.sessionId)).toEqual(['prefixed-conv']); + }); +}); + +describe('loadNativeSessions — Cursor absent', () => { + it('yields no Cursor sessions when there is no Cursor home', async () => { + process.env.CURSOR_HOME = join(cursorHome, 'does-not-exist'); + + const { rows } = await runLoader({ withManagedClaude: true }); + + expect(cursorRows(rows)).toEqual([]); + // The rest of the report is unaffected: Cursor simply is not there. + expect(rows.map((s) => s.sessionId)).toEqual(['cl1']); + }); + + it('yields no Cursor sessions when the Cursor home is empty', async () => { + mkdirSync(join(cursorHome, 'projects'), { recursive: true }); + + const { rows } = await runLoader(); + + expect(rows).toEqual([]); + }); +}); + +// This block's own tests write no bubbles at all, so `readCursorBubbles` returns a zeroed +// summary and the `usageUnavailableReason` path below still applies to them unchanged. With a +// real bubble token signal (see the "Cursor bubble enrichment" describe block further down), +// Cursor CAN report a partial `usagePartial`/`tokensByModel` usage instead — that path is +// conditional on `hasTokenSignal`, not universally absent, which is what makes this describe's +// old name ("never reports tokens") no longer an accurate universal claim. +describe('loadNativeSessions — Cursor reports no usage or line counts without a bubble signal', () => { + it('states why usage is unavailable instead of reporting zero', async () => { + writeTranscript('conv-a', conversation('add cursor analytics')); + + const { parsed } = await runLoader(); + + expect(parsed).toHaveLength(1); + expect(parsed[0].usageMeta?.usageUnavailableReason).toEqual(expect.stringContaining('Cursor')); + // A blank-with-a-reason session must not also claim a measured zero. + expect(parsed[0].usageMeta).not.toHaveProperty('totalTokens'); + expect(parsed[0].usageMeta).not.toHaveProperty('premiumRequests'); + }); + + it.skipIf(!hasNodeSqlite())('reports edited files without inventing line counts', async () => { + writeTranscript('conv-a', conversation('add cursor analytics')); + await writeTrackingDb([ + { + conversationId: 'conv-a', + fileName: join(projectDir, 'src', 'app.ts'), + model: 'claude-4.5-sonnet', + timestamp: FIRST_EDIT_MS, + }, + ]); + + const { rows } = await runLoader(); + const operation = cursorRows(rows)[0].deltas[0].fileOperations![0]; + + expect(operation.type).toBe('edit'); + expect(operation.linesAdded).toBeUndefined(); + expect(operation.linesRemoved).toBeUndefined(); + expect(operation.linesModified).toBeUndefined(); + }); +}); + +/** + * `cursorDiskKV` bubble rows in the SAME `state.vscdb` file `composerHeaders` lives in (see + * `cursor.bubbles.ts`) are the source for real per-tool success/failure counts + * and, on the sparse fraction of bubbles that carry a nonzero `tokenCount`, partial token + * pricing. Bubbles are keyed by composerId directly, so enrichment applies identically whether + * or not a transcript exists on disk for the conversation. + */ +describe.skipIf(!hasNodeSqlite())('loadNativeSessions — Cursor bubble enrichment from cursorDiskKV', () => { + it('reports real per-tool success/failure counts from resolved bubble statuses', async () => { + writeTranscript('conv-a', conversation('add cursor analytics')); + await writeCursorBubbles('conv-a', [ + { toolName: 'edit_file', toolStatus: 'completed' }, + { toolName: 'edit_file', toolStatus: 'error' }, + ]); + + const { rows } = await runLoader(); + + expect(cursorRows(rows)[0].deltas[0].toolStatus).toEqual({ edit_file: { success: 1, failure: 1 } }); + }); + + it('does not let a still-loading bubble skew or fabricate a tool outcome', async () => { + writeTranscript('conv-a', conversation('add cursor analytics')); + await writeCursorBubbles('conv-a', [ + { toolName: 'read_file', toolStatus: 'completed' }, + { toolName: 'run_terminal', toolStatus: 'loading' }, + ]); + + const { rows } = await runLoader(); + const toolStatus = cursorRows(rows)[0].deltas[0].toolStatus; + + expect(toolStatus).toEqual({ read_file: { success: 1, failure: 0 } }); + expect(toolStatus).not.toHaveProperty('run_terminal'); + }); + + it('reports usagePartial with the summed tokens when a bubble carries a real token signal', async () => { + writeTranscript('conv-a', conversation('add cursor analytics')); + await writeTrackingDb([ + { + conversationId: 'conv-a', + fileName: join(projectDir, 'src', 'app.ts'), + model: 'claude-4.5-sonnet', + timestamp: FIRST_EDIT_MS, + }, + ]); + await writeCursorBubbles('conv-a', [ + { inputTokens: 120, outputTokens: 40 }, + { inputTokens: 0, outputTokens: 0 }, // present but zero: must not itself trip the signal + ]); + + const { parsed } = await runLoader(); + + expect(parsed[0].usageMeta).toEqual({ + usagePartial: true, + tokensByModel: { 'claude-4.5-sonnet': { inputTokens: 120, outputTokens: 40 } }, + }); + }); + + it('keeps the usageUnavailableReason path when bubbles are present but carry no nonzero token count', async () => { + writeTranscript('conv-a', conversation('add cursor analytics')); + await writeCursorBubbles('conv-a', [ + { toolName: 'edit_file', toolStatus: 'completed' }, + { inputTokens: 0, outputTokens: 0 }, + ]); + + const { parsed } = await runLoader(); + + expect(parsed[0].usageMeta).toEqual({ + usageUnavailableReason: expect.stringContaining('Cursor'), + }); + expect(parsed[0].usageMeta).not.toHaveProperty('usagePartial'); + }); + + it('enriches a header-only session (no transcript) with real toolStatus from its bubbles', async () => { + await writeComposerHeaders([ + { composerId: 'header-only', projectPath: projectDir, createdAt: FIRST_EDIT_MS, updatedAt: LAST_EDIT_MS }, + ]); + await writeCursorBubbles('header-only', [{ toolName: 'edit_file', toolStatus: 'completed' }]); + + const { rows } = await runLoader(); + const row = cursorRows(rows).find((s) => s.sessionId === 'header-only'); + + expect(row).toBeDefined(); + expect(row!.deltas[0].toolStatus).toEqual({ edit_file: { success: 1, failure: 0 } }); + }); +}); + +/** + * Cursor's project slug is lossy — it replaces `/` and `_` alike with `-` — so a slug cannot be + * reversed by splitting on `-`. Nearly every real project trips this: a home directory like + * `/Users/ada_lovelace` or any project named `claude-code-router` de-slugs to a path that does + * not exist, and the session loses its project. These tests pin the recovery, and they use no + * tracking database on purpose: the database covers only the conversations it recorded edits + * for, so the slug is the only project signal the majority of sessions have. + */ +describe('loadNativeSessions — Cursor project attribution from a lossy slug', () => { + it('recovers a project whose directory name contains a hyphen', async () => { + const project = join(projectDir, 'claude-code-router'); + mkdirSync(project, { recursive: true }); + writeTranscript('conv-hyphen', conversation('ship it'), slugForPath(project)); + + const { rows } = await runLoader(); + + expect(cursorRows(rows)[0].startEvent!.data.workingDirectory).toBe(project); + }); + + it('recovers a project whose path contains an underscore', async () => { + const project = join(projectDir, 'my_project'); + mkdirSync(project, { recursive: true }); + writeTranscript('conv-underscore', conversation('ship it'), slugForPath(project)); + + const { rows } = await runLoader(); + + expect(cursorRows(rows)[0].startEvent!.data.workingDirectory).toBe(project); + }); + + it('stays silent rather than guessing when no candidate directory exists', async () => { + writeTranscript('conv-gone', conversation('ship it'), 'Users-nobody-vanished-project'); + + const { rows } = await runLoader(); + + expect(cursorRows(rows)[0].startEvent!.data.workingDirectory).toBe('Unknown'); + }); +}); + +/** + * Cursor stamps every user prompt with a human-readable ``. It is the only timing + * signal for a conversation the tracking database never recorded an edit for, and it beats the + * transcript file's birth/modification times badly: file times measure when the file was + * touched, so a session resumed days later reports a span of days rather than of minutes. + */ +describe('loadNativeSessions — Cursor activity window from transcript timestamps', () => { + /** A user line stamped the way Cursor writes it. */ + function stampedUserLine(stamp: string, text: string): unknown { + return { + role: 'user', + message: { + content: [{ type: 'text', text: `${stamp}${text}` }], + }, + }; + } + + it('takes the window from the first and last stamped prompt', async () => { + writeTranscript('conv-stamped', [ + stampedUserLine('Monday, Aug 31, 2026, 5:46 PM (UTC+3)', 'first'), + assistantLine('on it'), + stampedUserLine('Monday, Aug 31, 2026, 6:31 PM (UTC+3)', 'second'), + assistantLine('done'), + ]); + + const { rows } = await runLoader(); + const row = cursorRows(rows)[0]; + + expect(row.startEvent!.data.startTime).toBe(Date.parse('2026-08-31T17:46:00+03:00')); + expect(row.endEvent!.data.endTime).toBe(Date.parse('2026-08-31T18:31:00+03:00')); + }); + + it.skipIf(!hasNodeSqlite())('still prefers the tracking database when it recorded edits', async () => { + writeTranscript('conv-both', [ + stampedUserLine('Monday, Aug 31, 2026, 5:46 PM (UTC+3)', 'first'), + assistantLine('done'), + ]); + await writeTrackingDb([ + { + conversationId: 'conv-both', + fileName: join(projectDir, 'src', 'app.ts'), + model: 'claude-4.5-sonnet', + timestamp: FIRST_EDIT_MS, + }, + ]); + + const { rows } = await runLoader(); + + expect(cursorRows(rows)[0].startEvent!.data.startTime).toBe(FIRST_EDIT_MS); + }); + + it('falls back to file times when no prompt is stamped', async () => { + writeTranscript('conv-unstamped', [ + { role: 'user', message: { content: [{ type: 'text', text: 'no stamp here' }] } }, + assistantLine('done'), + ]); + + const { rows } = await runLoader(); + + expect(cursorRows(rows)[0].startEvent!.data.startTime).toBeGreaterThan(0); + }); +}); + +/** + * `/`, `_` and `-` all slugify to `-`, so one slug can describe two directories that both + * exist. Guessing between them would attribute a session confidently to the wrong project, + * which is worse than the honest gap of reporting none. + */ +describe('loadNativeSessions — Cursor refuses to guess between equally valid projects', () => { + it('reports no project when two directories share the slug', async () => { + mkdirSync(join(projectDir, 'my_app'), { recursive: true }); + mkdirSync(join(projectDir, 'my-app'), { recursive: true }); + writeTranscript('conv-ambiguous', conversation('ship it'), slugForPath(join(projectDir, 'my-app'))); + + const { rows } = await runLoader(); + + expect(cursorRows(rows)[0].startEvent!.data.workingDirectory).toBe('Unknown'); + }); +}); diff --git a/src/cli/commands/analytics/agent-labels.ts b/src/cli/commands/analytics/agent-labels.ts index 8a33af9b2..c7cb3c7bf 100644 --- a/src/cli/commands/analytics/agent-labels.ts +++ b/src/cli/commands/analytics/agent-labels.ts @@ -11,6 +11,7 @@ */ const AGENT_LABELS: Record = { 'copilot-cli': 'GitHub Copilot CLI', + cursor: 'Cursor', pi: 'Pi', 'gemini': 'Gemini CLI', }; diff --git a/src/cli/commands/analytics/aggregator.ts b/src/cli/commands/analytics/aggregator.ts index f00b5c1c1..db8cfadc4 100644 --- a/src/cli/commands/analytics/aggregator.ts +++ b/src/cli/commands/analytics/aggregator.ts @@ -418,7 +418,9 @@ export class AnalyticsAggregator { totalLinesRemoved, totalLinesModified, netLinesChanged, - filesChanged: changedPaths.size, + filesChanged: deltas.some((d) => d.filesChangedCount !== undefined) + ? deltas.reduce((sum, d) => sum + (d.filesChangedCount ?? 0), 0) + : changedPaths.size, filesWritten: writtenPaths.size, filesEdited: editedPaths.size, totalToolCalls, diff --git a/src/cli/commands/analytics/cost/__tests__/cost-enricher.test.ts b/src/cli/commands/analytics/cost/__tests__/cost-enricher.test.ts index 383ccf0bf..0773fc684 100644 --- a/src/cli/commands/analytics/cost/__tests__/cost-enricher.test.ts +++ b/src/cli/commands/analytics/cost/__tests__/cost-enricher.test.ts @@ -80,6 +80,31 @@ describe('enrichCosts', () => { expect(index.get('s1')!.agentSessionFile).toBeUndefined(); }); + it('prices from usageMeta.tokensByModel when no per-message reader produced usage (e.g. Cursor)', async () => { + // No messages at all — there is nothing for a per-message reader to walk — but the adapter + // supplied a session-level total via usageMeta, which is the fallback under test. + const deps: EnricherDeps = { + ...baseDeps, + parseNative: async () => + ({ + sessionId: 's1', + agentName: 'cursor', + metadata: {}, + messages: [], + usageMeta: { + usagePartial: true, + tokensByModel: { 'claude-sonnet-4-5': { inputTokens: 500_000, outputTokens: 0 } }, + }, + }) as never, + }; + const { index } = await enrichCosts(raw, deps); + const c = index.get('s1')!; + expect(c.priced).toBe(true); + expect(c.costUSD).toBeCloseTo(1.5, 6); // 500k input @ $3/1M sonnet-4-5 + expect(c.tokens.input).toBe(500_000); + expect(c.usagePartial).toBe(true); + }); + it('prices a codex session from token_count events', async () => { const { readFileSync } = await import('node:fs'); const { join } = await import('node:path'); @@ -230,7 +255,10 @@ describe('enrichCosts', () => { }; const { index, summary } = await enrichCosts(raw, deps); expect(index.get('s1')!.priced).toBe(true); - expect(index.get('s1')!.costUSD).toBe(0); + // Real tokens with no matching price row are estimated at the stand-in rate and badged, + // rather than reported as $0 (issue 02); the model is still listed as unpriced. + expect(index.get('s1')!.costUSD).toBeGreaterThan(0); + expect(index.get('s1')!.usagePartial).toBe(true); expect(summary.unpricedModels).toContain('no-such-model-xyz'); }); @@ -586,3 +614,59 @@ describe('buildCostSeries', () => { expect(s[s.length - 1].tokens).toBe(200); // last cumulative total preserved }); }); + +/** + * Sessions that recovered real tokens but whose model is absent from the price table + * (Cursor reports `default`/Auto) still deserve an API-equivalent estimate — a blank is + * less honest than a labelled floor. See issue 02 of the cursor-analytics-cost-honesty spec. + */ +describe('unpriced-model cost estimation', () => { + /** An adapter that supplies session-level `tokensByModel` instead of per-message usage (the Cursor shape). */ + const adapterTokens = (tokensByModel: Record): EnricherDeps => ({ + ...baseDeps, + parseNative: async () => ({ sessionId: 's1', agentName: 'cursor', metadata: {}, messages: [], usageMeta: { tokensByModel } }) as never, + }); + + it('estimates an Auto/unpriced model at the Sonnet stand-in rate without renaming the model', async () => { + const { index, summary } = await enrichCosts(raw, adapterTokens({ default: { inputTokens: 1_000_000, outputTokens: 0 } })); + const c = index.get('s1')!; + expect(c.costUSD).toBeCloseTo(3, 6); // 1M input @ $3/1M — the claude-sonnet-4 stand-in + expect(c.usagePartial).toBe(true); + expect(c.perModel[0].model).toBe('default'); // NOT renamed to a Claude model + expect(c.perModel[0].estimated).toBe(true); + // Coverage diagnostics must still confess the model had no real price. + expect(c.perModel[0].unpriced).toBe(true); + expect(summary.unpricedModels).toContain('default'); + expect(c.priced).toBe(true); // "had recoverable usage" + }); + + it('prefers a real price when the model is in the table', async () => { + const { index } = await enrichCosts(raw, adapterTokens({ 'claude-opus-4-1': { inputTokens: 1_000_000, outputTokens: 0 } })); + const c = index.get('s1')!; + expect(c.costUSD).toBeGreaterThan(3.5); // opus input rate, not the $3 sonnet stand-in + expect(c.perModel[0].unpriced).toBe(false); + expect(c.perModel[0].estimated).toBeFalsy(); + expect(c.usagePartial).toBeFalsy(); // a real price is not a partial estimate + }); + + it('fabricates no estimate for an unpriced model with zero tokens', async () => { + const { index, summary } = await enrichCosts(raw, adapterTokens({ default: { inputTokens: 0, outputTokens: 0 } })); + const c = index.get('s1')!; + expect(c.costUSD).toBe(0); + expect(c.perModel[0].estimated).toBeFalsy(); + expect(c.usagePartial).toBeFalsy(); + expect(summary.totalCostUSD).toBe(0); + }); + + it('leaves a session with no token signal at all unmeasurable', async () => { + const deps: EnricherDeps = { + ...baseDeps, + parseNative: async () => ({ sessionId: 's1', agentName: 'cursor', metadata: {}, messages: [], usageMeta: { usageUnavailableReason: 'no token telemetry' } }) as never, + }; + const c = (await enrichCosts(raw, deps)).index.get('s1')!; + expect(c.costUSD).toBe(0); + expect(c.priced).toBe(false); + expect(c.usageUnavailableReason).toBe('no token telemetry'); + expect(c.usagePartial).toBeFalsy(); + }); +}); diff --git a/src/cli/commands/analytics/cost/cost-enricher.ts b/src/cli/commands/analytics/cost/cost-enricher.ts index 544ce58d9..7a437c2e5 100644 --- a/src/cli/commands/analytics/cost/cost-enricher.ts +++ b/src/cli/commands/analytics/cost/cost-enricher.ts @@ -106,27 +106,66 @@ async function parseOne(raw: RawSessionData, deps: EnricherDeps): Promise): Map { + const out = new Map(); + for (const [model, t] of Object.entries(tokensByModel)) { + out.set(model, { + input: t.inputTokens, + output: t.outputTokens, + cacheRead: 0, + cacheCreation: 0, + cacheCreation1h: 0, + total: t.inputTokens + t.outputTokens, + }); + } + return out; +} + +/** + * Rate stand-in for a model that recovered real tokens but matches no pricing entry — Cursor + * delegates model choice and records `default` (displayed as Auto), and other + * agents occasionally report ids the table has not caught up with. Claude Sonnet is the + * published mid-tier API rate, so it is the defensible order-of-magnitude estimate; a blank + * cell would be less honest than a labelled floor when the token counts themselves are real. + * Callers must keep the session's own model label and mark the usage partial. + */ +const UNPRICED_ESTIMATE_MODEL = 'claude-sonnet-4'; + /** Phase 3: price an already-gathered (deduped) per-model usage map for one session. */ function priceUsage( sessionId: string, hadLog: boolean, usageByModel: Map -): { cost: SessionCost; unpriced: string[] } { +): { cost: SessionCost; unpriced: string[]; estimated: boolean } { const perModel: ModelCost[] = []; const unpriced: string[] = []; let sessionTokens = emptyUsage(); let sessionCost = 0; let cacheReadCostUSD = 0; + let estimated = false; for (const [rawModel, usage] of usageByModel) { const model = normalizeModelName(rawModel); - const price = lookupPrice(model); + // Real attribution always wins; the stand-in only steps in when there are genuine tokens + // to price. Zero tokens stay at $0 rather than becoming an invented estimate of nothing. + const ownPrice = lookupPrice(model); + const price = ownPrice ?? (usage.total > 0 ? lookupPrice(UNPRICED_ESTIMATE_MODEL) : null); const breakdown = price ? costBreakdown(usage, price) : null; const costUSD = breakdown ? breakdown.total : 0; - if (!price) { + const viaStandIn = !ownPrice && costUSD > 0; + if (!ownPrice) { + // Coverage diagnostics keep naming the real model (Auto/default), estimate or not. unpriced.push(model); } - perModel.push({ model, tokens: usage, costUSD, unpriced: !price }); + estimated = estimated || viaStandIn; + perModel.push({ model, tokens: usage, costUSD, unpriced: !ownPrice, ...(viaStandIn ? { estimated: true } : {}) }); sessionTokens = addUsage(sessionTokens, usage); sessionCost += costUSD; cacheReadCostUSD += breakdown ? breakdown.cacheRead : 0; @@ -138,6 +177,7 @@ function priceUsage( return { cost: { sessionId, tokens: sessionTokens, costUSD: sessionCost, cacheReadCostUSD, perModel, priced: perModel.length > 0, hadLog }, unpriced, + estimated, }; } @@ -359,7 +399,20 @@ export async function enrichCosts( series = []; records = []; } - const { cost, unpriced: u } = priceUsage(entry.sessionId, entry.hadLog, usageByModel); + // An adapter with no per-message usage reader (see usage-readers.ts) can still know a + // session-level total some other way — Cursor's real per-turn token counts live in a + // separate store with no alignment to transcript messages, so there is nothing for a + // per-message reader to walk. `usageMeta.tokensByModel` is that adapter-supplied total; + // only used as a fallback so an agent WITH a working per-message reader is never overridden. + if (usageByModel.size === 0 && entry.parsed?.usageMeta?.tokensByModel) { + usageByModel = tokensByModelUsage(entry.parsed.usageMeta.tokensByModel); + } + const { cost, unpriced: u, estimated } = priceUsage(entry.sessionId, entry.hadLog, usageByModel); + if (estimated) { + // Borrowed rates are an estimate by construction, so the report must badge them even + // when the adapter itself considered its token counts complete. + cost.usagePartial = true; + } if (entry.filePath) { // Same path that made hadLog/pricing true — so a consumer never sees "priced" and // "no file to show" disagree (see CR-002 in the file-location UI review). diff --git a/src/cli/commands/analytics/cost/types.ts b/src/cli/commands/analytics/cost/types.ts index fe0eb8e45..7b0516ef1 100644 --- a/src/cli/commands/analytics/cost/types.ts +++ b/src/cli/commands/analytics/cost/types.ts @@ -19,8 +19,23 @@ export interface TokenUsage { export interface ModelCost { model: string; // normalized model name tokens: TokenUsage; - costUSD: number; // 0 when unpriced + costUSD: number; // 0 when unpriced with no tokens to estimate from unpriced: boolean; // true when no pricing entry matched + /** + * True when `costUSD` came from the unpriced-model rate stand-in rather than this model's + * own rates — real tokens, borrowed prices. Always accompanies `unpriced`, and forces + * `SessionCost.usagePartial`, so the figure is never read as an invoice. + */ + estimated?: boolean; + /** + * Where this line's `costUSD` came from, when it is not CodeMie's own estimate. + * + * `'vendor-billed'` means the vendor billed this exact amount and CodeMie merely recorded it — + * currently only rows converted from a Cursor usage export (`cursor-usage-loader.ts`). Absent + * everywhere else, which keeps today's meaning — a figure computed from tokens and a pricing + * table — as the default rather than something every existing producer has to restate. + */ + costBasis?: 'vendor-billed'; } /** One cumulative point in a session's token & cost growth series. */ diff --git a/src/cli/commands/analytics/cursor-usage-loader.ts b/src/cli/commands/analytics/cursor-usage-loader.ts new file mode 100644 index 000000000..f7e2209aa --- /dev/null +++ b/src/cli/commands/analytics/cursor-usage-loader.ts @@ -0,0 +1,278 @@ +/** + * The Cursor usage export, converted into the analytics pipeline's own shapes. + * + * Cursor's local stores no longer carry billable token counts, so the dashboard's + * Usage → Export CSV is the only accurate record of what a Cursor session actually cost + * (see `cursor.usage-csv.ts` and `docs/CURSOR_INTEGRATION.md`). Rendering that file as its own + * isolated panel — which is what the first cut did — left every headline number in the report + * ignoring 39.9M real tokens and $25.25 of real cost. + * + * This module follows the OTEL precedent (`otel-loader.ts`), the existing answer to "a flat + * per-event file that has to behave like sessions": synthesize {@link RawSessionData} plus a + * canonical {@link SessionCostIndex}, hand both to the pipeline, and let the aggregator, the + * formatter, the exporter and the report client treat the result like any other session. No + * consumer needs a special case, so no consumer can forget one. + * + * Two rules keep the conversion honest: + * + * - **Conserved.** Every event lands in exactly one place. An event whose timestamp falls + * inside exactly one Cursor session's activity window is attributed to that session; anything + * else — no window, or several overlapping ones — goes to a per-day pseudo-session rather + * than to a guess. That refusal matches `81dbeb1`, which already declines to guess when a + * Cursor slug is ambiguous. Sum the output and you get the file's own totals, once. + * - **Attributed.** Every line this module produces carries `costBasis: 'vendor-billed'`. + * These are Cursor's own billed figures, not CodeMie's estimate from a pricing table, and + * that distinction has to survive the merge — it is the whole point of the honesty work. + * + * Overwriting a matched session's usage loses nothing: a local Cursor session carries $0 and + * zero tokens today, with a `usageUnavailableReason` explaining why. + */ + +import type { RawSessionData, SessionStartEvent, SessionEndEvent } from './data-loader.js'; +import type { MetricDelta } from '@/agents/core/metrics/types.js'; +import type { SessionCost, SessionCostIndex, CostSummary, TokenUsage, ModelCost } from './cost/types.js'; +import { emptyUsage, addUsage } from './cost/cost-calculator.js'; +import type { CursorUsageEvent, CursorUsageImport } from '@/agents/plugins/cursor/cursor.usage-csv.js'; +import { normalizeModelName } from '@/utils/model-normalizer.js'; + +/** The agent every synthesized session is attributed to — Cursor's rows are Cursor's. */ +const AGENT_NAME = 'cursor'; + +/** Prefix for a per-day pseudo-session id; `cursor-usage:`. */ +const PSEUDO_ID_PREFIX = 'cursor-usage:'; + +export interface CursorUsageSessions { + /** Pseudo-sessions for unmatched events. Matched events need no new session. */ + rawSessions: RawSessionData[]; + /** Cost rows for both matched real sessions and the pseudo-sessions. */ + costIndex: SessionCostIndex; + summary: CostSummary; + /** Events attributed to a real Cursor session. */ + matched: number; + /** Events that fell into a per-day pseudo-session instead. */ + unmatched: number; +} + +/** The window a session covers, from the events the native loader synthesized it with. */ +interface SessionWindow { + sessionId: string; + start: number; + end: number; +} + +/** The model an event is attributed to, normalized and with one fallback for a blank cell. */ +function modelOf(event: CursorUsageEvent): string { + return normalizeModelName(event.model || '(unknown)'); +} + +/** Append to a map of grouped events, creating the group on first sight. */ +function pushInto(groups: Map, key: string, event: CursorUsageEvent): void { + const group = groups.get(key); + if (group) { + group.push(event); + } else { + groups.set(key, [event]); + } +} + +/** Convert the export's token columns into the pipeline's normalized usage shape. */ +function toUsage(event: CursorUsageEvent): TokenUsage { + const { input, output, cacheRead, cacheCreation, total } = event.tokens; + return { + input, + output, + cacheRead, + cacheCreation, + // The export does not distinguish the 1h-TTL subset of cache creation, and inventing a split + // would misstate a figure Cursor never published. + cacheCreation1h: 0, + total, + }; +} + +/** + * The activity windows usable for matching. + * + * Only Cursor's own sessions are considered — no other agent's window can contain a Cursor usage + * event, and including them would only manufacture false ambiguity. A zero-width window is kept: + * an event stamped at that exact instant is still unambiguously that session's. A session with no + * usable start is not — there is nothing to compare against. + */ +function windowsOf(sessions: RawSessionData[]): SessionWindow[] { + const windows: SessionWindow[] = []; + for (const session of sessions) { + if (session.startEvent?.agentName !== AGENT_NAME) { + continue; + } + const start = session.startEvent?.data.startTime; + if (start === undefined || !Number.isFinite(start) || start <= 0) { + continue; + } + const end = session.endEvent?.data.endTime; + windows.push({ + sessionId: session.sessionId, + start, + end: end !== undefined && Number.isFinite(end) && end > start ? end : start, + }); + } + return windows; +} + +/** + * The one session whose window contains this timestamp, or undefined. + * + * Several containing windows means the data cannot say which session spent the tokens. Picking + * the narrowest — the tactic `otel-loader.ts` uses for parallel subagents — is defensible there + * because those windows describe nested work; Cursor conversations run side by side, so the + * tightest window carries no such meaning and the choice would be a coin toss printed as fact. + */ +function containingSession(windows: SessionWindow[], ms: number): string | undefined { + let found: string | undefined; + for (const w of windows) { + if (ms < w.start || ms > w.end) { + continue; + } + if (found !== undefined) { + return undefined; // ambiguous — refuse to guess + } + found = w.sessionId; + } + return found; +} + +/** Roll a group of events up into one canonical cost row. */ +function toSessionCost(sessionId: string, events: CursorUsageEvent[]): SessionCost { + const perModelMap = new Map(); + const perModelCost = new Map(); + let tokens = emptyUsage(); + let costUSD = 0; + + for (const event of events) { + const usage = toUsage(event); + tokens = addUsage(tokens, usage); + costUSD += event.costUSD; + // Normalized so a Cursor spelling collapses onto the same key every other source uses; the + // name is otherwise left alone, with provenance carried by `costBasis` rather than a suffix + // that would split one model across two rows in every by-model chart. + const model = modelOf(event); + perModelMap.set(model, addUsage(perModelMap.get(model) ?? emptyUsage(), usage)); + perModelCost.set(model, (perModelCost.get(model) ?? 0) + event.costUSD); + } + + const perModel: ModelCost[] = [...perModelMap.entries()] + .map(([model, modelTokens]): ModelCost => ({ + model, + tokens: modelTokens, + costUSD: perModelCost.get(model) ?? 0, + unpriced: false, + costBasis: 'vendor-billed', + })) + .sort((a, b) => b.costUSD - a.costUSD || b.tokens.total - a.tokens.total); + + return { + sessionId, + tokens, + costUSD, + perModel, + // Priced, but from Cursor's invoice rather than a native log — so `hadLog` stays false and + // no `agentSessionFile` is claimed for a file that does not exist. + priced: true, + hadLog: false, + }; +} + +/** One ordinary-looking Cursor session standing in for a day's unmatched events. */ +function pseudoSession(day: string, events: CursorUsageEvent[]): RawSessionData { + const sessionId = `${PSEUDO_ID_PREFIX}${day}`; + const stamps = events.map((e) => Date.parse(e.date)).filter((n) => Number.isFinite(n)); + const startTime = stamps.length ? Math.min(...stamps) : 0; + const endTime = stamps.length ? Math.max(...stamps) : 0; + const models = [...new Set(events.map(modelOf))]; + + const delta: MetricDelta = { + recordId: `${sessionId}-usage`, + sessionId, + agentSessionId: sessionId, + timestamp: startTime, + tools: {}, + models, + // Drives the session title, so the row reads as what it is rather than as a bare id. + userPrompts: [{ count: 1, text: `Cursor usage — ${day}` }], + syncStatus: 'synced', + syncAttempts: 0, + }; + + const startEvent: SessionStartEvent = { + recordId: sessionId, + type: 'session_start', + timestamp: startTime, + codeMieSessionId: sessionId, + agentName: AGENT_NAME, + syncStatus: 'synced', + // The export carries no project, and attributing a day's spend to whichever repo happened to + // be open would invent an association Cursor never recorded. + data: { provider: 'native', workingDirectory: 'Unknown', startTime }, + }; + + const endEvent: SessionEndEvent = { + recordId: `${sessionId}-end`, + type: 'session_end', + timestamp: endTime, + codeMieSessionId: sessionId, + agentName: AGENT_NAME, + syncStatus: 'synced', + data: { endTime, duration: Math.max(0, endTime - startTime), totalTurns: 1 }, + }; + + return { sessionId, startEvent, endEvent, deltas: [delta] }; +} + +/** + * Convert a parsed usage export into sessions and cost rows the pipeline already understands. + * + * Pass the run's whole session set: the matcher narrows to Cursor's own sessions itself, so no + * caller has to know which agent name the export belongs to. + */ +export function buildCursorUsageSessions( + usage: CursorUsageImport, + sessions: RawSessionData[] +): CursorUsageSessions { + const windows = windowsOf(sessions); + const byMatchedSession = new Map(); + const byDay = new Map(); + let matched = 0; + let unmatched = 0; + + for (const event of usage.events) { + const ms = Date.parse(event.date); + const sessionId = Number.isFinite(ms) ? containingSession(windows, ms) : undefined; + if (sessionId !== undefined) { + pushInto(byMatchedSession, sessionId, event); + matched += 1; + continue; + } + pushInto(byDay, event.day || 'unknown', event); + unmatched += 1; + } + + const costIndex: SessionCostIndex = new Map(); + for (const [sessionId, events] of byMatchedSession) { + costIndex.set(sessionId, toSessionCost(sessionId, events)); + } + + const rawSessions: RawSessionData[] = []; + for (const [day, events] of byDay) { + const session = pseudoSession(day, events); + rawSessions.push(session); + costIndex.set(session.sessionId, toSessionCost(session.sessionId, events)); + } + + const summary: CostSummary = { + totalCostUSD: [...costIndex.values()].reduce((sum, c) => sum + c.costUSD, 0), + pricedSessions: costIndex.size, + totalSessions: costIndex.size, + unpricedModels: [], + }; + + return { rawSessions, costIndex, summary, matched, unmatched }; +} diff --git a/src/cli/commands/analytics/formatter.ts b/src/cli/commands/analytics/formatter.ts index 94a8c697b..a8a1d5f21 100644 --- a/src/cli/commands/analytics/formatter.ts +++ b/src/cli/commands/analytics/formatter.ts @@ -169,9 +169,7 @@ export class AnalyticsFormatter { const providerLabel = session.provider === 'native-external' ? chalk.yellow('native [external ⚠ not CodeMie-managed]') - : session.provider === 'native-unmanaged' - ? chalk.gray('native [not CodeMie-managed — analytics only]') - : session.provider; + : session.provider; console.log(chalk.gray(` Provider: `) + providerLabel); console.log(chalk.gray(` Duration: ${this.formatDuration(session.duration)}`)); console.log(chalk.gray(` Turns: ${session.totalTurns}`)); diff --git a/src/cli/commands/analytics/index.ts b/src/cli/commands/analytics/index.ts index 5ff896601..e32adba3c 100644 --- a/src/cli/commands/analytics/index.ts +++ b/src/cli/commands/analytics/index.ts @@ -14,6 +14,9 @@ import { SessionsSource } from './sources/sessions-source.js'; import { OtelSource } from './sources/otel-source.js'; import type { AnalyticsSource } from './sources/types.js'; import { ConfigLoader } from '../../../utils/config.js'; +import type { CostSummary, SessionCostIndex } from './cost/types.js'; +import type { CursorUsageSessions } from './cursor-usage-loader.js'; +import type { CursorUsageImport } from '@/agents/plugins/cursor/cursor.usage-csv.js'; export function createAnalyticsCommand(): Command { const command = new Command('analytics') @@ -23,6 +26,9 @@ export function createAnalyticsCommand(): Command { applyCommonOptions(command) .option('--no-scan-native', 'Skip native agent-log discovery (use only CodeMie-tracked sessions)') .option('--include-external', 'Include non-CodeMie-owned native sessions in output (opt-in; matches pre-fix behavior)') + .option('--cursor-usage-csv ', 'Import a Cursor usage-events CSV (Cursor dashboard → Usage → Export) for real Cursor tokens and cost. No network call') + .option('--cursor-usage-user ', 'Which User column value to keep from the Cursor usage export (default: your configured CodeMie email)') + .option('--cursor-usage-fetch', 'Download the Cursor usage export instead of passing a file (makes a NETWORK CALL; requires CURSOR_USAGE_EXPORT_URL and a signed-in Cursor app)') .action((options: AnalyticsOptions) => runAnalytics(options, new SessionsSource())); // `codemie analytics otel --file ` — OTEL file source. @@ -71,12 +77,6 @@ export async function runAnalytics(options: AnalyticsOptions, source: AnalyticsS includeExternal: options.includeExternal }); - if (rawSessions.length === 0) { - console.log(chalk.yellow('\nNo sessions found matching the specified criteria.')); - console.log(chalk.dim('Run with different filters or check that metrics are being collected.\n')); - return; - } - // A report needs cost computed BEFORE aggregation so zero-delta sessions that still carry // real usage are retained instead of dropped as "empty". const wantReport = Boolean(options.report || options.reportOutput || options.open || options.reportFormat); @@ -86,22 +86,52 @@ export async function runAnalytics(options: AnalyticsOptions, source: AnalyticsS return; } + // The email the report is stamped with, and the default `User` value the Cursor usage export + // is filtered on. Read before the import so `--cursor-usage-user` keeps its documented + // default in a non-interactive run; the interactive prompt for a missing one stays in the + // report branch, which is the only place a report filename needs it. + let userEmail: string | undefined; + try { + const cfg = await ConfigLoader.loadMultiProviderConfig(); + userEmail = cfg.userEmail || undefined; + } catch { + // omit email gracefully + } + // Cost: authoritative from the source (OTEL) when present; otherwise enrich from correlated // logs, but only when a report needs it. Retain zero-delta sessions with real token usage. let costResult = cost; - let keepSessionIds: Set | undefined; - if (cost) { - keepSessionIds = new Set( - [...cost.index.values()].filter((c) => c.tokens.total > 0).map((c) => c.sessionId) - ); - } else if (wantReport) { + if (!cost && wantReport) { const { enrichCosts, realDeps } = await import('./cost/cost-enricher.js'); costResult = await enrichCosts(rawSessions, realDeps); - keepSessionIds = new Set( - [...costResult.index.values()].filter((c) => c.tokens.total > 0).map((c) => c.sessionId) - ); } + // #21/#22: the Cursor usage export, resolved here rather than inside the report branch so a + // run without any report flag no longer discards the flag in silence. Converted into + // ordinary sessions and canonical cost rows, it reaches the terminal totals, `--export` and + // the report from ONE place — nothing downstream needs to know the CSV exists. + const cursorUsage = await resolveCursorUsage(options, filter, userEmail); + if (cursorUsage) { + const { buildCursorUsageSessions } = await import('./cursor-usage-loader.js'); + const built = buildCursorUsageSessions(cursorUsage, rawSessions); + rawSessions.push(...built.rawSessions); + const index = new Map([...(costResult?.index ?? []), ...built.costIndex]); + costResult = { index, summary: summarize(index, costResult?.summary.unpricedModels ?? []) }; + reportCursorUsageImport(cursorUsage, built); + } + + if (rawSessions.length === 0) { + console.log(chalk.yellow('\nNo sessions found matching the specified criteria.')); + console.log(chalk.dim('Run with different filters or check that metrics are being collected.\n')); + return; + } + + // Zero-delta sessions that still carry real usage — a Cursor conversation priced only by the + // usage export among them — would otherwise be dropped by the aggregator as empty. + const keepSessionIds = costResult + ? new Set([...costResult.index.values()].filter((c) => c.tokens.total > 0).map((c) => c.sessionId)) + : undefined; + // Aggregate data (normalize models unless --verbose flag is set) const analytics = AnalyticsAggregator.aggregate(rawSessions, !options.verbose, keepSessionIds); @@ -142,15 +172,6 @@ export async function runAnalytics(options: AnalyticsOptions, source: AnalyticsS writeReportWithFallback } = await import('./report/report-generator.js'); - // Load user email for report metadata and filename; non-fatal if config is unavailable. - let userEmail: string | undefined; - try { - const cfg = await ConfigLoader.loadMultiProviderConfig(); - userEmail = cfg.userEmail || undefined; - } catch { - // omit email gracefully - } - if (userEmail === undefined && process.stdout.isTTY) { console.log(chalk.yellow('\n Warning: your email is not configured. It will be included in the report metadata and saved for future runs.')); try { @@ -248,6 +269,99 @@ export async function runAnalytics(options: AnalyticsOptions, source: AnalyticsS } } +/** + * The Cursor usage export for this run, from a local file or the same export fetched. + * + * Both paths end in the SAME parser, so a downloaded export can never be interpreted + * differently from one the operator saved by hand. Returns undefined — never throws — when no + * flag asked for one, when the file is unreadable, or when the user filter left nothing; every + * one of those says so on stdout first, because a silently ignored flag is what made this + * import look broken in the first place. + */ +async function resolveCursorUsage( + options: AnalyticsOptions, + filter: AnalyticsFilter, + userEmail: string | undefined +): Promise { + const wantedUser = options.cursorUsageUser ?? userEmail; + let usage: CursorUsageImport | undefined; + + if (options.cursorUsageCsv) { + const { loadCursorUsageCsv } = await import('@/agents/plugins/cursor/cursor.usage-csv.js'); + usage = loadCursorUsageCsv(options.cursorUsageCsv, { + ...(wantedUser !== undefined && { userEmail: wantedUser }), + }) ?? undefined; + if (!usage) { + console.log(chalk.yellow(`\n Could not read a Cursor usage export from ${options.cursorUsageCsv}. Continuing without it.`)); + } + } else if (options.cursorUsageFetch) { + // The only network call in the analytics path, and it needs all three of: the flag, a + // configured endpoint, and a signed-in Cursor. Any missing piece means no request. + const { readCursorSessionCookie, fetchCursorUsageExport } = await import('@/agents/plugins/cursor/cursor.usage-fetch.js'); + const cookie = await readCursorSessionCookie(); + usage = (await fetchCursorUsageExport({ + enabled: true, + ...(process.env.CURSOR_USAGE_EXPORT_URL !== undefined && { exportUrl: process.env.CURSOR_USAGE_EXPORT_URL }), + ...(cookie !== undefined && { cookie }), + ...(wantedUser !== undefined && { userEmail: wantedUser }), + ...(filter.fromDate !== undefined && { startDate: filter.fromDate.toISOString().slice(0, 10) }), + ...(filter.toDate !== undefined && { endDate: filter.toDate.toISOString().slice(0, 10) }), + })) ?? undefined; + if (!usage) { + console.log(chalk.yellow('\n Could not fetch the Cursor usage export. It needs CURSOR_USAGE_EXPORT_URL set and a signed-in')); + console.log(chalk.yellow(' Cursor app on this machine; the endpoint is undocumented and may have changed.')); + console.log(chalk.yellow(' The supported fallback is to export the CSV from the Cursor dashboard and pass --cursor-usage-csv .')); + console.log(chalk.dim(' Run with CODEMIE_DEBUG=true to see the status code. Continuing without it.')); + } + } + + if (usage && usage.events.length === 0) { + // The Cursor account's email is frequently NOT the CodeMie config email, which would + // otherwise silently filter every row away and look like an empty export. + console.log(chalk.yellow(`\n Cursor usage export matched no rows for ${wantedUser ?? '(no email configured)'}.`)); + if (usage.usersInFile.length) { + console.log(chalk.yellow(` The export contains: ${usage.usersInFile.join(', ')}`)); + console.log(chalk.yellow(' Re-run with --cursor-usage-user to pick one of those.')); + } + return undefined; + } + + return usage; +} + +/** What the import contributed, so the numbers below it are never unexplained. */ +function reportCursorUsageImport(usage: CursorUsageImport, built: CursorUsageSessions): void { + const cost = usage.hasCost ? `, $${built.summary.totalCostUSD.toFixed(2)} (Cursor's own billing)` : ', no cost column in this export'; + console.log( + chalk.dim( + `\n Imported ${usage.totals.events} Cursor usage event(s): ${usage.totals.tokens.total.toLocaleString('en-US')} tokens${cost}.` + ) + ); + console.log( + chalk.dim( + ` ${built.matched} attributed to a Cursor session; ${built.unmatched} in ${built.rawSessions.length} daily rollup(s) — no session window matched them unambiguously.` + ) + ); +} + +/** + * Re-derive the run's rollup from the merged index rather than adding two summaries. + * + * The import OVERWRITES the cost row of any session it matched, so adding the two totals would + * count a matched session on both sides. Reading the merged map is exact by construction and + * cannot drift as either side changes. `unpricedModels` is carried over untouched: it is a + * distinct set that no row in the map records, and the usage export prices everything it holds. + */ +function summarize(index: SessionCostIndex, unpricedModels: string[]): CostSummary { + const rows = [...index.values()]; + return { + totalCostUSD: rows.reduce((sum, c) => sum + c.costUSD, 0), + pricedSessions: rows.filter((c) => c.priced).length, + totalSessions: rows.length, + unpricedModels, + }; +} + /** * Parse filter options from command line arguments */ diff --git a/src/cli/commands/analytics/native-loader.ts b/src/cli/commands/analytics/native-loader.ts index a8933b773..4be5fd934 100644 --- a/src/cli/commands/analytics/native-loader.ts +++ b/src/cli/commands/analytics/native-loader.ts @@ -31,28 +31,12 @@ import { firstPiUserText } from '../../../agents/plugins/pi/session/pi-user-prom import { PI_FORKED_CONTINUATION, piForkedContinuations } from '../../../agents/plugins/pi/pi.session.js'; /** Agents whose native logs we discover + synthesize. */ -const NATIVE_AGENTS = ['claude', 'codex', 'copilot-cli', 'pi', 'gemini'] as const; +const NATIVE_AGENTS = ['claude', 'codex', 'copilot-cli', 'pi', 'gemini', 'cursor'] as const; function isPiAgent(agentName: string): boolean { return agentName.toLowerCase() === 'pi'; } -/** - * Agents CodeMie only reads analytics for and never installs, launches, or manages. - * - * The ownership gate below exists to stop analytics silently counting UNMANAGED runs of an - * agent CodeMie CAN manage (EPMCDME-13367). A truly analytics-only agent has no managed - * variant, so it can never carry an ownership marker — applying the gate would tag 100% of - * its sessions `native-external` and drop them from the default report. - */ -function isAnalyticsOnlyAgent(agentName: string): boolean { - try { - return AgentRegistry.getAgent(agentName)?.metadata.analyticsOnly === true; - } catch { - return false; - } -} - /** A discovered native session paired with its agent. */ export interface DiscoveredNative { agentName: string; @@ -363,6 +347,7 @@ function buildNativeRawSession( tools: parsed.metrics?.tools ?? {}, toolStatus: parsed.metrics?.toolStatus, fileOperations: parsed.metrics?.fileOperations as MetricDelta['fileOperations'], + ...(parsed.metrics?.filesChangedCount !== undefined && { filesChangedCount: parsed.metrics.filesChangedCount }), models, // Named invocations are extracted at parse time (e.g. claude.session.ts extractMetrics); // carry them through so native (untracked) sessions populate the skill/agent/command charts. @@ -572,7 +557,13 @@ export function synthesizeRawSession( return buildNativeRawSession(agentName, descriptor, parsed, { cwd: messages.find((m) => m.cwd)?.cwd ?? descriptor.projectPath ?? 'Unknown', - branch: modal(messages.map((m) => m.gitBranch).filter((b): b is string => !!b)), + // Per-message gitBranch is the primary signal (it can change mid-session, so a mode vote is + // the honest summary) — but a session that recorded no messages at all (e.g. a Cursor + // conversation known only through composerHeaders, with no matching transcript) has nothing + // to vote over. `parsed.metadata.branch` is where an adapter puts a session-level branch it + // knows some other way; falling back to it here is what lets such a session still report a + // branch instead of silently losing one. + branch: modal(messages.map((m) => m.gitBranch).filter((b): b is string => !!b)) ?? parsed.metadata.branch, startTime: timestamps.length ? Math.min(...timestamps) : descriptor.createdAt, endTime: timestamps.length ? Math.max(...timestamps) : descriptor.updatedAt ?? descriptor.createdAt, turns: Math.max(assistantMsgs.length, 1), @@ -749,15 +740,13 @@ export async function loadNativeSessions( continue; } const raw = synthesizeRawSession(agentName, descriptor, parsed); + // One rule for every agent: a session CodeMie cannot prove it launched is external, and + // external sessions are opt-in behind `--include-external`. That holds for analytics-only + // agents too — CodeMie did not run them, so the default report does not claim them. Set one + // up through CodeMie and its sessions carry an ownership marker, keeping the plain 'native' + // tag that means "CodeMie launched this" and showing with no flag. if (raw.startEvent && !deps.hasOwnershipMarker(descriptor.filePath)) { - // Truly analytics-only agents can never carry an ownership marker, so tagging them - // 'native-external' would drop 100% of their sessions from the default report. They - // still are not CodeMie-managed, so they get their own tag rather than the plain - // 'native' that means "CodeMie launched this". Managed agents, including Copilot CLI, - // remain 'native-external' when their transcript lacks CodeMie ownership. - raw.startEvent.data.provider = isAnalyticsOnlyAgent(agentName) - ? 'native-unmanaged' - : 'native-external'; + raw.startEvent.data.provider = 'native-external'; } out.push(raw); } diff --git a/src/cli/commands/analytics/report/__tests__/payload-builder.test.ts b/src/cli/commands/analytics/report/__tests__/payload-builder.test.ts index 6c3423ae4..85e0b0b51 100644 --- a/src/cli/commands/analytics/report/__tests__/payload-builder.test.ts +++ b/src/cli/commands/analytics/report/__tests__/payload-builder.test.ts @@ -522,3 +522,58 @@ describe('buildPayload — copilot-cli specific fields', () => { expect(cov).toEqual({ agentName: 'copilot-cli', total: 3, priced: 2, withLog: 3 }); }); }); + +/** + * A Cursor session priced from the usage export carries Cursor's own billed figures rather than + * a CodeMie estimate, and reaches the report through the ordinary cost index. Two things have to + * survive that trip: the `costBasis` tag that keeps the distinction legible, and `priced: true`, + * without which Coverage by agent goes on reporting Cursor as having no token data — directly + * contradicting the import the reader just made. + */ +describe('buildPayload — Cursor usage-CSV provenance', () => { + const cursorRoot = { + ...root, + projects: [ + { + projectPath: '/repo/app', + branches: [{ branchName: 'main', sessions: [session({ sessionId: 'cur1', agentName: 'cursor' })] }], + }, + ], + } as unknown as RootAnalytics; + + const cursorIndex: SessionCostIndex = new Map([ + [ + 'cur1', + { + sessionId: 'cur1', + tokens: { ...emptyTokens(), cacheCreation1h: 0, input: 30061, output: 1038, cacheRead: 118400, total: 149499 }, + costUSD: 0.07, + perModel: [ + { + model: 'auto', + tokens: { ...emptyTokens(), cacheCreation1h: 0, input: 30061, output: 1038, cacheRead: 118400, total: 149499 }, + costUSD: 0.07, + unpriced: false, + costBasis: 'vendor-billed' as const, + }, + ], + priced: true, + hadLog: false, + }, + ], + ]); + + it('carries costBasis through onto perModelCost', () => { + const payload = buildPayload(cursorRoot, cursorIndex, summary, ctxAll); + + const s = payload.sessions[0]; + expect(s.perModelCost[0]).toMatchObject({ model: 'auto', costUSD: 0.07, costBasis: 'vendor-billed' }); + }); + + it('reports Cursor as priced in Coverage by agent even with no native log', () => { + const payload = buildPayload(cursorRoot, cursorIndex, summary, ctxAll); + + const cov = payload.meta.coverage.find((c) => c.agentName === 'cursor')!; + expect(cov).toEqual({ agentName: 'cursor', total: 1, priced: 1, withLog: 0 }); + }); +}); diff --git a/src/cli/commands/analytics/report/__tests__/report-cost-honesty.test.ts b/src/cli/commands/analytics/report/__tests__/report-cost-honesty.test.ts new file mode 100644 index 000000000..f3a08cbf3 --- /dev/null +++ b/src/cli/commands/analytics/report/__tests__/report-cost-honesty.test.ts @@ -0,0 +1,90 @@ +/** + * Report client cost-honesty contract test. + * + * The report client is a no-build vanilla IIFE, so its formatting helpers cannot be + * imported. Like `report-views.test.ts`, this asserts the contract against the source + * text: unmeasurable usage must render as an em dash, subscription wording must be gone, + * and the all-unmeasurable views must carry an explicit "no local telemetry" note. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'fs'; +import { fileURLToPath } from 'url'; + +const app = readFileSync(fileURLToPath(new URL('../client/app.js', import.meta.url)), 'utf-8'); + +/** The source of a named `function name(...) { ... }` declaration, to its balanced closing brace. */ +function fnBody(name: string): string { + const start = app.indexOf(`function ${name}(`); + expect(start, `function ${name} not found`).toBeGreaterThan(-1); + let depth = 0; + for (let i = app.indexOf('{', start); i < app.length; i++) { + if (app[i] === '{') depth++; + else if (app[i] === '}' && --depth === 0) return app.slice(start, i + 1); + } + throw new Error(`unbalanced braces in ${name}`); +} + +/** The body of a top-level `VIEWS. = function (...) { ... }` block. */ +function viewSource(name: string): string { + const start = app.indexOf(`VIEWS.${name} = function`); + expect(start, `VIEWS.${name} not found`).toBeGreaterThan(-1); + const end = app.indexOf('\n VIEWS.', start + 1); + return app.slice(start, end === -1 ? app.length : end); +} + +describe('report client cost honesty', () => { + it('never labels unknown cost with subscription wording', () => { + expect(app).not.toMatch(/'Included'|"Included"/); + expect(app).not.toMatch(/covered by subscription/i); + }); + + it('formats unmeasurable cost and tokens as an em dash', () => { + expect(app).toMatch(/UNKNOWN_LABEL\s*=\s*'—'/); + // Per-session formatters dash on the session's own provenance; the aggregate one only + // when the whole group is unmeasurable, so a mixed group keeps showing the measured sum. + expect(fnBody('fmtUSDOf')).toMatch(/usageUnknown\(s\).*UNKNOWN_LABEL/); + expect(fnBody('fmtTokensOf')).toMatch(/usageUnknown\(s\).*UNKNOWN_LABEL/); + expect(fnBody('fmtUSDAgg')).toMatch(/anyMeasured\(list\).*UNKNOWN_LABEL/); + }); + + it('keeps mixed aggregates on the measured sum (aggregates dash only when nothing is measured)', () => { + expect(fnBody('anyMeasured')).toMatch(/\.some\(.*!usageUnknown\(s\)/); + }); + + it('states missing local telemetry in the session modal instead of a subscription', () => { + expect(app).toMatch(/usageUnknown\(s\) \? 'no local token telemetry' : 'API-equivalent'/); + }); +}); + +describe('report client all-unmeasurable empty state', () => { + const overview = viewSource('overview'); + const cost = viewSource('cost'); + + it('derives Overview cost and token KPIs from measured-set semantics', () => { + expect(overview).toMatch(/measured\s*=\s*anyMeasured\(fs\)/); + // Est. cost and every token KPI must go through `measured`, not a bare `totalCost`/`tTotal` truth test. + expect(overview).toMatch(/'Est\. cost', measured \? fmtUSD\(totalCost\) : UNKNOWN_LABEL/); + expect(overview).toMatch(/tkv = function \(v\) \{ return measured &&[^}]*UNKNOWN_LABEL/); + }); + + it('explains the absent local token telemetry on Overview and Cost', () => { + expect(app).toMatch(/NO_TELEMETRY_NOTE\s*=\s*'[^']*local token telemetry[^']*'/); + // Both views share one helper, so the note cannot drift between them. + expect(fnBody('appendNoTelemetryNote')).toMatch(/!anyMeasured\(list\).*NO_TELEMETRY_NOTE/); + expect(overview).toMatch(/appendNoTelemetryNote\(host, fs\)/); + expect(cost).toMatch(/appendNoTelemetryNote\(host, fs\)/); + }); + + it('keeps agent chips filtering by agent name only', () => { + expect(app).toMatch(/state\.agents\.has\(s\.agentName\)/); + expect(app).not.toMatch(/state\.agents\.has\(s\.model/); + }); + + it('keeps tool-call aggregation independent of usage measurability', () => { + // Tool tables read toolCalls/toolCallsTotal directly; they must not be gated on usage. + const toolStart = app.indexOf('var toolAgg'); + const toolBlock = app.slice(toolStart, app.indexOf("card('Tool usage & success rate')", toolStart)); + expect(toolBlock).not.toMatch(/usageUnknown|anyMeasured/); + }); +}); diff --git a/src/cli/commands/analytics/report/client/app.js b/src/cli/commands/analytics/report/client/app.js index 02bb7e76c..2ce8f3a73 100644 --- a/src/cli/commands/analytics/report/client/app.js +++ b/src/cli/commands/analytics/report/client/app.js @@ -19,7 +19,7 @@ // ---- palette ------------------------------------------------------------ var PALETTE = ['#7C5CFC', '#2297F6', '#F5A534', '#06B6D4', '#259F4C', '#F9303C', '#C084FC', '#E879A6']; - var AGENT_COLORS = { claude: '#7C5CFC', 'claude-acp': '#9D7BFF', 'claude-desktop': '#B79DFF', gemini: '#F5A534', codex: '#06B6D4', 'codemie-codex': '#06B6D4', opencode: '#259F4C', 'codemie-code': '#2297F6', 'copilot-cli': '#6E7681', pi: '#E879A6' }; + var AGENT_COLORS = { claude: '#7C5CFC', 'claude-acp': '#9D7BFF', 'claude-desktop': '#B79DFF', gemini: '#F5A534', codex: '#06B6D4', 'codemie-codex': '#06B6D4', opencode: '#259F4C', 'codemie-code': '#2297F6', 'copilot-cli': '#6E7681', pi: '#E879A6', cursor: '#E5484D' }; var seenAgentColor = {}; var colorCursor = 0; function colorFor(agent) { @@ -29,7 +29,7 @@ } // Agent keys are internal ids; these are what a human should read. Unmapped agents fall // through to the key itself, so listing an agent here is optional. - var AGENT_LABELS = { 'copilot-cli': 'GitHub Copilot CLI', pi: 'Pi', 'gemini': 'Gemini CLI' }; + var AGENT_LABELS = { 'copilot-cli': 'GitHub Copilot CLI', cursor: 'Cursor', pi: 'Pi', 'gemini': 'Gemini CLI' }; function labelFor(agent) { return AGENT_LABELS[agent] || agent; } // ---- formatting --------------------------------------------------------- @@ -53,6 +53,30 @@ if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K'; return String(n || 0); } + // ---- usage availability ------------------------------------------------- + // Some agents record no token or cost data at all (analytics-only agents such as Cursor; + // also older Copilot CLI builds). The payload marks those with `usageUnavailableReason`, + // and their costUSD/tokens are structural zeros. Rendering them as "$0.00" / "0" would + // read as "this session was free", so every money/token cell goes through these helpers + // and shows an em dash instead. Aggregates only dash out when NOTHING in the group was + // measurable — a mixed group still shows the real sum of what was measured. + // Cursor's own usage export calls such events Kind=Included ("covered by the plan"), but we + // deliberately do not reuse that word: the cost column is an API-equivalent estimate, not a + // bill, and a missing local token signal is not evidence that the usage was free. + var UNKNOWN_LABEL = '—'; + function usageUnknown(s) { return !!(s && s.usageUnavailableReason); } + function anyMeasured(list) { return (list || []).some(function (s) { return !usageUnknown(s); }); } + function fmtUSDOf(s, n) { return usageUnknown(s) ? UNKNOWN_LABEL : fmtUSD(n); } + function fmtTokensOf(s, n) { return usageUnknown(s) ? UNKNOWN_LABEL : fmtTokens(n); } + function fmtUSDAgg(list, n) { return anyMeasured(list) ? fmtUSD(n) : UNKNOWN_LABEL; } + // Shown on Overview/Cost when EVERY session in view is unmeasurable — the common shape after + // deselecting the measured agents and leaving only an analytics-only one such as Cursor. Without + // it the all-dash KPI row reads as a broken agent-chip filter rather than as absent data. + function appendNoTelemetryNote(host, list) { + if (list.length && !anyMeasured(list)) host.appendChild(el('div', 'alert alert-info', NO_TELEMETRY_NOTE)); + } + var NO_TELEMETRY_NOTE = 'No local token telemetry for the sessions in view — token and cost figures are unavailable, not zero. Analytics-only agents such as Cursor record transcripts and tool calls locally but no billable token counts; other panels on this report still work.'; + function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); } function shortPath(p) { var parts = String(p || '').split('/'); return parts[parts.length - 1] || p; } // Human-readable session label: the cleaned first-prompt title, falling back to a short id. @@ -311,17 +335,20 @@ var totalCost = sum(fs, function (s) { return s.costUSD; }); var priced = DATA.meta.totals.pricedSessions; + // Provenance, not arithmetic: a filtered set can sum to $0 either because nothing was spent or + // because nothing was measurable. Only the latter may claim a number, so gate on the sessions. + var measured = anyMeasured(fs); var kpis = [ ['Sessions', fmtNum(fs.length), ''], ['Duration', fmtDuration(sum(fs, function (s) { return s.durationMs; })), 'wall-clock span'], ['Turns', fmtNum(sum(fs, function (s) { return s.turns; })), fs.length ? (Math.round(sum(fs, function (s) { return s.turns; }) / fs.length) + ' / session') : ''], ['Files touched', fmtNum(sum(fs, function (s) { return s.fileOps; })), 'net ' + (sum(fs, function (s) { return s.netLines; }) >= 0 ? '+' : '') + fmtNum(sum(fs, function (s) { return s.netLines; })) + ' lines'], ['Tool calls', fmtNum(sum(fs, function (s) { return s.toolCallsTotal; })), successRate(fs) + '% success'], - ['Est. cost', totalCost ? fmtUSD(totalCost) : '—', priced < DATA.meta.totals.sessions ? ('priced ' + priced + '/' + DATA.meta.totals.sessions) : 'tokens × pricing'] + ['Est. cost', measured ? fmtUSD(totalCost) : UNKNOWN_LABEL, measured ? (priced < DATA.meta.totals.sessions ? ('priced ' + priced + '/' + DATA.meta.totals.sessions) : 'tokens × pricing') : 'no local token telemetry'] ]; var grid = el('div', 'kpi-grid'); kpis.forEach(function (k) { - var c = el('div', 'kpi' + (k[0] === 'Est. cost' && !totalCost ? ' soon' : '')); + var c = el('div', 'kpi' + (k[0] === 'Est. cost' && !measured ? ' soon' : '')); c.appendChild(el('div', 'kpi-label', k[0])); c.appendChild(el('div', 'kpi-value', k[1])); if (k[2]) c.appendChild(el('div', 'kpi-sub', k[2])); @@ -337,24 +364,25 @@ var tcWrite = sum(fs, function (s) { return s.tokens ? s.tokens.cacheCreation : 0; }); var tcRead = sum(fs, function (s) { return s.tokens ? s.tokens.cacheRead : 0; }); var tTotal = sum(fs, function (s) { return s.tokens ? s.tokens.total : 0; }); - var tkv = function (v) { return tTotal > 0 ? fmtTokens(v) : '—'; }; + var tkv = function (v) { return measured && tTotal > 0 ? fmtTokens(v) : UNKNOWN_LABEL; }; var tokenKpis = [ ['Input tokens', tkv(tIn), 'prompts sent to the model'], ['Output tokens', tkv(tOut), 'completions generated'], ['Cache write', tkv(tcWrite), 'tokens written to cache'], ['Cache read', tkv(tcRead), 'tokens served from cache'], - ['Total tokens', tkv(tTotal), priced < DATA.meta.totals.sessions ? ('priced ' + priced + '/' + DATA.meta.totals.sessions + ' sessions') : 'across sessions in view'] + ['Total tokens', tkv(tTotal), measured ? (priced < DATA.meta.totals.sessions ? ('priced ' + priced + '/' + DATA.meta.totals.sessions + ' sessions') : 'across sessions in view') : 'no local token telemetry'] ]; host.appendChild(el('div', 'kpi-section-label', 'Token usage')); var tgrid = el('div', 'kpi-grid kpi-grid-tokens'); tokenKpis.forEach(function (k) { var c = el('div', 'kpi'); c.appendChild(el('div', 'kpi-label', k[0])); - c.appendChild(el('div', 'kpi-value' + (tTotal > 0 ? '' : ' muted'), k[1])); + c.appendChild(el('div', 'kpi-value' + (measured && tTotal > 0 ? '' : ' muted'), k[1])); if (k[2]) c.appendChild(el('div', 'kpi-sub', k[2])); tgrid.appendChild(c); }); host.appendChild(tgrid); + appendNoTelemetryNote(host, fs); // efficiency headline KPIs (full detail on the Efficiency tab) var effCacheReadCost = sum(fs, function (s) { return s.cacheReadCostUSD || 0; }); @@ -481,7 +509,7 @@ var topModel = topOf(ss.flatMap(function (s) { return s.models; })); // No text-transform: it would render a mapped label as "Github Copilot Cli". // Unmapped keys keep their original look via the capitalize fallback below. - return ['' + esc(labelFor(a)) + '', fmtNum(ss.length), tdNum(sum(ss, function (s) { return s.turns; })), tdNum(sum(ss, function (s) { return s.fileOps; })), tdNum(sum(ss, function (s) { return s.netLines; })), '' + esc(topModel || '—') + '', tdNum(successRate(ss) + '%'), tdNum(fmtUSD(sum(ss, function (s) { return s.costUSD; })))]; + return ['' + esc(labelFor(a)) + '', fmtNum(ss.length), tdNum(sum(ss, function (s) { return s.turns; })), tdNum(sum(ss, function (s) { return s.fileOps; })), tdNum(sum(ss, function (s) { return s.netLines; })), '' + esc(topModel || '—') + '', tdNum(successRate(ss) + '%'), tdNum(fmtUSDAgg(ss, sum(ss, function (s) { return s.costUSD; })))]; }), [false, true, true, true, true, false, true, true]); host.appendChild(detail); @@ -509,7 +537,7 @@ fmtNum(g.length), Math.round((g.length / fs.length) * 1000) / 10 + '%', fmtNum(Math.round(sum(g, function(s) { return s.turns; }) / g.length)), - fmtUSD(sum(g, function(s) { return s.costUSD; }) / g.length), + fmtUSDAgg(g, sum(g, function(s) { return s.costUSD; }) / g.length), (avg >= 0 ? '+' : '') + fmtNum(Math.round(avg)), fmtNum(Math.round(sum(g, function(s) { return s.fileOps; }) / g.length)), successRate(g) + '%' @@ -554,11 +582,11 @@ + '−' + fmtNum(sum(ss, function (s) { return s.linesRemoved; })) + ''; }; rows.forEach(function (r, i) { - html += '▸ ' + esc(shortPath(r.p)) + '' + fmtNum(r.ss.length) + '' + fmtNum(sum(r.ss, function (s) { return s.turns; })) + '' + crudCells(r.ss) + '' + fmtNum(sum(r.ss, function (s) { return s.netLines; })) + '' + successRate(r.ss) + '%' + fmtUSD(sum(r.ss, function (s) { return s.costUSD; })) + ''; + html += '▸ ' + esc(shortPath(r.p)) + '' + fmtNum(r.ss.length) + '' + fmtNum(sum(r.ss, function (s) { return s.turns; })) + '' + crudCells(r.ss) + '' + fmtNum(sum(r.ss, function (s) { return s.netLines; })) + '' + successRate(r.ss) + '%' + fmtUSDAgg(r.ss, sum(r.ss, function (s) { return s.costUSD; })) + ''; // branch sub-rows (hidden) var byBranch = groupBy(r.ss, function (s) { return s.branch || '(none)'; }); byBranch.forEach(function (bss, b) { - html += '⎇ ' + esc(b) + '' + bss.length + '' + fmtNum(sum(bss, function (s) { return s.turns; })) + '' + crudCells(bss) + '' + fmtNum(sum(bss, function (s) { return s.netLines; })) + '' + successRate(bss) + '%' + fmtUSD(sum(bss, function (s) { return s.costUSD; })) + ''; + html += '⎇ ' + esc(b) + '' + bss.length + '' + fmtNum(sum(bss, function (s) { return s.turns; })) + '' + crudCells(bss) + '' + fmtNum(sum(bss, function (s) { return s.netLines; })) + '' + successRate(bss) + '%' + fmtUSDAgg(bss, sum(bss, function (s) { return s.costUSD; })) + ''; }); }); html += ''; @@ -698,7 +726,7 @@ return ['' + esc(truncStr(sessTitle(s), 44)) + '', '' + esc(labelFor(s.agentName)) + '', '' + esc((s.models && s.models[0]) || '—') + '', - fmtTokens(Math.round(x.ctx)), fmtTokens(s.tokens ? s.tokens.cacheRead : 0), fmtUSD(s.costUSD), (Math.round(x.bloat * 10) / 10) + '%']; + fmtTokensOf(s, Math.round(x.ctx)), fmtTokensOf(s, s.tokens ? s.tokens.cacheRead : 0), fmtUSDOf(s, s.costUSD), (Math.round(x.bloat * 10) / 10) + '%']; }), [false, false, false, true, true, true, true], bloated.map(function (x) { return 'class="clickable" data-session="' + esc(x.s.sessionId) + '"'; })) + ''; @@ -715,8 +743,8 @@ var deadCard = card('Dead sessions', 'cost spent, zero files changed and zero net lines — pure inference waste'); var dkv = el('div', 'kpi-grid'); dkv.style.gridTemplateColumns = 'repeat(3,1fr)'; [['Dead sessions', fmtNum(dead.length), fs.length ? (Math.round((dead.length / fs.length) * 100) + '% of sessions') : ''], - ['Wasted cost', fmtUSD(deadCost), totalCost ? (Math.round((deadCost / totalCost) * 100) + '% of spend') : ''], - ['Avg cost / dead', dead.length ? fmtUSD(deadCost / dead.length) : '—', 'per unproductive session'] + ['Wasted cost', fmtUSDAgg(dead, deadCost), totalCost ? (Math.round((deadCost / totalCost) * 100) + '% of spend') : ''], + ['Avg cost / dead', dead.length ? fmtUSDAgg(dead, deadCost / dead.length) : '—', 'per unproductive session'] ].forEach(function (k) { var c = el('div', 'kpi'); c.appendChild(el('div', 'kpi-label', k[0])); c.appendChild(el('div', 'kpi-value', k[1])); if (k[2]) c.appendChild(el('div', 'kpi-sub', k[2])); dkv.appendChild(c); }); @@ -798,25 +826,33 @@ VIEWS.cost = function (host, fs) { host.appendChild(el('h2', 'view-title', 'Cost')); - host.appendChild(el('p', 'view-sub', 'Estimated cost (API-equivalent) — token usage × model pricing. On a subscription you don’t pay per token; this is the equivalent metered API value.')); + host.appendChild(el('p', 'view-sub', 'Estimated cost (API-equivalent) — token usage × model pricing. This is what the same usage would have been metered at through the API, not an invoice.')); var total = sum(fs, function (s) { return s.costUSD; }); var priced = DATA.meta.totals.pricedSessions, totalSessions = DATA.meta.totals.sessions; var banner = el('div', 'alert ' + (priced < totalSessions ? 'alert-warning' : 'alert-info')); var msg = 'Priced ' + priced + ' of ' + totalSessions + ' sessions with recoverable token usage. ' - + 'The rest have no readable native log — coding agents rotate/delete old transcripts, so historical ' - + 'token data is incomplete (this does not affect the cost of the sessions that are priced). See Coverage by agent below.'; - if (DATA.meta.unpricedModels && DATA.meta.unpricedModels.length) msg += ' Unpriced models: ' + DATA.meta.unpricedModels.join(', ') + '.'; + + 'The rest carry no recoverable token counts: either the native log was rotated/deleted, or the agent ' + + 'records transcripts locally but no token telemetry at all (analytics-only agents such as Cursor). ' + + 'This does not affect the cost of the sessions that are priced. See Coverage by agent below.'; + if (DATA.meta.unpricedModels && DATA.meta.unpricedModels.length) msg += ' Models with no published price (estimated at a stand-in rate when tokens were recovered): ' + DATA.meta.unpricedModels.join(', ') + '.'; + // Rows imported from a Cursor usage export are the vendor's own billed figures, not a figure + // CodeMie derived from tokens and a pricing table. Folding them in without saying so would + // lose the one distinction the import exists to make. Read off the filtered sessions, so it + // is stated exactly when such a row is actually on screen. + var vendorBilled = fs.some(function (s) { return (s.perModelCost || []).some(function (m) { return m.costBasis === 'vendor-billed'; }); }); + if (vendorBilled) msg += ' Cursor figures here are Cursor\u2019s own billing, imported from your usage export \u2014 not a CodeMie estimate; every other cost on this page is computed from tokens and a pricing table.'; banner.textContent = msg; // textContent is safe — do not pre-escape (would double-escape) host.appendChild(banner); var grid = el('div', 'kpi-grid'); grid.style.gridTemplateColumns = 'repeat(3,1fr)'; var tok = fs.reduce(function (acc, s) { return acc + (s.tokens ? s.tokens.total : 0); }, 0); - [['Total est. cost', fmtUSD(total)], ['Total tokens', fmtTokens(tok)], ['Avg cost / session', fs.length ? fmtUSD(total / fs.length) : '—']].forEach(function (k) { + [['Total est. cost', fmtUSDAgg(fs, total)], ['Total tokens', anyMeasured(fs) ? fmtTokens(tok) : '—'], ['Avg cost / session', fs.length ? fmtUSDAgg(fs, total / fs.length) : '—']].forEach(function (k) { var c = el('div', 'kpi'); c.appendChild(el('div', 'kpi-label', k[0])); c.appendChild(el('div', 'kpi-value', k[1])); grid.appendChild(c); }); host.appendChild(grid); + appendNoTelemetryNote(host, fs); // per-agent coverage — answers "which tools' metrics are included?" var cov = DATA.meta.coverage || []; @@ -869,7 +905,7 @@ return [esc(s.sessionId.slice(0, 8)), '' + esc(labelFor(s.agentName)) + '', '' + esc(shortPath(s.project)) + '', - fmtTokens(tkIn(s)), fmtTokens(tkOut(s)), fmtTokens(tkCached(s)), fmtTokens(s.tokens ? s.tokens.total : 0), fmtUSD(s.costUSD)]; + fmtTokensOf(s, tkIn(s)), fmtTokensOf(s, tkOut(s)), fmtTokensOf(s, tkCached(s)), fmtTokensOf(s, s.tokens ? s.tokens.total : 0), fmtUSDOf(s, s.costUSD)]; }), [false, false, false, true, true, true, true, true]) + ''; host.appendChild(topCard); @@ -907,7 +943,7 @@ promptCell, '' + esc(labelFor(s.agentName)) + '', '' + esc(shortPath(s.project)) + '', branchCell, sourceCell, - fmtNum(s.turns), fmtNum(s.netLines), fmtTokens(tkIn(s)), fmtTokens(tkOut(s)), fmtTokens(tkCached(s)), fmtUSD(s.costUSD)]; + fmtNum(s.turns), fmtNum(s.netLines), fmtTokensOf(s, tkIn(s)), fmtTokensOf(s, tkOut(s)), fmtTokensOf(s, tkCached(s)), fmtUSDOf(s, s.costUSD)]; }), [false, false, false, false, false, false, true, true, true, true, true, true], shown.map(function (s) { return 'class="clickable" data-session="' + esc(s.sessionId) + '"'; })); @@ -1094,7 +1130,7 @@ // Session bar spans the full window (the activity envelope). Its label shows the envelope // span — equal to the tracked duration in the normal case, but revealing the true span when // the tracked duration under-counts (e.g. dispatches predating a post-compaction window). - gantt.appendChild(tlRow('session', '#259F4C', 0, 100, fmtTimelineDuration(ganttSpan), fmtUSD(s.costUSD), true)); + gantt.appendChild(tlRow('session', '#259F4C', 0, 100, fmtTimelineDuration(ganttSpan), fmtUSDOf(s, s.costUSD), true)); var occ = {}; dispatches.forEach(function (d) { @@ -1240,7 +1276,7 @@ // which bills in premium requests rather than tokens, and whose older CLI versions // recorded no telemetry at all). Appended so other agents' cards are unchanged. var costRows = [ - ['Cost', s.usageUnavailableReason ? '—' : fmtUSD(s.costUSD), s.usageUnavailableReason ? 'not measurable' : 'API-equivalent'], + ['Cost', fmtUSDOf(s, s.costUSD), usageUnknown(s) ? 'no local token telemetry' : 'API-equivalent'], ['Cache-read', s.cacheReadCostUSD ? fmtUSD(s.cacheReadCostUSD) : '—', ''], ['Duration', fmtDuration(s.durationMs || 0), ''], ['Started', '' + esc(fmtWhen(s.startTime)) + '', ''] @@ -1252,12 +1288,12 @@ if (s.usageUnavailableReason) { costCard._body.appendChild(el('div', 'text-muted', '' + esc(s.usageUnavailableReason) + '')); } else if (s.usagePartial) { - costCard._body.appendChild(el('div', 'text-muted', 'Partial usage — output tokens only; this session recorded no full rollup, so cost is understated.')); + costCard._body.appendChild(el('div', 'text-muted', 'Partial usage — this session recorded no full token rollup, or its model has no published price and was estimated at a stand-in rate. Treat the cost as an understated API-equivalent floor, not a bill.')); } var tokCard = card('Token usage'); tokCard._body.appendChild(statsEl([ - ['Input', fmtTokens(t.input), ''], ['Output', fmtTokens(t.output), ''], - ['Cache read', fmtTokens(t.cacheRead), ''], ['Cache create', fmtTokens(t.cacheCreation), ''], - ['Total', fmtTokens(t.total), ''] + ['Input', fmtTokensOf(s, t.input), ''], ['Output', fmtTokensOf(s, t.output), ''], + ['Cache read', fmtTokensOf(s, t.cacheRead), ''], ['Cache create', fmtTokensOf(s, t.cacheCreation), ''], + ['Total', fmtTokensOf(s, t.total), ''] ])); var actCard = card('Activity'); actCard._body.appendChild(statsEl([ ['Turns / API', fmtNum(s.turns), ''], @@ -1352,7 +1388,7 @@ var netLines = sum(sessions, function (s) { return s.netLines || 0; }); body.appendChild(statsEl([ ['Sessions', fmtNum(sessions.length)], - ['Total cost', fmtUSD(totalCost)], + ['Total cost', fmtUSDAgg(sessions, totalCost)], ['Turns', fmtNum(totalTurns)], ['Net lines', (netLines >= 0 ? '+' : '') + fmtNum(netLines)] ])); @@ -1367,7 +1403,7 @@ esc(truncStr(firstWords(sessTitle(s), 12), 100)), esc(fmtWhen(s.startTime)), fmtNum(s.turns || 0), - fmtUSD(s.costUSD || 0), + fmtUSDOf(s, s.costUSD || 0), '' ]; }), diff --git a/src/cli/commands/analytics/types.ts b/src/cli/commands/analytics/types.ts index 3428b4fdf..5f965462a 100644 --- a/src/cli/commands/analytics/types.ts +++ b/src/cli/commands/analytics/types.ts @@ -251,6 +251,19 @@ export interface AnalyticsOptions { scanNative?: boolean; /** When true (via --include-external), include non-CodeMie-owned native sessions in output (matches pre-fix behavior). */ includeExternal?: boolean; + /** Path to a Cursor usage-events CSV exported from the Cursor dashboard (no network call). */ + cursorUsageCsv?: string; + /** + * Which `User` column value to keep from that CSV. Defaults to the report owner's configured + * email, which is often a different address from the one on the Cursor account. + */ + cursorUsageUser?: string; + /** + * When true (via --cursor-usage-fetch), download the usage export instead of reading a file. + * Requires CURSOR_USAGE_EXPORT_URL and a signed-in Cursor session; the flag alone makes no + * network call, and neither does a readable session cookie on its own. + */ + cursorUsageFetch?: boolean; } /** Options for the `analytics otel` subcommand: the shared base plus OTEL-specific flags. */ diff --git a/tests/helpers/agent-smoke.ts b/tests/helpers/agent-smoke.ts index 0dc31fbca..1e6fed1f9 100644 --- a/tests/helpers/agent-smoke.ts +++ b/tests/helpers/agent-smoke.ts @@ -16,12 +16,27 @@ import { spawnSync, execFileSync, type SpawnSyncReturns } from 'node:child_proce import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; import { join, dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { platform } from 'node:os'; import { copySsoCredentials, ssoCleanEnv } from './sso-auth.js'; import { getTempDir } from './temp-workspace.js'; import { getCodemieTestUrl } from './test-env.js'; const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); +/** + * Synchronous "is this CLI on PATH" check for use in `describe.runIf(...)` at + * collection time (vitest evaluates that predicate before any beforeAll/async + * hook runs, so the async `commandExists()`/`plugin.isInstalled()` used at + * runtime can't gate suite collection). Mirrors the resolution used by + * `commandExists()` in src/utils/processes.ts. + */ +export function isCliInstalled(command: string): boolean { + const isWindows = platform() === 'win32'; + const whichCommand = isWindows ? 'C:\\Windows\\System32\\where.exe' : 'which'; + const result = spawnSync(whichCommand, [command], { stdio: 'ignore' }); + return result.status === 0; +} + export interface AgentSmokeOptions { /** bin file under bin/, e.g. 'codemie-opencode.js'. */ binName: string; diff --git a/tests/helpers/index.ts b/tests/helpers/index.ts index 98447ec6f..cd445dedf 100644 --- a/tests/helpers/index.ts +++ b/tests/helpers/index.ts @@ -11,4 +11,4 @@ export { spawnPty, type PtySession } from './pty-session.js'; export { getLatestMetricsRecord } from './metrics.js'; export { getTestEnvFlag, getTestEnvFlagOrDefault, stripNodeModulesBin, getTestEnvValue, getCodemieTestUrl, getCodemieTestModel, DEFAULT_CODEMIE_TEST_URL, DEFAULT_CODEMIE_TEST_MODEL } from './test-env.js'; export { pollForSession, type SessionPollOptions, type SessionPollResult } from './session-poll.js'; -export { runAgentTaskSmoke, type AgentSmokeOptions, type AgentSmokeRun } from './agent-smoke.js'; +export { runAgentTaskSmoke, isCliInstalled, type AgentSmokeOptions, type AgentSmokeRun } from './agent-smoke.js'; diff --git a/tests/helpers/sso-auth.ts b/tests/helpers/sso-auth.ts index c734fe9c7..93ed7c126 100644 --- a/tests/helpers/sso-auth.ts +++ b/tests/helpers/sso-auth.ts @@ -40,9 +40,18 @@ export function writeSsoProfile(codemieHome: string): void { } /** - * Strip CODEMIE_* vars from the process environment for SSO subprocess spawns. - * Uses a denylist (vs jwtCleanEnv's allowlist) to preserve HOME, proxy settings, - * and other vars that the OS keychain and network calls depend on. + * Strip CODEMIE_* and CLAUDE_CODE_* vars from the process environment for SSO + * subprocess spawns. Uses a denylist (vs jwtCleanEnv's allowlist) to preserve + * HOME, proxy settings, and other vars that the OS keychain and network calls + * depend on. + * + * CLAUDE_CODE_* is stripped because when the test suite itself runs inside a + * Claude Code session (e.g. a developer or CI agent driving `npm test` via + * Claude Code's own Bash tool), the outer session's CLAUDE_CODE_CHILD_SESSION + * marker leaks into the spawned test's env. The nested `claude` process under + * test then sees itself as a child session and disables transcript + * persistence, so no metrics are recorded — a false failure in tests like + * TC-024 that assert on session metrics, unrelated to the code under test. * * Also strips node_modules/.bin entries from PATH so locally-installed package * shims (e.g. @codemieai/codemie-opencode's `codemie` bin) don't shadow the @@ -50,7 +59,9 @@ export function writeSsoProfile(codemieHome: string): void { */ export function ssoCleanEnv(): NodeJS.ProcessEnv { const env = Object.fromEntries( - Object.entries(process.env).filter(([key]) => !key.startsWith('CODEMIE_') && !key.startsWith('CI_CODEMIE_')), + Object.entries(process.env).filter( + ([key]) => !key.startsWith('CODEMIE_') && !key.startsWith('CI_CODEMIE_') && !key.startsWith('CLAUDE_CODE_'), + ), ) as NodeJS.ProcessEnv; if (env.PATH) env.PATH = stripNodeModulesBin(env.PATH); return env; diff --git a/tests/integration/agent-codex.test.ts b/tests/integration/agent-codex.test.ts index 23ce3c1ac..b39b78c56 100644 --- a/tests/integration/agent-codex.test.ts +++ b/tests/integration/agent-codex.test.ts @@ -36,93 +36,100 @@ * Run: npx vitest run --project agent -- agent-codex */ -import '../setup/load-test-env.js'; -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { spawnSync, execFileSync } from 'node:child_process'; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; -import { join, dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import "../setup/load-test-env.js"; +import { execFileSync, spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { - copySsoCredentials, - ssoCleanEnv, - setupSsoAutotestProfile, - teardownSsoAutotestProfile, - getTempDir, - getCodemieTestUrl, -} from '../helpers/index.js'; + copySsoCredentials, + getCodemieTestUrl, + getTempDir, + isCliInstalled, + setupSsoAutotestProfile, + ssoCleanEnv, + teardownSsoAutotestProfile, +} from "../helpers/index.js"; -const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); -const CODEX_BIN = join(REPO_ROOT, 'bin', 'codemie-codex.js'); +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const CODEX_BIN = join(REPO_ROOT, "bin", "codemie-codex.js"); // A GPT/Codex model; the resolver fuzzy-matches to the newest available // deployment, so an exact catalog entry is not required. Overridable in case the // catalog naming shifts. -const CODEX_MODEL = process.env.CODEMIE_CODEX_MODEL ?? 'gpt-5.4'; +const CODEX_MODEL = process.env.CODEMIE_CODEX_MODEL ?? "gpt-5.4"; /** Write an sso-autotest profile carrying a Codex-appropriate model. */ function writeCodexProfile(home: string): void { - const url = getCodemieTestUrl(); - const config = { - version: 2, - activeProfile: 'sso-autotest', - profiles: { - 'sso-autotest': { - name: 'sso-autotest', - provider: 'ai-run-sso', - authMethod: 'sso', - codeMieUrl: url, - baseUrl: `${url}/code-assistant-api`, - apiKey: 'sso-authenticated', - model: CODEX_MODEL, - timeout: 300, - debug: false, - }, - }, - workspace: { codeMieUrl: url }, - }; - mkdirSync(home, { recursive: true }); - writeFileSync(join(home, 'codemie-cli.config.json'), JSON.stringify(config, null, 2), 'utf-8'); + const url = getCodemieTestUrl(); + const config = { + version: 2, + activeProfile: "sso-autotest", + profiles: { + "sso-autotest": { + name: "sso-autotest", + provider: "ai-run-sso", + authMethod: "sso", + codeMieUrl: url, + baseUrl: `${url}/code-assistant-api`, + apiKey: "sso-authenticated", + model: CODEX_MODEL, + timeout: 300, + debug: false, + }, + }, + workspace: { codeMieUrl: url }, + }; + mkdirSync(home, { recursive: true }); + writeFileSync( + join(home, "codemie-cli.config.json"), + JSON.stringify(config, null, 2), + "utf-8", + ); } -describe.runIf(process.env.SSO_AVAILABLE !== 'false')('Codex agent smoke (real)', () => { - let originalActiveProfile: string | undefined; - let testHome: string; - let result: ReturnType; +describe.runIf( + process.env.SSO_AVAILABLE !== "false" && isCliInstalled("codex"), +)("Codex agent smoke (real)", () => { + let originalActiveProfile: string | undefined; + let testHome: string; + let result: ReturnType; - beforeAll(() => { - originalActiveProfile = setupSsoAutotestProfile(); + beforeAll(() => { + originalActiveProfile = setupSsoAutotestProfile(); - testHome = mkdtempSync(join(getTempDir(), 'codemie-codex-')); - writeCodexProfile(testHome); - copySsoCredentials(testHome); - // Codex refuses to run outside a trusted (git) directory. - execFileSync('git', ['init', '-q', testHome], { stdio: 'ignore' }); + testHome = mkdtempSync(join(getTempDir(), "codemie-codex-")); + writeCodexProfile(testHome); + copySsoCredentials(testHome); + // Codex refuses to run outside a trusted (git) directory. + execFileSync("git", ["init", "-q", testHome], { stdio: "ignore" }); - result = spawnSync( - process.execPath, - [CODEX_BIN, '--task', 'Reply with only the single word READY'], - { - cwd: testHome, - env: { ...ssoCleanEnv(), CODEMIE_HOME: testHome }, - encoding: 'utf-8', - timeout: 150_000, - }, - ); - }, 180_000); + result = spawnSync( + process.execPath, + [CODEX_BIN, "--task", "Reply with only the single word READY"], + { + cwd: testHome, + env: { ...ssoCleanEnv(), CODEMIE_HOME: testHome }, + encoding: "utf-8", + timeout: 150_000, + }, + ); + }, 180_000); - afterAll(() => { - teardownSsoAutotestProfile(originalActiveProfile); - if (testHome) rmSync(testHome, { recursive: true, force: true }); - }); + afterAll(() => { + teardownSsoAutotestProfile(originalActiveProfile); + if (testHome) rmSync(testHome, { recursive: true, force: true }); + }); - it('exits 0', () => { - expect( - result.status, - `stdout:\n${result.stdout ?? ''}\nstderr:\n${result.stderr ?? ''}`, - ).toBe(0); - }); + it("exits 0", () => { + expect( + result.status, + `stdout:\n${result.stdout ?? ""}\nstderr:\n${result.stderr ?? ""}`, + ).toBe(0); + }); - it('resolves a live GPT/Codex model and routes the agent response to stdout', () => { - expect(result.stdout).toMatch(/READY/i); - }); + it("resolves a live GPT/Codex model and routes the agent response to stdout", () => { + expect(result.stdout).toMatch(/READY/i); + }); }); diff --git a/tests/integration/agent-gemini.test.ts b/tests/integration/agent-gemini.test.ts index f606bf3cf..833744b61 100644 --- a/tests/integration/agent-gemini.test.ts +++ b/tests/integration/agent-gemini.test.ts @@ -24,44 +24,48 @@ * Run: npx vitest run --project agent -- agent-gemini */ -import '../setup/load-test-env.js'; -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { rmSync } from 'node:fs'; +import "../setup/load-test-env.js"; +import { rmSync } from "node:fs"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { - runAgentTaskSmoke, - setupSsoAutotestProfile, - teardownSsoAutotestProfile, - type AgentSmokeRun, -} from '../helpers/index.js'; + type AgentSmokeRun, + isCliInstalled, + runAgentTaskSmoke, + setupSsoAutotestProfile, + teardownSsoAutotestProfile, +} from "../helpers/index.js"; -describe.runIf(process.env.SSO_AVAILABLE !== 'false')('Gemini agent smoke (real)', () => { - let originalActiveProfile: string | undefined; - let run: AgentSmokeRun; +describe.runIf( + process.env.SSO_AVAILABLE !== "false" && + isCliInstalled(process.env.CODEMIE_GEMINI_BIN || "gemini"), +)("Gemini agent smoke (real)", () => { + let originalActiveProfile: string | undefined; + let run: AgentSmokeRun; - beforeAll(() => { - originalActiveProfile = setupSsoAutotestProfile(); - run = runAgentTaskSmoke({ - binName: 'codemie-gemini.js', - // Must be a real gemini-* deployment from the catalog (see MODEL NOTE). - model: process.env.CODEMIE_GEMINI_MODEL ?? 'gemini-3.1-pro', - isolateHome: true, // keep Gemini's settings.json out of the real ~/.gemini - extraEnv: { GEMINI_CLI_TRUST_WORKSPACE: 'true' }, - }); - }, 180_000); + beforeAll(() => { + originalActiveProfile = setupSsoAutotestProfile(); + run = runAgentTaskSmoke({ + binName: "codemie-gemini.js", + // Must be a real gemini-* deployment from the catalog (see MODEL NOTE). + model: process.env.CODEMIE_GEMINI_MODEL ?? "gemini-3.1-pro", + isolateHome: true, // keep Gemini's settings.json out of the real ~/.gemini + extraEnv: { GEMINI_CLI_TRUST_WORKSPACE: "true" }, + }); + }, 180_000); - afterAll(() => { - teardownSsoAutotestProfile(originalActiveProfile); - if (run?.testHome) rmSync(run.testHome, { recursive: true, force: true }); - }); + afterAll(() => { + teardownSsoAutotestProfile(originalActiveProfile); + if (run?.testHome) rmSync(run.testHome, { recursive: true, force: true }); + }); - it('exits 0', () => { - expect( - run.result.status, - `stdout:\n${run.result.stdout ?? ''}\nstderr:\n${run.result.stderr ?? ''}`, - ).toBe(0); - }); + it("exits 0", () => { + expect( + run.result.status, + `stdout:\n${run.result.stdout ?? ""}\nstderr:\n${run.result.stderr ?? ""}`, + ).toBe(0); + }); - it('routes the agent response to stdout', () => { - expect(run.result.stdout).toMatch(/READY/i); - }); + it("routes the agent response to stdout", () => { + expect(run.result.stdout).toMatch(/READY/i); + }); }); diff --git a/tests/integration/agent-kimi.test.ts b/tests/integration/agent-kimi.test.ts index 8c5013203..94e9230c3 100644 --- a/tests/integration/agent-kimi.test.ts +++ b/tests/integration/agent-kimi.test.ts @@ -10,7 +10,9 @@ * ~/.kimi-code/bin, so redirecting HOME would hide it. The model must be a * kimi-* deployment (kimi-k2 is used; kimi accepts any locally, then resolves). * - * Gated on SSO_AVAILABLE. Cleanup: profile restored + temp home removed. + * Gated on SSO_AVAILABLE and the `kimi` CLI being on PATH (skips gracefully + * on machines that haven't run `codemie install kimi`). Cleanup: profile + * restored + temp home removed. * * Run: npx vitest run --project agent -- agent-kimi */ @@ -22,10 +24,11 @@ import { runAgentTaskSmoke, setupSsoAutotestProfile, teardownSsoAutotestProfile, + isCliInstalled, type AgentSmokeRun, } from '../helpers/index.js'; -describe.runIf(process.env.SSO_AVAILABLE !== 'false')('Kimi agent smoke (real)', () => { +describe.runIf(process.env.SSO_AVAILABLE !== 'false' && isCliInstalled('kimi'))('Kimi agent smoke (real)', () => { let originalActiveProfile: string | undefined; let run: AgentSmokeRun; diff --git a/tests/integration/agent-opencode.test.ts b/tests/integration/agent-opencode.test.ts index 44ec7d23b..81989f414 100644 --- a/tests/integration/agent-opencode.test.ts +++ b/tests/integration/agent-opencode.test.ts @@ -8,8 +8,10 @@ * was verified only by hand. Uses the shared agent-smoke harness; HOME is * isolated so opencode's own state dir stays out of the developer's real home. * - * Gated on SSO_AVAILABLE (tests/setup/agent-build-setup.ts). Cleanup: profile - * restored + temp home removed in afterAll. + * Gated on SSO_AVAILABLE (tests/setup/agent-build-setup.ts) and the `opencode` + * CLI being on PATH (skips gracefully on machines that haven't run + * `codemie install opencode`). Cleanup: profile restored + temp home removed + * in afterAll. * * Run: npx vitest run --project agent -- agent-opencode */ @@ -21,10 +23,11 @@ import { runAgentTaskSmoke, setupSsoAutotestProfile, teardownSsoAutotestProfile, + isCliInstalled, type AgentSmokeRun, } from '../helpers/index.js'; -describe.runIf(process.env.SSO_AVAILABLE !== 'false')('OpenCode agent smoke (real)', () => { +describe.runIf(process.env.SSO_AVAILABLE !== 'false' && isCliInstalled(process.env.CODEMIE_OPENCODE_BIN || 'opencode'))('OpenCode agent smoke (real)', () => { let originalActiveProfile: string | undefined; let run: AgentSmokeRun; diff --git a/tests/integration/agent-pi.test.ts b/tests/integration/agent-pi.test.ts index 20f33e4a8..1a4b7f05c 100644 --- a/tests/integration/agent-pi.test.ts +++ b/tests/integration/agent-pi.test.ts @@ -10,7 +10,9 @@ * HOME is isolated: Pi redirects its agent dir into /.pi/codemie, so a * temp HOME keeps the run self-contained. Pi accepts a claude model. * - * Gated on SSO_AVAILABLE. Cleanup: profile restored + temp home removed. + * Gated on SSO_AVAILABLE and the `pi` CLI being on PATH (skips gracefully on + * machines that haven't run `codemie install pi`). Cleanup: profile restored + * + temp home removed. * * Run: npx vitest run --project agent -- agent-pi */ @@ -22,10 +24,11 @@ import { runAgentTaskSmoke, setupSsoAutotestProfile, teardownSsoAutotestProfile, + isCliInstalled, type AgentSmokeRun, } from '../helpers/index.js'; -describe.runIf(process.env.SSO_AVAILABLE !== 'false')('Pi agent smoke (real)', () => { +describe.runIf(process.env.SSO_AVAILABLE !== 'false' && isCliInstalled(process.env.CODEMIE_PI_BIN || 'pi'))('Pi agent smoke (real)', () => { let originalActiveProfile: string | undefined; let run: AgentSmokeRun;