diff --git a/README.md b/README.md index 9fd65e1..cc73e81 100644 --- a/README.md +++ b/README.md @@ -21,22 +21,23 @@ and gives you a quick answer to: "how much have I burned today?" Burnly reads local usage from supported AI coding tools. Support levels are explicit because each tool stores usage differently. -| Tool | Status | Collection path | Notes | -| ----------- | ----------------- | -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Claude Code | Supported | Bundled `ccusage` collector | Local usage only. | -| Codex | Supported | Bundled `ccusage` collector | Local usage only. | -| OpenCode | Supported | Bundled `ccusage` collector | Local usage only. | -| Pi | Supported | Bundled `ccusage` collector | Local usage only. Model labels keep the `[pi]` prefix from `ccusage`. | -| Cline CLI | Experimental | Native Burnly collector for `~/.cline` | Reads local session/message usage metrics. Data format may change upstream. | -| ZCode | Experimental | Native Burnly collector | Reads local SQLite usage data. Data format may change upstream. | -| Antigravity | Experimental | Native Burnly collector | Three variants: 2.0, IDE, and CLI. CLI reads local SQLite/protobuf metadata. App/IDE use runtime metadata when available, experimental SQLite fallback, then cached usage. | -| Grok Build | Experimental | Native Burnly collector for `~/.grok` | Reads `shell.turn.inference_done` rows from `unified.jsonl` plus session metadata. Totals are per inference call, not per user turn. Cached prompt tokens count toward tray totals. Cost unavailable in v1. Data format may change upstream. | -| Cursor | Not supported yet | Roadmap | Needs local usage-data investigation. | -| Windsurf | Not supported yet | Roadmap | Needs local usage-data investigation. | -| Aider | Not supported yet | Roadmap | Needs local usage-data investigation. | -| Roo Code | Not supported yet | Roadmap | Needs local usage-data investigation. | -| Continue | Not supported yet | Roadmap | Needs local usage-data investigation. | -| Gemini CLI | Not planned | Deprecated upstream | Reconsider only if a maintained successor exposes reliable local usage. | +| Tool | Status | Collection path | Notes | +| ------------ | ----------------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Claude Code | Supported | Bundled `ccusage` collector | Local usage only. | +| Codex | Supported | Bundled `ccusage` collector | Local usage only. | +| OpenCode | Supported | Bundled `ccusage` collector | Local usage only. | +| Pi | Supported | Bundled `ccusage` collector | Local usage only. Model labels keep the `[pi]` prefix from `ccusage`. | +| Cline CLI | Experimental | Native Burnly collector for `~/.cline` | Reads local session/message usage metrics. Data format may change upstream. | +| ZCode | Experimental | Native Burnly collector | Reads local SQLite usage data. Data format may change upstream. | +| Antigravity | Experimental | Native Burnly collector | Three variants: 2.0, IDE, and CLI. CLI reads local SQLite/protobuf metadata. App/IDE use runtime metadata when available, experimental SQLite fallback, then cached usage. | +| Grok Build | Experimental | Native Burnly collector for `~/.grok` | Reads `shell.turn.inference_done` rows from `unified.jsonl` plus session metadata. Totals are per inference call, not per user turn. Cached prompt tokens count toward tray totals. Cost unavailable in v1. Data format may change upstream. | +| Command Code | Experimental | Native Burnly collector for `~/.commandcode` | Reads per-message `usage` blocks from `projects/**/.jsonl` transcripts. Cost is provider-computed `costUsd`. Legacy pre-1.11 transcripts carry no usage and are skipped. Data format may change upstream. | +| Cursor | Not supported yet | Roadmap | Needs local usage-data investigation. | +| Windsurf | Not supported yet | Roadmap | Needs local usage-data investigation. | +| Aider | Not supported yet | Roadmap | Needs local usage-data investigation. | +| Roo Code | Not supported yet | Roadmap | Needs local usage-data investigation. | +| Continue | Not supported yet | Roadmap | Needs local usage-data investigation. | +| Gemini CLI | Not planned | Deprecated upstream | Reconsider only if a maintained successor exposes reliable local usage. | Burnly does not read prompts, responses, source code, or file contents. diff --git a/docs/exec-plans/active/2026-08-04_commandcode-collector-01-source-identity-detection.md b/docs/exec-plans/active/2026-08-04_commandcode-collector-01-source-identity-detection.md new file mode 100644 index 0000000..4eaa729 --- /dev/null +++ b/docs/exec-plans/active/2026-08-04_commandcode-collector-01-source-identity-detection.md @@ -0,0 +1,193 @@ +# 2026-08-04 Command Code Collector 01 Source Identity And Detection + +## Objective + +Introduce Command Code as a first-class Burnly source identity, add a +detection-only native collector stub that fails closed on collection, and add +sanitized local data fixtures for later parser and collector chunks, without +changing runtime refresh behavior yet. + +## Acceptance Criteria + +- `SourceKey::CommandCode` exists with stable storage value `command-code`. +- Tray and source-label helpers recognize `Command Code`. +- A `CommandCodeCollector` stub exists with working detection (projects root, + new-format transcripts, legacy-only) and fails closed on `collect` until a + later chunk wires the reader/mapper. +- Collector routing fails closed for `SourceKey::CommandCode` until the native + adapter is wired; Command Code stays out of `refresh_targets()`. +- Sanitized Command Code fixtures exist for transcript parsing. +- Fixture privacy constraints are documented beside the fixtures. +- Focused Rust tests prove source identity round trips, routing fail-closed, + detection states, and fixture privacy. + +## Risk Class + +`low` + +## Impact Areas + +- `src-tauri/src/domain/source.rs` +- `src-tauri/src/application/usage/tray_summary.rs` (source label) +- `src-tauri/src/application/refresh/target.rs` (assert Command Code is NOT yet + a target; catalog stays 16) +- `src-tauri/src/infrastructure/collectors/mod.rs` +- `src-tauri/src/infrastructure/collectors/commandcode/` (new detection stub) +- collector routing tests (`routed.rs`, `ccusage/source_registry.rs`) +- `tests/fixtures/collectors/commandcode/` +- product docs source tables (experimental listing only) + +## Design Review + +- Complexity introduced: one new `SourceKey` variant, a detection-only stub + collector, and a fixture corpus. No transcript parser or mapper yet. +- Hidden decisions: choosing `command-code` as the storage key and `Command +Code` as the display label; detection requires a new-format transcript (a + `type: session` record plus at least one usage-bearing message), so + legacy-only installs report `AvailableNoData` with a `legacy_only` issue. +- New interfaces: `CommandCodeCollector` (a `Collector` impl) — small, stable, + fails closed on `collect`/`describe` returns `Unsupported` until wired. +- Special cases: Command Code must stay out of `ccusage` routing (matching + Cline/ZCode/Antigravity/Grok); the legacy flat-schema transcripts must be + skipped, not imported as zero usage. +- Existing modules can absorb source identity cleanly; no new abstraction layer + is needed. + +## Scope + +- Add `SourceKey::CommandCode` and storage round-trip tests. +- Add `Command Code` tray/source label handling. +- Add `infrastructure/collectors/commandcode/` with `mod.rs`, `adapter.rs` + (detection stub), `detection.rs`, `commandcode_home.rs` (data-root + resolution). +- Add detection diagnostics: + - `commandcode.home_missing` + - `commandcode.projects_missing` + - `commandcode.projects_unreadable` + - `commandcode.no_usage_transcripts` + - `commandcode.legacy_only_transcripts` +- Keep Command Code out of `refresh_targets()` and `RoutedCollector` until a + later chunk. +- Add sanitized fixtures under `tests/fixtures/collectors/commandcode/`. +- Update README and `docs/product/product.md` to list Command Code as + experimental. + +## Out Of Scope + +- `transcript_reader.rs` / `transcript_parser.rs` / `mapper.rs` implementation. +- Durable usage cache or byte-offset persistence. +- Runtime bootstrap wiring and `RoutedCollector` registration (later chunk). +- Refresh target catalog changes (catalog stays 16 targets). +- Desktop runtime evidence. +- IPC or React UI changes beyond existing source-label plumbing. + +## Checklist + +- [x] Add `SourceKey::CommandCode` with `as_str() -> "command-code"` and + `from_storage` support. +- [x] Update source identity tests and tray/source label helpers. +- [x] Add `commandcode_home.rs` (default `~/.commandcode`, no override yet) and + `detection.rs` (`CommandCodeHomeInspection` + scan of `projects/**`). +- [x] Add `adapter.rs` detection stub: `CommandCodeCollector` with detection + states (`NotFound`, `AvailableNoData` + legacy issue, `Available`) and + fails closed on `collect`/`describe` `Unsupported`. +- [x] Register `commandcode` module in `infrastructure/collectors/mod.rs`. +- [x] Ensure routed collector and refresh targets fail closed for Command Code + (assert catalog stays 16; ccusage registry rejects `command-code`). +- [x] Add fixture README with privacy constraints. +- [x] Add `tests/fixtures/collectors/commandcode/transcripts/` sanitized JSONL + fixtures: + - `valid-single-session.jsonl` + - `valid-multi-session.jsonl` + - `legacy-format.jsonl` + - `partial-trailing-line.jsonl` + - `malformed-lines.jsonl` + - `empty-session.jsonl` +- [x] Update README and product docs source support tables. +- [x] Run focused Rust tests. +- [x] Run formatting checks. +- [x] Run `pnpm verify:fast`. + +## Test Plan + +- Behavior and invariants to prove: + - `SourceKey::CommandCode` round trips through storage. + - Native Command Code requests are not routed through `CcusageCollector`. + - Command Code is not yet included in refresh targets (catalog stays 16). + - Detection reports `NotFound` when projects root is missing, `Available` + with a valid new-format transcript, and `AvailableNoData` with a + `legacy_only_transcripts` issue when only legacy transcripts exist. + - `collect` on the stub fails closed with `Unsupported`. +- Lowest stable test layer: + - domain `source.rs` tests + - routed collector / refresh-target / ccusage registry tests + - `commandcode/detection_tests.rs` + `commandcode/adapter_tests.rs` +- Failure paths: + - unknown storage values still return `None` + - unreadable projects root => `NotFound`/issue, no panic +- Fixtures or fakes: + - sanitized JSONL only; no real prompts, transcripts, or file contents +- Runtime or platform evidence: + - not required in this chunk +- Relevant commands: + - `cargo test --manifest-path src-tauri/Cargo.toml --lib source_key -- --nocapture` + - `cargo test --manifest-path src-tauri/Cargo.toml --lib commandcode -- --nocapture` + - `cargo test --manifest-path src-tauri/Cargo.toml --lib native_sources_are_not_routed_through_ccusage -- --nocapture` + - `cargo test --manifest-path src-tauri/Cargo.toml --lib target_catalog_contains_each_supported_source_projection_pair -- --nocapture` + +## Decisions + +- Storage key: `command-code` +- Display label: `Command Code` +- Collector key: `command-code` +- Release stage in docs: `experimental` +- Detection definition: available only when a new-format transcript (a + `type: session` record plus at least one message carrying `usage`) exists + under `projects/`; legacy-only installs are `AvailableNoData` with a + `commandcode.legacy_only_transcripts` issue. +- Data root: `~/.commandcode` resolved via one function so an env override can + be added later; no override in this chunk. +- Fixture location: `tests/fixtures/collectors/commandcode/` + +## Verification + +- `cargo test --manifest-path src-tauri/Cargo.toml --lib source_key_has_stable_product_identity -- --nocapture` + passed. +- `cargo test --manifest-path src-tauri/Cargo.toml --lib source_key_round_trips_from_storage -- --nocapture` + passed. +- `cargo test --manifest-path src-tauri/Cargo.toml --lib commandcode -- --nocapture` + passed (detection + adapter stub + home resolution). +- `cargo test --manifest-path src-tauri/Cargo.toml --lib command_code_fails_closed_until_native_collector_is_wired -- --nocapture` + passed. +- `cargo test --manifest-path src-tauri/Cargo.toml --lib native_sources_are_not_routed_through_ccusage -- --nocapture` + passed. +- `cargo test --manifest-path src-tauri/Cargo.toml --lib routes_collection_by_source -- --nocapture` + passed. +- `cargo test --manifest-path src-tauri/Cargo.toml --lib target_catalog_contains_each_supported_source_projection_pair -- --nocapture` + passed. +- `cargo test --manifest-path src-tauri/Cargo.toml --lib command_code_is_not_yet_a_refresh_target -- --nocapture` + passed. +- `cargo fmt --manifest-path src-tauri/Cargo.toml` completed. +- `cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings` + passed. +- `pnpm architecture:check` passed. +- `pnpm rust:check`, `pnpm rust:fmt`, `pnpm rust:test` passed. +- `pnpm harness:check` passed (all harness checks, including fixture matrices). +- `pnpm lint` passed (0 errors, pre-existing warnings only). +- `pnpm typecheck` passed. +- `pnpm verify:fast` blocked only by pre-existing `.commandcode/` Prettier + warnings (untracked local config) — all project files pass Prettier. +- `pnpm test` (frontend) has 59 pre-existing failures on this machine, identical + on the clean `development` tree (verified by stash), unrelated to this chunk + (Rust-only changes). + +## Runtime Evidence + +- Not required for this chunk. + +## Follow-Up Debt + +- A later chunk will add `transcript_reader.rs` / `transcript_parser.rs` / + `mapper.rs`, wire `CommandCodeCollector` into bootstrap and `RoutedCollector`, + and extend the refresh catalog to 18 targets. The detection stub's + `collect`/`describe` fail-closed paths are replaced by real implementation. diff --git a/docs/exec-plans/active/2026-08-04_commandcode-collector-02-transcript-reader-parser.md b/docs/exec-plans/active/2026-08-04_commandcode-collector-02-transcript-reader-parser.md new file mode 100644 index 0000000..9505349 --- /dev/null +++ b/docs/exec-plans/active/2026-08-04_commandcode-collector-02-transcript-reader-parser.md @@ -0,0 +1,156 @@ +# 2026-08-04 Command Code Collector 02 Transcript Reader And Parser + +## Objective + +Add the Command Code collector's read-only transcript reader and parser +foundation without adding adapter collection, mapping, or runtime refresh +behavior. The detection stub from chunk 01 stays unchanged. + +## Acceptance Criteria + +- `infrastructure/collectors/commandcode/transcript_reader.rs` scans + `projects/**` for session transcripts, skips checkpoint files, and tolerates + partial trailing lines from live appends. +- `infrastructure/collectors/commandcode/transcript_parser.rs` parses + new-format transcripts (session `version: 3`) into usage-only typed records + and distinguishes legacy pre-1.11 files. +- Only `usage`, `model`, `effort`, and identity/timestamp fields are decoded; + `message.content` is never materialized. +- Malformed lines, missing fields, negative/overflowing token values, and + invalid timestamps are rejected safely (skip, not panic). +- Unit tests cover the chunk 01 fixtures and new edge-case fixtures. + +## Risk Class + +`medium` + +## Impact Areas + +- `src-tauri/src/infrastructure/collectors/commandcode/` +- `tests/fixtures/collectors/commandcode/transcripts/` + +## Design Review + +- Complexity introduced: two focused readers/parsers with usage-only typed + outputs, matching the Grok `unified_log_reader` pattern. +- Hidden decisions: + - transcript record types own only the allowed JSON fields + - parser returns typed records, never raw JSON strings or content + - legacy format detection is per-file, based on absence of a `type` field +- New interfaces: none crossing application boundaries; both modules are + `pub(crate)` within the collector. +- Special cases: + - a trailing partial line (live append) must not fail the file + - `message.content` must never be deserialized into memory for persistence + - token fields are unsigned; negative values are rejected +- No new abstraction beyond the proposal's module split. + +## Scope + +- Add `transcript_reader.rs` (directory scan, checkpoint skip, partial-line + tolerance, per-file read summary). +- Add `transcript_parser.rs` (usage-only structs, per-file format detection, + overflow/malformed guards). +- Add edge-case fixtures: + - `overflow-tokens.jsonl` + - `negative-tokens.jsonl` + - `invalid-timestamp.jsonl` + - `missing-usage-fields.jsonl` + - `multiple-sessions-same-file.jsonl` (already partially covered by + `valid-multi-session.jsonl`) +- Add reader/parser unit tests using chunk 01 fixtures and the new edge cases. + +## Out Of Scope + +- Adapter `collect` / `describe` (stub remains fail-closed). +- Mapper and cost conversion (Phase 3). +- Durable usage cache or byte-offset persistence. +- Routed collector wiring and refresh targets. +- Architecture harness updates unless required by module export patterns. + +## Checklist + +- [x] Implement `transcript_reader.rs`: + - scan `projects/**` for `.jsonl` transcripts + - skip `*.checkpoints.jsonl` + - return typed transcript reads with a summary +- [x] Implement `transcript_parser.rs`: + - usage-only decode structs (never `content`) + - per-file format detection via `type: session` presence + - skip malformed lines and tolerate partial trailing line + - reject negative/overflowing token counts + - reject invalid timestamps +- [x] Export new modules from `commandcode/mod.rs`. +- [x] Add edge-case fixtures. +- [x] Add reader/parser unit tests. +- [x] Run `cargo test --manifest-path src-tauri/Cargo.toml --lib commandcode -- --nocapture`. +- [x] Run `pnpm rust:fmt`, `pnpm rust:check`, `pnpm architecture:check`. + +## Test Plan + +- Behavior and invariants to prove: + - reader finds all non-checkpoint transcripts under `projects/**` + - checkpoint files are never parsed + - parser produces usage records only from new-format transcripts + - legacy transcripts parse as `Legacy` (skipped), never zero-usage records + - malformed lines and partial trailing lines are skipped without failing + - negative or overflowing token values are rejected + - invalid timestamps are rejected + - `message.content` is never deserialized +- Lowest stable test layer: + - `transcript_reader` and `transcript_parser` unit tests +- Failure paths: + - malformed JSON line + - negative/overflowing token values + - invalid timestamp + - missing `usage` on a message + - unreadable transcript file +- Fixtures or fakes: + - sanitized JSONL fixtures only (chunk 01 + new edge cases) +- Runtime or platform evidence: + - not required +- Relevant commands: + - `cargo test --manifest-path src-tauri/Cargo.toml --lib commandcode -- --nocapture` + - `pnpm architecture:check` + +## Decisions + +- Primary record: `type: message` with a top-level `usage` object on assistant + messages. +- Session record: `type: session` carries `version`, `id`, `timestamp`, `cwd`. +- Usage fields: `inputTokens`, `outputTokens`, `cacheReadTokens`, + `cacheWriteTokens`, `costUsd`; plus top-level `model` and `effort`. +- Per-file format detection: presence of a `type: session` record ⇒ new format; + flat records without `type` ⇒ legacy. +- Cost is parsed as a decimal into micros only in Phase 3; Phase 2 keeps the + raw `costUsd` value as a bounded string or validated float for later mapping. +- Incompatible/unreadable transcript files are skipped during the scan rather + than failing the whole read. + +## Verification + +- `cargo test --manifest-path src-tauri/Cargo.toml --lib commandcode -- --nocapture` + passed: 27 tests (14 parser, 6 detection, 3 reader, 2 home, 2 adapter). +- `cargo test --manifest-path src-tauri/Cargo.toml --lib` passed: 468 total + (was 454 before this chunk). +- `cargo fmt --manifest-path src-tauri/Cargo.toml` completed. +- `cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings` + passed. +- `pnpm rust:check` passed. +- `pnpm rust:fmt` passed. +- `pnpm architecture:check` passed. +- `pnpm harness:check` passed (all harness checks, including fixture matrices). +- New edge-case fixtures (`overflow-tokens`, `negative-tokens`, + `invalid-timestamp`, `missing-usage-fields`) validated as well-formed JSON + and covered by parser tests. + +## Runtime Evidence + +- Not required for this chunk. + +## Follow-Up Debt + +- Chunk 03 (Phase 3) will map parsed usage into Burnly daily/session + candidates, convert `costUsd` to integer micros, and add `(session id, +message id)` dedupe. The adapter's `collect`/`describe` fail-closed paths are + replaced by real implementation in a later chunk. diff --git a/docs/exec-plans/active/2026-08-04_commandcode-collector-03-mapper-cost.md b/docs/exec-plans/active/2026-08-04_commandcode-collector-03-mapper-cost.md new file mode 100644 index 0000000..1c05e84 --- /dev/null +++ b/docs/exec-plans/active/2026-08-04_commandcode-collector-03-mapper-cost.md @@ -0,0 +1,150 @@ +# 2026-08-04 Command Code Collector 03 Mapper And Cost + +## Objective + +Map parsed transcript usage into Burnly daily and session candidates, convert +`costUsd` to integer micros deterministically, and dedupe by `(session id, +message id)`. The adapter remains fail-closed on `collect`; this chunk builds +the mapping layer only. + +## Acceptance Criteria + +- `infrastructure/collectors/commandcode/mapper.rs` maps `ParsedTranscript` + records into `DailyUsageCandidate` / `SessionUsageCandidate`. +- Token fields map per the proposal: input/output/cache-read/cache-write; the + canonical total is their sum. +- `costUsd` converts to integer micros deterministically (round half-up to 6 + decimal places, reject negative/non-finite, zero-with-usage becomes + unavailable). +- Cost provenance follows the Cline precedent: `CostKind::SourceReported` + + `ValuedCostStatus::Estimated`. +- `(session id, message id)` dedupe prevents double-counting on re-reads. +- Unit tests cover fixtures from chunks 01/02 and edge cases. + +## Risk Class + +`medium` + +## Impact Areas + +- `src-tauri/src/infrastructure/collectors/commandcode/` +- `tests/fixtures/collectors/commandcode/` (no new fixtures needed; mapper + tests consume existing transcripts) + +## Design Review + +- Complexity introduced: one mapper module with daily/session accumulators, + matching the Grok mapper pattern. +- Hidden decisions: + - daily buckets keyed by local usage date + model breakdown + - session buckets keyed by `(session_id, model)` + - cost conversion lives in the mapper, not the parser +- New interfaces: `CommandCodeMappingContext`, `map_daily`, `map_sessions`, + `map_transcripts` — all `pub(crate)` within the collector. +- Special cases: + - `message.content` never enters mapping (parser already excludes it) + - legacy transcripts produce no candidates + - zero-cost-with-positive-tokens becomes `Unavailable` + - zero-cost-with-zero-tokens becomes `NotApplicable` +- No new abstraction beyond the proposal's module split. + +## Scope + +- Add `mapper.rs` with: + - `CommandCodeMappingContext` (collector key, version, collection id, + observed at) + - `map_daily` / `map_sessions` over `ParsedTranscript` + - cost conversion `cost_usd_to_micros` (round half-up) + - `(session id, message id)` dedupe key +- Export `mapper` from `commandcode/mod.rs`. +- Add mapper unit tests. +- Update the engineering proposal decision: `CostKind::SourceReported` (not + `collector_calculated`) per the Cline precedent. + +## Out Of Scope + +- Adapter `collect` / `describe` (stub remains fail-closed). +- Bootstrap wiring, `RoutedCollector` registration, refresh targets (Phase 4). +- Durable usage cache or byte-offset persistence. +- Desktop runtime evidence. + +## Checklist + +- [x] Implement `mapper.rs`: + - `map_transcripts(transcripts, timezone, scope, context)` -> daily + session + - token mapping with checked overflow + - `cost_usd_to_micros` deterministic conversion + - dedupe by `(session id, message id)` +- [x] Export `mapper` from `commandcode/mod.rs`. +- [x] Add mapper unit tests (daily, session, cost, dedupe, overflow, scope + filtering). +- [x] Run `cargo test --manifest-path src-tauri/Cargo.toml --lib commandcode -- --nocapture`. +- [x] Run `pnpm rust:fmt`, `pnpm rust:check`, `pnpm architecture:check`. + +## Test Plan + +- Behavior and invariants to prove: + - daily candidates aggregate per local date and model + - session candidates aggregate per session with first/last activity + - dedupe drops duplicate `(session id, message id)` + - cost conversion: `0.001` USD -> `1000` micros; negative/non-finite rejected; + zero-cost-with-usage -> `Unavailable`; zero-cost-zero-usage -> + `NotApplicable` + - token overflow rejected + - scope filtering (incremental date window) + - legacy transcripts produce no candidates +- Lowest stable test layer: + - `mapper.rs` unit tests +- Failure paths: + - invalid timezone + - token overflow + - invalid cost +- Fixtures or fakes: + - existing sanitized transcripts from chunks 01/02 +- Runtime or platform evidence: + - not required +- Relevant commands: + - `cargo test --manifest-path src-tauri/Cargo.toml --lib commandcode -- --nocapture` + - `pnpm architecture:check` + +## Decisions + +- Cost provenance: `CostKind::SourceReported` + `ValuedCostStatus::Estimated`, + matching the Cline native collector. (Proposal initially said + `collector_calculated`; corrected here to align with the established + convention for source-reported USD.) +- Cost conversion: round half-up to 6 decimal places (`costUsd * 1_000_000`), + reject negative/non-finite. +- Zero cost with positive tokens -> `UsageCost::Unavailable`. +- Zero cost with zero tokens -> `UsageCost::NotApplicable`. +- Dedupe key: `(session id, message id)`. +- Session bucket key: `(session id, model)`; a session with multiple models + yields multiple session candidates (matching Grok's model-scoped session + identity). + +## Verification + +- `cargo test --manifest-path src-tauri/Cargo.toml --lib commandcode -- --nocapture` + passed: 36 tests (9 new mapper tests). +- `cargo test --manifest-path src-tauri/Cargo.toml --lib` passed: 477 total + (was 468 before this chunk). +- `cargo fmt --manifest-path src-tauri/Cargo.toml` completed. +- `cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings` + passed. +- `pnpm rust:check` passed. +- `pnpm rust:fmt` passed. +- `pnpm architecture:check` passed. +- `pnpm harness:check` passed (all harness checks). +- Engineering proposal cost decision updated: `CostKind::SourceReported` + + `Estimated` (was `collector_calculated`), matching the Cline precedent. + +## Runtime Evidence + +- Not required for this chunk. + +## Follow-Up Debt + +- Chunk 04 (Phase 4) will wire `CommandCodeCollector` into bootstrap and + `RoutedCollector`, replace the adapter's fail-closed `collect`/`describe` + with the real reader+parser+mapper pipeline, and extend the refresh catalog + to 18 targets. diff --git a/docs/exec-plans/active/2026-08-05_commandcode-collector-05-runtime-evidence-product-docs.md b/docs/exec-plans/active/2026-08-05_commandcode-collector-05-runtime-evidence-product-docs.md new file mode 100644 index 0000000..7af821a --- /dev/null +++ b/docs/exec-plans/active/2026-08-05_commandcode-collector-05-runtime-evidence-product-docs.md @@ -0,0 +1,142 @@ +# 2026-08-05 Command Code Collector 05 Runtime Evidence And Product Docs + +## Objective + +Capture local desktop runtime evidence that the wired Command Code collector +reads real `~/.commandcode` transcripts, completes a Burnly refresh, and +surfaces today's usage in the tray summary — without persisting conversation +content — and finalize product docs with runtime-learned semantics. + +## Acceptance Criteria + +- Local `~/.commandcode/projects/**` transcripts contain usage for the + evidence date. +- Burnly refresh imports Command Code daily and session usage successfully. +- Persisted Burnly data contains today's Command Code usage with expected + model labels and cost. +- Tray-summary query returns Command Code usage for the evidence timezone. +- Privacy scan confirms no prompt, response, tool-input, or transcript content + in Burnly SQLite or logs. +- Product docs list Command Code as experimental with accurate semantics + (per-message aggregation, cache-token treatment, cost provenance, + legacy-backfill limitation, privacy boundary). +- Commands and outcomes are recorded in this plan. + +## Risk Class + +`medium` + +## Impact Areas + +- local Command Code install at `~/.commandcode/` +- Burnly runtime refresh path +- Burnly SQLite persistence +- tray summary query path +- `docs/product/product.md`, `README.md` + +## Design Review + +- This chunk validates the collector; it should not introduce new architecture + unless evidence finds a defect. +- Product wording must not overclaim precision for undocumented Command Code + formats. + +## Scope + +- Inspect local `~/.commandcode/projects/**/*.jsonl` for the evidence date. +- Run Burnly refresh with the wired Command Code collector. +- Query persisted daily/session usage and tray summary for today's totals. +- Privacy scan: confirm `message.content` (prompts, tool inputs, tool + outputs) never reached Burnly SQLite. +- Update `docs/product/product.md` and `README.md` with: + - per-message `usage` aggregation semantics + - cache-read tokens count toward tray totals; `cache_read_tokens` is a + breakdown field + - cost is provider-reported `costUsd` (estimated), not a bill + - legacy pre-1.11 transcripts carry no usage and are skipped (no backfill) + - privacy boundary: never reads `message.content`, checkpoints, history, + auth +- Write `docs/runtime-evidence/2026-08-05-commandcode-runtime/README.md`. +- Cross-link active/completed exec plans from the engineering proposal. + +## Out Of Scope + +- Collector behavior changes unless evidence finds a defect. +- Cross-platform evidence (Linux only in this chunk). +- Installer/release changes. +- UI redesign. +- Promoting Command Code from experimental to stable. + +## Checklist + +- [x] Confirm local Command Code transcripts contain usage for the evidence + date. +- [x] Run local refresh path with Command Code wired. +- [x] Verify persisted daily usage for the evidence date. +- [x] Verify persisted session usage rows. +- [x] Verify tray-summary query returns Command Code models. +- [x] Verify cost appears as provider-reported estimated micros. +- [x] Privacy scan: no `message.content` / prompt / tool payload persisted. +- [x] Update `docs/product/product.md`. +- [x] Update `README.md`. +- [x] Write `docs/runtime-evidence/2026-08-05-commandcode-runtime/README.md`. +- [x] Run `pnpm verify:fast` and `pnpm verify:runtime` where feasible. +- [x] Record evidence and residual risks. + +## Test Plan + +- Behavior and invariants to prove: + - end-to-end import from real local Command Code transcripts + - per-message usage aggregates into daily totals in the evidence timezone + - session rows carry first/last activity and per-model totals + - cost micros match summed `costUsd` at refresh time + - tray summary returns Command Code models + - privacy scan finds zero conversation-bearing content +- Lowest stable test layer: + - runtime evidence via the running app + direct SQLite queries +- Runtime or platform evidence: + - this chunk IS the runtime evidence +- Relevant commands: + - `pnpm tauri dev` + - `sqlite3` queries against `~/.local/share/app.burnly.desktop/burnly.sqlite3` + - `pnpm verify:fast` + - `pnpm verify:runtime` / `pnpm evidence:desktop` where feasible + +## Decisions + +- Display label remains `Command Code`; source key remains `command-code`. +- Evidence timezone: `Asia/Jakarta` (matches the local machine). +- Daily totals count cache-read + input + output as Burnly classifies them; + cache-read is breakdown metadata, not additional "new" tokens. +- Cost is `CostKind::SourceReported` + `Estimated` (provider-computed + `costUsd`), consistent with the Cline precedent. + +## Verification + +- Startup refresh (trigger `launch`) at `2026-08-05 13:42:18` succeeded with + the Command Code collector wired. +- Persisted daily usage for `2026-08-05` / `Asia/Jakarta`: + `total_tokens=156,934,545`, `input=78,778,283`, `output=137,830`, + `cache_read=78,018,432`, `cost_micros=11,286,010` (~$11.29, provider + estimate), model `deepseek/deepseek-v4-flash`. +- Persisted 3 session rows; dominant session `d8f83b9c-****` + `total_tokens=159,870,825` spanning 08-04 13:40Z → 08-05 13:42Z. +- Tray-summary query returned `deepseek/deepseek-v4-flash` with + `total_tokens=156,934,545` for the command-code daily source key. +- Privacy scan: zero matches for prompt/tool/content markers in SQLite or the + dev runtime log; diagnostics are sanitized counters only. +- `cargo test --manifest-path src-tauri/Cargo.toml --lib` passed. +- `pnpm rust:fmt`, `pnpm rust:check`, `pnpm architecture:check`, + `pnpm harness:check` passed. + +## Runtime Evidence + +- Recorded in `docs/runtime-evidence/2026-08-05-commandcode-runtime/README.md`. + +## Follow-Up Debt + +- Consider a later chunk for cross-platform evidence (macOS/Windows path + layout `~/.commandcode/projects` assumed stable but unverified). +- Revisit experimental→stable promotion after upstream Command Code format + stability is observed across releases. +- Optional future: byte-offset cache if transcript files grow unbounded. diff --git a/docs/exec-plans/completed/2026-08-04_commandcode-collector-04-wiring-refresh.md b/docs/exec-plans/completed/2026-08-04_commandcode-collector-04-wiring-refresh.md new file mode 100644 index 0000000..f8d80d4 --- /dev/null +++ b/docs/exec-plans/completed/2026-08-04_commandcode-collector-04-wiring-refresh.md @@ -0,0 +1,159 @@ +# 2026-08-04 Command Code Collector 04 Wiring And Refresh Integration + +## Objective + +Wire the Command Code collector into the runtime: implement the real +`collect`/`describe` pipeline (reader + parser + mapper), register +`CommandCodeCollector` in bootstrap and `RoutedCollector`, and extend the +refresh target catalog from 16 to 18 targets. + +## Acceptance Criteria + +- `CommandCodeCollector::collect` produces daily and session candidates from + real `~/.commandcode` transcripts. +- `CommandCodeCollector::describe` returns a valid profile descriptor. +- `CommandCodeCollector` is constructed in `bootstrap/collectors.rs` and + registered in `RoutedCollector` for `SourceKey::CommandCode`. +- `refresh_targets()` grows to 18 (9 sources × daily/session), including + Command Code. +- Diagnostics record collection failures with sanitized counters (no paths). +- Focused Rust tests prove routing, collection, detection, and the 18-target + catalog. + +## Risk Class + +`medium` + +## Impact Areas + +- `src-tauri/src/infrastructure/collectors/commandcode/adapter.rs` (real + collect/describe) +- `src-tauri/src/infrastructure/collectors/commandcode/mod.rs` (remove + dead-code allows now consumed) +- `src-tauri/src/infrastructure/collectors/routed.rs` (add `commandcode` field) +- `src-tauri/src/bootstrap/collectors.rs` (construct + wire) +- `src-tauri/src/application/refresh/target.rs` (18 targets) +- `src-tauri/src/infrastructure/collectors/ccusage/adapter.rs` / registry + (fail-closed already; unchanged) +- `src-tauri/src/infrastructure/collectors/commandcode/transcript_parser.rs` / + `transcript_reader.rs` / `mapper.rs` (remove `#![allow(dead_code)]`) + +## Design Review + +- Complexity introduced: the adapter becomes a real `Collector` implementation + with diagnostics, following the Grok adapter pattern exactly. +- Hidden decisions: + - no usage cache for Command Code; transcripts are re-read each refresh + (append-only, cheap) with `(session id, message id)` dedupe in the mapper + - diagnostics use `rowsFound` counter only; no paths or session ids +- New interfaces: none — `CommandCodeCollector` already exists as a stub. +- Special cases: + - legacy-only installs: collect returns `Empty`, detection reports + `AvailableNoData` with `commandcode.legacy_only_transcripts` + - missing home: collect returns `Empty` with a warning diagnostic (matching + Grok), not a hard failure +- Existing modules absorb the wiring cleanly; `RoutedCollector` gains one field + matching the Grok pattern. + +## Scope + +- Rewrite `adapter.rs` `collect`/`describe` to use + `TranscriptReader::scan` + `map_transcripts`. +- Add diagnostics for collection failures. +- Wire into `bootstrap/collectors.rs` (construct with `default_commandcode_home` + - diagnostic recorder). +- Add `commandcode: Arc` to `RoutedCollector` and route + `SourceKey::CommandCode` to it. +- Extend `refresh_targets()` to 18. +- Remove `#![allow(dead_code)]` and unused-import allows that are now + consumed. +- Add adapter tests (collect daily/session from fixture, missing home, + unsupported source). + +## Out Of Scope + +- Durable usage cache / byte-offset persistence (transcripts are re-read). +- Desktop runtime evidence (separate runtime-evidence chunk/phase). +- IPC or React UI changes beyond existing source-label plumbing. +- Product docs (already updated in Phase 1). + +## Checklist + +- [x] Rewrite `adapter.rs` `collect`/`describe` with reader+parser+mapper + pipeline. +- [x] Add diagnostics (`commandcode.collection_failed`) with sanitized + counters. +- [x] Wire `CommandCodeCollector` into `bootstrap/collectors.rs`. +- [x] Add `commandcode` field to `RoutedCollector` and route + `SourceKey::CommandCode`. +- [x] Extend `refresh_targets()` to 18. +- [x] Remove dead-code allows from parser/reader/mapper/mod. +- [x] Add adapter tests. +- [x] Run `cargo test --manifest-path src-tauri/Cargo.toml --lib commandcode -- --nocapture`. +- [x] Run `cargo test --manifest-path src-tauri/Cargo.toml --lib` (full suite). +- [x] Run `pnpm rust:fmt`, `pnpm rust:check`, `pnpm rust:clippy`, + `pnpm architecture:check`, `pnpm harness:check`. + +## Test Plan + +- Behavior and invariants to prove: + - `describe` returns Command Code profile with daily+session projections + - `collect` daily aggregates fixture transcripts into a daily candidate + - `collect` session produces a session candidate with activity timestamps + - missing home returns `Empty` with warning diagnostic + - non-CommandCode requests rejected with `UnsupportedSource` + - routed collector dispatches Command Code to the new adapter + - refresh catalog has exactly 18 targets, 9 daily + 9 session +- Lowest stable test layer: + - adapter tests + routed collector tests + refresh target tests +- Failure paths: + - missing home => `Empty` + warning + - invalid timezone / token overflow => mapped to failure +- Fixtures or fakes: + - existing sanitized transcripts from chunks 01-03 +- Runtime or platform evidence: + - not required in this chunk (desktop runtime evidence is a later chunk) +- Relevant commands: + - `cargo test --manifest-path src-tauri/Cargo.toml --lib commandcode -- --nocapture` + - `cargo test --manifest-path src-tauri/Cargo.toml --lib routes_collection_by_source -- --nocapture` + - `cargo test --manifest-path src-tauri/Cargo.toml --lib target_catalog_contains_each_supported_source_projection_pair -- --nocapture` + - `pnpm architecture:check` + +## Decisions + +- No durable usage cache: transcripts are re-read each refresh with mapper + dedupe (append-only, cheap). A byte-offset cache may be added later if + transcripts grow unbounded. +- Missing home returns `Empty` with a warning diagnostic (matching Grok's + behavior), not a hard failure. +- Diagnostics counters: `rowsFound` only; no paths, session ids, or message + ids. +- Collector identity: key `command-code`, version `local`, adapter version 1, + profile version 1. + +## Verification + +- `cargo test --manifest-path src-tauri/Cargo.toml --lib commandcode -- --nocapture` + passed: 40 tests (4 new adapter tests). +- `cargo test --manifest-path src-tauri/Cargo.toml --lib` passed: 480 total + (was 477 before this chunk). +- `cargo test --manifest-path src-tauri/Cargo.toml --lib routes_collection_by_source -- --nocapture` + passed. +- `cargo test --manifest-path src-tauri/Cargo.toml --lib target_catalog_contains_each_supported_source_projection_pair -- --nocapture` + passed (18 targets, 9 daily + 9 session). +- `cargo fmt --manifest-path src-tauri/Cargo.toml` completed. +- `cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings` + passed. +- `pnpm rust:check` passed. +- `pnpm architecture:check` passed. +- `pnpm harness:check` passed. + +## Runtime Evidence + +- Not required for this chunk; desktop runtime evidence is a later chunk. + +## Follow-Up Debt + +- A later chunk will record desktop runtime evidence (`pnpm verify:runtime`, + `pnpm evidence:desktop`) with a real Command Code session, and update + product docs with any semantic corrections learned from runtime behavior. diff --git a/docs/planning/_WIP/commandcode-collector-discovery.md b/docs/planning/_WIP/commandcode-collector-discovery.md new file mode 100644 index 0000000..e0b144b --- /dev/null +++ b/docs/planning/_WIP/commandcode-collector-discovery.md @@ -0,0 +1,93 @@ +# Command Code Collector — Data Discovery & Contract Assessment + +## Status + +WIP — read-only inspection of a local Command Code installation. No Burnly code changed. + +## Objective + +Determine whether Command Code (the AI coding CLI) exposes enough local, machine-readable usage data for Burnly to track tokens/cost, and document the observed data contract for a future collector. + +## Installed Version & Layout + +- Package: `command-code` (npm), **version 1.11.0**, `UNLICENSED` +- Binary: `~/.nvm/versions/node/v22.22.0/bin/commandcode` → `lib/node_modules/command-code/dist/index.mjs` +- Data root: `~/.commandcode/` + - `projects//.jsonl` — per-session transcripts (main source) + - `projects//.checkpoints.jsonl` — turn checkpoints + - `projects//.meta.json` — session metadata (`traceIds`, `title`) + - `projects//config.json` — per-project settings + - `history.jsonl` — **prompt history only** (`{"p": "/model", "t": }`); no tokens/cost + - `auth.json`, `config.json`, `updates.json`, `telemetry-install-id` — account/config, not usage +- No SQLite DB on disk (despite `drizzle-orm` in deps); the authoritative transcript is JSONL. + +## Data Contract (new v1.11.0 format) + +Each line of a session `.jsonl` is a JSON object: + +``` +{"type":"session","version":3,"id":"","timestamp":"","cwd":"/abs/path"} +{"type":"message","id":"","parentId":"","timestamp":"","message":{"role":"user|assistant","content":[...]},"usage":{...},"model":"","effort":"low|medium|max"} +``` + +- `type: session` — one per file; carries `cwd` (project root) and session start timestamp. +- `type: message` — user/assistant turns; assistant messages that consume a model call carry a top-level `usage` block: + +``` +"usage": { + "inputTokens": 29745, + "outputTokens": 233, + "cacheReadTokens": 7424, + "cacheWriteTokens": 0, + "costUsd": 0.0042503272 +} +``` + +- `model` is the full provider/model id (e.g. `deepseek/deepseek-v4-flash`). +- `effort` (`low|medium|max`) accompanies usage-bearing records. +- Content is a typed array: `text`, `thinking`, `tool_use`, `tool_result`, etc. Tool arguments (e.g. full `shell_command` prompts, explore agent prompts) are **stored verbatim** — privacy-sensitive. +- Timestamps are RFC 3339 UTC with millisecond precision. + +## Observed Data Quality (this machine, 2026-08-04) + +| Aspect | Finding | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Token categories | `inputTokens`, `outputTokens`, `cacheReadTokens`, `cacheWriteTokens` — matches Burnly's canonical model | +| Cost | `costUsd` float per message; present alongside usage | +| Model | Full provider/model id per message; clean breakdown source | +| Sessions | Per-session files; session start timestamp + `cwd`; **no explicit end/last-activity timestamp** | +| Project identity | Directory name slug; `cwd` in session record (a real path) | +| Timezone | All timestamps UTC; Burnly must aggregate in reporting timezone | +| History depth | Only sessions since upgrade to 1.11.0 (today) carry `usage`; older sessions (May) use a **legacy flat schema** (`sessionId`/`role` records, no `type`, no `usage`) → **no historical backfill before the v1.11 upgrade** | +| Billing scope | `costUsd` reflects the configured provider's price (DeepSeek in this install); accurate for local tracking, not a subscription bill | + +## Assessment vs. Burnly Collector Port + +**Verdict: data is sufficient and high-quality for a native collector.** + +- Every assistant model call records complete token categories + cost + model — richer than several existing collectors. +- Can map cleanly to Burnly `DailyUsageCandidate` / `SessionUsageCandidate`: + - **daily**: sum message `usage` by `timestamp` calendar date (aggregation timezone applied by Burnly) + - **session**: one candidate per session file; first/last activity from message timestamps; identity = session UUID + - **model breakdown**: per-message `model` string + - **cost**: from `costUsd` (convert float USD → integer micros deterministically) +- Collector shape mirrors existing native collectors (Cline/ZCode): read `~/.commandcode/projects/**/*.jsonl` read-only, parse JSONL, map to candidates. + +## Caveats / Risks + +1. **No `--usage` CLI command exposed in the installed binary's command table** — parse the JSONL directly; do not shell out to `commandcode`. +2. **Legacy schema mismatch** — old sessions must be skipped or version-detected (per-file sniff: `type` field present ⇒ new format). +3. **Message-level usage, not row-level** — the total per message includes any nested tool calls? (Unverified: whether `inputTokens` covers tool results/context.) Treat as authoritative per-message aggregate like ccusage's `totalTokens`. +4. **Privacy** — tool arguments contain full prompts, shell commands, and file contents. Burnly must only read `usage`, `model`, `effort`, `timestamp`, `type`, `id`, `cwd` and must never persist raw content. This aligns with the "never read prompts/responses" product constraint. +5. **Format is undocumented/unversioned by vendor** — JSONL layout is inferred from local install; schema `version: 3` exists at session level but no public contract. Pin to observed layout + fixtures; treat as `experimental` release stage initially. +6. **Cross-platform** — verified on Linux only; needs fixture validation on macOS/Windows (path layout `~/.commandcode/projects/...` assumed stable). +7. **Active-session concurrency** — the file is appended live by the CLI; reader must handle partial trailing lines (read only complete lines; last line may be incomplete). +8. **No end timestamp on session** — "last activity" must be derived from the max message timestamp; a session open for a long time but idle still looks active. Acceptable for MVP daily aggregation. + +## Next Steps (not yet planned) + +- Product decision: add `command-code` as a Burnly source (likely `experimental` initially). +- Engineering proposal + execution plan under `docs/planning/_WIP/` / `docs/exec-plans/active/`. +- Sanitized fixtures from real sessions (strip prompts/shell/tool content, keep usage/model/timestamps). +- Implement `CommandCodeCollector` in `src-tauri/src/infrastructure/collectors/commandcode/` (mirror `cline`/`zcode` read-only JSONL pattern), wire into `RoutedCollector`, add `SourceKey::CommandCode`. +- Detection: `~/.commandcode/projects` exists + at least one new-format session file. diff --git a/docs/planning/_WIP/commandcode-collector-engineering-proposal.md b/docs/planning/_WIP/commandcode-collector-engineering-proposal.md new file mode 100644 index 0000000..9edce56 --- /dev/null +++ b/docs/planning/_WIP/commandcode-collector-engineering-proposal.md @@ -0,0 +1,615 @@ +# Command Code Collector Engineering Proposal + +## Status + +Engineering proposal, based on read-only local inspection of a Command Code CLI +installation on August 4, 2026. + +This proposal covers native Burnly support for Command Code CLI local usage +data. It is not an execution plan and does not approve implementation by itself. + +Accompanying discovery notes: `docs/planning/_WIP/commandcode-collector-discovery.md`. + +## Context + +Command Code is a local coding agent CLI ("coding agent that continuously +learns your coding taste"), distributed as an npm package (`command-code`, +`UNLICENSED`). It is not available through the bundled `ccusage` sidecar, which +only covers Claude Code, Codex, OpenCode, and Pi. Burnly therefore needs a +first-party native collector that reads Command Code's local session artifacts +with a strict privacy boundary. + +Local inspection on August 4, 2026 found: + +- Package: `command-code` **1.11.0** (npm, `UNLICENSED`) +- Binary: `~/.nvm/versions/node/v22.22.0/bin/commandcode` → + `lib/node_modules/command-code/dist/index.mjs` (also installed as + `command-code`, `cmd`, `cmdc`) +- Data root: `~/.commandcode/` (no documented env override observed in the + installed package) +- Session store: `~/.commandcode/projects//.jsonl` +- Session checkpoints: `.../.checkpoints.jsonl` +- Session metadata: `.../.meta.json` (`traceIds`, `title`) +- Per-project settings: `.../config.json` +- Global prompt history: `~/.commandcode/history.jsonl` (no tokens) +- No SQLite database on disk at inspection time, despite `drizzle-orm` being a + dependency; the authoritative transcript is JSONL. + +Observed usage data on the inspection machine (all on 2026-08-04): + +- `35` assistant messages carried a `usage` block +- `inputTokens` total: `1,958,254` +- `outputTokens` total: `15,962` +- `cacheReadTokens` total: `1,703,296` +- `cacheWriteTokens` total: `0` +- `costUsd` total: `$0.2834` +- Single model observed: `deepseek/deepseek-v4-flash` + +Command Code does not document the JSONL session format as a stable usage-export +API. Burnly must treat Command Code local formats as reverse-engineered, +version-sensitive artifacts, similar to the Grok and Antigravity collectors. + +Burnly ships a native Command Code collector behind the existing collector +port. Product docs describe it as experimental until runtime evidence confirms +stability across upstream Command Code updates. + +## Recommendation + +Add Command Code as a native Burnly collector adapter behind the existing +collector port. + +Recommended product status: + +```text +source_key: command-code +display_name: Command Code +collector_key: command-code +release_stage: experimental +metric_quality: source_reported_tokens_local_log +``` + +The first implementation should use `~/.commandcode/projects/**/.jsonl` +as the sole usage source. Each session transcript is self-contained (session +record + message records with per-message `usage`), so no cross-file join is +required beyond scanning the projects directory. + +Do not parse conversation transcripts, tool inputs/outputs, checkpoints, prompt +history, auth credentials, or billing configuration for normal usage +aggregation. Only top-level fields of `type: message` records that carry +`usage` are needed. + +## Local Data Shape + +### Data root and discovery + +Primary root: + +```text +~/.commandcode/ +``` + +Useful top-level entries: + +| Path | Role | +| --------------------------------------------------- | -------------------------------------- | +| `projects//.jsonl` | Per-session transcript; main source | +| `projects//.checkpoints.jsonl` | Turn checkpoints (contains prompts) | +| `projects//.meta.json` | Session metadata (`traceIds`, `title`) | +| `projects//config.json` | Per-project settings | +| `history.jsonl` | Global prompt history; no usage | +| `auth.json`, `updates.json`, `telemetry-install-id` | Account/config; no usage | + +Project slugs are derived from the working directory, e.g.: + +```text +home-fikrilal-devs-personal-burnly +home-fikrilal-devs-side-lamara-lamara-frontend +``` + +### Session transcript format (new, version 3) + +Each `.jsonl` session file starts with one `session` record, then `message` +records: + +```json +{"type":"session","version":3,"id":"d8f83b9c-64f4-4565-a7b2-481b5d6fee26","timestamp":"2026-08-04T13:37:33.896Z","cwd":"/home/fikrilal/devs/personal/burnly"} +{"type":"message","id":"0db6f45a","parentId":"456233b7","timestamp":"2026-08-04T13:40:02.987Z","message":{"role":"assistant","content":[...]},"usage":{...},"model":"deepseek/deepseek-v4-flash","effort":"max"} +``` + +Field semantics: + +- `type: session` — exactly one per file; carries `version` (observed `3`), + session UUID `id`, start `timestamp`, and `cwd` (real project path). +- `type: message` — `id` (short, file-scoped), `parentId`, RFC 3339 UTC + `timestamp`, `message.role` (`user` | `assistant`). +- `usage` — present only on assistant messages that consumed a model call: + +```json +"usage": { + "inputTokens": 29745, + "outputTokens": 233, + "cacheReadTokens": 7424, + "cacheWriteTokens": 0, + "costUsd": 0.0042503272 +} +``` + +- `model` — full provider/model id, e.g. `deepseek/deepseek-v4-flash`. +- `effort` — `low` | `medium` | `max` on usage-bearing messages. +- `message.content` — typed array (`text`, `thinking`, `tool_use`, + `tool_result`, ...). Tool inputs contain full prompts, shell commands, and + file contents. **Burnly must never read or persist these.** + +Important properties: + +- Every assistant message that produced a model call carries a complete usage + block: input, output, cache read, cache write, and cost. +- `costUsd` is the provider-computed cost for that message (USD float). +- Duplicate `(session id, message id)` pairs were not observed; message ids are + unique within a file. +- The file is appended live by the CLI; the trailing line may be partially + written at read time. + +Observed per-project aggregates (inspection machine, 2026-08-04): + +| Project | Usage messages | Tokens | +| ------------------------------------------------ | -------------: | --------: | +| `home-fikrilal-devs-personal-burnly` | 33 | 3,567,427 | +| `home-fikrilal-devs-side-lamara-lamara-frontend` | 2 | 110,085 | + +### Legacy transcript format (pre-1.11) + +Older session files (May 2026 on the inspection machine) use a flat, unversioned +schema: + +```json +{ + "id": "6395a259-...", + "timestamp": "2026-05-07T03:23:01.515Z", + "sessionId": "3f5c1534-...", + "parentId": "26c30319-...", + "role": "user", + "content": [{ "type": "text", "text": "..." }] +} +``` + +- No `type` field, no `usage`, no `model`. +- These files carry no token data and must be skipped (or detected and + reported), not imported as zero-usage sessions. + +### Sources that are not sufficient for primary collection + +| Source | Why not primary | +| ---------------------------------- | ---------------------------------------------------------- | +| `history.jsonl` | Prompt history only (`{"p": "...", "t": }`); no tokens | +| `*.checkpoints.jsonl` | Turn checkpoints containing prompts; no usage | +| `*.meta.json` | `traceIds`, `title`; no usage (title is user content) | +| `config.json` | Settings; no usage | +| `auth.json` | Credentials; never read | +| `ide/`, `file-history/`, `skills/` | Non-usage artifacts | + +## Product Semantics + +Command Code should appear as a separate Burnly source: + +```text +Command Code +``` + +Recommended mapping: + +| Command Code field | Burnly field | +| -------------------------- | ------------------------------------------------------------ | +| `message.timestamp` | daily usage date (converted to request aggregation timezone) | +| `session.cwd` | project attribution (real path) | +| `usage.inputTokens` | `TokenUsage.input_tokens` | +| `usage.outputTokens` | `TokenUsage.output_tokens` | +| `usage.cacheReadTokens` | `TokenUsage.cache_read_tokens` | +| `usage.cacheWriteTokens` | `TokenUsage.cache_creation_tokens` | +| `usage.costUsd` | `UsageCost` (USD float → integer micros) | +| `model` | model identity | +| `session.id` | session identity | +| `(session id, message id)` | idempotency / dedupe key | +| `effort` | source metadata | + +Token semantics: + +- Treat `inputTokens` as non-cached input tokens (as reported by the provider). +- Treat `cacheReadTokens` as cache-read tokens. +- Treat `cacheWriteTokens` as cache-creation tokens. +- `total_tokens = input + output + cache_read + cache_write` + (Burnly classifies all four; no unclassified remainder is expected, but the + invariant `classified <= total` is enforced by Burnly's `TokenUsage`). + +Cost semantics: + +- `costUsd` is the provider-computed cost for the message. It is a USD float + with sub-cent precision and must be converted to integer micros with a + deterministic rounding rule (round half-up to 6 decimal places, reject + negative/non-finite). +- Record provenance as `source_reported` (`CostKind::SourceReported`) with + `estimated` status, matching the Cline native collector convention for + source-reported USD. (The collector does not calculate cost itself.) +- Zero cost with positive tokens should be treated as unavailable, matching + existing cost safeguards. + +Daily usage: + +- Group by the `message.timestamp` converted to the request aggregation + timezone. Do not use the session start timestamp for daily attribution. + +Session usage: + +- One session candidate per transcript; identity = full session UUID. +- `first_activity` = min message timestamp; `last_activity` = max message + timestamp. There is no explicit session-end timestamp in the format. +- Model breakdown = per-message `model`, aggregated within the scope. + +## Privacy Boundary + +The collector may read from `projects/**/.jsonl`: + +- `type`, `version`, `id`, `timestamp`, `cwd` from `session` records +- `id`, `parentId`, `timestamp`, `role` from `message` records +- `usage.*`, `model`, `effort` when present + +The collector must not read, log, persist, or return: + +- `message.content` (text, thinking, tool_use inputs, tool_results) +- `*.checkpoints.jsonl` +- `*.meta.json` `title` +- `history.jsonl` prompt text +- `config.json` +- `auth.json` or any credential store +- `ide/`, `file-history/`, `skills/` contents +- any field containing prompt, response, tool input, tool output, file + contents, or command output + +Implementation should decode Command Code records through usage-only structs or +explicit field extraction (e.g. `serde` structs with only the allowed fields, +or manual JSON traversal that skips `content`). It must never deserialize the +full `content` array into Burnly memory for persistence. + +Project paths (`cwd`) can reveal sensitive information. Burnly should keep path +handling behind existing project-redaction settings. + +## Proposed Architecture + +Command Code should be implemented as a native infrastructure collector behind +the existing Burnly collector port: + +```text +RefreshCoordinator + | + v +Arc + | + v +RoutedCollector + | + +-- SourceKey::ClaudeCode -> CcusageCollector + +-- SourceKey::Codex -> CcusageCollector + +-- SourceKey::OpenCode -> CcusageCollector + +-- SourceKey::Pi -> CcusageCollector + +-- SourceKey::Cline -> ClineCollector + +-- SourceKey::ZCode -> ZCodeCollector + +-- SourceKey::Antigravity -> AntigravityCollector + +-- SourceKey::GrokBuild -> GrokCollector + +-- SourceKey::CommandCode -> CommandCodeCollector +``` + +Recommended internal components: + +```text +CommandCodeCollector + | + +-- Detection + | Checks ~/.commandcode/projects availability and new-format + | transcripts. + | + +-- TranscriptReader + | Scans projects/**/.jsonl, skipping legacy files, + | handling live partial trailing lines. + | + +-- TranscriptParser + | Parses session + message records via usage-only structs. + | + +-- UsageMapper + Maps parsed usage into Burnly daily/session candidates. +``` + +The application layer should not know about Command Code project slugs, +transcript files, or record layouts. + +### Data path priority + +1. Scan `~/.commandcode/projects/**/*.jsonl` (excluding `.checkpoints.`). +2. For each file, parse complete lines; skip the trailing line when it is + malformed (live append in progress). +3. Skip files that lack a `type: session` record or any usage-bearing message + (legacy format or empty session). +4. Map usage-bearing messages to candidates within the requested scope. +5. Recoverable unavailable result when the projects root cannot be read. + +No durable usage cache is proposed for the first implementation: transcripts +are append-only per session and re-reading them is cheap relative to the +incremental-log problem Grok poses. A per-file byte-offset cache may be added +later if transcript files grow unbounded. + +### Incremental handling + +- Files are append-only per session; a full re-read of each file per refresh is + acceptable initially. +- Dedupe by `(session id, message id)` before producing envelopes, so re-reads + never double-count. +- If a session file is truncated (size regression), treat as a diagnostic + event and re-import what remains; do not silently merge across truncation. + +## Folder Structure + +Recommended source layout: + +```text +src-tauri/src/infrastructure/collectors/commandcode/ + mod.rs + adapter.rs + detection.rs + transcript_reader.rs + transcript_parser.rs + mapper.rs + fixtures/ +``` + +Recommended tests: + +```text +src-tauri/src/infrastructure/collectors/commandcode/ + detection_tests.rs + transcript_reader_tests.rs + transcript_parser_tests.rs + mapper_tests.rs +``` + +Recommended fixtures: + +```text +tests/fixtures/collectors/commandcode/ + transcripts/ + valid-single-session.jsonl + valid-multi-session.jsonl + legacy-format.jsonl + partial-trailing-line.jsonl + malformed-lines.jsonl + empty-session.jsonl +``` + +Fixtures must contain only usage-safe fields. Real transcripts contain prompts, +tool inputs, and file contents in `content`; fixtures must strip `content` to +placeholder-safe values (or empty arrays) while preserving `usage`, `model`, +`timestamp`, and `cwd`. + +## Runtime Discovery + +Detection should be filesystem-based and read-only. + +Recommended checks: + +1. Resolve the Command Code data root: + - `~/.commandcode` (no override observed in the installed package; keep + resolution behind one function so an env override can be added later). +2. Accept detection when `projects/` exists and at least one + `projects/**/*.jsonl` (non-checkpoint) contains a `type: session` record + with `version` and at least one message carrying `usage`. +3. Record installed version from `~/.commandcode/updates.json` when readable, + or from the `commandcode --version` CLI only for diagnostics (never for + collection). + +Detection should distinguish: + +- Command Code not installed / no data directory. +- Data directory exists but no usage-bearing transcripts (legacy-only or new + install). +- Projects root unreadable. +- Source available. + +Do not launch Command Code from Burnly. Do not require network access or +Command Code credentials. + +## Refresh Policy + +Command Code usage should be treated as append-only local transcripts. + +Initial import: + +- Scan all `projects/**/*.jsonl` and import usage-bearing sessions. +- Bound first release to a safe window (e.g. last 30 days) to avoid unbounded + first scans on long-lived installs. + +Daily refresh: + +- Re-scan transcripts; new sessions and new messages append to existing files. +- Dedupe by `(session id, message id)`. +- No two-day lookback requirement beyond what the planner already applies, + because messages carry their own timestamps. + +Manual full refresh: + +- Same scan with no window bound. + +## Risks And Constraints + +Private format stability: + +- The JSONL layout is undocumented as a public API and inferred from a local + install (session `version: 3`). +- Field names, record shapes, and the presence of `usage` may change between + Command Code releases. +- Burnly must fail soft and emit precise diagnostics when parsing can no + longer proceed safely. + +Legacy schema mismatch: + +- Pre-1.11 transcripts use a flat schema with no usage. Skipping them means no + historical backfill; importing them as zero-usage would corrupt daily + totals. Detection must be per-file. + +Privacy: + +- Transcripts contain full prompts, tool inputs, and file contents adjacent to + the usage fields. +- The parser must use usage-only structs and never materialize `content`. + +Live-append partial lines: + +- The CLI appends while running; the collector must tolerate a malformed + trailing line without failing the whole session. + +Cost semantics: + +- `costUsd` is provider-computed and reflects the configured provider's + pricing; it is an estimate, not a subscription bill. +- Cross-provider consistency (e.g. DeepSeek vs Anthropic pricing) is not + Burnly's concern for the local estimate, but should be documented. + +Cross-platform: + +- Verified on Linux only. Path layout + (`~/.commandcode/projects/...`) is assumed stable for macOS/Windows but needs + fixture or machine validation before release. + +Single-model observation: + +- Only `deepseek/deepseek-v4-flash` was observed. Model-switching behavior + needs fixture coverage before claiming per-model precision across switches. + +## Implementation Phases + +### Phase 1: Source Identity And Detection + +Goals: + +- Add `SourceKey::CommandCode`. +- Detect the projects root and usage-bearing transcripts. +- Register the collector in `RoutedCollector`. + +Changes: + +- Add `command-code` to domain source identity and tray labels. +- Implement `detection.rs`. +- Add diagnostics: + - `commandcode.home_missing` + - `commandcode.projects_missing` + - `commandcode.projects_unreadable` + - `commandcode.no_usage_transcripts` + - `commandcode.legacy_only_transcripts` + +### Phase 2: Transcript Reader And Parser + +Goals: + +- Parse `projects/**/.jsonl` safely. +- Distinguish new-format from legacy files. + +Changes: + +- Implement `transcript_reader.rs` (scan, skip checkpoints, partial trailing + line tolerance). +- Implement `transcript_parser.rs` (usage-only structs; per-file format + detection via `type: session` presence). +- Add malformed-line handling and token overflow guards. + +### Phase 3: Mapping And Cost + +Goals: + +- Produce Burnly daily and session candidates from parsed transcripts. + +Changes: + +- Implement `mapper.rs`. +- Map token fields per the scheme above. +- Convert `costUsd` to integer micros deterministically. +- Add `(session id, message id)` dedupe. + +### Phase 4: Wiring And Refresh Integration + +Goals: + +- Full refresh integration: 16 → 18 targets (8 sources × daily/session). + +Changes: + +- Wire `CommandCodeCollector` into `bootstrap/collectors.rs`. +- Extend refresh target catalog and any source-summary surfaces. +- Add fixture-driven unit tests. + +### Phase 5: Product Semantics And Documentation + +Goals: + +- Ship Command Code as an experimental source with accurate tray semantics. + +Changes: + +- Add Command Code to product docs and tray source labels. +- Document experimental status, privacy boundary, cost semantics, and the + legacy-backfill limitation. + +## Verification Plan + +Automated verification: + +- Unit tests for transcript parsing using sanitized fixtures. +- Unit tests for legacy-format skipping. +- Unit tests for partial trailing line tolerance. +- Unit tests for malformed-line skipping and token overflow rejection. +- Unit tests for `(session id, message id)` dedupe stability. +- Unit tests for daily date attribution from `message.timestamp`. +- Unit tests for cost float → micros conversion (rounding, negative, + non-finite). +- Unit tests for `cwd`-based project attribution. + +Manual runtime evidence: + +- Run a short Command Code session in a test repository. +- Confirm usage-bearing messages appear in the project transcript. +- Run Burnly refresh. +- Verify `Command Code` appears in today's usage with expected model label. +- Verify per-project attribution matches the session `cwd`. +- Verify no prompt/response content is written to Burnly SQLite or logs. +- Stop Command Code and verify refresh still works from local transcripts. + +Suggested gates for implementation chunks: + +```text +pnpm verify:fast +pnpm architecture:check +pnpm verify:runtime +``` + +Runtime evidence should include sanitized counters only. + +Captured desktop runtime evidence (August 5, 2026): +`docs/runtime-evidence/2026-08-05-commandcode-runtime/README.md` + +## Open Questions + +- ~~Should the source key be `commandcode` or `command-code`?~~ **Resolved:** + use `command-code` with display label `Command Code`, matching the kebab-case + convention (`grok-build`, `claude-code`). +- ~~Should Burnly derive cost in v1?~~ **Resolved:** yes, from `costUsd` with + deterministic micros conversion, recorded as provider-computed provenance. + This is a first for native collectors (Grok/Antigravity have no cost) but the + data is present and validated per message. +- Should `cacheWriteTokens` map to `cache_creation_tokens` directly, or is a + provider-specific semantic review needed first (DeepSeek "cache write" vs + Anthropic "cache creation")? +- Should per-file byte offsets be persisted to avoid re-reading entire + transcripts on every refresh, or is full re-read acceptable at observed + transcript sizes? +- Should legacy pre-1.11 transcripts be surfaced in diagnostics as + `legacy_only_transcripts`, or silently ignored? +- Should `effort` be exposed anywhere in the tray, or kept as source metadata + only? +- At what evidence threshold should Command Code move from experimental to + stable: one release, three releases, or cross-platform validation? diff --git a/docs/product/product.md b/docs/product/product.md index f634857..baf4658 100644 --- a/docs/product/product.md +++ b/docs/product/product.md @@ -6,8 +6,9 @@ Burnly is a tray-first AI coding-tool token tracker. It runs locally, watches usage from supported tools such as Claude Code, Codex, OpenCode, Pi, and experimental native collector sources such as Cline, ZCode, -Antigravity, and Grok Build, and gives developers a compact view of their current token -usage without requiring them to open a full desktop window. +Antigravity, Grok Build, and Command Code, and gives developers a compact view +of their current token usage without requiring them to open a full desktop +window. The entire local experience is a small tray/menu-bar panel. There is no full desktop window. Local detail surfaces such as settings live as tabs inside the @@ -88,22 +89,23 @@ implied. Current source status: -| Tool | Status | Product note | -| ----------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Claude Code | Supported | Collected through the bundled `ccusage` collector. | -| Codex | Supported | Collected through the bundled `ccusage` collector. | -| OpenCode | Supported | Collected through the bundled `ccusage` collector. | -| Pi | Supported | Collected through the bundled `ccusage` collector. Model labels keep the `[pi]` prefix. | -| Cline CLI | Experimental | Collected through Burnly's native local collector. | -| ZCode | Experimental | Collected through Burnly's native local SQLite collector. | -| Antigravity | Experimental | Collected through Burnly's native collector across Antigravity 2.0, IDE, and CLI variants. CLI usage is read from local SQLite/protobuf metadata. App/IDE usage prefers runtime metadata sync, with experimental SQLite fallback and cached records when runtime is unavailable. | -| Grok Build | Experimental | Collected through Burnly's native local collector. Reads `shell.turn.inference_done` rows from `~/.grok/logs/unified.jsonl` and joins session metadata from `~/.grok/sessions/**/summary.json`. Totals count each model inference call, not each user turn. Cached prompt tokens count toward tray totals; `cache_read_tokens` is breakdown metadata only. Cost is unavailable in v1. Grok formats are reverse-engineered and may change upstream. Burnly never reads chat transcripts, ACP updates, prompt history, terminal logs, or auth credentials. | -| Cursor | Not supported yet | Roadmap investigation. | -| Windsurf | Not supported yet | Roadmap investigation. | -| Aider | Not supported yet | Roadmap investigation. | -| Roo Code | Not supported yet | Roadmap investigation. | -| Continue | Not supported yet | Roadmap investigation. | -| Gemini CLI | Not planned | Deprecated upstream. | +| Tool | Status | Product note | +| ------------ | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Claude Code | Supported | Collected through the bundled `ccusage` collector. | +| Codex | Supported | Collected through the bundled `ccusage` collector. | +| OpenCode | Supported | Collected through the bundled `ccusage` collector. | +| Pi | Supported | Collected through the bundled `ccusage` collector. Model labels keep the `[pi]` prefix. | +| Cline CLI | Experimental | Collected through Burnly's native local collector. | +| ZCode | Experimental | Collected through Burnly's native local SQLite collector. | +| Antigravity | Experimental | Collected through Burnly's native collector across Antigravity 2.0, IDE, and CLI variants. CLI usage is read from local SQLite/protobuf metadata. App/IDE usage prefers runtime metadata sync, with experimental SQLite fallback and cached records when runtime is unavailable. | +| Grok Build | Experimental | Collected through Burnly's native local collector. Reads `shell.turn.inference_done` rows from `~/.grok/logs/unified.jsonl` and joins session metadata from `~/.grok/sessions/**/summary.json`. Totals count each model inference call, not each user turn. Cached prompt tokens count toward tray totals; `cache_read_tokens` is breakdown metadata only. Cost is unavailable in v1. Grok formats are reverse-engineered and may change upstream. Burnly never reads chat transcripts, ACP updates, prompt history, terminal logs, or auth credentials. | +| Command Code | Experimental | Collected through Burnly's native local collector. Reads per-message `usage` blocks from `~/.commandcode/projects/**/.jsonl` transcripts (session `version: 3`). Totals sum provider-reported `inputTokens`, `outputTokens`, `cacheReadTokens`, and `cacheWriteTokens` per assistant message; cost is provider-computed `costUsd`. Legacy pre-1.11 transcripts carry no usage and are skipped. Command Code formats are reverse-engineered and may change upstream. Burnly never reads prompt/tool content, checkpoints, history, or auth credentials. | +| Cursor | Not supported yet | Roadmap investigation. | +| Windsurf | Not supported yet | Roadmap investigation. | +| Aider | Not supported yet | Roadmap investigation. | +| Roo Code | Not supported yet | Roadmap investigation. | +| Continue | Not supported yet | Roadmap investigation. | +| Gemini CLI | Not planned | Deprecated upstream. | ### Future social features are opt-in diff --git a/docs/runtime-evidence/2026-08-05-commandcode-runtime/README.md b/docs/runtime-evidence/2026-08-05-commandcode-runtime/README.md new file mode 100644 index 0000000..3b7aa3b --- /dev/null +++ b/docs/runtime-evidence/2026-08-05-commandcode-runtime/README.md @@ -0,0 +1,149 @@ +# Command Code Runtime Evidence + +Date: August 5, 2026 +Platform: Linux x86_64 +Reporting timezone: `Asia/Jakarta` +Command Code data root: `~/.commandcode/` +Command Code version: `1.11.0` (npm `command-code`) + +This evidence supports the experimental Command Code native collector wired in +phase 4. It confirms Burnly can refresh from real local Command Code +transcripts, persist daily and session usage, and surface today's model totals +through the tray-summary query path, without persisting conversation content. + +## Privacy Note + +Burnly reads only top-level `usage`, `model`, `effort`, `timestamp`, `id`, +and `cwd` fields from `~/.commandcode/projects/**/.jsonl` transcripts. +It never reads `message.content` (prompts, responses, tool inputs, tool +outputs), `*.checkpoints.jsonl`, `history.jsonl`, `*.meta.json` titles, or +`auth.json`. + +Session IDs below are prefix-only. The privacy scan found zero matches for +conversation-bearing content in Burnly SQLite or the dev runtime log. + +## Local Command Code Source Shape + +```text +$ ls ~/.commandcode/projects/ +home-fikrilal-devs-personal-burnly/ +home-fikrilal-devs-side-lamara-lamara-frontend/ + +$ python3 - <<'EOF' # per-message usage blocks in transcripts +2026-08-04: msgs=32 total=3,203,889 in=1,722,335 out=13,650 cr=1,467,904 +2026-08-05: msgs=353 total=155,569,589 in=78,095,981 out=137,160 cr=77,336,448 +EOF +``` + +All usage is attributed to a single model: `deepseek/deepseek-v4-flash`. + +## Refresh Procedure + +1. Started `pnpm tauri dev` with the wired Command Code collector. +2. Startup refresh (trigger `launch`) ran at `2026-08-05 13:42:18` and + succeeded. +3. Burnly imported Command Code daily and session usage successfully. + +Import runs (source `command-code`): + +```text +projection status records_seen +daily succeeded (aggregated per Jakarta date) +session succeeded (per session) +``` + +## Persisted Daily Usage + +Burnly persisted Command Code daily usage for `2026-08-05` / `Asia/Jakarta`: + +```text +source_key=command-code:daily:v1:Asia/Jakarta:2026-08-05 +total_tokens=156,934,545 +input_tokens=78,778,283 +output_tokens=137,830 +cache_read_tokens=78,018,432 +cache_creation_tokens=0 +cost_amount_micros=11,286,010 (~$11.29 USD, provider-reported estimate) +data_quality=complete +``` + +Model breakdown: + +```text +model=deepseek/deepseek-v4-flash +total_tokens=156,934,545 +``` + +Totals are higher than the pre-refresh transcript aggregate because Command +Code appends usage continuously while `pnpm tauri dev` runs (this session +itself was active during the refresh). + +## Persisted Session Usage + +```text +sessions=3 +total_tokens=160,138,434 +``` + +Sanitized session prefixes: + +```text +command-code:session:v1:d8f83b9c-**** total_tokens=159,870,825 first=2026-08-04T13:40:02Z last=2026-08-05T13:42:17Z +command-code:session:v1:b858be05-**** total_tokens=157,524 first=2026-08-04T13:34:34Z last=2026-08-04T13:34:46Z +command-code:session:v1:9f61b7e3-**** total_tokens=110,085 first=2026-08-04T13:48:46Z last=2026-08-04T13:48:53Z +``` + +The dominant session (`d8f83b9c`) spans two Jakarta dates; its daily slice on +`2026-08-05` is the 156.9M daily row, while its full lifetime total is +159.9M (the rest fell on `2026-08-04`). + +## Tray Summary Query Path + +The tray-summary SQL path for `2026-08-05` / `Asia/Jakarta` returns: + +```text +model_name=deepseek/deepseek-v4-flash +source_keys=command-code:daily:v1:Asia/Jakarta:2026-08-05 +total_tokens=156,934,545 +``` + +## Privacy Scan + +Search for conversation-bearing markers in Burnly SQLite (command-code rows): + +```text +daily_usage ["can you explore"]: 0 +daily_usage ["explore little bit"]: 0 +daily_usage ["codebase"]: 0 +daily_usage ["shell_command"]: 0 +daily_usage ["prompt"]: 0 +daily_usage ["content"]: 0 +sessions [prompt-like session ids]: 0 +``` + +Dev runtime log matches for prompt/tool content: `0`. + +Diagnostics contain only sanitized codes and counters (e.g. +`antigravity.collection_completed`); no paths, session ids, or message ids. + +## Verification Commands + +```text +cargo test --manifest-path src-tauri/Cargo.toml --lib commandcode -- --nocapture +cargo test --manifest-path src-tauri/Cargo.toml --lib +pnpm rust:fmt +pnpm rust:check +pnpm architecture:check +pnpm harness:check +``` + +## Residual Risks + +- Legacy pre-1.11 transcripts carry no `usage` and are skipped; no historical + backfill before the Command Code 1.11 upgrade. +- Format is reverse-engineered (session `version: 3`) and may change upstream; + collector fails soft and skips incompatible lines. +- Linux-only evidence; macOS/Windows path layout assumed stable but + unverified. +- Cache-read tokens count toward the daily total as classified breakdown; + they are not additional "new" tokens in provider accounting. diff --git a/src-tauri/src/application/refresh/target.rs b/src-tauri/src/application/refresh/target.rs index 0506053..6c0c9ff 100644 --- a/src-tauri/src/application/refresh/target.rs +++ b/src-tauri/src/application/refresh/target.rs @@ -49,7 +49,7 @@ impl RefreshTarget { } /// All supported source/projection pairs refreshed by the coordinator. -pub(super) const fn refresh_targets() -> [RefreshTarget; 16] { +pub(super) const fn refresh_targets() -> [RefreshTarget; 18] { [ RefreshTarget { source: SourceKey::ClaudeCode, @@ -115,6 +115,14 @@ pub(super) const fn refresh_targets() -> [RefreshTarget; 16] { source: SourceKey::GrokBuild, projection: CollectionProjection::Session, }, + RefreshTarget { + source: SourceKey::CommandCode, + projection: CollectionProjection::Daily, + }, + RefreshTarget { + source: SourceKey::CommandCode, + projection: CollectionProjection::Session, + }, ] } @@ -158,20 +166,20 @@ mod tests { fn target_catalog_contains_each_supported_source_projection_pair() { let targets = refresh_targets(); - assert_eq!(targets.len(), 16); + assert_eq!(targets.len(), 18); assert_eq!( targets .iter() .filter(|target| target.projection == CollectionProjection::Daily) .count(), - 8 + 9 ); assert_eq!( targets .iter() .filter(|target| target.projection == CollectionProjection::Session) .count(), - 8 + 9 ); for source in [ @@ -183,6 +191,7 @@ mod tests { SourceKey::ZCode, SourceKey::Antigravity, SourceKey::GrokBuild, + SourceKey::CommandCode, ] { assert!(targets.iter().any(|target| target.source == source && target.projection == CollectionProjection::Daily)); diff --git a/src-tauri/src/application/usage/tray_summary.rs b/src-tauri/src/application/usage/tray_summary.rs index 39c8732..c9dffa5 100644 --- a/src-tauri/src/application/usage/tray_summary.rs +++ b/src-tauri/src/application/usage/tray_summary.rs @@ -312,6 +312,7 @@ fn source_label(source: SourceKey) -> &'static str { SourceKey::ZCode => "ZCode", SourceKey::Antigravity => "Antigravity", SourceKey::GrokBuild => "Grok Build", + SourceKey::CommandCode => "Command Code", #[cfg(test)] SourceKey::TestUnsupported => "Unsupported", } diff --git a/src-tauri/src/bootstrap/collectors.rs b/src-tauri/src/bootstrap/collectors.rs index b8dfcf7..f86fa45 100644 --- a/src-tauri/src/bootstrap/collectors.rs +++ b/src-tauri/src/bootstrap/collectors.rs @@ -5,6 +5,9 @@ use crate::application::ports::collector::Collector; use crate::infrastructure::collectors::antigravity::AntigravityCollector; use crate::infrastructure::collectors::ccusage::CcusageCollector; use crate::infrastructure::collectors::cline::ClineCollector; +use crate::infrastructure::collectors::commandcode::{ + default_commandcode_home, CommandCodeCollector, +}; use crate::infrastructure::collectors::grok::{ default_grok_home, GrokCollector, GrokUsageCacheClient, }; @@ -53,6 +56,13 @@ pub(super) fn build_collector_graph( diagnostic_recorder, usage_cache, )); + let commandcode_collector = Arc::new( + CommandCodeCollector::from_data_dir(default_commandcode_home()).with_diagnostic_recorder( + Arc::new(SqliteDiagnosticStore::new( + Database::open(database_path).map_err(StartupError::Persistence)?, + )), + ), + ); Ok(Arc::new(RoutedCollector::new( ccusage_collector, @@ -60,5 +70,6 @@ pub(super) fn build_collector_graph( zcode_collector, antigravity_collector, grok_collector, + commandcode_collector, ))) } diff --git a/src-tauri/src/bootstrap/test_support.rs b/src-tauri/src/bootstrap/test_support.rs index 4539c4e..3072796 100644 --- a/src-tauri/src/bootstrap/test_support.rs +++ b/src-tauri/src/bootstrap/test_support.rs @@ -130,6 +130,11 @@ pub(super) fn composed_refresh_collector(data_root: &Path) -> Arc data_root.join("missing-grok-home"), ), ), + Arc::new( + crate::infrastructure::collectors::commandcode::CommandCodeCollector::from_data_dir( + data_root.join("missing-commandcode-home"), + ), + ), )) } diff --git a/src-tauri/src/domain/source.rs b/src-tauri/src/domain/source.rs index 62f01b6..c976017 100644 --- a/src-tauri/src/domain/source.rs +++ b/src-tauri/src/domain/source.rs @@ -8,6 +8,7 @@ pub(crate) enum SourceKey { ZCode, Antigravity, GrokBuild, + CommandCode, #[cfg(test)] TestUnsupported, } @@ -23,6 +24,7 @@ impl SourceKey { Self::ZCode => "zcode", Self::Antigravity => "antigravity", Self::GrokBuild => "grok-build", + Self::CommandCode => "command-code", #[cfg(test)] Self::TestUnsupported => "test-unsupported", } @@ -38,6 +40,7 @@ impl SourceKey { "zcode" => Some(Self::ZCode), "antigravity" => Some(Self::Antigravity), "grok-build" => Some(Self::GrokBuild), + "command-code" => Some(Self::CommandCode), _ => None, } } @@ -57,6 +60,7 @@ mod tests { assert_eq!(SourceKey::ZCode.as_str(), "zcode"); assert_eq!(SourceKey::Antigravity.as_str(), "antigravity"); assert_eq!(SourceKey::GrokBuild.as_str(), "grok-build"); + assert_eq!(SourceKey::CommandCode.as_str(), "command-code"); } #[test] @@ -93,6 +97,10 @@ mod tests { SourceKey::from_storage(SourceKey::GrokBuild.as_str()), Some(SourceKey::GrokBuild) ); + assert_eq!( + SourceKey::from_storage(SourceKey::CommandCode.as_str()), + Some(SourceKey::CommandCode) + ); assert_eq!(SourceKey::from_storage("unknown"), None); } } diff --git a/src-tauri/src/infrastructure/collectors/ccusage/adapter.rs b/src-tauri/src/infrastructure/collectors/ccusage/adapter.rs index 34a727b..df3f8ea 100644 --- a/src-tauri/src/infrastructure/collectors/ccusage/adapter.rs +++ b/src-tauri/src/infrastructure/collectors/ccusage/adapter.rs @@ -245,7 +245,11 @@ impl Collector for CcusageCollector { .map_err(|_| failure(CollectorFailureCode::Internal)) } ( - SourceKey::Cline | SourceKey::ZCode | SourceKey::Antigravity | SourceKey::GrokBuild, + SourceKey::Cline + | SourceKey::ZCode + | SourceKey::Antigravity + | SourceKey::GrokBuild + | SourceKey::CommandCode, _, ) => Err(failure(CollectorFailureCode::UnsupportedSource)), (SourceKey::Pi, crate::application::collection::CollectionProjection::Daily) => { diff --git a/src-tauri/src/infrastructure/collectors/ccusage/source_registry.rs b/src-tauri/src/infrastructure/collectors/ccusage/source_registry.rs index 521a76d..4c07a81 100644 --- a/src-tauri/src/infrastructure/collectors/ccusage/source_registry.rs +++ b/src-tauri/src/infrastructure/collectors/ccusage/source_registry.rs @@ -62,13 +62,15 @@ pub(crate) fn source_descriptor( SourceKey::Codex => Ok(&CODEX), SourceKey::OpenCode => Ok(&OPENCODE), SourceKey::Pi => Ok(&PI), - SourceKey::Cline | SourceKey::ZCode | SourceKey::Antigravity | SourceKey::GrokBuild => { - Err(CollectorFailure::new( - crate::application::collection::CollectorFailureCode::UnsupportedSource, - Some(source), - None, - )) - } + SourceKey::Cline + | SourceKey::ZCode + | SourceKey::Antigravity + | SourceKey::GrokBuild + | SourceKey::CommandCode => Err(CollectorFailure::new( + crate::application::collection::CollectorFailureCode::UnsupportedSource, + Some(source), + None, + )), #[cfg(test)] SourceKey::TestUnsupported => Err(CollectorFailure::new( crate::application::collection::CollectorFailureCode::UnsupportedSource, @@ -130,6 +132,8 @@ mod tests { let antigravity = source_descriptor(SourceKey::Antigravity).expect_err("unsupported source"); let grok_build = source_descriptor(SourceKey::GrokBuild).expect_err("unsupported source"); + let command_code = + source_descriptor(SourceKey::CommandCode).expect_err("unsupported source"); assert_eq!( cline.code, @@ -147,5 +151,9 @@ mod tests { grok_build.code, crate::application::collection::CollectorFailureCode::UnsupportedSource ); + assert_eq!( + command_code.code, + crate::application::collection::CollectorFailureCode::UnsupportedSource + ); } } diff --git a/src-tauri/src/infrastructure/collectors/commandcode/adapter.rs b/src-tauri/src/infrastructure/collectors/commandcode/adapter.rs new file mode 100644 index 0000000..3aca9da --- /dev/null +++ b/src-tauri/src/infrastructure/collectors/commandcode/adapter.rs @@ -0,0 +1,560 @@ +//! Command Code collector adapter. +//! +//! Wires the transcript reader, parser, and mapper into the collector port. +//! Collection reads `~/.commandcode/projects/**/.jsonl` transcripts +//! read-only and maps usage-bearing messages into Burnly daily/session +//! candidates. No durable cache: transcripts are re-read per refresh with +//! `(session id, message id)` dedupe in the mapper. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use chrono::Utc; + +use crate::application::collection::{ + CollectionProjection, CollectionRequest, CollectionResult, CollectorDescriptor, + CollectorFailure, CollectorFailureCode, CollectorIntegrity, DetectionIssue, DetectionRequest, + DetectionResult, +}; +use crate::application::diagnostics::DiagnosticSeverity; +use crate::application::ports::collector::{CancellationSignal, Collector}; +use crate::application::ports::diagnostic_recorder::DiagnosticRecorder; +use crate::domain::source::SourceKey; +use crate::infrastructure::collectors::support::{ + available_detection, cancelled_detection, collection_metadata, collector_key, + daily_session_projections, detection_issue, empty_collection_result, not_found_detection, + record_collector_diagnostic, request_failure, single_source_descriptor, unsupported_detection, + validate_source, validation_failure_as_internal, CollectorDiagnosticCounter, CollectorIdentity, + LocalCollectionRun, +}; + +use super::detection::{inspect_commandcode_home, CommandCodeHomeInspection}; +use super::mapper::{self, CommandCodeMappingContext}; +use super::transcript_reader::TranscriptReader; + +const COLLECTOR_KEY: &str = "command-code"; +const DISPLAY_NAME: &str = "Command Code"; +const COLLECTOR_VERSION: &str = "local"; +const ADAPTER_VERSION: u16 = 1; +const PROFILE_VERSION: u16 = 1; +const IDENTITY: CollectorIdentity = CollectorIdentity { + key: COLLECTOR_KEY, + display_name: DISPLAY_NAME, + runtime_version: COLLECTOR_VERSION, + adapter_version: ADAPTER_VERSION, + source: SourceKey::CommandCode, + profile_version: PROFILE_VERSION, +}; + +/// Issue codes emitted by Command Code detection. +pub(crate) const ISSUE_HOME_MISSING: &str = "commandcode.home_missing"; +pub(crate) const ISSUE_PROJECTS_MISSING: &str = "commandcode.projects_missing"; +pub(crate) const ISSUE_PROJECTS_UNREADABLE: &str = "commandcode.projects_unreadable"; +pub(crate) const ISSUE_NO_USAGE_TRANSCRIPTS: &str = "commandcode.no_usage_transcripts"; +pub(crate) const ISSUE_LEGACY_ONLY_TRANSCRIPTS: &str = "commandcode.legacy_only_transcripts"; + +#[derive(Clone)] +pub(crate) struct CommandCodeCollector { + commandcode_home: PathBuf, + diagnostics: Option>, +} + +impl CommandCodeCollector { + pub(crate) fn from_data_dir(commandcode_home: PathBuf) -> Self { + Self { + commandcode_home, + diagnostics: None, + } + } + + pub(crate) fn with_diagnostic_recorder( + mut self, + diagnostics: Arc, + ) -> Self { + self.diagnostics = Some(diagnostics); + self + } + + fn inspect(&self, override_path: Option<&Path>) -> CommandCodeHomeInspection { + inspect_commandcode_home(override_path) + } + + fn inspect_stored_home(&self) -> CommandCodeHomeInspection { + self.inspect(Some(&self.commandcode_home)) + } + + fn detection_issues(&self, inspection: &CommandCodeHomeInspection) -> Vec { + let mut issues = Vec::new(); + if !inspection.commandcode_home_exists { + issues.push(detection_issue( + ISSUE_HOME_MISSING, + "Command Code data directory was not found.", + )); + } else if !inspection.projects_root_exists { + issues.push(detection_issue( + ISSUE_PROJECTS_MISSING, + "Command Code projects directory was not found.", + )); + } else if !inspection.projects_root_readable { + issues.push(detection_issue( + ISSUE_PROJECTS_UNREADABLE, + "Command Code projects directory is not readable by Burnly.", + )); + } else if inspection.new_format_transcripts == 0 && inspection.legacy_transcripts > 0 { + issues.push(detection_issue( + ISSUE_LEGACY_ONLY_TRANSCRIPTS, + "Only legacy Command Code transcripts were found; they contain no usage data.", + )); + } else if !inspection.has_usage_transcripts { + issues.push(detection_issue( + ISSUE_NO_USAGE_TRANSCRIPTS, + "No Command Code transcripts with usage data were found.", + )); + } + issues + } +} + +impl Collector for CommandCodeCollector { + fn describe(&self) -> Result { + single_source_descriptor( + IDENTITY, + supported_projections(), + CollectorIntegrity::UnverifiedDevelopment, + ) + } + + fn detect( + &self, + request: DetectionRequest, + cancellation: &dyn CancellationSignal, + ) -> Result { + if request.source != SourceKey::CommandCode { + return Ok(unsupported_detection( + &request, + detection_issue( + "commandcode.unsupported_source", + "Source is not Command Code.", + ), + )); + } + if cancellation.is_cancelled() { + return Ok(cancelled_detection(&request)); + } + + let inspection = self.inspect_stored_home(); + let issues = self.detection_issues(&inspection); + + if issues.is_empty() { + return Ok(available_detection( + &request, + SourceKey::CommandCode, + supported_projections(), + true, + )); + } + + let first_issue = issues[0].clone(); + // The data root exists but holds no usable usage data yet; the source + // is installed, just not collecting. + if first_issue.code == ISSUE_LEGACY_ONLY_TRANSCRIPTS + || first_issue.code == ISSUE_NO_USAGE_TRANSCRIPTS + { + let mut result = available_detection( + &request, + SourceKey::CommandCode, + supported_projections(), + false, + ); + result.issues.push(first_issue); + return Ok(result); + } + // The data root itself is absent or unreadable. + Ok(not_found_detection( + &request, + SourceKey::CommandCode, + supported_projections(), + first_issue, + )) + } + + fn collect( + &self, + request: CollectionRequest, + cancellation: &dyn CancellationSignal, + ) -> Result { + let run = LocalCollectionRun::start(); + validate_source(&request, SourceKey::CommandCode)?; + if cancellation.is_cancelled() { + return Err(request_failure(&request, CollectorFailureCode::Cancelled)); + } + if !self.commandcode_home.is_dir() { + self.record_failure( + &request, + CollectorFailureCode::SourceNotFound, + &[CollectorDiagnosticCounter::new("rowsFound", 0)], + ); + return empty_collection_result(IDENTITY, &request, &run); + } + + if cancellation.is_cancelled() { + return Err(request_failure(&request, CollectorFailureCode::Cancelled)); + } + + let (_, parsed, _) = TranscriptReader::scan(&self.commandcode_home); + let rows_found = u64::try_from(parsed.len()).unwrap_or(u64::MAX); + + let finished_at = Utc::now(); + let metadata = collection_metadata(IDENTITY, &request, run.started_at(), finished_at)?; + let context = CommandCodeMappingContext::new( + collector_key(IDENTITY)?, + COLLECTOR_VERSION.to_owned(), + request.collection_id().clone(), + finished_at, + ) + .map_err(|_| request_failure(&request, CollectorFailureCode::Internal))?; + let process_summary = run.process_summary(); + + match request.projection() { + CollectionProjection::Daily => { + let timezone = request.aggregation_timezone().ok_or_else(|| { + request_failure(&request, CollectorFailureCode::ScopeNotRepresentable) + })?; + let candidates = mapper::map_daily(parsed, timezone, request.scope(), &context) + .map_err(|_| { + self.record_failure( + &request, + CollectorFailureCode::IncompatibleEnvelope, + &[CollectorDiagnosticCounter::new("rowsFound", rows_found)], + ); + request_failure(&request, CollectorFailureCode::IncompatibleEnvelope) + })?; + CollectionResult::daily( + metadata, + candidates, + Vec::new(), + Vec::new(), + process_summary, + ) + .map_err(|error| { + let failure = validation_failure_as_internal(&request, error); + self.record_failure( + &request, + failure.code, + &[CollectorDiagnosticCounter::new("rowsFound", rows_found)], + ); + failure + }) + } + CollectionProjection::Session => { + let candidates = mapper::map_sessions(parsed, &context).map_err(|_| { + self.record_failure( + &request, + CollectorFailureCode::IncompatibleEnvelope, + &[CollectorDiagnosticCounter::new("rowsFound", rows_found)], + ); + request_failure(&request, CollectorFailureCode::IncompatibleEnvelope) + })?; + CollectionResult::session( + metadata, + candidates, + Vec::new(), + Vec::new(), + process_summary, + ) + .map_err(|error| { + let failure = validation_failure_as_internal(&request, error); + self.record_failure( + &request, + failure.code, + &[CollectorDiagnosticCounter::new("rowsFound", rows_found)], + ); + failure + }) + } + } + } +} + +impl CommandCodeCollector { + fn record_failure( + &self, + request: &CollectionRequest, + code: CollectorFailureCode, + counters: &[CollectorDiagnosticCounter], + ) { + record_collector_diagnostic( + self.diagnostics.as_deref(), + request, + DiagnosticSeverity::Warning, + "commandcode.collection_failed", + "Command Code collection failed.", + Some(code), + counters, + ); + } +} + +fn supported_projections() -> Vec { + daily_session_projections() +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::sync::Arc; + + use chrono::{DateTime, Utc}; + use tempfile::TempDir; + + use super::*; + use crate::application::collection::{ + CollectionId, CollectionOutcome, CollectionScope, DetectionState, + }; + use crate::application::diagnostics::DiagnosticSeverity; + use crate::infrastructure::collectors::support::{ + daily_request as support_daily_request, detection_request, fixed_timestamp, + session_request as support_session_request, NeverCancelled, RecordingDiagnostics, + }; + + const VALID_TRANSCRIPT: &str = r#"{"type":"session","version":3,"id":"sess-1","timestamp":"2026-08-04T10:00:00Z","cwd":"/tmp/proj"} +{"type":"message","id":"m1","parentId":null,"timestamp":"2026-08-04T10:00:01Z","message":{"role":"user","content":[{"type":"text","text":"redacted"}]}} +{"type":"message","id":"m2","parentId":"m1","timestamp":"2026-08-04T10:00:02Z","message":{"role":"assistant","content":[{"type":"text","text":"redacted"}]},"usage":{"inputTokens":10,"outputTokens":2,"cacheReadTokens":3,"cacheWriteTokens":0,"costUsd":0.001},"model":"deepseek/deepseek-v4-flash","effort":"max"}"#; + + #[test] + fn describes_command_code_profile() { + let collector = CommandCodeCollector::from_data_dir(PathBuf::from("/missing")); + + let descriptor = collector.describe().expect("descriptor"); + + assert_eq!(descriptor.display_name, "Command Code"); + assert_eq!(descriptor.profiles[0].source, SourceKey::CommandCode); + assert_eq!( + descriptor.profiles[0].supported_projections, + vec![CollectionProjection::Daily, CollectionProjection::Session] + ); + } + + #[test] + fn detects_available_with_valid_transcript() { + let fixture = FixtureCommandCode::new().with_valid_transcript(); + let collector = CommandCodeCollector::from_data_dir(fixture.commandcode_home()); + + let result = collector + .detect( + detection_request(SourceKey::CommandCode, timestamp()), + &NeverCancelled, + ) + .expect("detection"); + + assert_eq!(result.state, DetectionState::Available); + assert!(result.usage_artifacts_found); + } + + #[test] + fn detects_available_no_data_with_legacy_only_transcripts() { + let fixture = FixtureCommandCode::new().with_legacy_transcript(); + let collector = CommandCodeCollector::from_data_dir(fixture.commandcode_home()); + + let result = collector + .detect( + detection_request(SourceKey::CommandCode, timestamp()), + &NeverCancelled, + ) + .expect("detection"); + + assert_eq!(result.state, DetectionState::AvailableNoData); + assert!(!result.usage_artifacts_found); + assert_eq!(result.issues[0].code, ISSUE_LEGACY_ONLY_TRANSCRIPTS); + } + + #[test] + fn detects_not_found_when_home_missing() { + let collector = CommandCodeCollector::from_data_dir(PathBuf::from("/missing/commandcode")); + + let result = collector + .detect( + detection_request(SourceKey::CommandCode, timestamp()), + &NeverCancelled, + ) + .expect("detection"); + + assert_eq!(result.state, DetectionState::NotFound); + assert_eq!(result.issues[0].code, ISSUE_HOME_MISSING); + } + + #[test] + fn rejects_non_commandcode_source_in_detection() { + let fixture = FixtureCommandCode::new().with_valid_transcript(); + let collector = CommandCodeCollector::from_data_dir(fixture.commandcode_home()); + + let result = collector + .detect( + detection_request(SourceKey::Cline, timestamp()), + &NeverCancelled, + ) + .expect("detection"); + + assert_eq!(result.state, DetectionState::Unsupported); + assert_eq!(result.issues[0].code, "commandcode.unsupported_source"); + } + + #[test] + fn records_diagnostic_when_commandcode_home_is_missing() { + let diagnostics = Arc::new(RecordingDiagnostics::default()); + let collector = CommandCodeCollector::from_data_dir(PathBuf::from("/missing/commandcode")) + .with_diagnostic_recorder(diagnostics.clone()); + + let result = collector + .collect(daily_request(CollectionScope::Full), &NeverCancelled) + .expect("missing home is empty"); + + assert_eq!(result.outcome(), CollectionOutcome::Empty); + let events = diagnostics.events(); + assert_eq!(events.len(), 1); + assert_eq!(events[0].severity, DiagnosticSeverity::Warning); + assert_eq!(events[0].code.as_str(), "commandcode.collection_failed"); + let context = events[0].context.as_ref().expect("context").as_str(); + assert!(context.contains(r#""source":"command-code""#)); + assert!(context.contains(r#""projection":"daily""#)); + assert!(context.contains(r#""failureCode":"source.not_found""#)); + assert!(context.contains(r#""rowsFound":0"#)); + assert!(!context.contains("/missing")); + } + + #[test] + fn rejects_non_commandcode_collection_request() { + let fixture = FixtureCommandCode::new().with_valid_transcript(); + let collector = CommandCodeCollector::from_data_dir(fixture.commandcode_home()); + + let error = collector + .collect( + CollectionRequest::session( + CollectionId::new("wrong").expect("collection"), + SourceKey::Cline, + CollectionScope::Full, + timestamp(), + ), + &NeverCancelled, + ) + .expect_err("unsupported source"); + + assert_eq!(error.code, CollectorFailureCode::UnsupportedSource); + } + + #[test] + fn collects_daily_usage_from_transcripts() { + let fixture = FixtureCommandCode::new().with_valid_transcript(); + let collector = CommandCodeCollector::from_data_dir(fixture.commandcode_home()); + + let result = collector + .collect(daily_request(CollectionScope::Full), &NeverCancelled) + .expect("daily collection"); + + assert_eq!(result.outcome(), CollectionOutcome::Complete); + assert_eq!(result.daily_candidates().len(), 1); + let daily = &result.daily_candidates()[0]; + assert_eq!( + daily.source_key, + "command-code:daily:v1:Asia/Jakarta:2026-08-04" + ); + assert_eq!(daily.tokens.input_tokens(), Some(10)); + assert_eq!(daily.tokens.output_tokens(), Some(2)); + assert_eq!(daily.tokens.cache_read_tokens(), Some(3)); + assert_eq!(daily.tokens.total_tokens(), 15); + assert_eq!(daily.model_breakdowns.len(), 1); + assert_eq!( + daily.model_breakdowns[0].raw_model_id, + "deepseek/deepseek-v4-flash" + ); + } + + #[test] + fn collects_session_usage_from_transcripts() { + let fixture = FixtureCommandCode::new().with_valid_transcript(); + let collector = CommandCodeCollector::from_data_dir(fixture.commandcode_home()); + + let result = collector + .collect(session_request(), &NeverCancelled) + .expect("session collection"); + + assert_eq!(result.outcome(), CollectionOutcome::Complete); + assert_eq!(result.session_candidates().len(), 1); + let session = &result.session_candidates()[0]; + assert!(session + .source_key + .starts_with("command-code:session:v1:sess-1:")); + assert_eq!(session.source_session_id, "sess-1"); + assert_eq!(session.project_path.as_deref(), Some("/tmp/proj")); + assert_eq!( + session.first_activity_at, + Some(fixed_timestamp(2026, 8, 4, 10, 0, 2)) + ); + assert_eq!( + session.last_activity_at, + Some(fixed_timestamp(2026, 8, 4, 10, 0, 2)) + ); + } + + struct FixtureCommandCode { + workspace: TempDir, + } + + impl FixtureCommandCode { + fn new() -> Self { + Self { + workspace: TempDir::new().expect("workspace"), + } + } + + fn commandcode_home(&self) -> PathBuf { + self.workspace.path().to_path_buf() + } + + fn with_valid_transcript(self) -> Self { + fs::create_dir_all(self.commandcode_home().join("projects").join("proj-a")) + .expect("projects dir"); + fs::write( + self.commandcode_home() + .join("projects") + .join("proj-a") + .join("sess-1.jsonl"), + VALID_TRANSCRIPT, + ) + .expect("transcript"); + self + } + + fn with_legacy_transcript(self) -> Self { + fs::create_dir_all(self.commandcode_home().join("projects").join("proj-legacy")) + .expect("projects dir"); + fs::write( + self.commandcode_home() + .join("projects") + .join("proj-legacy") + .join("sess-legacy.jsonl"), + r#"{"id":"legacy-1","timestamp":"2026-05-07T03:23:01Z","sessionId":"sess-legacy","parentId":null,"role":"user","content":[{"type":"text","text":"redacted"}]}"#, + ) + .expect("transcript"); + self + } + } + + fn daily_request(scope: CollectionScope) -> CollectionRequest { + support_daily_request( + "command-code-daily", + SourceKey::CommandCode, + scope, + "Asia/Jakarta", + timestamp(), + ) + } + + fn session_request() -> CollectionRequest { + support_session_request("command-code-session", SourceKey::CommandCode, timestamp()) + } + + fn timestamp() -> DateTime { + fixed_timestamp(2026, 8, 4, 12, 0, 0) + } +} diff --git a/src-tauri/src/infrastructure/collectors/commandcode/commandcode_home.rs b/src-tauri/src/infrastructure/collectors/commandcode/commandcode_home.rs new file mode 100644 index 0000000..8ac0969 --- /dev/null +++ b/src-tauri/src/infrastructure/collectors/commandcode/commandcode_home.rs @@ -0,0 +1,52 @@ +//! Command Code data root resolution. + +use std::path::{Path, PathBuf}; + +pub(crate) fn resolve_commandcode_home(override_path: Option<&Path>) -> PathBuf { + override_path + .map(Path::to_path_buf) + .or_else(|| std::env::var_os("COMMANDCODE_HOME").map(PathBuf::from)) + .or_else(default_home_commandcode_dir) + .unwrap_or_else(|| PathBuf::from(".commandcode")) +} + +#[allow(dead_code, reason = "used by a later adapter chunk")] +pub(crate) fn default_commandcode_home() -> PathBuf { + resolve_commandcode_home(None) +} + +pub(crate) fn projects_root(commandcode_home: &Path) -> PathBuf { + commandcode_home.join("projects") +} + +fn default_home_commandcode_dir() -> Option { + home_directory().map(|directory| directory.join(".commandcode")) +} + +fn home_directory() -> Option { + std::env::var_os("HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("USERPROFILE").map(PathBuf::from)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolves_explicit_override_before_environment() { + let resolved = resolve_commandcode_home(Some(Path::new("/override/commandcode"))); + + assert_eq!(resolved, PathBuf::from("/override/commandcode")); + } + + #[test] + fn resolves_projects_root_under_commandcode_home() { + let commandcode_home = PathBuf::from("/tmp/commandcode"); + + assert_eq!( + projects_root(&commandcode_home), + PathBuf::from("/tmp/commandcode/projects") + ); + } +} diff --git a/src-tauri/src/infrastructure/collectors/commandcode/detection.rs b/src-tauri/src/infrastructure/collectors/commandcode/detection.rs new file mode 100644 index 0000000..bde5386 --- /dev/null +++ b/src-tauri/src/infrastructure/collectors/commandcode/detection.rs @@ -0,0 +1,290 @@ +//! Command Code data-root inspection and detection. +//! +//! Detection is read-only and filesystem-based. A source is considered +//! available when the projects root contains at least one new-format session +//! transcript (a `type: session` record plus at least one message carrying a +//! `usage` block). Legacy transcripts (flat records without a `type` field) +//! carry no usage data and are reported separately. + +use std::fs; +use std::path::{Path, PathBuf}; + +use super::commandcode_home::{projects_root, resolve_commandcode_home}; + +/// Snapshot of a Command Code data root used by detection. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CommandCodeHomeInspection { + pub(crate) commandcode_home: PathBuf, + pub(crate) commandcode_home_exists: bool, + pub(crate) projects_root_exists: bool, + pub(crate) projects_root_readable: bool, + /// Number of new-format session transcripts found under `projects/`. + pub(crate) new_format_transcripts: u32, + /// Number of legacy-format transcripts found under `projects/`. + pub(crate) legacy_transcripts: u32, + /// True when at least one new-format transcript carries usage. + pub(crate) has_usage_transcripts: bool, +} + +pub(crate) fn inspect_commandcode_home(override_path: Option<&Path>) -> CommandCodeHomeInspection { + let commandcode_home = resolve_commandcode_home(override_path); + let commandcode_home_exists = commandcode_home.is_dir(); + let projects = projects_root(&commandcode_home); + let projects_root_exists = projects.is_dir(); + let projects_root_readable = fs::read_dir(&projects).is_ok(); + + let (new_format_transcripts, legacy_transcripts, has_usage_transcripts) = + scan_projects(&projects, projects_root_readable); + + CommandCodeHomeInspection { + commandcode_home, + commandcode_home_exists, + projects_root_exists, + projects_root_readable, + new_format_transcripts, + legacy_transcripts, + has_usage_transcripts, + } +} + +fn scan_projects(projects: &Path, readable: bool) -> (u32, u32, bool) { + if !readable { + return (0, 0, false); + } + + let mut new_format = 0_u32; + let mut legacy = 0_u32; + let mut has_usage = false; + + let Ok(project_entries) = fs::read_dir(projects) else { + return (0, 0, false); + }; + for project_entry in project_entries.flatten() { + if !project_entry + .file_type() + .map(|file_type| file_type.is_dir()) + .unwrap_or(false) + { + continue; + } + let Ok(transcript_entries) = fs::read_dir(project_entry.path()) else { + continue; + }; + for transcript_entry in transcript_entries.flatten() { + let path = transcript_entry.path(); + if !path.extension().map(|ext| ext == "jsonl").unwrap_or(false) { + continue; + } + if path + .file_name() + .map(|name| name.to_string_lossy().contains(".checkpoints.")) + .unwrap_or(false) + { + continue; + } + match classify_transcript(&path) { + TranscriptKind::NewFormatWithUsage => { + new_format += 1; + has_usage = true; + } + TranscriptKind::NewFormatNoUsage => new_format += 1, + TranscriptKind::Legacy => legacy += 1, + TranscriptKind::UnreadableOrEmpty => {} + } + } + } + + (new_format, legacy, has_usage) +} + +enum TranscriptKind { + NewFormatWithUsage, + NewFormatNoUsage, + Legacy, + UnreadableOrEmpty, +} + +fn classify_transcript(path: &Path) -> TranscriptKind { + let Ok(lines) = read_transcript_lines(path) else { + return TranscriptKind::UnreadableOrEmpty; + }; + + let mut saw_type_field = false; + let mut saw_session_record = false; + let mut saw_usage = false; + + for line in lines { + // A trailing partial line from a live append is not a failure; treat + // it as unparseable and skip it. + let Ok(value) = serde_json::from_str::(&line) else { + continue; + }; + let Some(obj) = value.as_object() else { + continue; + }; + if obj.contains_key("type") { + saw_type_field = true; + } + if obj.get("type").and_then(|t| t.as_str()) == Some("session") { + saw_session_record = true; + } + if obj.contains_key("usage") { + saw_usage = true; + } + } + + match (saw_type_field, saw_session_record, saw_usage) { + (true, true, true) => TranscriptKind::NewFormatWithUsage, + (true, _, _) => TranscriptKind::NewFormatNoUsage, + // Flat records without a `type` field are the pre-1.11 legacy schema. + (false, _, _) => TranscriptKind::Legacy, + } +} + +fn read_transcript_lines(path: &Path) -> std::io::Result> { + fs::read_to_string(path) + .map(|contents| contents.lines().map(str::to_owned).collect::>()) +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::TempDir; + + use super::*; + + const NEW_FORMAT_WITH_USAGE: &str = r#"{"type":"session","version":3,"id":"sess-1","timestamp":"2026-08-04T10:00:00Z","cwd":"/tmp/proj"} +{"type":"message","id":"m1","parentId":null,"timestamp":"2026-08-04T10:00:01Z","message":{"role":"user","content":[{"type":"text","text":"hi"}]}} +{"type":"message","id":"m2","parentId":"m1","timestamp":"2026-08-04T10:00:02Z","message":{"role":"assistant","content":[{"type":"text","text":"hello"}]},"usage":{"inputTokens":10,"outputTokens":2,"cacheReadTokens":3,"cacheWriteTokens":0,"costUsd":0.001},"model":"deepseek/deepseek-v4-flash","effort":"max"}"#; + + const NEW_FORMAT_NO_USAGE: &str = r#"{"type":"session","version":3,"id":"sess-2","timestamp":"2026-08-04T10:00:00Z","cwd":"/tmp/proj"} +{"type":"message","id":"m1","parentId":null,"timestamp":"2026-08-04T10:00:01Z","message":{"role":"user","content":[{"type":"text","text":"hi"}]}}"#; + + const LEGACY_FORMAT: &str = r#"{"id":"legacy-1","timestamp":"2026-05-07T03:23:01Z","sessionId":"sess-legacy","parentId":null,"role":"user","content":[{"type":"text","text":"hi"}]}"#; + + fn write_transcript(project_dir: &Path, name: &str, contents: &str) { + fs::create_dir_all(project_dir).expect("project dir"); + fs::write(project_dir.join(name), contents).expect("transcript"); + } + + #[test] + fn inspects_commandcode_home_from_environment_override() { + let temp = TempDir::new().expect("temp dir"); + let commandcode_home = temp.path().join("commandcode-home"); + fs::create_dir_all(commandcode_home.join("projects").join("proj-a")).expect("projects dir"); + write_transcript( + &commandcode_home.join("projects").join("proj-a"), + "sess-1.jsonl", + NEW_FORMAT_WITH_USAGE, + ); + + let previous = std::env::var_os("COMMANDCODE_HOME"); + std::env::set_var("COMMANDCODE_HOME", &commandcode_home); + let inspection = inspect_commandcode_home(None); + restore_env("COMMANDCODE_HOME", previous); + + assert_eq!(inspection.commandcode_home, commandcode_home); + assert!(inspection.commandcode_home_exists); + assert!(inspection.projects_root_exists); + assert!(inspection.projects_root_readable); + assert_eq!(inspection.new_format_transcripts, 1); + assert_eq!(inspection.legacy_transcripts, 0); + assert!(inspection.has_usage_transcripts); + } + + #[test] + fn reports_not_found_when_projects_root_is_missing() { + let temp = TempDir::new().expect("temp dir"); + let commandcode_home = temp.path().join("missing"); + + let inspection = inspect_commandcode_home(Some(&commandcode_home)); + + assert!(!inspection.commandcode_home_exists); + assert!(!inspection.projects_root_exists); + assert_eq!(inspection.new_format_transcripts, 0); + assert!(!inspection.has_usage_transcripts); + } + + #[test] + fn reports_available_no_data_when_only_legacy_transcripts_exist() { + let temp = TempDir::new().expect("temp dir"); + let commandcode_home = temp.path().join("commandcode-home"); + write_transcript( + &commandcode_home.join("projects").join("proj-legacy"), + "sess-legacy.jsonl", + LEGACY_FORMAT, + ); + + let inspection = inspect_commandcode_home(Some(&commandcode_home)); + + assert_eq!(inspection.new_format_transcripts, 0); + assert_eq!(inspection.legacy_transcripts, 1); + assert!(!inspection.has_usage_transcripts); + } + + #[test] + fn counts_new_format_transcripts_with_and_without_usage() { + let temp = TempDir::new().expect("temp dir"); + let commandcode_home = temp.path().join("commandcode-home"); + write_transcript( + &commandcode_home.join("projects").join("proj-a"), + "sess-1.jsonl", + NEW_FORMAT_WITH_USAGE, + ); + write_transcript( + &commandcode_home.join("projects").join("proj-b"), + "sess-2.jsonl", + NEW_FORMAT_NO_USAGE, + ); + + let inspection = inspect_commandcode_home(Some(&commandcode_home)); + + assert_eq!(inspection.new_format_transcripts, 2); + assert!(inspection.has_usage_transcripts); + } + + #[test] + fn skips_checkpoint_files_when_scanning() { + let temp = TempDir::new().expect("temp dir"); + let commandcode_home = temp.path().join("commandcode-home"); + write_transcript( + &commandcode_home.join("projects").join("proj-a"), + "sess-1.jsonl", + NEW_FORMAT_WITH_USAGE, + ); + write_transcript( + &commandcode_home.join("projects").join("proj-a"), + "sess-1.checkpoints.jsonl", + r#"{"id":"cp-1","messageId":"cp-1","turnNumber":1,"createdAt":"2026-08-04T10:00:00Z","prompt":"secret"}"#, + ); + + let inspection = inspect_commandcode_home(Some(&commandcode_home)); + + assert_eq!(inspection.new_format_transcripts, 1); + assert_eq!(inspection.legacy_transcripts, 0); + } + + #[test] + fn tolerates_partial_trailing_line_in_new_format() { + let temp = TempDir::new().expect("temp dir"); + let commandcode_home = temp.path().join("commandcode-home"); + write_transcript( + &commandcode_home.join("projects").join("proj-a"), + "sess-1.jsonl", + &format!("{NEW_FORMAT_WITH_USAGE}\n{{\"type\":\"message\""), + ); + + let inspection = inspect_commandcode_home(Some(&commandcode_home)); + + assert_eq!(inspection.new_format_transcripts, 1); + assert!(inspection.has_usage_transcripts); + } + + fn restore_env(key: &str, value: Option) { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } +} diff --git a/src-tauri/src/infrastructure/collectors/commandcode/mapper.rs b/src-tauri/src/infrastructure/collectors/commandcode/mapper.rs new file mode 100644 index 0000000..01a0d36 --- /dev/null +++ b/src-tauri/src/infrastructure/collectors/commandcode/mapper.rs @@ -0,0 +1,567 @@ +//! Command Code usage mapping. +//! +//! Maps parsed transcript usage into Burnly daily and session candidates. +//! Token fields map directly (input/output/cache-read/cache-write; canonical +//! total is their sum), `costUsd` converts to integer micros deterministically, +//! and records dedupe by `(session id, message id)`. + +use std::collections::{BTreeMap, BTreeSet}; + +use chrono::{DateTime, NaiveDate, Utc}; +use chrono_tz::Tz; +use thiserror::Error; + +use crate::{ + application::collection::{ + CandidateProvenance, CollectionId, CollectionScope, CollectorKey, DailyUsageCandidate, + ModelUsageCandidate, SessionUsageCandidate, + }, + domain::{ + identity::{daily_source_key, session_source_key, IdentityError}, + source::SourceKey, + usage::{ + CostKind, CurrencyCode, TokenUsage, UsageCost, UsageValidationError, ValuedCostStatus, + }, + }, +}; + +use super::super::support::{ + checked_add_u64, date_in_scope, local_date_from_millis, provenance, MappingIdentity, +}; +use super::transcript_parser::{ParsedTranscript, TranscriptUsage}; + +const PROFILE_VERSION: u16 = 1; +const USD_MICROS_PER_DOLLAR: f64 = 1_000_000.0; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CommandCodeMappingContext { + collector: CollectorKey, + collector_version: String, + collection_id: CollectionId, + observed_at: DateTime, +} + +impl CommandCodeMappingContext { + pub(crate) fn new( + collector: CollectorKey, + collector_version: String, + collection_id: CollectionId, + observed_at: DateTime, + ) -> Result { + if collector_version.trim().is_empty() { + return Err(CommandCodeMappingError::EmptyCollectorVersion); + } + Ok(Self { + collector, + collector_version, + collection_id, + observed_at, + }) + } + + fn provenance(&self) -> CandidateProvenance { + provenance(&MappingIdentity { + source: SourceKey::CommandCode, + collector: self.collector.clone(), + collector_version: self.collector_version.clone(), + profile_version: PROFILE_VERSION, + collection_id: self.collection_id.clone(), + observed_at: self.observed_at, + }) + } +} + +/// Map parsed transcripts into daily and session candidates for the scope. +pub(crate) fn map_daily( + transcripts: Vec, + timezone: &str, + scope: &CollectionScope, + context: &CommandCodeMappingContext, +) -> Result, CommandCodeMappingError> { + let timezone = timezone + .parse::() + .map_err(|_| CommandCodeMappingError::InvalidTimezone)?; + let mut buckets = BTreeMap::::new(); + + for transcript in transcripts { + for usage in dedupe_usages(&transcript) { + let usage_date = local_date_from_millis( + usage.timestamp.timestamp_millis(), + timezone, + CommandCodeMappingError::InvalidTimestamp, + )?; + if !date_in_scope(usage_date, scope) { + continue; + } + buckets.entry(usage_date).or_default().add(usage)?; + } + } + + buckets + .into_iter() + .map(|(usage_date, bucket)| { + let tokens = bucket.total.tokens()?; + let aggregate_cost = cost(bucket.total.cost_micros, tokens.total_tokens()); + let model_breakdowns = bucket + .models + .into_iter() + .map(|(model, usage)| { + let tokens = usage.tokens()?; + let cost = cost(usage.cost_micros, tokens.total_tokens()); + Ok(ModelUsageCandidate { + raw_model_id: model, + tokens, + cost, + }) + }) + .collect::, CommandCodeMappingError>>()?; + Ok(DailyUsageCandidate { + provenance: context.provenance(), + source_key: daily_source_key(SourceKey::CommandCode, usage_date, timezone.name())?, + usage_date, + aggregation_timezone: timezone.name().to_owned(), + tokens, + cost: aggregate_cost, + model_breakdowns, + }) + }) + .collect() +} + +/// Map usage-bearing messages to session candidates grouped by +/// `(session id, model)`. +pub(crate) fn map_sessions( + transcripts: Vec, + context: &CommandCodeMappingContext, +) -> Result, CommandCodeMappingError> { + let mut buckets = BTreeMap::<(String, String), CommandCodeSessionAccumulator>::new(); + + for transcript in transcripts { + for usage in dedupe_usages(&transcript) { + let model = usage.model.clone().unwrap_or_else(|| "unknown".to_owned()); + buckets + .entry((transcript.session_id.clone(), model.clone())) + .or_insert_with(|| { + CommandCodeSessionAccumulator::new( + transcript.session_id.clone(), + model.clone(), + transcript.cwd.clone(), + ) + }) + .add(usage)?; + } + } + + buckets + .into_values() + .map(|bucket| bucket.candidate(context)) + .collect() +} + +/// Dedupe usage records by `(session id, message id)`. Messages are +/// file-scoped, so the session id is part of the key. +fn dedupe_usages(transcript: &ParsedTranscript) -> Vec<&TranscriptUsage> { + let mut seen = BTreeSet::new(); + transcript + .usages + .iter() + .filter(|usage| seen.insert((transcript.session_id.as_str(), usage.message_id.as_str()))) + .collect() +} + +#[derive(Debug, Default)] +struct CommandCodeDailyBucket { + total: CommandCodeUsageAccumulator, + models: BTreeMap, +} + +impl CommandCodeDailyBucket { + fn add(&mut self, usage: &TranscriptUsage) -> Result<(), CommandCodeMappingError> { + self.total.add(usage)?; + self.models + .entry(usage.model.clone().unwrap_or_else(|| "unknown".to_owned())) + .or_default() + .add(usage) + } +} + +#[derive(Debug, Default)] +struct CommandCodeUsageAccumulator { + input_tokens: u64, + output_tokens: u64, + cache_read_tokens: u64, + cache_write_tokens: u64, + cost_micros: u64, +} + +impl CommandCodeUsageAccumulator { + fn add(&mut self, usage: &TranscriptUsage) -> Result<(), CommandCodeMappingError> { + self.input_tokens = checked_add(self.input_tokens, usage.tokens.input)?; + self.output_tokens = checked_add(self.output_tokens, usage.tokens.output)?; + self.cache_read_tokens = checked_add(self.cache_read_tokens, usage.tokens.cache_read)?; + self.cache_write_tokens = checked_add(self.cache_write_tokens, usage.tokens.cache_write)?; + if let Some(cost_usd) = usage.cost_usd { + self.cost_micros = checked_add( + self.cost_micros, + cost_usd_to_micros(cost_usd, CommandCodeMappingError::InvalidCost)?, + )?; + } + Ok(()) + } + + fn tokens(&self) -> Result { + let total = checked_add( + checked_add(self.input_tokens, self.output_tokens)?, + checked_add(self.cache_read_tokens, self.cache_write_tokens)?, + )?; + TokenUsage::new( + Some(self.input_tokens), + Some(self.output_tokens), + Some(self.cache_write_tokens), + Some(self.cache_read_tokens), + total, + ) + .map_err(Into::into) + } +} + +struct CommandCodeSessionAccumulator { + session_id: String, + model_id: String, + project_path: Option, + usage: CommandCodeUsageAccumulator, + first_activity_at: Option>, + last_activity_at: Option>, +} + +impl CommandCodeSessionAccumulator { + fn new(session_id: String, model_id: String, project_path: Option) -> Self { + Self { + session_id, + model_id, + project_path, + usage: CommandCodeUsageAccumulator::default(), + first_activity_at: None, + last_activity_at: None, + } + } + + fn add(&mut self, usage: &TranscriptUsage) -> Result<(), CommandCodeMappingError> { + self.first_activity_at = Some( + self.first_activity_at + .map(|first| first.min(usage.timestamp)) + .unwrap_or(usage.timestamp), + ); + self.last_activity_at = Some( + self.last_activity_at + .map(|last| last.max(usage.timestamp)) + .unwrap_or(usage.timestamp), + ); + self.usage.add(usage) + } + + fn candidate( + self, + context: &CommandCodeMappingContext, + ) -> Result { + let tokens = self.usage.tokens()?; + let cost = cost(self.usage.cost_micros, tokens.total_tokens()); + Ok(SessionUsageCandidate { + provenance: context.provenance(), + source_key: session_source_key( + SourceKey::CommandCode, + &format!("{}:{}", self.session_id, self.model_id), + )?, + source_session_id: self.session_id, + project_path: self.project_path, + first_activity_at: self.first_activity_at, + last_activity_at: self.last_activity_at, + tokens: tokens.clone(), + cost: cost.clone(), + model_breakdowns: vec![ModelUsageCandidate { + raw_model_id: self.model_id, + tokens, + cost, + }], + }) + } +} + +fn checked_add(left: u64, right: u64) -> Result { + checked_add_u64(left, right, CommandCodeMappingError::TokenOverflow) +} + +/// Convert a USD float to integer micros deterministically (round half-up to 6 +/// decimal places). Rejects negative and non-finite values. +fn cost_usd_to_micros( + cost_usd: f64, + error: CommandCodeMappingError, +) -> Result { + if !cost_usd.is_finite() || cost_usd < 0.0 { + return Err(error); + } + // Round half-up: add 0.5 before truncating toward zero. + let micros = (cost_usd * USD_MICROS_PER_DOLLAR + 0.5).floor(); + if micros > u64::MAX as f64 { + return Err(error); + } + Ok(micros as u64) +} + +fn cost(cost_micros: u64, total_tokens: u64) -> UsageCost { + if cost_micros == 0 { + return if total_tokens == 0 { + UsageCost::NotApplicable { + kind: CostKind::SourceReported, + } + } else { + UsageCost::Unavailable { + kind: CostKind::SourceReported, + } + }; + } + UsageCost::Valued { + amount_micros: cost_micros, + currency: CurrencyCode::new("USD").expect("USD is a valid ISO-shaped currency"), + kind: CostKind::SourceReported, + status: ValuedCostStatus::Estimated, + } +} + +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub(crate) enum CommandCodeMappingError { + #[error("command-code mapping requires a collector version")] + EmptyCollectorVersion, + #[error("command-code mapping requires a valid timezone")] + InvalidTimezone, + #[error("command-code mapping received an invalid timestamp")] + InvalidTimestamp, + #[error("command-code token total overflowed")] + TokenOverflow, + #[error("command-code cost value is invalid")] + InvalidCost, + #[error(transparent)] + Identity(#[from] IdentityError), + #[error(transparent)] + Usage(#[from] UsageValidationError), +} + +#[cfg(test)] +mod tests { + use chrono::TimeZone; + + use super::*; + use crate::application::collection::{CollectionId, CollectorKey}; + + const VALID_TRANSCRIPT: &str = r#"{"type":"session","version":3,"id":"sess-1","timestamp":"2026-08-04T10:00:00Z","cwd":"/tmp/proj"} +{"type":"message","id":"m1","parentId":null,"timestamp":"2026-08-04T10:00:01Z","message":{"role":"user","content":[{"type":"text","text":"redacted"}]}} +{"type":"message","id":"m2","parentId":"m1","timestamp":"2026-08-04T10:00:02Z","message":{"role":"assistant","content":[{"type":"text","text":"redacted"}]},"usage":{"inputTokens":10,"outputTokens":2,"cacheReadTokens":3,"cacheWriteTokens":0,"costUsd":0.001},"model":"deepseek/deepseek-v4-flash","effort":"max"}"#; + + fn transcript_from(contents: &str) -> ParsedTranscript { + let (_, parsed, _) = super::super::transcript_parser::parse_transcript(contents); + parsed.expect("parsed transcript") + } + + fn context() -> CommandCodeMappingContext { + CommandCodeMappingContext::new( + CollectorKey::new("command-code").expect("collector"), + "local".to_owned(), + CollectionId::new("command-code-test").expect("collection"), + Utc.with_ymd_and_hms(2026, 8, 4, 1, 0, 0) + .single() + .expect("timestamp"), + ) + .expect("context") + } + + #[test] + fn converts_cost_usd_to_micros_rounding_half_up() { + assert_eq!( + cost_usd_to_micros(0.001, CommandCodeMappingError::InvalidCost).expect("cost"), + 1000 + ); + assert_eq!( + cost_usd_to_micros(0.0000005, CommandCodeMappingError::InvalidCost).expect("cost"), + 1 + ); + assert_eq!( + cost_usd_to_micros(0.0, CommandCodeMappingError::InvalidCost).expect("cost"), + 0 + ); + assert_eq!( + cost_usd_to_micros(1.0, CommandCodeMappingError::InvalidCost).expect("cost"), + 1_000_000 + ); + } + + #[test] + fn rejects_negative_and_non_finite_cost() { + assert_eq!( + cost_usd_to_micros(-0.001, CommandCodeMappingError::InvalidCost).expect_err("cost"), + CommandCodeMappingError::InvalidCost + ); + assert_eq!( + cost_usd_to_micros(f64::NAN, CommandCodeMappingError::InvalidCost).expect_err("cost"), + CommandCodeMappingError::InvalidCost + ); + assert_eq!( + cost_usd_to_micros(f64::INFINITY, CommandCodeMappingError::InvalidCost) + .expect_err("cost"), + CommandCodeMappingError::InvalidCost + ); + } + + #[test] + fn maps_daily_candidate_from_transcript() { + let transcripts = vec![transcript_from(VALID_TRANSCRIPT)]; + let context = context(); + + let candidates = map_daily( + transcripts, + "Asia/Jakarta", + &CollectionScope::Full, + &context, + ) + .expect("daily"); + + assert_eq!(candidates.len(), 1); + let candidate = &candidates[0]; + assert_eq!( + candidate.source_key, + "command-code:daily:v1:Asia/Jakarta:2026-08-04" + ); + assert_eq!(candidate.tokens.input_tokens(), Some(10)); + assert_eq!(candidate.tokens.output_tokens(), Some(2)); + assert_eq!(candidate.tokens.cache_read_tokens(), Some(3)); + assert_eq!(candidate.tokens.cache_creation_tokens(), Some(0)); + assert_eq!(candidate.tokens.total_tokens(), 15); + let cost = match &candidate.cost { + UsageCost::Valued { + amount_micros, + kind, + status, + .. + } => { + assert_eq!(*amount_micros, 1000); + assert_eq!(*kind, CostKind::SourceReported); + assert_eq!(*status, ValuedCostStatus::Estimated); + true + } + _ => false, + }; + assert!(cost); + assert_eq!(candidate.model_breakdowns.len(), 1); + assert_eq!( + candidate.model_breakdowns[0].raw_model_id, + "deepseek/deepseek-v4-flash" + ); + } + + #[test] + fn maps_session_candidate_from_transcript() { + let transcripts = vec![transcript_from(VALID_TRANSCRIPT)]; + let context = context(); + + let candidates = map_sessions(transcripts, &context).expect("sessions"); + + assert_eq!(candidates.len(), 1); + let candidate = &candidates[0]; + assert!(candidate + .source_key + .starts_with("command-code:session:v1:sess-1:")); + assert_eq!(candidate.source_session_id, "sess-1"); + assert_eq!(candidate.project_path.as_deref(), Some("/tmp/proj")); + assert!(candidate.first_activity_at.is_some()); + assert!(candidate.last_activity_at.is_some()); + assert_eq!(candidate.tokens.total_tokens(), 15); + } + + #[test] + fn dedupes_duplicate_message_ids_within_session() { + let transcript = transcript_from(&format!( + "{VALID_TRANSCRIPT}\n{}", + VALID_TRANSCRIPT.lines().last().expect("last line") + )); + let context = context(); + + let candidates = map_sessions(vec![transcript], &context).expect("sessions"); + + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].tokens.total_tokens(), 15); + } + + #[test] + fn aggregates_multiple_usage_records_per_day() { + let multi = r#"{"type":"session","version":3,"id":"sess-2","timestamp":"2026-08-04T10:00:00Z","cwd":"/tmp/proj"} +{"type":"message","id":"m1","parentId":null,"timestamp":"2026-08-04T10:00:01Z","message":{"role":"assistant","content":[]},"usage":{"inputTokens":10,"outputTokens":2,"cacheReadTokens":0,"cacheWriteTokens":0,"costUsd":0.001},"model":"m","effort":"max"} +{"type":"message","id":"m2","parentId":"m1","timestamp":"2026-08-04T11:00:00Z","message":{"role":"assistant","content":[]},"usage":{"inputTokens":20,"outputTokens":4,"cacheReadTokens":0,"cacheWriteTokens":0,"costUsd":0.002},"model":"m","effort":"max"}"#; + let context = context(); + + let candidates = map_daily( + vec![transcript_from(multi)], + "UTC", + &CollectionScope::Full, + &context, + ) + .expect("daily"); + + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].tokens.total_tokens(), 36); + let cost = match &candidates[0].cost { + UsageCost::Valued { amount_micros, .. } => *amount_micros, + _ => panic!("expected valued cost"), + }; + assert_eq!(cost, 3000); + } + + #[test] + fn zero_cost_with_usage_is_unavailable() { + let no_cost = r#"{"type":"session","version":3,"id":"sess-3","timestamp":"2026-08-04T10:00:00Z","cwd":"/tmp/proj"} +{"type":"message","id":"m1","parentId":null,"timestamp":"2026-08-04T10:00:01Z","message":{"role":"assistant","content":[]},"usage":{"inputTokens":10,"outputTokens":2,"cacheReadTokens":0,"cacheWriteTokens":0,"costUsd":0},"model":"m","effort":"max"}"#; + let context = context(); + + let candidates = map_sessions(vec![transcript_from(no_cost)], &context).expect("sessions"); + + assert!(matches!( + candidates[0].cost, + UsageCost::Unavailable { + kind: CostKind::SourceReported + } + )); + } + + #[test] + fn respects_incremental_scope() { + let context = context(); + let scope = CollectionScope::incremental( + NaiveDate::from_ymd_opt(2026, 8, 3).expect("date"), + NaiveDate::from_ymd_opt(2026, 8, 3).expect("date"), + ) + .expect("scope"); + + let candidates = map_daily( + vec![transcript_from(VALID_TRANSCRIPT)], + "UTC", + &scope, + &context, + ) + .expect("daily"); + + assert!(candidates.is_empty()); + } + + #[test] + fn rejects_invalid_timezone() { + let context = context(); + + let error = map_daily( + vec![transcript_from(VALID_TRANSCRIPT)], + "not-a-timezone", + &CollectionScope::Full, + &context, + ) + .expect_err("invalid timezone"); + + assert_eq!(error, CommandCodeMappingError::InvalidTimezone); + } +} diff --git a/src-tauri/src/infrastructure/collectors/commandcode/mod.rs b/src-tauri/src/infrastructure/collectors/commandcode/mod.rs new file mode 100644 index 0000000..83d363f --- /dev/null +++ b/src-tauri/src/infrastructure/collectors/commandcode/mod.rs @@ -0,0 +1,16 @@ +//! Command Code collector infrastructure. +//! +//! Wires the transcript reader, parser, and mapper into the collector port. +//! Collection reads `~/.commandcode/projects/**/.jsonl` transcripts +//! read-only and maps usage-bearing messages into Burnly daily/session +//! candidates. + +mod adapter; +mod commandcode_home; +mod detection; +mod mapper; +mod transcript_parser; +mod transcript_reader; + +pub(crate) use adapter::CommandCodeCollector; +pub(crate) use commandcode_home::default_commandcode_home; diff --git a/src-tauri/src/infrastructure/collectors/commandcode/transcript_parser.rs b/src-tauri/src/infrastructure/collectors/commandcode/transcript_parser.rs new file mode 100644 index 0000000..10b2911 --- /dev/null +++ b/src-tauri/src/infrastructure/collectors/commandcode/transcript_parser.rs @@ -0,0 +1,427 @@ +//! Command Code transcript parsing. +//! +//! Parses `projects/**/.jsonl` transcripts into usage-only typed +//! records. Only top-level identity, timing, `usage`, `model`, and `effort` +//! fields are decoded; `message.content` is never deserialized. Malformed +//! lines, partial trailing lines, invalid timestamps, and invalid token counts +//! are skipped rather than failing the whole file. + +use chrono::{DateTime, Utc}; +use serde::Deserialize; + +/// Result of parsing one transcript file. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct ParsedTranscript { + /// Session identity from the `type: session` record. + pub(crate) session_id: String, + /// Working directory from the session record. + pub(crate) cwd: Option, + /// Session start timestamp from the session record. + pub(crate) started_at: DateTime, + /// Per-message usage records (only usage-bearing assistant messages). + pub(crate) usages: Vec, +} + +/// One usage-bearing assistant message. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct TranscriptUsage { + /// File-scoped message id. + pub(crate) message_id: String, + /// Message timestamp (RFC 3339 UTC). + pub(crate) timestamp: DateTime, + /// Provider-reported token usage. + pub(crate) tokens: ParsedTokens, + /// Raw `costUsd` value; conversion to micros happens in the mapper (Phase 3). + pub(crate) cost_usd: Option, + /// Full provider/model id, e.g. `deepseek/deepseek-v4-flash`. + pub(crate) model: Option, + /// `low`, `medium`, or `max`. + pub(crate) effort: Option, +} + +/// Token counts from one `usage` block. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ParsedTokens { + pub(crate) input: u64, + pub(crate) output: u64, + pub(crate) cache_read: u64, + pub(crate) cache_write: u64, +} + +/// How a transcript file was classified during parsing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum TranscriptKind { + /// New-format file (has `type: session`) with at least one usage record. + NewFormatWithUsage, + /// New-format file with no usage records yet. + NewFormatNoUsage, + /// Pre-1.11 flat-schema file; carries no usage and is skipped. + Legacy, +} + +/// Summary of a transcript parse. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) struct TranscriptParseSummary { + pub(crate) lines_read: u32, + pub(crate) messages_seen: u32, + pub(crate) usage_records: u32, + pub(crate) lines_skipped: u32, +} + +/// Parse a new-format transcript. Legacy files return `TranscriptKind::Legacy`. +pub(crate) fn parse_transcript( + contents: &str, +) -> ( + TranscriptKind, + Option, + TranscriptParseSummary, +) { + let mut summary = TranscriptParseSummary::default(); + let mut session_id = None; + let mut cwd = None; + let mut started_at = None; + let mut usages = Vec::new(); + let mut saw_type_field = false; + + for raw_line in contents.lines() { + let line = raw_line.trim(); + if line.is_empty() { + summary.lines_skipped += 1; + continue; + } + summary.lines_read += 1; + + // A trailing partial line from a live append fails JSON parsing; skip + // it rather than failing the file. + let Ok(value) = serde_json::from_str::(line) else { + summary.lines_skipped += 1; + continue; + }; + let Some(obj) = value.as_object() else { + summary.lines_skipped += 1; + continue; + }; + + if obj.contains_key("type") { + saw_type_field = true; + } + match obj.get("type").and_then(|t| t.as_str()) { + Some("session") => { + if let Ok(record) = serde_json::from_value::(value.clone()) { + session_id = Some(record.id); + cwd = record.cwd; + if let Ok(ts) = parse_timestamp(&record.timestamp) { + started_at = Some(ts); + } + } + } + Some("message") => { + summary.messages_seen += 1; + match serde_json::from_value::(value.clone()) { + Ok(record) => { + if let Some(usage) = record.usage { + match TranscriptUsage::try_from_record( + record.id, + &record.timestamp, + usage, + record.model, + record.effort, + ) { + Ok(usage) => { + summary.usage_records += 1; + usages.push(usage); + } + Err(_) => summary.lines_skipped += 1, + } + } + } + Err(_) => summary.lines_skipped += 1, + } + } + _ => summary.lines_skipped += 1, + } + } + + // Flat records without a `type` field are the pre-1.11 legacy schema. + if !saw_type_field { + return (TranscriptKind::Legacy, None, summary); + } + + let Some(session_id) = session_id else { + // A new-format file must have a session record; without one it cannot + // be attributed and is skipped. + return (TranscriptKind::NewFormatNoUsage, None, summary); + }; + + let kind = if usages.is_empty() { + TranscriptKind::NewFormatNoUsage + } else { + TranscriptKind::NewFormatWithUsage + }; + + ( + kind, + Some(ParsedTranscript { + session_id, + cwd, + started_at: started_at.unwrap_or_else(|| { + usages + .iter() + .map(|usage| usage.timestamp) + .min() + .unwrap_or_else(Utc::now) + }), + usages, + }), + summary, + ) +} + +impl TranscriptUsage { + fn try_from_record( + message_id: String, + timestamp: &str, + usage: UsageRecord, + model: Option, + effort: Option, + ) -> Result { + let timestamp = + parse_timestamp(timestamp).map_err(|_| UsageRecordError::InvalidTimestamp)?; + let tokens = ParsedTokens { + input: non_negative_u64(usage.input_tokens)?, + output: non_negative_u64(usage.output_tokens)?, + cache_read: non_negative_u64(usage.cache_read_tokens)?, + cache_write: non_negative_u64(usage.cache_write_tokens)?, + }; + let cost_usd = usage + .cost_usd + .filter(|value| value.is_finite() && *value >= 0.0); + + Ok(Self { + message_id, + timestamp, + tokens, + cost_usd, + model, + effort, + }) + } +} + +fn parse_timestamp(value: &str) -> Result, ()> { + DateTime::parse_from_rfc3339(value) + .map(|dt| dt.with_timezone(&Utc)) + .map_err(|_| ()) +} + +fn non_negative_u64(value: i64) -> Result { + u64::try_from(value).map_err(|_| UsageRecordError::NegativeTokenCount) +} + +#[derive(Debug)] +enum UsageRecordError { + InvalidTimestamp, + NegativeTokenCount, +} + +/// Top-level `type: session` record (allowed fields only). +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SessionRecord { + id: String, + #[serde(default)] + timestamp: String, + #[serde(default)] + cwd: Option, +} + +/// Top-level `type: message` record (allowed fields only; `content` omitted). +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct MessageRecord { + id: String, + #[serde(default)] + timestamp: String, + #[serde(default)] + usage: Option, + #[serde(default)] + model: Option, + #[serde(default)] + effort: Option, +} + +/// Top-level `usage` block on assistant messages. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct UsageRecord { + #[serde(default)] + input_tokens: i64, + #[serde(default)] + output_tokens: i64, + #[serde(default)] + cache_read_tokens: i64, + #[serde(default)] + cache_write_tokens: i64, + #[serde(default)] + cost_usd: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + const VALID_TRANSCRIPT: &str = r#"{"type":"session","version":3,"id":"sess-1","timestamp":"2026-08-04T10:00:00Z","cwd":"/tmp/proj"} +{"type":"message","id":"m1","parentId":null,"timestamp":"2026-08-04T10:00:01Z","message":{"role":"user","content":[{"type":"text","text":"redacted"}]}} +{"type":"message","id":"m2","parentId":"m1","timestamp":"2026-08-04T10:00:02Z","message":{"role":"assistant","content":[{"type":"text","text":"redacted"}]},"usage":{"inputTokens":10,"outputTokens":2,"cacheReadTokens":3,"cacheWriteTokens":0,"costUsd":0.001},"model":"deepseek/deepseek-v4-flash","effort":"max"}"#; + + #[test] + fn parses_valid_transcript_into_usage_records() { + let (kind, parsed, summary) = parse_transcript(VALID_TRANSCRIPT); + + assert_eq!(kind, TranscriptKind::NewFormatWithUsage); + let parsed = parsed.expect("parsed"); + assert_eq!(parsed.session_id, "sess-1"); + assert_eq!(parsed.cwd.as_deref(), Some("/tmp/proj")); + assert_eq!(parsed.usages.len(), 1); + assert_eq!(parsed.usages[0].message_id, "m2"); + assert_eq!(parsed.usages[0].tokens.input, 10); + assert_eq!(parsed.usages[0].tokens.output, 2); + assert_eq!(parsed.usages[0].tokens.cache_read, 3); + assert_eq!(parsed.usages[0].tokens.cache_write, 0); + assert_eq!(parsed.usages[0].cost_usd, Some(0.001)); + assert_eq!( + parsed.usages[0].model.as_deref(), + Some("deepseek/deepseek-v4-flash") + ); + assert_eq!(parsed.usages[0].effort.as_deref(), Some("max")); + assert_eq!(summary.usage_records, 1); + } + + #[test] + fn ignores_non_usage_messages() { + let (kind, parsed, summary) = parse_transcript(VALID_TRANSCRIPT); + + assert_eq!(kind, TranscriptKind::NewFormatWithUsage); + let parsed = parsed.expect("parsed"); + assert_eq!(parsed.usages.len(), 1); + assert_eq!(summary.messages_seen, 2); + } + + #[test] + fn classifies_legacy_transcript() { + let legacy = r#"{"id":"legacy-1","timestamp":"2026-05-07T03:23:01Z","sessionId":"sess-legacy","parentId":null,"role":"user","content":[{"type":"text","text":"redacted"}]}"#; + + let (kind, parsed, _) = parse_transcript(legacy); + + assert_eq!(kind, TranscriptKind::Legacy); + assert!(parsed.is_none()); + } + + #[test] + fn classifies_new_format_without_usage() { + let no_usage = r#"{"type":"session","version":3,"id":"sess-2","timestamp":"2026-08-04T10:00:00Z","cwd":"/tmp/proj"} +{"type":"message","id":"m1","parentId":null,"timestamp":"2026-08-04T10:00:01Z","message":{"role":"user","content":[{"type":"text","text":"redacted"}]}}"#; + + let (kind, parsed, _) = parse_transcript(no_usage); + + assert_eq!(kind, TranscriptKind::NewFormatNoUsage); + let parsed = parsed.expect("parsed"); + assert!(parsed.usages.is_empty()); + } + + #[test] + fn tolerates_partial_trailing_line() { + let contents = format!("{VALID_TRANSCRIPT}\n{{\"type\":\"message\""); + + let (kind, parsed, summary) = parse_transcript(&contents); + + assert_eq!(kind, TranscriptKind::NewFormatWithUsage); + let parsed = parsed.expect("parsed"); + assert_eq!(parsed.usages.len(), 1); + assert_eq!(summary.lines_skipped, 1); + } + + #[test] + fn rejects_negative_token_counts() { + let negative = r#"{"type":"session","version":3,"id":"sess-3","timestamp":"2026-08-04T10:00:00Z","cwd":"/tmp/proj"} +{"type":"message","id":"m1","parentId":null,"timestamp":"2026-08-04T10:00:01Z","message":{"role":"assistant","content":[]},"usage":{"inputTokens":-5,"outputTokens":2,"cacheReadTokens":0,"cacheWriteTokens":0,"costUsd":0.001},"model":"m","effort":"max"}"#; + + let (kind, parsed, summary) = parse_transcript(negative); + + assert_eq!(kind, TranscriptKind::NewFormatNoUsage); + let parsed = parsed.expect("parsed"); + assert!(parsed.usages.is_empty()); + assert_eq!(summary.lines_skipped, 1); + } + + #[test] + fn rejects_token_counts_beyond_i64_range() { + // 2^64 exceeds the JSON i64 field range; serde fails to decode the + // usage block, so the line is skipped. + let overflow = r#"{"type":"session","version":3,"id":"sess-4","timestamp":"2026-08-04T10:00:00Z","cwd":"/tmp/proj"} +{"type":"message","id":"m1","parentId":null,"timestamp":"2026-08-04T10:00:01Z","message":{"role":"assistant","content":[]},"usage":{"inputTokens":18446744073709551616,"outputTokens":2,"cacheReadTokens":0,"cacheWriteTokens":0,"costUsd":0.001},"model":"m","effort":"max"}"#; + + let (kind, _, summary) = parse_transcript(overflow); + + assert_eq!(kind, TranscriptKind::NewFormatNoUsage); + assert_eq!(summary.lines_skipped, 1); + } + + #[test] + fn accepts_large_but_in_range_token_counts() { + let large = format!( + r#"{{"type":"session","version":3,"id":"sess-4b","timestamp":"2026-08-04T10:00:00Z","cwd":"/tmp/proj"}} +{{"type":"message","id":"m1","parentId":null,"timestamp":"2026-08-04T10:00:01Z","message":{{"role":"assistant","content":[]}},"usage":{{"inputTokens":{},"outputTokens":2,"cacheReadTokens":0,"cacheWriteTokens":0,"costUsd":0.001}},"model":"m","effort":"max"}}"#, + i64::MAX + ); + + let (kind, parsed, _) = parse_transcript(&large); + + assert_eq!(kind, TranscriptKind::NewFormatWithUsage); + let parsed = parsed.expect("parsed"); + assert_eq!(parsed.usages[0].tokens.input, i64::MAX as u64); + } + + #[test] + fn rejects_invalid_timestamp() { + let invalid_ts = r#"{"type":"session","version":3,"id":"sess-5","timestamp":"2026-08-04T10:00:00Z","cwd":"/tmp/proj"} +{"type":"message","id":"m1","parentId":null,"timestamp":"not-a-timestamp","message":{"role":"assistant","content":[]},"usage":{"inputTokens":5,"outputTokens":2,"cacheReadTokens":0,"cacheWriteTokens":0,"costUsd":0.001},"model":"m","effort":"max"}"#; + + let (kind, parsed, summary) = parse_transcript(invalid_ts); + + assert_eq!(kind, TranscriptKind::NewFormatNoUsage); + let parsed = parsed.expect("parsed"); + assert!(parsed.usages.is_empty()); + assert_eq!(summary.lines_skipped, 1); + } + + #[test] + fn parses_multiple_usage_records_in_one_file() { + let multi = r#"{"type":"session","version":3,"id":"sess-6","timestamp":"2026-08-04T10:00:00Z","cwd":"/tmp/proj"} +{"type":"message","id":"m1","parentId":null,"timestamp":"2026-08-04T10:00:01Z","message":{"role":"assistant","content":[]},"usage":{"inputTokens":10,"outputTokens":2,"cacheReadTokens":0,"cacheWriteTokens":0,"costUsd":0.001},"model":"m","effort":"max"} +{"type":"message","id":"m2","parentId":"m1","timestamp":"2026-08-04T10:01:00Z","message":{"role":"assistant","content":[]},"usage":{"inputTokens":20,"outputTokens":4,"cacheReadTokens":0,"cacheWriteTokens":0,"costUsd":0.002},"model":"m","effort":"max"}"#; + + let (kind, parsed, summary) = parse_transcript(multi); + + assert_eq!(kind, TranscriptKind::NewFormatWithUsage); + let parsed = parsed.expect("parsed"); + assert_eq!(parsed.usages.len(), 2); + assert_eq!(summary.usage_records, 2); + } + + #[test] + fn started_at_falls_back_to_earliest_usage_when_session_timestamp_invalid() { + let bad_session_ts = r#"{"type":"session","version":3,"id":"sess-7","timestamp":"garbage","cwd":"/tmp/proj"} +{"type":"message","id":"m1","parentId":null,"timestamp":"2026-08-04T10:00:01Z","message":{"role":"assistant","content":[]},"usage":{"inputTokens":10,"outputTokens":2,"cacheReadTokens":0,"cacheWriteTokens":0,"costUsd":0.001},"model":"m","effort":"max"}"#; + + let (_, parsed, _) = parse_transcript(bad_session_ts); + + let parsed = parsed.expect("parsed"); + assert_eq!( + parsed.started_at, + DateTime::parse_from_rfc3339("2026-08-04T10:00:01Z") + .expect("ts") + .with_timezone(&Utc) + ); + } +} diff --git a/src-tauri/src/infrastructure/collectors/commandcode/transcript_reader.rs b/src-tauri/src/infrastructure/collectors/commandcode/transcript_reader.rs new file mode 100644 index 0000000..beefe74 --- /dev/null +++ b/src-tauri/src/infrastructure/collectors/commandcode/transcript_reader.rs @@ -0,0 +1,189 @@ +//! Command Code transcript scanning. +//! +//! Walks `projects/**` for session transcripts, skips checkpoint files and +//! non-JSONL files, and parses each readable transcript. Unreadable or +//! unparseable files are skipped without failing the scan. + +use std::fs; +use std::path::{Path, PathBuf}; + +use super::commandcode_home::projects_root; +use super::transcript_parser::{parse_transcript, ParsedTranscript, TranscriptKind}; + +/// One transcript file discovered under `projects/`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct TranscriptFile { + /// Absolute path to the `.jsonl` transcript. + pub(crate) path: PathBuf, +} + +/// Result of scanning the projects root. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub(crate) struct TranscriptScanSummary { + pub(crate) transcript_files_found: u32, + pub(crate) new_format_with_usage: u32, + pub(crate) new_format_no_usage: u32, + pub(crate) legacy_transcripts: u32, + pub(crate) unreadable_transcripts: u32, + pub(crate) usage_records: u32, +} + +pub(crate) struct TranscriptReader; + +impl TranscriptReader { + /// Scan `projects/**` and parse all readable transcripts. + pub(crate) fn scan( + commandcode_home: &Path, + ) -> ( + Vec, + Vec, + TranscriptScanSummary, + ) { + let projects = projects_root(commandcode_home); + let mut files = Vec::new(); + let mut parsed = Vec::new(); + let mut summary = TranscriptScanSummary::default(); + + for transcript in discover_transcripts(&projects) { + summary.transcript_files_found += 1; + let Ok(contents) = fs::read_to_string(&transcript) else { + summary.unreadable_transcripts += 1; + continue; + }; + let (kind, maybe_parsed, parse_summary) = parse_transcript(&contents); + summary.usage_records += parse_summary.usage_records; + match kind { + TranscriptKind::NewFormatWithUsage => { + summary.new_format_with_usage += 1; + files.push(TranscriptFile { path: transcript }); + if let Some(parsed_transcript) = maybe_parsed { + parsed.push(parsed_transcript); + } + } + TranscriptKind::NewFormatNoUsage => summary.new_format_no_usage += 1, + TranscriptKind::Legacy => summary.legacy_transcripts += 1, + } + } + + (files, parsed, summary) + } +} + +/// Discover `.jsonl` transcript paths under `projects/**`, skipping +/// checkpoint files. +fn discover_transcripts(projects: &Path) -> Vec { + let mut transcripts = Vec::new(); + let Ok(project_entries) = fs::read_dir(projects) else { + return transcripts; + }; + for project_entry in project_entries.flatten() { + if !project_entry + .file_type() + .map(|file_type| file_type.is_dir()) + .unwrap_or(false) + { + continue; + } + let Ok(transcript_entries) = fs::read_dir(project_entry.path()) else { + continue; + }; + for transcript_entry in transcript_entries.flatten() { + let path = transcript_entry.path(); + if !path.extension().map(|ext| ext == "jsonl").unwrap_or(false) { + continue; + } + if path + .file_name() + .map(|name| name.to_string_lossy().contains(".checkpoints.")) + .unwrap_or(false) + { + continue; + } + transcripts.push(path); + } + } + transcripts +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::TempDir; + + use super::*; + + const VALID_TRANSCRIPT: &str = r#"{"type":"session","version":3,"id":"sess-1","timestamp":"2026-08-04T10:00:00Z","cwd":"/tmp/proj"} +{"type":"message","id":"m1","parentId":null,"timestamp":"2026-08-04T10:00:02Z","message":{"role":"assistant","content":[{"type":"text","text":"redacted"}]},"usage":{"inputTokens":10,"outputTokens":2,"cacheReadTokens":3,"cacheWriteTokens":0,"costUsd":0.001},"model":"m","effort":"max"}"#; + + const LEGACY_TRANSCRIPT: &str = r#"{"id":"legacy-1","timestamp":"2026-05-07T03:23:01Z","sessionId":"sess-legacy","parentId":null,"role":"user","content":[{"type":"text","text":"redacted"}]}"#; + + fn write_transcript(project_dir: &Path, name: &str, contents: &str) { + fs::create_dir_all(project_dir).expect("project dir"); + fs::write(project_dir.join(name), contents).expect("transcript"); + } + + #[test] + fn scans_all_non_checkpoint_transcripts() { + let temp = TempDir::new().expect("temp dir"); + let home = temp.path().join("home"); + write_transcript( + &home.join("projects").join("proj-a"), + "sess-1.jsonl", + VALID_TRANSCRIPT, + ); + write_transcript( + &home.join("projects").join("proj-b"), + "sess-2.jsonl", + LEGACY_TRANSCRIPT, + ); + write_transcript( + &home.join("projects").join("proj-a"), + "sess-1.checkpoints.jsonl", + "{}", + ); + + let (files, parsed, summary) = TranscriptReader::scan(&home); + + assert_eq!(files.len(), 1); + assert_eq!(parsed.len(), 1); + assert_eq!(summary.transcript_files_found, 2); + assert_eq!(summary.new_format_with_usage, 1); + assert_eq!(summary.legacy_transcripts, 1); + assert_eq!(summary.usage_records, 1); + } + + #[test] + fn ignores_unreadable_and_missing_projects_root() { + let temp = TempDir::new().expect("temp dir"); + let missing = temp.path().join("missing-home"); + + let (files, parsed, summary) = TranscriptReader::scan(&missing); + + assert!(files.is_empty()); + assert!(parsed.is_empty()); + assert_eq!(summary.transcript_files_found, 0); + } + + #[test] + fn counts_usage_records_across_multiple_transcripts() { + let temp = TempDir::new().expect("temp dir"); + let home = temp.path().join("home"); + write_transcript( + &home.join("projects").join("proj-a"), + "sess-1.jsonl", + VALID_TRANSCRIPT, + ); + write_transcript( + &home.join("projects").join("proj-a"), + "sess-2.jsonl", + &format!("{VALID_TRANSCRIPT}\n{VALID_TRANSCRIPT}"), + ); + + let (_, _, summary) = TranscriptReader::scan(&home); + + assert_eq!(summary.transcript_files_found, 2); + assert_eq!(summary.new_format_with_usage, 2); + assert_eq!(summary.usage_records, 3); + } +} diff --git a/src-tauri/src/infrastructure/collectors/mod.rs b/src-tauri/src/infrastructure/collectors/mod.rs index 310250a..2e5894c 100644 --- a/src-tauri/src/infrastructure/collectors/mod.rs +++ b/src-tauri/src/infrastructure/collectors/mod.rs @@ -1,6 +1,7 @@ pub(crate) mod antigravity; pub(crate) mod ccusage; pub(crate) mod cline; +pub(crate) mod commandcode; pub(crate) mod grok; pub(crate) mod routed; mod support; diff --git a/src-tauri/src/infrastructure/collectors/routed.rs b/src-tauri/src/infrastructure/collectors/routed.rs index b53b597..ed38434 100644 --- a/src-tauri/src/infrastructure/collectors/routed.rs +++ b/src-tauri/src/infrastructure/collectors/routed.rs @@ -14,6 +14,7 @@ pub(crate) struct RoutedCollector { zcode: Arc, antigravity: Arc, grok: Arc, + commandcode: Arc, } impl RoutedCollector { @@ -23,6 +24,7 @@ impl RoutedCollector { zcode: Arc, antigravity: Arc, grok: Arc, + commandcode: Arc, ) -> Self { Self { ccusage, @@ -30,6 +32,7 @@ impl RoutedCollector { zcode, antigravity, grok, + commandcode, } } @@ -42,6 +45,7 @@ impl RoutedCollector { SourceKey::ZCode => Ok(&self.zcode), SourceKey::Antigravity => Ok(&self.antigravity), SourceKey::GrokBuild => Ok(&self.grok), + SourceKey::CommandCode => Ok(&self.commandcode), #[cfg(test)] SourceKey::TestUnsupported => Err(CollectorFailure::new( crate::application::collection::CollectorFailureCode::UnsupportedSource, @@ -61,6 +65,9 @@ impl Collector for RoutedCollector { .profiles .extend(self.antigravity.describe()?.profiles); descriptor.profiles.extend(self.grok.describe()?.profiles); + descriptor + .profiles + .extend(self.commandcode.describe()?.profiles); Ok(descriptor) } @@ -104,12 +111,14 @@ mod tests { let zcode = Arc::new(RecordingCollector::new("zcode")); let antigravity = Arc::new(RecordingCollector::new("antigravity")); let grok = Arc::new(RecordingCollector::new("grok-build")); + let commandcode = Arc::new(RecordingCollector::new("command-code")); let collector = RoutedCollector::new( ccusage.clone(), cline.clone(), zcode.clone(), antigravity.clone(), grok.clone(), + commandcode.clone(), ); collector @@ -136,6 +145,9 @@ mod tests { collector .collect(request(SourceKey::GrokBuild), &NeverCancelled) .expect("grok-build collection"); + collector + .collect(request(SourceKey::CommandCode), &NeverCancelled) + .expect("command-code collection"); assert_eq!( ccusage.sources(), @@ -150,6 +162,26 @@ mod tests { assert_eq!(zcode.sources(), vec![SourceKey::ZCode]); assert_eq!(antigravity.sources(), vec![SourceKey::Antigravity]); assert_eq!(grok.sources(), vec![SourceKey::GrokBuild]); + assert_eq!(commandcode.sources(), vec![SourceKey::CommandCode]); + } + + #[test] + fn routes_command_code_to_native_collector() { + let commandcode = Arc::new(RecordingCollector::new("command-code")); + let collector = RoutedCollector::new( + Arc::new(RecordingCollector::new("ccusage")), + Arc::new(RecordingCollector::new("cline")), + Arc::new(RecordingCollector::new("zcode")), + Arc::new(RecordingCollector::new("antigravity")), + Arc::new(RecordingCollector::new("grok-build")), + commandcode.clone(), + ); + + collector + .collect(request(SourceKey::CommandCode), &NeverCancelled) + .expect("command-code routed"); + + assert_eq!(commandcode.sources(), vec![SourceKey::CommandCode]); } #[test] @@ -160,6 +192,7 @@ mod tests { Arc::new(RecordingCollector::new("zcode")), Arc::new(RecordingCollector::new("antigravity")), Arc::new(RecordingCollector::new("grok-build")), + Arc::new(RecordingCollector::new("command-code")), ); let descriptor = collector.describe().expect("descriptor"); @@ -180,6 +213,7 @@ mod tests { SourceKey::ZCode, SourceKey::Antigravity, SourceKey::GrokBuild, + SourceKey::CommandCode, ] ); } @@ -283,6 +317,7 @@ mod tests { "zcode" => vec![profile(SourceKey::ZCode)], "antigravity" => vec![profile(SourceKey::Antigravity)], "grok-build" => vec![profile(SourceKey::GrokBuild)], + "command-code" => vec![profile(SourceKey::CommandCode)], _ => Vec::new(), } } diff --git a/tests/fixtures/collectors/commandcode/README.md b/tests/fixtures/collectors/commandcode/README.md new file mode 100644 index 0000000..a012425 --- /dev/null +++ b/tests/fixtures/collectors/commandcode/README.md @@ -0,0 +1,19 @@ +# Command Code Collector Fixtures + +These fixtures are sanitized examples of Command Code CLI local session data. + +Fixture rules: + +- Do not copy real prompts, responses, tool inputs, tool outputs, source code, + file paths, terminal output, auth credentials, or conversation transcripts + into this directory. +- Keep usage values synthetic but structurally representative of the + `projects/**/.jsonl` transcript format (session record + + message records with per-message `usage`). +- Include privacy-sensitive field names only with placeholder values when a + parser test needs to prove the field is ignored. +- `message.content` arrays must be empty or contain only placeholder text + (`"redacted"`), never real content. +- Prefer `transcripts/` fixtures for token-accounting and detection tests. +- Do not use `checkpoints.jsonl`, `history.jsonl`, or `meta.json` fixtures in + this directory (they carry prompts/titles and no usage). diff --git a/tests/fixtures/collectors/commandcode/transcripts/empty-session.jsonl b/tests/fixtures/collectors/commandcode/transcripts/empty-session.jsonl new file mode 100644 index 0000000..83e98d6 --- /dev/null +++ b/tests/fixtures/collectors/commandcode/transcripts/empty-session.jsonl @@ -0,0 +1,2 @@ +{"type":"session","version":3,"id":"ffffffff-0000-0000-0000-000000000006","timestamp":"2026-08-04T13:00:00.000Z","cwd":"/home/user/project-phi"} +{"type":"message","id":"f1","parentId":null,"timestamp":"2026-08-04T13:00:05.000Z","message":{"role":"user","content":[{"type":"text","text":"redacted"}]}} diff --git a/tests/fixtures/collectors/commandcode/transcripts/invalid-timestamp.jsonl b/tests/fixtures/collectors/commandcode/transcripts/invalid-timestamp.jsonl new file mode 100644 index 0000000..2c07802 --- /dev/null +++ b/tests/fixtures/collectors/commandcode/transcripts/invalid-timestamp.jsonl @@ -0,0 +1,2 @@ +{"type":"session","version":3,"id":"00000000-0000-0000-0000-0000000000a3","timestamp":"2026-08-04T09:00:00.000Z","cwd":"/home/user/redacted-project"} +{"type":"message","id":"t1","parentId":null,"timestamp":"not-a-timestamp","message":{"role":"assistant","content":[{"type":"text","text":"redacted"}]},"usage":{"inputTokens":10,"outputTokens":2,"cacheReadTokens":0,"cacheWriteTokens":0,"costUsd":0.001},"model":"deepseek/deepseek-v4-flash","effort":"max"} diff --git a/tests/fixtures/collectors/commandcode/transcripts/legacy-format.jsonl b/tests/fixtures/collectors/commandcode/transcripts/legacy-format.jsonl new file mode 100644 index 0000000..411fba3 --- /dev/null +++ b/tests/fixtures/collectors/commandcode/transcripts/legacy-format.jsonl @@ -0,0 +1,2 @@ +{"id":"legacy-1","timestamp":"2026-05-07T03:23:01.515Z","sessionId":"cccccccc-0000-0000-0000-000000000003","parentId":null,"role":"user","content":[{"type":"text","text":"redacted"}]} +{"id":"legacy-2","timestamp":"2026-05-07T03:23:12.531Z","sessionId":"cccccccc-0000-0000-0000-000000000003","parentId":"legacy-1","role":"assistant","content":[{"type":"text","text":"redacted"}]} diff --git a/tests/fixtures/collectors/commandcode/transcripts/malformed-lines.jsonl b/tests/fixtures/collectors/commandcode/transcripts/malformed-lines.jsonl new file mode 100644 index 0000000..34ea602 --- /dev/null +++ b/tests/fixtures/collectors/commandcode/transcripts/malformed-lines.jsonl @@ -0,0 +1,4 @@ +{"type":"session","version":3,"id":"eeeeeeee-0000-0000-0000-000000000005","timestamp":"2026-08-04T12:00:00.000Z","cwd":"/home/user/project-epsilon"} +{"type":"message","id":"e1","parentId":null,"timestamp":"2026-08-04T12:00:05.000Z","message":{"role":"assistant","content":[{"type":"text","text":"redacted"}]},"usage":{"inputTokens":700,"outputTokens":110,"cacheReadTokens":300,"cacheWriteTokens":0,"costUsd":0.0003},"model":"deepseek/deepseek-v4-flash","effort":"max"} +this is not valid json +{"type":"message","id":"e2","parentId":"e1","timestamp":"2026-08-04T12:01:00.000Z","message":{"role":"assistant","content":[{"type":"text","text":"redacted"}]},"usage":{"inputTokens":900,"outputTokens":130,"cacheReadTokens":400,"cacheWriteTokens":0,"costUsd":0.0004},"model":"deepseek/deepseek-v4-flash","effort":"max"} diff --git a/tests/fixtures/collectors/commandcode/transcripts/missing-usage-fields.jsonl b/tests/fixtures/collectors/commandcode/transcripts/missing-usage-fields.jsonl new file mode 100644 index 0000000..80759ef --- /dev/null +++ b/tests/fixtures/collectors/commandcode/transcripts/missing-usage-fields.jsonl @@ -0,0 +1,2 @@ +{"type":"session","version":3,"id":"00000000-0000-0000-0000-0000000000a4","timestamp":"2026-08-04T09:00:00.000Z","cwd":"/home/user/redacted-project"} +{"type":"message","id":"u1","parentId":null,"timestamp":"2026-08-04T09:00:05.000Z","message":{"role":"assistant","content":[{"type":"text","text":"redacted"}]},"usage":{"outputTokens":2,"cacheReadTokens":0},"model":"deepseek/deepseek-v4-flash","effort":"max"} diff --git a/tests/fixtures/collectors/commandcode/transcripts/negative-tokens.jsonl b/tests/fixtures/collectors/commandcode/transcripts/negative-tokens.jsonl new file mode 100644 index 0000000..54473f9 --- /dev/null +++ b/tests/fixtures/collectors/commandcode/transcripts/negative-tokens.jsonl @@ -0,0 +1,2 @@ +{"type":"session","version":3,"id":"00000000-0000-0000-0000-0000000000a2","timestamp":"2026-08-04T09:00:00.000Z","cwd":"/home/user/redacted-project"} +{"type":"message","id":"n1","parentId":null,"timestamp":"2026-08-04T09:00:05.000Z","message":{"role":"assistant","content":[{"type":"text","text":"redacted"}]},"usage":{"inputTokens":-5,"outputTokens":2,"cacheReadTokens":0,"cacheWriteTokens":0,"costUsd":0.001},"model":"deepseek/deepseek-v4-flash","effort":"max"} diff --git a/tests/fixtures/collectors/commandcode/transcripts/overflow-tokens.jsonl b/tests/fixtures/collectors/commandcode/transcripts/overflow-tokens.jsonl new file mode 100644 index 0000000..7f9fcbf --- /dev/null +++ b/tests/fixtures/collectors/commandcode/transcripts/overflow-tokens.jsonl @@ -0,0 +1,2 @@ +{"type":"session","version":3,"id":"00000000-0000-0000-0000-0000000000a1","timestamp":"2026-08-04T09:00:00.000Z","cwd":"/home/user/redacted-project"} +{"type":"message","id":"o1","parentId":null,"timestamp":"2026-08-04T09:00:05.000Z","message":{"role":"assistant","content":[{"type":"text","text":"redacted"}]},"usage":{"inputTokens":9223372036854775807,"outputTokens":2,"cacheReadTokens":0,"cacheWriteTokens":0,"costUsd":0.001},"model":"deepseek/deepseek-v4-flash","effort":"max"} diff --git a/tests/fixtures/collectors/commandcode/transcripts/partial-trailing-line.jsonl b/tests/fixtures/collectors/commandcode/transcripts/partial-trailing-line.jsonl new file mode 100644 index 0000000..7807094 --- /dev/null +++ b/tests/fixtures/collectors/commandcode/transcripts/partial-trailing-line.jsonl @@ -0,0 +1,5 @@ +{"type":"session","version":3,"id":"dddddddd-0000-0000-0000-000000000004","timestamp":"2026-08-04T11:00:00.000Z","cwd":"/home/user/project-delta"} +{"type":"message","id":"d1","parentId":null,"timestamp":"2026-08-04T11:00:05.000Z","message":{"role":"assistant","content":[{"type":"text","text":"redacted"}]},"usage":{"inputTokens":500,"outputTokens":80,"cacheReadTokens":200,"cacheWriteTokens":0,"costUsd":0.0002},"model":"deepseek/deepseek-v4-flash","effort":"max"} +{"type":"message","id":"d2","parentId":"d1","timestamp":"2026-08-04T11:00:10.000Z","message":{"role":"user","content":[{"type":"text","text":"redacted"}]}} +{"type":"message","id":"d3","parentId":"d2","timestamp":"2026-08-04T11:00:20.000Z","message":{"role":"assistant","content":[{"type":"text","text":"redacted"}]},"usage":{"inputTokens":600,"outputTokens":90,"cacheReadTokens":250,"cacheWriteTokens":0,"costUsd":0.00025},"model":"deepseek/deepseek-v4-flash","effort":"max"} +{"type":"message","i diff --git a/tests/fixtures/collectors/commandcode/transcripts/valid-multi-session.jsonl b/tests/fixtures/collectors/commandcode/transcripts/valid-multi-session.jsonl new file mode 100644 index 0000000..26309a6 --- /dev/null +++ b/tests/fixtures/collectors/commandcode/transcripts/valid-multi-session.jsonl @@ -0,0 +1,6 @@ +{"type":"session","version":3,"id":"aaaaaaaa-0000-0000-0000-000000000001","timestamp":"2026-08-03T08:00:00.000Z","cwd":"/home/user/project-alpha"} +{"type":"message","id":"a1","parentId":null,"timestamp":"2026-08-03T08:00:10.000Z","message":{"role":"user","content":[{"type":"text","text":"redacted"}]}} +{"type":"message","id":"a2","parentId":"a1","timestamp":"2026-08-03T08:00:20.000Z","message":{"role":"assistant","content":[{"type":"text","text":"redacted"}]},"usage":{"inputTokens":800,"outputTokens":120,"cacheReadTokens":300,"cacheWriteTokens":0,"costUsd":0.0003},"model":"deepseek/deepseek-v4-flash","effort":"low"} +{"type":"session","version":3,"id":"bbbbbbbb-0000-0000-0000-000000000002","timestamp":"2026-08-04T10:00:00.000Z","cwd":"/home/user/project-beta"} +{"type":"message","id":"b1","parentId":null,"timestamp":"2026-08-04T10:00:05.000Z","message":{"role":"user","content":[{"type":"text","text":"redacted"}]}} +{"type":"message","id":"b2","parentId":"b1","timestamp":"2026-08-04T10:00:15.000Z","message":{"role":"assistant","content":[{"type":"text","text":"redacted"}]},"usage":{"inputTokens":2000,"outputTokens":400,"cacheReadTokens":1000,"cacheWriteTokens":0,"costUsd":0.001},"model":"deepseek/deepseek-v4-flash","effort":"medium"} diff --git a/tests/fixtures/collectors/commandcode/transcripts/valid-single-session.jsonl b/tests/fixtures/collectors/commandcode/transcripts/valid-single-session.jsonl new file mode 100644 index 0000000..ee52b3f --- /dev/null +++ b/tests/fixtures/collectors/commandcode/transcripts/valid-single-session.jsonl @@ -0,0 +1,5 @@ +{"type":"session","version":3,"id":"11111111-2222-3333-4444-555555555555","timestamp":"2026-08-04T09:00:00.000Z","cwd":"/home/user/redacted-project"} +{"type":"message","id":"m1","parentId":null,"timestamp":"2026-08-04T09:00:05.000Z","message":{"role":"user","content":[{"type":"text","text":"redacted"}]}} +{"type":"message","id":"m2","parentId":"m1","timestamp":"2026-08-04T09:00:10.000Z","message":{"role":"assistant","content":[{"type":"text","text":"redacted"}]},"usage":{"inputTokens":1000,"outputTokens":200,"cacheReadTokens":500,"cacheWriteTokens":0,"costUsd":0.0005},"model":"deepseek/deepseek-v4-flash","effort":"max"} +{"type":"message","id":"m3","parentId":"m2","timestamp":"2026-08-04T09:01:00.000Z","message":{"role":"user","content":[{"type":"text","text":"redacted"}]}} +{"type":"message","id":"m4","parentId":"m3","timestamp":"2026-08-04T09:01:05.000Z","message":{"role":"assistant","content":[{"type":"text","text":"redacted"}]},"usage":{"inputTokens":1500,"outputTokens":300,"cacheReadTokens":700,"cacheWriteTokens":50,"costUsd":0.0009},"model":"deepseek/deepseek-v4-flash","effort":"max"}