diff --git a/docs/adr/0009-usage-scorecard-local-transcript-analytics.md b/docs/adr/0009-usage-scorecard-local-transcript-analytics.md index f55a270d..94eb15f8 100644 --- a/docs/adr/0009-usage-scorecard-local-transcript-analytics.md +++ b/docs/adr/0009-usage-scorecard-local-transcript-analytics.md @@ -2,6 +2,13 @@ - **Status:** Implemented - **Date:** 2026-07-25 +- **Updated:** 2026-09-29 — usage schema 25 → 26 delivers bounded session surface/provider + evidence, per-turn Codex import ownership, positive component usage without responses, + one Claude message charge owner across the bounded two-window pool, separate host-reported + reconciliation signals, explicit OpenCode source/cost/coverage semantics and timezone-aware + cache reuse. Footprint remains schema 8. The + [current accounting contracts](../usage-scorecard-metrics.md#current-accounting-and-cache-contracts) + define source bounds and compatibility; historical measurements below are not fresh results. - **Updated:** 2026-09-20 — ADR-0054 adds an explicit, offline, allowlisted fleet export boundary; local analytics and dashboard collection semantics remain unchanged. - **Earlier update:** 2026-09-09 — reconciled against repository source and tests for issue #211 diff --git a/docs/adr/0052-codex-usage-attribution.md b/docs/adr/0052-codex-usage-attribution.md index 2e5a6ec2..55343df8 100644 --- a/docs/adr/0052-codex-usage-attribution.md +++ b/docs/adr/0052-codex-usage-attribution.md @@ -12,6 +12,18 @@ record with at least one response (`usage-aggregate.mjs`'s `buildSessionRows`), so the file's 176,326 tokens never reach any total regardless of the explanation. The advisory is right in substance; classification is unchanged. Extends the "Not done" bullet below with the measured counts. +- **Updated:** 2026-09-29 — Unit 8 re-read the one gap candidate selected from a read-only schema-25 + cache under a 2 MiB source bound. Its current source has five token snapshots with full + input/cache/output components, no normalized assistant response, tool items and an abort. + The parser already retains its component row; aggregation now admits a Codex record with + positive component usage even when responses are zero. A positive total-only counter + remains unsupported: it supplies no input/cache/output split or price. Source health + discloses such events as `total-only-token-count` and reports zero-response records with + counted components separately from those without attributable component rows. +- **Updated:** 2026-09-29 — accepted V6 also classifies guardian reviews and other thread sources, + rolls up verified acyclic parent links, retains effort and host-reported first-token timing, + and exposes compaction bounds. Auto-review models without supported prices are unpriced. + The delivered cache migration is 25 → 26; earlier v23 evidence below describes the original fix. - **Deciders:** agentic-kit maintainers - **Related:** [ADR-0009](0009-usage-scorecard-local-transcript-analytics.md), [ADR-0038](0038-consistent-cross-host-session-metrics.md), @@ -121,16 +133,50 @@ lists them). Their turns are stamped `external-import-turn-N`, they have no "responses" and a large share of prompts, priced at model `unknown`, $0, while the real data lives in the Claude transcript. -The in-rollout marker (the `turn_id` prefix) is the signal, so detection works -without the imports file. Parsing stops at the first such line; the record is kept -out of aggregation, out of every yield statistic, and counted in -`diagnostics.importedExcluded` (796 on the reference machine). Nothing is dropped -silently. The record itself is still cached, so a rescan is cheap. - -Project discovery applies the same marker to each rollout's bounded head (256 KiB, 40 lines): an -imported copy names no project, host or Desktop origin, and the scan reports how many it set aside -(`importedExcluded`, 924 on the reference machine on 2026-09-27, every marker on the rollout's -second line). +The in-rollout marker (`payload.turn_id` prefix) is the signal; the import map is not a +runtime dependency. Exclusion is per turn. A valid native `task_started` or identified +`turn_context` opens native ownership; explicit record IDs must agree with that boundary. +Missing or conflicting IDs in mixed files remain unattributable. An absent context ID may +enrich an identified turn, but an explicitly invalid boundary ID breaks adjacency and marks +ownership incomplete. A foreign completion does +not close the active turn. Replayed parent history still cannot count as child activity. +Marker text in messages and later `session_meta` declarations establish no ownership. + +Copied prompts, responses, tools, context and tokens are excluded. Excluded cumulative +snapshots advance the baseline; native snapshots book only their deltas. Decreasing counters +or an explicit first native `last_token_usage == total_token_usage` reset start a new segment +(the latter excludes identical re-emissions). First session identity remains authoritative. +A native turn's cwd can establish its genuine project; otherwise the first declared cwd is +eligible only after own activity is proved. The first declared app surface applies, without +assuming every mixed file came from Desktop. + +Files without proven own activity remain `imported: true` with unknown/imported-copy origin +and are cached but excluded. Mixed files retain `importEvidence`: `importedTurns`, +`importedRecords`, `ambiguousRecords` and `nativeRecords`, with no turn IDs or copied content. +The unique imported-turn set retains at most 4,096 bounded IDs; `importedTurnCountComplete` +marks a lower-bound count when capped. Mixed sources with clipped lines, skipped nonblank +records or explicitly invalid turn-boundary IDs are conservatively excluded in their entirety +with `ownershipComplete: false`, pending a complete readable source. String and streaming +readers expose the last pass's skipped-record count as `importEvidence.malformedRecords`; +clipping retains its existing diagnostic. This may omit proven activity before a gap but +cannot carry native ownership across unreadable copied-turn metadata. A subagent +whose replay cannot be separated also has incomplete ownership. Source-health counters +`importOwnershipIncompleteFiles` and `importedTurnCountIncompleteFiles` retain these gaps +even when the file is excluded or served from cache. +Usage diagnostics expose `importedExcluded`, `importedMixed`, `importedTurnsExcluded` and +`importAmbiguousRecords`; the ambiguous count discloses excluded records without proved +ownership. Aggregate rows and session detail preserve the same evidence. Schema 26 remains +the single unreleased migration; no personal cache is rebuilt during implementation. + +Discovery first reads its usual 256 KiB/40-line head. Import-marked heads additionally read +at most a 256 KiB head and a 2 MiB tail, capped at 20,000 records per window and 512 MiB of +additional reads per scan. An unread middle resets ownership. Positive native activity can +establish a mixed sighting; imported-only exclusion requires a complete, unambiguous read. +Malformed envelopes or explicitly invalid turn-boundary IDs also leave an import candidate +unresolved. A sampled subagent without a complete replay boundary remains unresolved. Sources and the +discovery summary expose `importedMixed` and `importedUnresolved`; unresolved imports make +`complete` and `sessionCountComplete` false and cannot trigger encoded-directory recovery. +These are bounded observations, not an exhaustive turn census. ### 4. Cumulative counter restarts are summed, per event @@ -171,7 +217,7 @@ tools and `FunctionCallOutput` the known set. Only a type in none of them warns. ## Consequences -- Cache schema **v23**: every cached Codex record and its `parseStats` re-derive. +- Original implementation cache schema **v23**: every cached Codex record and its `parseStats` re-derive. Earlier records carry the wrong imports, subagent usage, replay counts, last-wins totals, single-day/model rows and permanent diagnostics. - Codex subagent sessions now have real tokens and cost. Cost totals, the @@ -204,28 +250,26 @@ subagent and previously dropped usage is now priced. ## Not done (recorded follow-ups) -- Guardian-review classification, unread host fields, and the context-coverage - denominator remain as audited; this ADR does not change them. +- Guardian-review classification, effort, first-token timing and compaction evidence are now + captured. Compaction lower/nullable upper bounds preserve uncertain pairing. This does not + establish support for every unread host field or change the context-coverage denominator. - A stream tee or push channel for live oversized rollouts is out of scope. - The cause of counter restarts is unknown (decision 4). - A subagent with no ordinals still reports no usage (decision 2). -- One rollout carries `token_count`s but no agent message, so the pre-existing - `partial-response-yield` warning remains — measured on the reference machine (2026-09-28): of 1,714 - Codex rollouts (734 token-bearing), exactly 1 is such a gap file. It is explained by a cached fact - (`session.aborts > 0`, tool-only activity) but that does not change what is counted: the aggregate - never builds a session row for a record with zero responses (`usage-aggregate.mjs`'s - `buildSessionRows`), so the file's usage (176,326 tokens, its own `last_token_usage.total_tokens` - sum across its `token_count` events) reaches no total either way. Counting that usage, or - documenting the shape more precisely, is left to the usage-accuracy branch. -- Whole-rollout exclusion may drop real usage (open, plausible, 2026-09-27). On the reference - machine 6 of 924 imported rollouts carry a later turn that is not an import: one `task_started` - whose `turn_id` starts with `rollout-`, no `user_message` event, `role: user` response items in - five of the six (2 to 76 per file) and non-zero `token_count` usage (the per-file sum of - `last_token_usage.total_tokens` is about 8k to 449k). Both usage and discovery set the whole file - aside at the marker, so this usage is not counted. With no `user_message`, the turn may be - automatic (a compaction or title pass). Measured from counts only. Decided 2026-09-27 (audit - decision 12): Branch 8 excludes per turn instead of per file, so imported turns are never counted - and later turns are, after it establishes whether they are the user's work or an automatic pass. +- The 2026-09-28 full-corpus count (1,714 rollouts, 734 token-bearing, one + zero-response gap) is historical. Unit 8's bounded 2026-09-29 re-read of that gap + candidate found 11,082 uncached input, 163,456 cached input and 1,788 output + tokens in a native, zero-response record. Those components now reach aggregate + totals and cost estimation. Total-only counters still cannot yield a split or + price and are diagnosed rather than silently treated as free usage. +- The historical 2026-09-27 observation (6 of 924 import-marked files) did not prove + that every token snapshot in those files belonged to a native turn. Unit 7 implements + decision 12 per turn. The 2026-09-29 metadata-only reproduction found 6 mixed files among + 945 import-marked candidates, with 64 native-turn responses. Only one token snapshot fell + inside a native interval, and its input/cache/output components were all zero; the other + snapshots were copied-turn evidence. No billable components are inferred from total-only + counters. The native turn's initiator remains unknown unless separately declared; this + does not prove whether it was user work or an automatic pass. ## Verification diff --git a/docs/adr/0060-session-surface-initiator-and-product-names.md b/docs/adr/0060-session-surface-initiator-and-product-names.md index 098892ff..63e29e2f 100644 --- a/docs/adr/0060-session-surface-initiator-and-product-names.md +++ b/docs/adr/0060-session-surface-initiator-and-product-names.md @@ -1,13 +1,13 @@ # ADR-0060 — Session surface, initiator and official product names -- **Status:** Proposed; §3 implemented for project discovery (2026-09-27), the rest staged follow-on +- **Status:** Accepted - **Date:** 2026-09-26 -- **Updated:** 2026-09-27 — §3 implemented for project discovery and the System projects note: - imported copies give no project, host or origin and are counted. The ledger-derived source labels - (Cursor, Cowork) and the other views remain proposed. +- **Updated:** 2026-09-29 — delivered shared classification, usage/cache and census evidence, + Runtime application attribution, and CLI/dashboard presentation. Dedicated Cowork storage remains + an optional follow-up (#257); acceptance covers the bounded sources described below. - **Deciders:** agentic-kit maintainers - **Related:** [ADR-0050](0050-dashboard-project-identity-and-context-reporting.md) (session origin - rule, superseded in part by this record once accepted), + rule, superseded in part by this record), [ADR-0052](0052-codex-usage-attribution.md) (imported Codex rollouts excluded from usage), [ADR-0025](0025-machine-footprint-metrics.md) (Runtime census labels), [ADR-0027](0027-shared-project-census.md) (project census), @@ -25,7 +25,7 @@ the product's official commercial name, with no duplicate or false categories. Research on 2026-09-26 read only enumerated log fields and counts (no prompt or response content), the vendors' current documentation, the openai/codex source at `7f6c0f9`, and the installed Claude -Code 2.1.283 and Claude Desktop 2.9939.2 builds. What it established: +Code 2.1.283 and Claude Desktop 2.9939.2 builds. What that historical sample established (not a fresh census or current behavior): 1. **The logs declare where a session came from.** Claude Code transcripts carry `entrypoint`; Codex rollouts carry `session_meta.originator`, `source` and `thread_source`. Folder location is @@ -66,9 +66,9 @@ Code 2.1.283 and Claude Desktop 2.9939.2 builds. What it established: copied in five files. The Runtime census counts `Claude.app` as the Claude Code host (basename match) while `ChatGPT.app` is invisible. -## Decision (proposed) +## Decision -### 1. Two dimensions from declared fields, raw value always kept +### 1. Separate dimensions from declared fields, bounded raw evidence Every session record carries: @@ -77,18 +77,23 @@ Every session record carries: - **Initiator** — `person`, `automation`, `agent` (a subagent or reviewer spawned by another session) or `imported-copy`. Claude: person when interactive or the entrypoint is one Claude Code itself treats as attended (`claude-vscode`, `claude-desktop*`, `local-agent`, `remote*`, - `ssh-remote`, Claude Tag values); automation for `sdk-*`, `mcp`, `claude-code-github-action`, and + `ssh-remote`, Claude Tag values); automation for `sdk-py`, `sdk-ts`, `sdk-cli`, `mcp`, `claude-code-github-action`, and for `sessionKind` `bg`/`daemon`/`daemon-worker`. Codex: `thread_source` `user` and `chatgpt_handoff` are person, except that `codex exec` top-level threads are automation; - `subagent` and `guardian_review` are agent; app feature values such as `automation` are + `subagent`, `guardian_review` and `agent_created_thread` are agent; app feature values such as `automation` are automation. -- **Raw evidence** — the exact declared values (`entrypoint:cli`, - `originator:codex_exec/source:exec`). An unrecognized value is shown as "Other" with its raw value, - never merged into a larger bucket. +- **Raw evidence** — bounded tokens from the named declaration fields, such as `entrypoint:cli` and + `originator:codex_exec/source:exec`. Unfamiliar valid tokens remain available in local detail, with Other or Unknown + classification and no inferred product or provider. Tokens must be at most 80 characters, + begin with a letter and contain only letters, digits, underscores, dots or hyphens; the known + `Codex Desktop` value is the sole space-containing exception. Malformed values are omitted. Folder class (ChatGPT Projects folder, projectless Work folder, Cowork data, temporary folder) is an -explanatory attribute, never the classifier. The first record that declares a value wins, and -the rule is stated in code and tests. +explanatory attribute, never the classifier. The first eligible declaring record wins within each reader's bounded evidence window; +invalid values remain Unknown and do not authorize a search for a preferred later identity. +A later replayed parent declaration cannot replace the child's identity. Git scope, host, +surface, initiator and provider are separate fields and filters. Cloud choices appear only +when a covered record declares a cloud surface. ### 2. Official names @@ -101,8 +106,8 @@ Claude (per code.claude.com and claude.com documentation): | `claude-desktop`, `claude-desktop-3p` | Claude Desktop (attribute "on 3P" for the second) | person | | `local-agent`, `local_agent`, `remote_cowork` | Cowork | person | | `remote`, `remote_desktop`, `remote_mobile`, `remote_projects` | Cloud session (attribute: started from Desktop, mobile, web or a project) | person | -| `remote_trigger`, `remote_cowork_trigger` | Cloud session (routine) | automation | -| `sdk-py`, `sdk-ts` | Claude Agent SDK (attribute: Python or TypeScript; "plugin hook" when evidenced) | automation | +| `remote_trigger`, `remote_cowork_trigger` | Cloud session | automation | +| `sdk-py`, `sdk-ts` | Claude Agent SDK | automation | | `sdk-cli` | Non-interactive mode (`claude -p`) | automation | | `claude-code-github-action` | GitHub Actions | automation | | `claude_in_slack`, `claude-in-slack`, `claude-in-teams` | Claude Tag (Slack or Teams) | person | @@ -126,7 +131,8 @@ OpenAI (per learn.chatgpt.com, which developers.openai.com/codex now redirects t | `codex_work_web`, `codex_work_mobile`, `codex_work_cca`, `chatgpt_cca` | ChatGPT Work (cloud) | by `thread_source` | | any other | Other OpenAI client (raw value shown) | by `thread_source` | -Subagents and Auto-review roll up under their parent surface ("Subagents", "Auto-review", OpenAI's +Codex subagents and Auto-review with a verified, acyclic parent link in the observed record set +roll up under their parent surface ("Subagents", "Auto-review", OpenAI's own labels) and are never separate products. `source="vscode"` never produces a "VS Code" label. Hosts are named **Claude Code**, **Codex**, **OpenCode** (by Anomaly) and **Hermes Agent** (Nous @@ -135,10 +141,20 @@ Desktop** and **ChatGPT desktop app**; they are applications, not hosts. ### 3. Imported copies are excluded everywhere, and counted -ADR-0052's rule extends to project discovery and every origin view: a rollout stamped -`external-import-turn-*` (or listed in the imports ledger when present) contributes no project -sighting, origin, facet count or Runtime attribution. Each view reports how many it excluded, labelled -"Imported from Claude Code" (or Cursor, or Cowork, from the ledger's source path). +ADR-0052's rule extends to project discovery and origin views **per turn**. The portable +signal is `payload.turn_id` beginning `external-import-turn`; an import map is not required. +Copied turns contribute no usage or project/origin sighting. A later proven native turn can +establish the first declared app surface and a genuine project; unknown or conflicting turn +boundaries remain excluded with diagnostics. A Desktop declaration is not inferred for +other declared products. First session identity and parent replay exclusion remain intact. + +Pure imports remain unknown/imported-copy. Mixed usage rows retain import-exclusion counts. +Discovery distinguishes confirmed exclusions (`importedExcluded`), proven mixed observations +(`importedMixed`) and bounded observations that cannot settle ownership (`importedUnresolved`). +The latter make coverage incomplete, without inventing a project from an encoded directory. +See ADR-0052 §3 for exact byte/record budgets and cumulative-counter rules. Intelligence, +System Projects and `ak system` disclose these populations and source incompleteness. +Optional ledger source labels remain follow-on work. ### 4. One vocabulary module and one label table @@ -151,56 +167,59 @@ Labels are tested once; views test that they use the shared table. - The Runtime census treats `Claude.app` and `ChatGPT.app` symmetrically as desktop applications, neither as a host; their bundled CLIs are attributed to the hosted session (lane D's Claude rule extended to the Codex bundle). -- Cowork transcripts become an optional discovery source; until then views say Cowork is not - covered. +- Dedicated Cowork storage remains uncovered (#257), and views disclose that limit. Covered + Claude transcript records may still declare the Cowork surface; that does not prove coverage + of the separate store. -### 6. Counting rules +### 6. Counting rules and compatibility -Claude sessions are counted by `sessionId`, excluding `subagents/` transcripts and non-conversation -records; Codex subagent and reviewer rollouts roll up to their parent; `thread_source` is classified -in full. +Claude project census sessions use declared `sessionId`, excluding subagent and bridge-only +transcripts. Rows expose `countBasis`: declared-session IDs, transcript files, database sessions, +recovered-project sightings or mixed observations. Encoded-directory recovery can establish a +project sighting with zero session weight; missing identity and bounded reads keep completeness +visible. Census session observations are distinct from billed Usage sessions. + +Project `sessionSurfaces` is additive: legacy `sessionOrigins` remains for compatibility. Raw +project detail unions retain at most 16 sorted values per named field per classification group; +`rawEvidenceComplete: false` discloses truncation. The raw-token policy was explicitly approved +by the maintainer for local detail; it does not authorize publishing private tokens. + +Old coarse `codex-desktop` snapshots render Unknown surface with a ChatGPT desktop app family +note because their mode was not recorded. Old `claude-desktop` snapshots retain Claude Desktop; +initiator and provider remain Unknown. Saved legacy origin filters preserve their membership +and use explicit legacy labels. Missing newer fields are not evidence of a precise mode. + +### 7. Provider evidence is independent + +Claude provider-specific assistant model IDs may establish Amazon Bedrock or Google Vertex AI +metadata (`assistant-model-id`); ordinary or conflicting IDs leave Unknown. Codex and OpenCode +may provide a recorded provider ID. Both are observed source metadata, not network attestation. +Current environment, routing configuration, application identity and price-table identity cannot +establish a historical serving provider. The `on 3P` attribute remains visible independently of +provider Unknown. Codex Auto-review tokens remain unpriced when no supported price exists. ## Consequences -- Usage, System → Projects, Maintenance facets, Intelligence designation (which today mixes Git - scope, origin and host in one enum) and the Runtime table change labels and counts. On this - machine 32 project folders lost a false Desktop origin when discovery began setting imports aside - (re-measured 2026-09-27; 23 on 2026-09-26). -- The usage cache schema changes (new session fields); a rebuild is expected. -- Tests that pin current names change together (inventory in the audit record, Addendum 3). -- `CLAUDE_CODE_ENTRYPOINT` and the transcript format are internal to Claude Code and may change; - keeping the raw value and an "Other" fallback bounds that risk. -- Privacy is unchanged: only enumerated values and counts are read. - -## Open questions for acceptance - -- Whether "Cloud session" should appear at all in local views, given none was observed locally. -- Whether the "on 3P" attribute is worth showing. -- How ADR-0057's role lenses consume surface and initiator. -- Whether a later turn inside an imported copy that is not itself an import (6 of 924 rollouts on - 2026-09-27, with real token usage) counts as the importing app's own session. Decided 2026-09-27 - (audit decision 12): it counts, excluded per turn in Branch 8 - ([ADR-0052](0052-codex-usage-attribution.md), "Not done"). - -## Verification (when implemented) - -Fixtures per raw value; an import-ledger join fixture; a census reproduction of the 2026-09-26 counts -from enumerated values; one-label-per-value UI assertions across views; no prompt content in any -fixture. - -## Implementation status - -§3 is implemented for project discovery and the System projects note (2026-09-27): an imported copy -gives no project, host or origin, and discovery counts it in `importedExcluded`. The per-source -labels from the imports ledger, Runtime attribution and §1, §2 and §4–§6 remain follow-on work -(the audit record's Addendum 3). - -Three views already show the smaller project counts but do not yet say how many imported copies were -set aside; §3's "each view reports how many it excluded" is still owed for them: - -- the Intelligence census line (`src/lib/dashboard/client/intelligence.mjs`, which prints - `everSeen`; the server's `readCensus` in `src/lib/dashboard-server.mjs` drops `importedExcluded`); -- the System → Projects liner (`sysProjectsLinerHtml` in - `src/lib/dashboard/client/system-projects.mjs`); -- the `ak system` text output (`renderProjects` in `src/commands/system.mjs`, which prints only the - count; `ak system --json` carries `importedExcluded`). +- Shared vocabulary in `src/lib/session-surface.mjs` supplies Usage, project details, Maintenance, + Intelligence and Runtime labels. Desktop applications have no host identity; bundled CLIs need + observed session attribution, and a Codex app-server process is a service. +- Usage cache schema changes exactly **25 → 26**; old entries require rebuilding. Footprint + snapshot schema remains **8**, with additive evidence and explicit legacy presentation. +- First-declaration, parent-link, import-ownership and source bounds prevent these observations + from establishing whole-corpus coverage. Historical research counts above are not release metrics. +- Internal host fields can change. Unknown, bounded raw evidence and source-health diagnostics + preserve uncertainty without deriving products from directories or `source="vscode"`. + +## Verification and remaining limits + +Synthetic fixtures cover shared vocabulary, first declaring records, parent/reviewer attribution, +legacy filters, raw-token caps, provider evidence, import ownership, session-count bases and +Runtime application/service distinctions. CLI/dashboard consumer assertions cover the shared +labels and disclosures. The implementation is bound to the accepted V6 source units; final +integration gates and publication are separate decisions. + +Dedicated Cowork storage (#257), optional import-ledger source labels, missing parent evidence +and records outside bounded readers remain uncovered. No new live corpus, provider, performance +or billing measurement is claimed by this documentation update. See +[Usage metrics](../usage-scorecard-metrics.md#current-accounting-and-cache-contracts) for the +bounded accounting, source selection and cache contracts delivered alongside this vocabulary. diff --git a/docs/adr/README.md b/docs/adr/README.md index 7b12aab7..8eb84908 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -66,7 +66,7 @@ Consequences**, and cites the grounded source it rests on where relevant. | [0054](0054-fleet-evidence-export.md) | Vendor-neutral fleet evidence export | Implemented | | [0055](0055-aqe-embedding-lifecycle.md) | AQE embedding lifecycle and qualified readiness | Implemented | | [0058](0058-managed-ruflo-components.md) | Managed ruflo components | Accepted (implementation in progress — see Implementation status) | -| [0060](0060-session-surface-initiator-and-product-names.md) | Session surface, initiator and official product names | Proposed; §3 implemented for project discovery (2026-09-27), the rest staged follow-on | +| [0060](0060-session-surface-initiator-and-product-names.md) | Session surface, initiator and official product names | Accepted | | [0061](0061-brain-reclaim-stuck-remediation.md) | RuvNet Brain "unresolved rollback state" remediation | Accepted | | [0062](0062-aqe-project-store-integrity.md) | AQE project store integrity | Accepted | | [0063](0063-evidence-store-and-refresh-vocabulary.md) | One evidence store and the refresh vocabulary | Accepted; CLI and dashboard refresh operation delivered | @@ -399,11 +399,13 @@ component out. ## ADR-0060 — Session surface, initiator and official product names -[ADR-0060](0060-session-surface-initiator-and-product-names.md) (Proposed; §3 implemented for -project discovery) derives a session's surface and initiator from the hosts' declared log fields, -keeps every raw value, uses official product names (Claude Desktop, ChatGPT desktop app, Codex CLI, -and others), and excludes imported session copies from every origin view. Project discovery already -sets imported copies aside and counts them; the other decisions remain proposed. +[ADR-0060](0060-session-surface-initiator-and-product-names.md) (Accepted; updated 2026-09-29) +separates Git scope, host, session surface, initiator and provider using declared source evidence +and shared official labels. Local detail retains approved bounded origin tokens; observed provider +metadata is not network attestation. Per-turn import ownership, count bases and completeness remain +visible, desktop applications are distinct from hosts, and legacy filters preserve their labeled +membership. Dedicated Cowork storage remains uncovered (#257). Usage schema changes 25 → 26; +footprint stays 8. ## ADR-0062 — AQE project store integrity diff --git a/docs/archive/2026-09-28-plan-usage-accuracy.md b/docs/archive/2026-09-28-plan-usage-accuracy.md new file mode 100644 index 00000000..0c0eae8c --- /dev/null +++ b/docs/archive/2026-09-28-plan-usage-accuracy.md @@ -0,0 +1,200 @@ +# Usage accuracy execution plan + +- **Branch:** `fix/usage-accuracy`, based on `develop@e2f9dcae0554ff63921df618a819fd5e6afe80d2` +- **Scope sources:** [v2 V6](../plans/2026-09-28-remediation-program-v2.md), + [v1 Wave 4](../plans/2026-09-26-remediation-program.md), + [ADR-0060](../adr/0060-session-surface-initiator-and-product-names.md). + +## Status + +Implemented on `fix/usage-accuracy`; all 24 units are independently accepted. +The whole-branch review and its scoped correction review passed through `6d5979cc`. +A one-line legacy test correction at `1c02db91` passed controller review, followed +by all eight local gates on that exact clean commit: 6,405 unit tests passed, +seven skipped, legacy suites passed, and browser checks passed (514 legacy +assertions plus 16 native tests). Coverage was 94.19% lines, 83.82% branches and +93.50% functions. Feature PR CI, develop integration and final human main review +remain separate gates at this archival capture. No release, installation or real +store operation is claimed. Unit 11 captures Claude Code's +latest valid cumulative `cost-state` checkpoint as a separate reconciliation +signal. It reports provable time or token scope differences while preserving +message-derived cost totals. The observed checkpoint has no end time or serving +provider attestation, so equal counters remain unverified. Unit 12 now retains +bounded, hashed Claude API message identities per cached file and reconciles +copied charges after discovery. Aggregate responses/tokens/cost count a shared +message once; each session's `responses` still counts what its transcript +recorded, with `accountedResponses` showing its aggregate share. Unit 12 is +accepted, including its bounded global-message-owner policy. The Claude identity pool always covers the +displayed window and its equal-length predecessor, regardless of the +`previous` or `lookbackDays` options. One owner is elected per identity across +that pool before either window is projected; a copied message therefore +contributes to at most one of the two windows. Explicit deeper lookback can +support other history views but cannot change an eligible owner's charge. +Eligibility requires both the file mtime and transcript session end to reach +the fixed horizon; older-mtime copies discovered by a deeper lookback cannot +steal or enlarge it. Distinct historical messages remain visible under that +explicit request, with out-of-pool coverage reported in source health. Claude +reads may reach twice the displayed window (capped at 730 days for the +dashboard's 365-day maximum), with the cap reported for wider callers. +Unit 13 records an entry-level local calendar context: the resolved full timezone +identity plus Node's tzdata and ICU versions. A mismatch or missing/invalid context +reparses available source records; no timestamp is inferred from a cached day. +Process memo and single-flight keys include that context. Degraded OpenCode entries +retain their original marker but cannot contribute incompatible day/punchcard rows; +source health reports `timezoneCacheEntriesExcluded`. Unset `TZ` uses the runtime's +resolved machine zone; an unresolved zone declines cache and aggregate-memo reuse. +Schema remains 26, preserving Unit 15's independent OpenCode cost marker. Unit 13 +is accepted. Unit 14 adds count-only Claude record coverage for known handled, +known ignored, unknown, invalid-type and malformed JSON lines. A v26 cache entry +without those counters reparses; unknown or malformed records degrade source +health without changing message usage or cost. The bounded real-data sample and +focused verification are in the ignored task14 handoff report. Unit 14 and Units 15–19 are independently accepted. + +Pricing retains its existing local `row.day` contract (`usage-parsers.localDay`, +`pricing.costOf`, and the cache-saving probes documented in usage metrics). Cold +and warm reads within a zone must agree, including dated rates. A timezone change +can move a row across a dated rate boundary and therefore change its API-equivalent +estimate; this unit neither establishes a provider billing timezone nor freezes a +price from the old local day. Reported OpenCode cost stays observed, and token, +provider, model, Claude cost-state, Codex fields and duration semantics stay intact. + +Unit 2 is limited to the agreed classifier interface, parser fields and one +usage-cache schema bump. +The maintainer approved retaining unfamiliar, bounded tokens from named origin +fields as raw evidence for local detail. The classifier now retains those tokens +without inferring a product or provider; malformed, oversized and non-string +values remain excluded. Units 22/23 delivered the local detail UI and count/coverage disclosures. Unit 24 records +ADR-0060 acceptance against those implemented contracts, subject to documentation review. + +Unit 6 records Amazon Bedrock or Google Vertex AI only when a Claude assistant +message carries a provider-specific model ID. Conflicting or ordinary IDs leave +the provider unknown. Historical transcripts do not capture launch environment +or settings, so current configuration cannot identify their serving provider; +OpenRouter, other gateways and private endpoints remain unknown without bound +session evidence. The detail stays under `sessionOrigin.thirdPartyProvider` with +`thirdPartyProviderBasis: assistant-model-id` when known. Units 22/23 implement display. + +## Gates and ownership + +All source units have completed their assigned implementation and independent review. Unit 24 +owns the affected ADR bodies and living guides in the assigned worktree. The controller owns +shared indexes/manifests, final integration gates, whole-branch review, plan archival and any +separately authorized publication. Unit 24 runs documentation gates only. No private transcript +content, raw identifiers, paths or observed costs enter public documentation. + +## Capture units + +| Unit | Source boundary and acceptance | Focused evidence / dependency | +|---|---|---| +| 1 | Shared raw-value → surface → initiator → label vocabulary; bounded raw evidence, unknown and provider separate. Adapt footprint origin with legacy fields retained. | ADR table fixtures, first declaration, privacy, identity and imports regressions. Accepted. | +| 2 | Parser and usage cache integration, exactly one schema 25→26 bump. Carry new fields and rebuild old cache. | Parser, cache migration and aggregate tests; after unit 1. Footprint schema stays 8. | +| 3 | Full Codex `thread_source` classification, subagent/reviewer rollup and unpriced Auto-review models (X-7). | Per-value parser fixtures and counts; after unit 2. | +| 4 | Count Claude by `sessionId`, exclude subagent and bridge transcripts, and remeasure source-bound census. | Duplicate/session fixtures plus enumerated-count reproduction; after unit 2. | +| 5 | Runtime census symmetry for Claude.app and ChatGPT.app; attribute bundled CLIs to observed sessions. | Runtime fixtures on both app forms; after units 2–4. | +| 6 | Third-party Claude provider from session-bound provider-specific assistant model ID; unknown remains unknown and provider is a separate detail field. Cloud label renders only for observations. | Evidence-precedence and unknown fixtures; after unit 2. | +| 7 | Imported turn exclusion per turn; later genuine Codex turns count and establish an actual app origin (decision 12/B1-4). | Mixed import/real-turn fixture and enumerated-count reproduction; after unit 2. | +| 8 | Token-bearing Codex record with zero responses: count it or document exact unsupported shape (UA-5). | Minimal shape reproduction and reconciliation; after unit 2. | +| 9 | O-7 session `byProvider` last-wins repair. | Count-only reproduction, provider totals; after parser integration. | +| 10 | X-8 Codex effort, first-token time and compaction capture. | Field fixtures and aggregate reconciliation; after parser integration. | +| 11 | C-6 Claude `cost-state` reconciliation. | Cost-state fixture and count-only sample; after parser integration. | +| 12 | C-8 cross-file message-id dedup. | Duplicate message fixture and count-only sample; after parser integration. | +| 13 | C-9 local-timezone day bucketing frozen in cache. | Boundary-day fixtures in two zones; after cache integration. | +| 14 | C-11 unknown-record counter. | Known/unknown record fixtures; after parser integration. | +| 15–19 | O-6, O-9, O-10, O-11, O-12, each in a separate commit. | Accepted: cost trust and cache semantics, explicit database selection, V2/legacy coverage warnings, bounded compaction/reconciliation and child fingerprint exclusion. | +| 20 | StatusLine classifier reads local managed settings. | Settings fixture and classifier regression; independently reordered before V4 integration. Other managed policy channels remain unobserved. | +| 21 | Shell wrapper around footer helper is `custom` (UA-4). | Wrapper fixture; after unit 20. | +| 22 | Shared labels across Usage, System Projects, Maintenance and Intelligence; unknown has one label and designations use separate axes. | View assertions; **after V3 merges into develop**, then integrate develop. | +| 23 | Show imported exclusion count in Intelligence census, System Projects and `ak system`; disclose Cowork source coverage is absent. | Three render assertions and source-bound counts; after V3 and unit 7. | +| 24 | Accept ADR-0060 and align DDD/docs to verified implementation. | Docs links and drift checks; assigned documentation writer after all prior units; controller owns shared indexes. | + +## Source and test map + +This table preserves the initial candidate boundaries. Exact delivered files and source-bound +results are in the private unit reports; the candidates grant no new edit authority. All source +units are accepted and only the documentation candidate remains under review. + +| Unit | Candidate source | Test entrypoint | +|---|---|---| +| 2 | `src/lib/usage-parsers.mjs`, `usage-project-evidence.mjs`, `usage-index.mjs` | `tests/kit/usage-index.test.mjs`, `usage-codex-attribution.test.mjs` | +| 3 | `src/lib/usage-parsers.mjs`, `usage-aggregate.mjs` | `tests/kit/usage-codex-attribution.test.mjs`, `usage-classify.test.mjs` | +| 4 | `src/lib/usage-parsers.mjs`, `footprint/project-sources.mjs` | `tests/kit/usage-claude-dedup.test.mjs`, `dashboard-project-identity.test.mjs` | +| 5 | `src/lib/footprint/runtime.mjs`, `project-census.mjs` | `tests/kit/footprint-collectors.test.mjs`, `system-summary.test.mjs` | +| 6 | `src/lib/usage-parsers.mjs`, `usage-local-provider.mjs` | `tests/kit/usage-provenance.test.mjs`, `usage-local-pricing.test.mjs` | +| 7 | `src/lib/codex-import-marker.mjs`, `usage-parsers.mjs`, `footprint/project-sources.mjs` | `tests/kit/project-sources-imports.test.mjs`, `usage-codex-attribution.test.mjs` | +| 8 | `src/lib/usage-parsers.mjs`, `usage-aggregate.mjs` | `tests/kit/usage-codex-attribution.test.mjs`, `usage-codex-large-rollout.test.mjs` | +| 9 | `src/lib/usage-opencode.mjs`, `usage-aggregate.mjs`; parser row identity already exists | `tests/kit/usage-opencode.test.mjs`, `usage-index-opencode.test.mjs`, `usage-index.test.mjs` | +| 10 | `src/lib/usage-parsers.mjs`, `usage-insights.mjs` | `tests/kit/usage-codex-attribution.test.mjs`, `usage-context.test.mjs` | +| 11 | `src/lib/usage-parsers.mjs`, `usage-cost.mjs`, `usage-aggregate.mjs`, `usage-index.mjs` | `tests/kit/usage-claude-cost-state.test.mjs`, `usage-claude-dedup.test.mjs`, `usage-index.test.mjs` | +| 12 | `src/lib/usage-parsers.mjs`, `usage-index.mjs` | `tests/kit/usage-claude-dedup.test.mjs`, `usage-index.test.mjs` | +| 13 | `src/lib/usage-index.mjs`, `usage-aggregate.mjs` | `tests/kit/usage-index.test.mjs`, `usage-claude-window-pairing.test.mjs` | +| 14 | `src/lib/usage-parsers.mjs`, `usage-aggregate.mjs` | `tests/kit/usage-index.test.mjs`, `usage-telemetry.test.mjs` | +| 15 O-6 | `src/lib/usage-opencode.mjs`, `usage-cost.mjs` | `tests/kit/usage-opencode.test.mjs`; accepted | +| 16 O-9 | `src/lib/usage-opencode.mjs`, `usage-opencode-bounds.mjs` | `tests/kit/usage-index-opencode.test.mjs`; accepted | +| 17 O-10 | `src/lib/usage-opencode.mjs`, `usage-index.mjs` | `tests/kit/usage-opencode.test.mjs`; accepted | +| 18 O-11 | `src/lib/usage-opencode.mjs`, `usage-aggregate.mjs` | `tests/kit/usage-opencode.test.mjs`; accepted | +| 19 O-12 | `src/lib/usage-opencode.mjs`, `usage-parsers.mjs` | `tests/kit/usage-opencode.test.mjs`; accepted | +| 20 | `src/lib/quota.mjs` | `tests/kit/quota.test.mjs`, `usage-limits-empty-state.test.mjs` | +| 21 | `src/lib/quota.mjs` | `tests/kit/quota.test.mjs` | +| 22 | `src/lib/dashboard/client/usage.mjs`, `system-projects.mjs`, `intelligence.mjs`, `maintenance-filters.mjs` | `tests/kit/dashboard-project-groups.test.mjs`, `intelligence-table-groups.test.mjs`, `maintenance-dashboard-client-labels.test.mjs` | +| 23 | `src/lib/dashboard/client/intelligence.mjs`, `system-projects.mjs`, `src/commands/system.mjs` | `tests/kit/dashboard-intel-integration.test.mjs`, `system-command.test.mjs` | +| 24 | `docs/adr/0060-session-surface-initiator-and-product-names.md`, relevant DDD guide | `tests/kit/docs-layout.test.mjs` and Markdown lint | + +Units 9–19 recorded bounded source observations and synthetic affected-row evidence in their +unit reports. A sample without an affected row is not proof of current-user impact. Reference +counts in ADR-0060 remain historical. No new real-data probe runs in Unit 24. + +## Unit 18 accepted: OpenCode compaction and reconciliation + +The parser reads bounded compaction parts and selected session metadata. A user +compaction request plus an error-free assistant summary with a finish value and +that actual parent link establishes one completed observation per request. +Requests alone, orphan summaries and in-flight markers retain uncertainty in the +lower/upper bounds. Failed or aborted summaries do not establish completion. +The OpenCode aggregate projection now retains those bounds; Codex and Claude +projections retain their existing behavior. + +Session counters are diagnostic only. Exact OpenCode v1.18.33 source shows that +session totals accumulate step-finish parts, but assistant tokens hold the latest +step. Reconciliation therefore requires completed valid messages, exactly one +matching valid step per assistant, populated valid session counters, and no V2 +rows in that session. Multiple or missing steps, incomplete metadata, unsupported +versions/token bases and untrusted hosted zero costs remain unknown. Matching or +mismatching counters never replace or add to message usage. No steps are billed a +second time. The cache marker extends cost-trust-v2 with observations-v1; schema +26, source identity and timezone checks remain intact. + +The bounded local metadata sample contained three sessions, no compaction parts, +no in-flight markers and no populated session counters. Its database digest was +unchanged. This is not positive affected-user evidence; synthetic fixtures cover +the supported and failure cases. Detailed commands and evidence are in the +ignored task18 report. + +Unit 18 review fixes bind warm reuse to a SHA-256 digest of the selected session's +observation metadata, relevant message fields, compaction/step-finish parts, and +V2 scope presence. Both the probe and parser stay within the same per-session +acquisition ceilings and their own read snapshots; the persisted digest comes +from the parse snapshot. Unchanged inputs reuse the cache; same-count rewrites +and removals invalidate it even when upstream timestamps do not change. No +whole-database payload hash or prompt-body hash is used. + +Response-free request evidence now remains in the current/previous compaction +bounds. Refused acquisitions contribute only their unknown bound, preserving the +existing rule that they do not become ordinary zero-cost session rows. Neither +path manufactures responses, tokens or billing. Unit 18 was accepted before +Unit 19 began. + +## Unit 19 accepted: OpenCode child prompt fingerprints + +OpenCode sessions with a nonempty `parent_id` retain prompts, turns, tokens, +provider costs and subagent classification, but produce no prompt fingerprints. +Both scan and selected-session parsing apply this rule. Aggregation also excludes +old cached child fingerprints from typed-prompt metrics, current prompt patterns +and historical baselines; the cached bytes remain until normal invalidation. +Main-session behavior and Unit 18 cache identity, timezone and observation marker +checks remain intact. Schema 26 is unchanged. + +Synthetic native-schema tests cover matching and distinct parent/child text, +child-only historical windows, absent parent rows, scan/read parity, stale warm +cache consumption and retained provider usage. The prior bounded preflight found +no child sessions; it does not establish current-user impact. Unit 19 is independently accepted; Unit 24 is the documentation candidate. Commands and results are in the ignored +task19 report. diff --git a/docs/archive/2026-09-29-plan-v6-opencode-cost.md b/docs/archive/2026-09-29-plan-v6-opencode-cost.md new file mode 100644 index 00000000..d9cd70ff --- /dev/null +++ b/docs/archive/2026-09-29-plan-v6-opencode-cost.md @@ -0,0 +1,35 @@ +# V6 Unit 15: OpenCode reported-zero cost trust + +## Status + +The scoped parser/cost work and core cache invalidation handoff are implemented and independently accepted. The final OpenCode marker also includes observation semantics. +Full V6 verification passed at `1c02db91`; feature PR CI and develop integration +remain separate gates at this archival capture. No live store mutation is claimed. + +## Decision and scope + +OpenCode may record zero when a model has no configured rate. A positive-token +assistant response with recorded cost zero is therefore unpriced when its +provider is nonlocal or unknown. A known local provider's zero remains observed. +Positive recorded costs, zero-token responses, and missing-cost estimates keep +their existing treatment. Provider attribution remains per response. + +This unit changes only the OpenCode parser and the shared cost reader at its +OpenCode-specific row boundary. No rate is inferred from the model name, host, +or environment. + +## Evidence and acceptance + +- A bounded, read-only live-store query checks counts and shape, with a file + digest before and after. No affected positive-token zero-cost row was found. +- Synthetic SQLite messages with the real storage shape test remote, unknown, + and local provider IDs; mixed observed and missing cost; malformed costs; + and cold/warm cache conservation. +- Focused tests, typecheck, scoped lint, and diff checks gate the unit commit. + +## Integration dependency + +Existing schema-26 cached OpenCode records cannot reconstruct which responses +had an untrusted reported zero after per-response data was coalesced. The core +owner must invalidate or reparse those old records before release. This unit +does not edit the core-owned cache/index module. diff --git a/docs/archive/2026-09-29-plan-v6-surfaces-ui.md b/docs/archive/2026-09-29-plan-v6-surfaces-ui.md new file mode 100644 index 00000000..1f0c5d26 --- /dev/null +++ b/docs/archive/2026-09-29-plan-v6-surfaces-ui.md @@ -0,0 +1,20 @@ +# V6 session presentation execution + +## Status + +Units 22/23 and their consumer/default-suite handoff are implemented and independently accepted; final legacy Unknown filter compatibility is included. +Full V6 verification passed at `1c02db91`; feature PR CI and develop integration +remain separate gates at this archival capture. No live store mutation is claimed. + +Approved Units 22/23, based on `4a414024`; sole writer in `task/v6-surfaces-ui`. + +1. Add a shared presentation vocabulary and additive project session surface evidence. + Preserve legacy origin/count bases and honest uncertainty in old snapshots. +2. Render independent host, surface, initiator, provider and Git scope dimensions. + Retain bounded local raw details without inferring products or network providers. +3. Disclose pure imported exclusions, mixed files and unresolved ownership separately + in Intelligence, System Projects and the system command, including Cowork coverage. +4. Validate focused contracts and actual renderers, static checks and browser behavior. + Stop after unit commits for independent review; no publishing or personal data reads. + +Baseline: 29 focused renderer/import/empty-state tests passed before changes. diff --git a/docs/archive/2026-09-29-v6-codex-thread-source-evidence.md b/docs/archive/2026-09-29-v6-codex-thread-source-evidence.md new file mode 100644 index 00000000..9f8f9e5e --- /dev/null +++ b/docs/archive/2026-09-29-v6-codex-thread-source-evidence.md @@ -0,0 +1,31 @@ +# V6 Unit 3: Codex thread sources and Auto-review pricing + +Source state: `bf8babd4` plus this unit's changes. Checked 2026-09-29 10:57 UTC with `codex-cli 0.158.0`. + +## Findings and change + +- **Verified kit defect:** an unfamiliar `thread_source` on a known interactive originator inherited `person`. The classifier now reports `unknown`, while recognized user, handoff, agent, and automation values retain their declared initiators. The fixed SDK, exec, and MCP rules still apply. +- **Verified kit defect:** SQLite ledger backfill changed `threadSource` without rebuilding `sessionOrigin`. It now classifies from bounded raw declaration evidence and preserves the first rollout declaration over a later replayed parent declaration. +- **Verified kit gap:** the observed `source.subagent.thread_spawn.parent_thread_id` was not retained. The first metadata line now accepts only a UUID-shaped parent ID. Aggregate rollup uses a parent present in the same Codex record set, rejects missing links and cycles, and groups child and reviewer records under that parent's surface. It keeps child-owned usage and strips only a ledger-identified subagent whose rollout could not separate replay. +- **Verified kit defect:** `codex-auto-review` token rows were assigned unknown-model fallback dollars. This exact server-side alias now contributes tokens and unpriced-message coverage, with zero estimated dollars and no invented cache saving. Published known-model pricing is unchanged. + +## Bounded local metadata probe + +Read only the first JSONL line of 1,806 local Codex rollout files, plus `turn_context.model` for guardian-review files. This is a file census, not a unique-session census or a billing statement. The `thread_source` counts were: `subagent` 510, `user` 200, absent/null 994, `guardian_review` 90, `chatgpt_handoff` 10, `agent_created_thread` 2. None had a structured `thread_source`; 600 had structured `source.subagent` (500 `thread_spawn`, 90 `other` with guardian review, 10 `other` with subagent). The 500 observed `thread_spawn` parent IDs were UUID-shaped. Guardian-review files contained 843 `turn_context` declarations of `codex-auto-review`. The probe did not read prompt text, deduplicate sessions, inspect imports, or verify a published price. + +## Verification + +- RED: new focused tests failed on unfamiliar source classification, parent extraction, ledger origin, and Auto-review fallback pricing. A separate cycle fixture failed before the cycle guard. +- GREEN: `node scripts/run-tests.mjs exec -- --test tests/kit/usage-codex-attribution.test.mjs tests/kit/usage-session-surface.test.mjs tests/kit/session-surface.test.mjs tests/kit/usage-index-v6.test.mjs tests/kit/usage-codex-thread-source.test.mjs tests/kit/pricing.test.mjs` — 101 passed, 0 failed. +- `node node_modules/typescript/bin/tsc --noEmit` — passed. `node node_modules/eslint/bin/eslint.js` on the six changed source/test files — passed. `git diff --check` — passed. +- The synthetic aggregate fixture checks parent, child, and reviewer totals and cold/warm cache consistency without touching the real usage cache. + +## Accounting limits + +The 26 schema remains unchanged. Existing schema-26 caches created before this unit may lack parsed parent IDs until a fresh cache rebuild; the final pre-PR gate owns that rebuild. A reviewer without a verified parent remains on its declared surface because no parent can be inferred. No provider calls, full unit/UI suite, real cache migration, or user database writes were made. + +## Independent review fix round 1 — 2026-09-29 11:05 UTC + +- **P2 confirmed:** a present `thread_source` rejected by the bounded token parser (`{}` or a string containing spaces) became `null`, allowing a known interactive originator's `person` default. The classifier now distinguishes an absent declaration from a rejected one without retaining rejected content. Focused fixtures cover direct classification, the first rollout metadata line, ledger overlay, and the SDK, exec, and MCP fixed initiators. +- **P3 confirmed:** ledger backfill called `classifySessionSurface` without the imported-copy flag and could replace an imported copy's `initiator` with `agent`. `ledgerOrigin` now preserves the parser's imported-copy override. The regression fixture uses a real minimal imported rollout and a synthetic guardian-review ledger row; the exported helper remains safe even though `buildIndex` filters imported records before aggregation. +- RED: both new defect fixtures failed against `5fe016d1`. GREEN: the same six focused test files listed above passed, **103 tests, 0 failures**. TypeScript `--noEmit`, targeted ESLint on the three changed source/test files, and `git diff --check` passed. No full unit/UI run or local corpus repeat was performed. diff --git a/docs/archive/README.md b/docs/archive/README.md index 6e65c9bb..3929324e 100644 --- a/docs/archive/README.md +++ b/docs/archive/README.md @@ -69,6 +69,10 @@ reconfirmed by this metadata audit. The per-file inventory and limitations are r | File | Original location | What it was | Why it's historical | |---|---|---|---| | [2026-09-28-plan-follow-ups-v2.md](2026-09-28-plan-follow-ups-v2.md) | `docs/plans/2026-09-28-follow-ups-v2.md` | V4 product, CLI, memory, process-lifecycle and upstream integration follow-ups. | All eight local gates and independent whole-branch review passed at `29152654`; final-head PR CI and squash integration were pending at archival. Conditional Ruflo #3419 guidance remains deferred; native Windows AQE was not tested. | +| [2026-09-28-plan-usage-accuracy.md](2026-09-28-plan-usage-accuracy.md) | `docs/plans/2026-09-28-usage-accuracy.md` | Completed V6 usage and session evidence plan | All 24 units independently accepted; whole-branch review and all eight local gates passed through `1c02db91`. Feature PR CI and develop integration remain separate gates at archival. Current contracts: [Usage metrics](../usage-scorecard-metrics.md) and [ADR-0060](../adr/0060-session-surface-initiator-and-product-names.md). | +| [2026-09-29-plan-v6-surfaces-ui.md](2026-09-29-plan-v6-surfaces-ui.md) | `docs/plans/2026-09-29-v6-surfaces-ui.md` | Completed V6 session presentation plan | Shared vocabulary, legacy filter compatibility, independent evidence dimensions and source-coverage disclosures; included in the V6 gates at `1c02db91`. | +| [2026-09-29-plan-v6-opencode-cost.md](2026-09-29-plan-v6-opencode-cost.md) | `docs/plans/2026-09-29-v6-opencode-cost.md` | Completed OpenCode reported-zero trust plan | Scoped cost work and mandatory core cache handoff accepted; versioned observation semantics are documented in the living usage guide. | +| [2026-09-29-v6-codex-thread-source-evidence.md](2026-09-29-v6-codex-thread-source-evidence.md) | `.superpowers/sdd/2026-09-28-usage-accuracy/task3-report.md` | Historical V6 Unit 3 evidence | Original bounded metadata census and initial/fix tests retained verbatim. Its interim cache note describes that capture stage, not a live-cache mutation or current release claim. | | [2026-09-29-plan-upstream-watch-followups.md](2026-09-29-plan-upstream-watch-followups.md) | `docs/plans/2026-09-29-upstream-watch-followups.md` | V4 C4 execution plan for bounded PR polling, deterministic retry failures and twelve deferred watcher minors. | Implementation and independent review complete; all eight local gates passed at `b75c1e3f`. Final PR CI and merge were pending at archival. Current contract: [Upstream watch](../upstream-watch.md). | | [2026-06-upstream-findings-f1-f6.md](2026-06-upstream-findings-f1-f6.md) | `docs/upstream/ruflo-self-improvement-findings.md` | The F1–F6 findings series: proofs/refutations of ruflo's self-improvement claims (Q-learning persistence, state-encoder collapse, SONA learn→inference wiring, native-training misreporting), with filed upstream issues. | Every finding is now fixed upstream: F2 in 3.10.6 ([#2222](https://github.com/ruvnet/ruflo/issues/2222)), F2b in 3.10.7, F3 in 3.10.11 ([#2239](https://github.com/ruvnet/ruflo/issues/2239)), F4 in `@ruvector/ruvllm` 2.5.6 ([RuVector#519](https://github.com/ruvnet/RuVector/issues/519)), F6 in 3.18.1/3.19.0 + ruvllm 2.5.7 ([#2549](https://github.com/ruvnet/ruflo/issues/2549), closed 2026-07-03). | | [2026-06-token-consumption-incident.md](2026-06-token-consumption-incident.md) | `docs/usage/token-consumption-findings-and-mitigation-2026-06.md` | Root-cause report for the June 2026 token-burn incident: six immortal auto-started daemons consumed ~8.1B tokens over 7 days via headless worker sessions. Produced the opt-in daemon policy, TTL reaper, ⚙ statusline alarm, and `ruflo-token-audit`. | The root cause was fixed upstream in ruflo 3.27/3.28 ([#2661](https://github.com/ruvnet/ruflo/issues/2661)): AI workers are opt-in, launches are governed by a machine-wide budget with telemetry, one supervisor daemon per repo, native daemon TTL. The kit's daemon policy flipped back to default-on (local-only workers) on that baseline; the reapers and token-audit remain as an independent check. | diff --git a/docs/codex-usage-diagnostic.md b/docs/codex-usage-diagnostic.md index 4151b979..d9284fcc 100644 --- a/docs/codex-usage-diagnostic.md +++ b/docs/codex-usage-diagnostic.md @@ -9,6 +9,16 @@ Everything you need is below: what was wrong, why the fix can be trusted without re-auditing the code yourself, how to run one script, and exactly what to send back. +**Updated 2026-09-29 — diagnostic scope.** This script remains an independent historical +cumulative-snapshot comparison. It does not reproduce the current parser's per-turn import +ownership, replay subtraction, counter segments, per-day/model attribution, or positive component +usage with zero responses. Use [ADR-0052](adr/0052-codex-usage-attribution.md) and the +[current accounting contracts](usage-scorecard-metrics.md#current-accounting-and-cache-contracts) +to interpret differences. Total-only counters remain unsupported for split/pricing, Auto-review +models may be unpriced, and host-reported first-token/compaction evidence is separate. A mismatch +with this script alone is not evidence of a present accounting defect. The figures below are +historical, not a new corpus measurement. + --- ## The short version diff --git a/docs/dashboard.md b/docs/dashboard.md index ad48fdea..0654494c 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -237,6 +237,24 @@ or proof of subscription billing. Claude Code writes one transcript line per con repeats the message's usage on each, so Claude tokens, cost, responses and context samples count each API message once. +**Evidence and compatibility (updated 2026-09-29).** Surface, initiator and provider are +separate from host and Git scope. Local session/project detail exposes bounded declared origin +tokens and observed provider basis; Unknown remains explicit, and provider metadata is not +network attestation. Legacy desktop filters retain their labeled membership. A coarse legacy +Codex desktop snapshot gives a ChatGPT desktop app family note without guessing its mode. +Cloud choices require observations; dedicated Cowork storage remains uncovered. Census views +show imported exclusions, mixed/unresolved observations, count basis and incomplete coverage. +Runtime distinguishes desktop applications from their observed CLI sessions. + +Usage counts proven native intervals in mixed imports and positive Codex token components even +without responses. Claude copied messages have one charge owner across the bounded current and +previous window pool; session-local observations remain qualified. OpenCode unknown/nonlocal +positive-token zero cost is unpriced. Source warnings remain visible without current-window +activity, and ambiguous databases require explicit selection. Older caches may rebuild; old +OpenCode child fingerprints are ignored by prompt metrics while usage remains. See the +[current contracts](usage-scorecard-metrics.md#current-accounting-and-cache-contracts) for exact +bounds, timezone behavior and the version-limited OpenCode reconciliation signal. + **Two hero rows.** The first carries sessions, api-equivalent cost, tokens, engaged time, and cache read. Each tile pairs its figure with a change against the previous window of the same length and a per-day sparkline, so the number and its direction arrive together. The change is read @@ -267,7 +285,8 @@ each with its percentile markers laid over the bars. A percentile that lands in top bucket renders with a `≥` prefix — the bucket has no upper edge, so the honest claim is a floor rather than a point. A window holding no samples reads `not measured` instead of a row of zero bars. **Response latency is the gap between a prompt and the response that answered it. It is not -time-to-first-token**, which no local transcript records. +time-to-first-token**. Codex can separately record host-reported first-token timing; that +observation does not rename or replace the completion-latency metric. **How you run** answers permission posture, who drove, and who served. Posture is a closed four-value vocabulary — guarded, auto-edit, plan, unrestricted — mapped from each host's own @@ -279,9 +298,9 @@ own nested transcript, so that cost is discovered, priced, and included; a forke rollout opens with its parent's replayed history, so only what follows the replay is counted — the subagent's own tokens are priced and the parent is never billed twice. A subagent from a host that records no event ordinals cannot have its replay separated and reads `$0.00`, which means not -measurable rather than cheap. Codex sessions imported from Claude Code are not Codex activity and are -excluded from every Codex figure. The panel does not rank window cost by inference provider: a transcript host is not a vendor. -Codex and OpenCode can record a serving provider, while Claude history lacks that field; +measurable rather than cheap. Copied turns imported from Claude Code are excluded from Codex figures; proven native +turns in mixed files remain eligible, with unresolved ownership disclosed. The panel does not rank window cost by inference provider: a transcript host is not a vendor. +Codex and OpenCode can record a serving provider, while Claude may expose provider-specific assistant model metadata; identity is reported per session on the Sessions detail strip — beside the provenance backing it — rather than as a window axis. diff --git a/docs/ddd/machine-footprint.md b/docs/ddd/machine-footprint.md index a27b8295..c7ad16f3 100644 --- a/docs/ddd/machine-footprint.md +++ b/docs/ddd/machine-footprint.md @@ -646,15 +646,22 @@ declarations and unclassified sightings. `countBasis` distinguishes transcript f sessions, recovered-project sightings and mixed observations; a recovered directory is not one verified session. -Imported session copies are not sightings. A Codex rollout stamped `external-import-turn-*` is a -Claude Code transcript that the ChatGPT desktop app imported; it is a copy, and the original Claude -Code session is counted where its transcript still exists. A folder that only an import names is -therefore not a project. Discovery skips it before reading its cwd, so it adds no project, host or Session -origin, and counts it in `importedExcluded` (per host scan and in total). `complete` is unaffected. - -**Proposed change ([ADR-0060](../adr/0060-session-surface-initiator-and-product-names.md)).** -`sessionOrigins` is to be replaced by session surface and initiator, derived from the same declared -fields but keeping every raw value. +Updated 2026-09-29: imported Codex turns are not sightings. Pure copies add no project, host or +surface and count in `importedExcluded`; proven native activity in a mixed file can establish a +sighting and counts in `importedMixed`. Bounded observations that cannot settle ownership count +in `importedUnresolved` and make coverage incomplete. The budgets and malformed-record behavior +are specified in [ADR-0052](../adr/0052-codex-usage-attribution.md#3-sessions-codex-imported-from-claude-code-are-not-codex-sessions). + +Accepted [ADR-0060](../adr/0060-session-surface-initiator-and-product-names.md) adds +`sessionSurfaces` alongside legacy `sessionOrigins`. Claude census counts declared session IDs, +excluding subagent and bridge-only records; recovery has zero session weight. Raw detail retains +at most 16 validated tokens per field per classification group and reports truncation. Cloud +surfaces appear only with observations; dedicated Cowork storage remains uncovered. Old coarse +Codex desktop origins cannot identify an app mode. Footprint schema remains 8. + +Runtime distinguishes Claude Desktop and the ChatGPT desktop app as applications with no host. +Their bundled CLI sessions require observed process attribution; Codex app-server is a service. +Application presence alone does not establish an active conversation or historical provider. `project-identity.mjs` relates directories through canonical Git metadata and, for linked worktrees, a verified common directory plus backlink. It preserves unknown association when evidence is diff --git a/docs/ddd/ubiquitous-language.md b/docs/ddd/ubiquitous-language.md index c62b9a92..dda55274 100644 --- a/docs/ddd/ubiquitous-language.md +++ b/docs/ddd/ubiquitous-language.md @@ -133,20 +133,21 @@ missing price. `Dual-host` describes two enabled peer hosts, not an execution command and not evidence that two inference vendors served a workflow. Generalized execution belongs to `ak run`. -## Session surface language (mostly proposed) +## Session surface language -These terms are proposed by [ADR-0060](../adr/0060-session-surface-initiator-and-product-names.md). -Only **Imported session copy** is implemented so far, and only in usage and project discovery. For -the rest, the implemented contract is ADR-0050's **session origin** (`claude-desktop`, -`codex-desktop` or `unknown`), which an imported copy never supplies. +Updated 2026-09-29 to match accepted +[ADR-0060](../adr/0060-session-surface-initiator-and-product-names.md). Git scope, host, surface, +initiator and provider are separate dimensions. Legacy origin keys remain compatibility evidence. | Term | Meaning | |------|---------| | Session surface | The product surface that started a session, read from the host's own declared field (Claude `entrypoint`; Codex `originator` with `source`) and shown by its official name, such as Claude Code CLI, Claude Desktop, ChatGPT desktop app · Codex, or Codex CLI | -| Initiator | Who started a session: a person, automation (scripts, SDKs, non-interactive runs, CI), an agent (a subagent or reviewer spawned by another session), or an imported copy | -| Imported session copy | A session one tool copied from another, such as a Claude Code transcript the ChatGPT desktop app imported as a Codex thread; excluded from usage, origin and project counts and reported as a count | -| Raw surface value | The exact declared value a surface was derived from; always kept, and shown for any value the vocabulary does not recognize | +| Initiator | Who started a session: a person, automation (scripts, SDKs, non-interactive runs, CI), an agent (a subagent or reviewer spawned by another session), an imported copy, or Unknown | +| Imported session copy | A session one tool copied from another, such as a Claude Code transcript the ChatGPT desktop app imported as a Codex thread; whose copied turns are excluded from usage and project/origin sightings; proven native turns in mixed files remain eligible, with exclusion and incompleteness counts | +| Raw surface value | A named declaration token retained only under the approved 80-character validation rule; unfamiliar valid tokens can appear in local detail without inferring a product or provider | | Tool workspace | A folder a tool creates for its own work outside the user's projects, such as `~/.codex/.chatgpt-projects/…` or `~/Documents/Codex/…`; an explanation attribute, never a surface | +| Session count basis | The counted unit: declared session IDs, transcript files, database sessions, recovered sightings or mixed observations; zero-weight recovery is not a verified session | +| Provider evidence basis | Recorded provider ID or provider-specific assistant model ID; observed metadata, not network attestation | | Desktop application | Claude Desktop or the ChatGPT desktop app; an application that can start sessions, not a host | Say **session surface** for where a session came from; the Live event `surface` field (native, ruflo, diff --git a/docs/plans/2026-09-28-remediation-v2-develop-execution.md b/docs/plans/2026-09-28-remediation-v2-develop-execution.md index bed7dd78..c0d5c16e 100644 --- a/docs/plans/2026-09-28-remediation-v2-develop-execution.md +++ b/docs/plans/2026-09-28-remediation-v2-develop-execution.md @@ -130,6 +130,11 @@ V4's temporary busy-rule/memory-routing CI probe is sandboxed and removed before V6 reads the actual cache schema before making exactly one migration; the old plan's 25 → 26 number must not overwrite a schema bump that has already landed. +V6 Unit 21 implements static `statusLine` command classification for direct helper +invocations and treats shell wrappers, inline programs, and chains as `custom`. +Its focused and unit gates passed; the unit commit is pending independent review. +V6's UI copy remains with V3 until that stream integrates. + ### Scheduling and team shape The session has four slots total: controller plus at most three workers. Ruflo or AQE diff --git a/docs/transcripts.md b/docs/transcripts.md index cb3a61b7..357ed048 100644 --- a/docs/transcripts.md +++ b/docs/transcripts.md @@ -30,29 +30,37 @@ checks run in the test suite --- +**Updated 2026-09-29.** The scan and reader share source selection and parse semantics, while +cross-file Claude charge ownership occurs only after discovery. Session-local transcript counts +need not equal aggregate accounted responses. Usage cache schema 26 additionally requires calendar, +source and semantic compatibility; the read-path figure below illustrates the original split, +not every current cache key. OpenCode child user turns remain readable but do not enter prompt +fingerprints. See the [current accounting contracts](usage-scorecard-metrics.md#current-accounting-and-cache-contracts) +for bounded imports, ownership, source coverage, reconciliation and warm-cache validation. + ## 1. Transcript stores Claude and Codex use JSONL files; OpenCode uses its SQLite session/message/part store. The kit reads source histories without rewriting them (transcripts are never -rewritten; rule 3 of the module header, `usage-index.mjs:22`): +rewritten; rule 3 of the module header, `usage-index.mjs:24`): | Host | Store | Discovered by | |---|---|---| -| Claude Code | `~/.claude/projects//.jsonl` | `listClaude` (`usage-index.mjs:374-388`) — exactly one level of project directories | -| Claude Code (subagent) | `~/.claude/projects///subagents/agent-.jsonl` | `listClaudeSubagents` (`usage-index.mjs:349-354`) — the one nested shape `listClaude` descends into | -| Codex CLI | `~/.codex/sessions///
/rollout--.jsonl` | `listCodex` (`usage-index.mjs:391-411`) — the `yyyy/mm/dd` tree walk | +| Claude Code | `~/.claude/projects//.jsonl` | `listClaude` (`usage-index.mjs:460-474`) — exactly one level of project directories | +| Claude Code (subagent) | `~/.claude/projects///subagents/agent-.jsonl` | `listClaudeSubagents` (`usage-index.mjs:435-440`) — the one nested shape `listClaude` descends into | +| Codex CLI | `~/.codex/sessions///
/rollout--.jsonl` | `listCodex` (`usage-index.mjs:477-497`) — the `yyyy/mm/dd` tree walk | | OpenCode | platform data root `opencode/opencode.db` (normally `~/.local/share/opencode/opencode.db` on Unix) | `usage-opencode.mjs` reads session/message/part rows with read-only SQLite queries | -Roots come from `defaultRoots()` (`usage-index.mjs:324-329`) and are injectable +Roots come from `defaultRoots()` (`usage-index.mjs:374-415`) and are injectable for tests. A malformed line is skipped, never fatal (`jsonLines`, -`usage-parsers.mjs:174-180` — one corrupt line must not cost a whole file). +`usage-parsers.mjs:183-191` — one corrupt line must not cost a whole file). A session's **delegated** work is a real transcript of its own, written beside the parent under `/subagents/`. Discovery is that one nested shape and no more — not a recursive walk — so a directory that is not a session-id directory with a `subagents` child contributes nothing rather than being crawled. Each such record takes a **namespaced** id, `/` -(`usage-index.mjs:354`), because Claude Code names every subagent file +(`usage-index.mjs:440`), because Claude Code names every subagent file `agent-.jsonl` and that stem is not unique across two parent sessions; an unnamespaced id would silently collide two unrelated records into one. §4.1 covers how a namespaced id is validated and resolved back to its file. @@ -68,22 +76,22 @@ evidence when their native records name it; Claude history normally leaves it un ### 1.1 Claude entry vocabulary Each line has a top-level `type`. The parser (`parseClaude`, -`usage-parsers.mjs:747-777`) reads: +`usage-parsers.mjs:822-871`) reads: | `type` | What the parser takes from it | |---|---| -| "ai-title" | The model-written session title (`usage-parsers.mjs:768`) — preferred over the first-prompt fallback | -| `user` | A user-**role** turn — which is *not* the same as "the human"; see §3. On a turn that passes isHumanPrompt, also its `permissionMode` — the session's permission posture, read on the person's own turn only (`usage-parsers.mjs:593-609`) — and the opening of the response-latency window | -| `assistant` | One content block of a model message: `model` id, the message's `usage` token counts (repeated on every block's line, so counted once per `message.id`, else `requestId`, last line winning), `tool_use` blocks (`usage-parsers.mjs:661-743`) | -| any | Side-band fields read regardless of type: `attributionSkill`/`attributionPlugin` (`usage-parsers.mjs:770-771`), `isSidechain` (`usage-parsers.mjs:772-773`), `cwd` for project derivation | +| "ai-title" | The model-written session title (`usage-parsers.mjs:852`) — preferred over the first-prompt fallback | +| `user` | A user-**role** turn — which is *not* the same as "the human"; see §3. On a turn that passes isHumanPrompt, also its `permissionMode` — the session's permission posture, read on the person's own turn only (`usage-parsers.mjs:628-644`) — and the opening of the response-latency window | +| `assistant` | One content block of a model message: `model` id, the message's `usage` token counts (repeated on every block's line, so counted once per `message.id`, else `requestId`, last line winning), `tool_use` blocks (`usage-parsers.mjs:708-818`) | +| any | Side-band fields read regardless of type: `attributionSkill`/`attributionPlugin` (`usage-parsers.mjs:854-855`), `isSidechain` (`usage-parsers.mjs:856-862`), `cwd` for project derivation | A real assistant completion also closes two pieces of per-entry evidence the transcript does not state outright. It **closes the latency window** the preceding human prompt opened, into one `noteLatencySample` call over the gap -between them (`usage-parsers.mjs:722-725`); and it **conditionally sets `ctxLastTokens`** to the +between them (`usage-parsers.mjs:779-782`); and it **conditionally sets `ctxLastTokens`** to the tokens actually in the model's window for that turn — fresh input plus what was served from cache — so the field always describes the last completion rather -than a running total (`usage-parsers.mjs:405-410`; the call site is at lines 658–668). That write is +than a running total (`usage-parsers.mjs:430-435`; the call site is at lines 658–668). That write is evidence-gated: an entry whose `message.usage` is absent decodes to all-zeros, and a zero is not a measurement of an empty context, so it must not overwrite a real prior value. Neither is a field Claude Code writes; both are derived, per @@ -93,7 +101,7 @@ An assistant entry with `isApiErrorMessage: true` is a **local placeholder** Claude Code writes when a request dies before a real completion (connection drop, rate limit, auth failure — `model: ""`, all-zero usage). It is real engaged time but not a model attempt: counted as an *exception*, never -pushed into `models`, priced, or counted as a response (`usage-parsers.mjs:700-735`; the full story is +pushed into `models`, priced, or counted as a response (`usage-parsers.mjs:757-792`; the full story is [`usage-scorecard-metrics.md`](usage-scorecard-metrics.md) §10). It is not a latency sample either — the pending window is deliberately left open, so the first *real* completion that eventually follows is what gets timed. @@ -101,22 +109,22 @@ first *real* completion that eventually follows is what gets timed. ### 1.2 Codex entry vocabulary Codex rollout lines carry `type` + `payload`. The parser (`parseCodex`, -`usage-parsers.mjs:1183-1240`) reads: +`usage-parsers.mjs:1355-1442`) reads: | `type` / `payload.type` | What the parser takes from it | |---|---| -| `session_meta` | Authoritative session id, `cwd`, and `thread_source` — the FIRST such line in the file wins for all three, AND for `inferenceProvider`/`providerProvenance` too (`usage-parsers.mjs:831-841`, gate; `:767-784`, why); a subagent rollout replays its PARENT thread's own session_meta line later in the same file, and a later-wins rule let that relabel the record `subagent`→`user` and re-key its id to the parent's — `"subagent"` marks a delegated thread whose rollout may open with its parent's replayed history; events before the replay boundary (`codex-replay.mjs`) count for nothing, so the subagent's own tokens are counted and the replay is not, and the record stays flagged `subagent` (`usage-parsers.mjs:1188-1195`; `usage-scorecard-metrics.md` Appendix A, Bug B) | -| `turn_context` | The model id in effect from this point on, plus `approval_policy` (a string) and `sandbox_policy` (an **object** keyed `.type`, e.g. `{"type":"danger-full-access"}`) — the permission posture, last evidence winning, since a session may renegotiate mid-run (`usage-parsers.mjs:843-864`) | -| `event_msg` → `token_count` | A **cumulative** usage snapshot, turned into its increase over the previous one and booked on the event's local day under the model of the turn in effect; a snapshot lower than its predecessor starts a new segment (the host's counter restarted), so every segment counts. A replayed snapshot only advances the running total (`usage-parsers.mjs:898-909`) | -| `event_msg` → `task_started` | `model_context_window` — denominator-only compatibility evidence, not proof of a paired input/window sample — and the turn's start time (`usage-parsers.mjs:915-934`) | -| `event_msg` → `task_complete` | The host's own `duration_ms` for the turn, taken as a latency sample only when no prompt-to-response gap already covered it; a non-null `error` counts as an exception (`usage-parsers.mjs:936-954`) | -| `event_msg` → `turn_aborted` | An explicit interrupt: counted in `aborts`, and it clears both latency states so an unanswered prompt is never timed against a later, unrelated response (`usage-parsers.mjs:1048-1089`) | +| `session_meta` | Authoritative session id, `cwd`, and `thread_source` — the FIRST such line in the file wins for all three, AND for `inferenceProvider`/`providerProvenance` too (`usage-parsers.mjs:925-941`, gate; `:1343-1373`, why); a subagent rollout replays its PARENT thread's own session_meta line later in the same file, and a later-wins rule let that relabel the record `subagent`→`user` and re-key its id to the parent's — `"subagent"` marks a delegated thread whose rollout may open with its parent's replayed history; events before the replay boundary (`codex-replay.mjs`) count for nothing, so the subagent's own tokens are counted and the replay is not, and the record stays flagged `subagent` (`usage-parsers.mjs:1360-1367`; `usage-scorecard-metrics.md` Appendix A, Bug B) | +| `turn_context` | The model id in effect from this point on, plus `approval_policy` (a string) and `sandbox_policy` (an **object** keyed `.type`, e.g. `{"type":"danger-full-access"}`) — the permission posture, last evidence winning, since a session may renegotiate mid-run (`usage-parsers.mjs:943-964`) | +| `event_msg` → `token_count` | A **cumulative** usage snapshot, turned into its increase over the previous one and booked on the event's local day under the model of the turn in effect; a snapshot lower than its predecessor starts a new segment (the host's counter restarted), so every segment counts. A replayed snapshot only advances the running total (`usage-parsers.mjs:1003-1022`) | +| `event_msg` → `task_started` | `model_context_window` — denominator-only compatibility evidence, not proof of a paired input/window sample — and the turn's start time (`usage-parsers.mjs:1028-1047`) | +| `event_msg` → `task_complete` | The host's own `duration_ms` for the turn, taken as a latency sample only when no prompt-to-response gap already covered it; a non-null `error` counts as an exception (`usage-parsers.mjs:1049-1076`) | +| `event_msg` → `turn_aborted` | An explicit interrupt: counted in `aborts`, and it clears both latency states so an unanswered prompt is never timed against a later, unrelated response (`usage-parsers.mjs:1170-1218`) | | `event_msg` → `user_message` | A legacy-format prompt CANDIDATE — Codex does not route tool output through this event, but the text still needs the human-prompt gate below before it counts | | `event_msg` → `agent_message` | A legacy-format model response | | `event_msg` → `item_completed` → `UserMessage` | A current-format prompt candidate; text blocks use the observed lowercase `text` discriminator; also gated below | | `event_msg` → `item_completed` → `AgentMessage` | A current-format model response; text blocks use the observed uppercase `Text` discriminator | -| Human-prompt gate (`isCodexHumanMessage`, `usage-parsers.mjs:970-974`) | Codex carries no discipline of its own for telling a typed prompt apart from harness output or a mirrored cross-host envelope replayed into the rollout rather than typed there. Reuses "HARNESS_OUTPUT_RE" verbatim (Claude's own envelope markers reproduce byte-for-byte inside a mirrored rollout) plus two Codex-specific machine markers (`CODEX_MACHINE_ENVELOPE_RE`, `usage-parsers.mjs:956-973`): a `/` with one slash, where the parent half reuses `VALID_ID`'s own charset and the child half must match the real on-disk `agent-…` shape. The namespaced grammar is a **narrowing** of the plain one, never a loosening: both are the same path-traversal guard, and a traversal shape is rejected at either tier. -2. **Locate by id** across both roots (`locate`, `usage-index.mjs:1031-1050`), +2. **Locate by id** across both roots (`locate`, `usage-index.mjs:1227-1246`), consulting the scan cache when present but never requiring it — `readSession` works with no prior buildIndex. A namespaced id resolves through this call: `locateSubagent(nested.parentId, nested.stem, r.claude, id)` - (`usage-index.mjs:1048`), which builds the + (`usage-index.mjs:1244`), which builds the nested path from the two **already-validated capture groups** rather than from raw request text. -3. **Realpath containment** (`usage-index.mjs:1122-1135`) — the resolved file +3. **Realpath containment** (`usage-index.mjs:1318-1331`) — the resolved file must live under a transcript root *after* `realpathSync` collapses symlinks; a symlink planted inside a root pointing at `/etc/anything` passes a lexical `startsWith` but fails this. Roots are realpath'd too so a symlinked dotfiles setup still works. -4. **Size cap** — `MAX_SESSION_BYTES` (64 MB, `usage-index.mjs:195`): a +4. **Size cap** — `MAX_SESSION_BYTES` (64 MB, `usage-index.mjs:241`): a transcript is read whole and JSON-expands ~5×, so an unbounded read is a memory-amplification primitive. Oversized reads as unavailable, not risky. ### 4.2 Parse and price The file is parsed with `withTurns: true` by the provider's parser -(`usage-index.mjs:1157-1164`), and `meta` is assembled by `sessionPayload` -(`usage-aggregate.mjs:1324-1361`). Its call builds a narrower subset of the Sessions +(`usage-index.mjs:1330-1339`), and `meta` is assembled by `sessionPayload` +(`usage-aggregate.mjs:1479-1518`). Its call builds a narrower subset of the Sessions view fields: `prompts`, `responses`, `exceptions`, `sidechain`, `threadSource`, `models`, `tools`, `skill`/`plugin`, worktree — plus a `cost` priced from the same per-model usage rows `aggregate()` uses. OpenCode recorded row cost wins over @@ -421,11 +429,11 @@ never renames a retained session model, changes historical token pricing, or rew ### 4.3 Mask, then truncate — both marked, differently -Every turn body is passed through `maskSecrets` (`usage-aggregate.mjs:168-173` — the +Every turn body is passed through `maskSecrets` (`usage-aggregate.mjs:171-176` — the configured secret shapes) **server-side, before serialization**, then length-capped at `MAX_TURN_CHARS` (40,000, -`usage-aggregate.mjs:73`) with the marker appended at the truncation call -("originalChars is measured", `usage-aggregate.mjs:1346-1355`). Two invariants: +`usage-aggregate.mjs:76`) with the marker appended at the truncation call +("originalChars is measured", `usage-aggregate.mjs:1503-1512`). Two invariants: * **Presence is the signal.** `truncated`/`originalChars` are emitted only when the slice fired, so a complete turn cannot be misread as abridged. @@ -604,7 +612,7 @@ was wrong before, for the curious. `isHumanPrompt` once counted `harness-output` envelopes as human prompts — 32 claimed vs 20 real on the reference session. Cached session records carried the inflated counts, hence the wholesale `SCHEMA_VERSION` 5 cache - invalidation ("no longer count as human prompts", `usage-index.mjs:70-73`). + invalidation ("no longer count as human prompts", `usage-index.mjs:78-81`). * **Session expander fields shipped but unrendered.** The per-session fields §6.1's expander now renders (classification `basis` + confidence, the token split, flags) once travelled on the wire and rendered nowhere. @@ -612,7 +620,7 @@ was wrong before, for the curious. assembled `meta` left cost undefined, and `fmtUsd(undefined)` renders the truthy string `"$0.00"` — a fixed-looking zero on a panel whose whole subject is cost. `meta.cost` is now priced via this call: `sessionCost(rec, deps)` - (`usage-aggregate.mjs:1318`) — over the same per-model usage rows aggregate() reads. + (`usage-aggregate.mjs:1473`) — over the same per-model usage rows aggregate() reads. * **Aggregate-side incidents** (the v4/v5 cache bumps, the Codex parsing defects) are recorded in `usage-scorecard-metrics.md` Appendix A. diff --git a/docs/usage-scorecard-metrics.md b/docs/usage-scorecard-metrics.md index ed402269..b5da1724 100644 --- a/docs/usage-scorecard-metrics.md +++ b/docs/usage-scorecard-metrics.md @@ -52,6 +52,98 @@ this document to check an arithmetic claim. --- +## Current accounting and cache contracts + +Updated 2026-09-29 against the delivered V6 source. Older dated reproductions later in this +reference remain historical measurements. These contracts qualify the formulas below. + +**Classification and counting.** [ADR-0060](adr/0060-session-surface-initiator-and-product-names.md) +separates Git scope, host, surface, initiator and provider. The shared vocabulary uses declared +fields, never folder location or Codex `source="vscode"` alone. Observed metadata can identify +an assistant model's provider family or a recorded provider ID, but neither is network +attestation. Price-table identity and current configuration cannot establish historical serving +provider. Auto-review models without supported rates contribute tokens and unpriced coverage. +Census `countBasis` and source bounds remain distinct from Usage's billed-session rules; +zero-weight project recovery is not a verified session. Dedicated Cowork storage is uncovered. + +**Codex accounting.** Copied turns are excluded individually; proven native intervals in mixed +files remain eligible. Incomplete ownership, malformed mixed input and unseparable replay remain +conservatively excluded with diagnostics. Discovery reports pure exclusions, mixed observations +and unresolved bounded observations separately. A native record with positive input/cache/output +components can count even with zero responses. A total-only counter cannot establish that split +or a price; `total-only-token-count` reports the gap. Child-owned usage still counts after parent +replay exclusion. Effort and host-reported first-token timing are separate observations from +completion latency. Compactions expose a lower bound and nullable upper bound; missing pairing +never becomes an invented exact count. See [ADR-0052](adr/0052-codex-usage-attribution.md). + +**Claude charge ownership.** `usage-claude-dedup.mjs` reconciles bounded, hashed API message +identities across files after discovery. One owner is elected in a fixed pool covering twice the +displayed day window, capped at 730 days, before projecting either the current or previous window. +Both file mtime and session end must reach the pool cutoff. Requesting a previous comparison or +more historical lookback cannot make an eligible copied message charge twice or change its owner. +This is not whole-corpus uniqueness: source health qualifies outside-pool history and incomplete +identity coverage. Transcript-local `responses`, context samples and tools retain their local +meaning; `accountedResponses` records the aggregate charge share. The extra cold-read horizon is +a bound, not a measured performance guarantee. + +Claude's latest valid cumulative `cost-state` is a separate host-reported reconciliation signal, +never an addition to message-derived cost. Bounded model keys and timestamp/token-scope diagnostics +identify supported differences. A missing checkpoint end and missing serving-provider evidence +prevent an equality claim even when counters agree. Record coverage separately counts known +handled, known ignored, unknown, invalid-type and malformed JSON records. Unknown/malformed +records degrade source health without rewriting valid message cost. + +**OpenCode cost and source.** Positive reported cost remains observed. A reported zero with +positive tokens from a nonlocal or unknown provider is unpriced, rather than evidence of a free +request; a recognized local provider's zero retains its observed meaning. Missing-cost estimation +and unpriced coverage remain separate. No recorded or estimated amount is an invoice. + +The shared source resolver serves Usage, selected-session detail and project discovery. An +explicit root takes precedence; `OPENCODE_DB` can select an absolute path or a path relative to +the OpenCode data root. `:memory:` opens nothing. `OPENCODE_DISABLE_CHANNEL_DB=1` or `true` selects +the main database. Otherwise bounded discovery requires one canonical candidate: multiple +main/channel candidates require explicit selection, without a silent main/mtime preference or +combining copied stores. Canonical path identity gates cache and memo reuse; it is not a database +content digest. Conventional footprint storage sizing is a separate measurement boundary. + +V2 `session_message` presence and legacy JSON storage are observed as unsupported coverage, +without reading those formats into usage. Unknown observation scope is explicit. Those warnings +refresh on warm scans and with no current-window records: source availability and completeness +are independent of current-window usage, and presence does not prove lost current-window usage. + +OpenCode compaction evidence pairs requests and completed summaries and preserves failed, +pending, orphaned and in-flight uncertainty. Session counter reconciliation is supported only +for recorded version **1.18.33**, no V2 scope, complete valid messages, trusted costs and exactly +one matching `step-finish` per assistant message. Other versions, multiple steps, unpopulated +counters or unproved scope report unknown; session counters never add a second charge. + +Warm reuse requires `cost-trust-v2-observations-v1`, source identity, calendar compatibility and a +matching `observationFingerprint`. The fingerprint comes from bounded allowlisted session, +message and part observations in a read-only snapshot; it detects relevant same-count rewrites +and removals without hashing prompt bodies or persisting their raw fields. Missing digests, +budget refusal or read failures do not authorize normal reuse. Compatible last-good records may +still be retained under explicit degraded-source semantics. These digest probes add warm-scan +work; no performance improvement is claimed. + +OpenCode sessions with a nonempty `parent_id` keep their prompts/turns, tokens and cost, but their +user turns do not enter typed-prompt fingerprints. Aggregate session prompt metrics, current +prompt patterns and historical baselines also ignore old cached child fingerprints. Those cached +bytes can remain until normal invalidation; they do not force another global schema bump. + +**Cache compatibility.** Usage schema changes exactly **25 → 26**; footprint stays **8**. A v25 +usage cache requires rebuilding. Current v26 entries also need each source's semantic markers, +identity/coverage fields and calendar context; a valid schema number alone does not authorize +reuse. Local calendar context includes the resolved full timezone plus Node tzdata and ICU +versions. Missing, invalid or changed context reparses available sources; incompatible degraded +OpenCode records cannot contribute cached day/punchcard rows and are counted in +`timezoneCacheEntriesExcluded`. Unresolved zones decline cache and aggregate-memo reuse. +Local-day rebucketing can change a dated API-equivalent rate; it does not establish a provider's +billing timezone. Legacy surface fallback remains explicitly labeled, not precise new evidence. + +Implementation boundaries: `session-surface.mjs`, `usage-index.mjs`, `usage-claude-dedup.mjs`, +`usage-opencode-source.mjs`, `usage-opencode-storage-coverage.mjs`, +`usage-opencode-observations.mjs`, `usage-opencode-cache.mjs` and `usage-aggregate.mjs`. + ## 0. How to read an entry Every metric section below follows the same shape: @@ -71,9 +163,10 @@ Every metric section below follows the same shape: ## 1. Data provenance Two JSONL transcript stores plus OpenCode's SQLite store, read without source edits — the derived -record is cached "keyed by (path, mtime, size)" (`src/lib/usage-index.mjs:10`), +record is cached using source identity, file metadata, calendar context and source-specific +semantic evidence (see the current contracts above), and the whole cache is invalidated on a `SCHEMA_VERSION` change -(`usage-index.mjs:177`). A main Claude session's entry is additionally keyed on its statusline +(`usage-index.mjs:198`). A main Claude session's entry is additionally keyed on its statusline window ledger's own mtime and size, so a ledger that appears or changes re-parses the session: | Transcript host | Store | Format | @@ -84,22 +177,22 @@ window ledger's own mtime and size, so a ledger that appears or changes re-parse | OpenCode | platform data root `opencode/opencode.db` | SQLite session/message/part rows; per-assistant token fields and optional recorded cost/provider identity | Discovery is **one level of project directories plus that one nested shape**, -not a recursive walk: `listClaude` (`usage-index.mjs:374-388`) descends into a +not a recursive walk: `listClaude` (`usage-index.mjs:460-474`) descends into a session-id directory only through "listClaudeSubagents" -(`usage-index.mjs:349-354`), which reads exactly +(`usage-index.mjs:435-440`), which reads exactly `//subagents/*.jsonl`. A directory that is not a session-id dir with a `subagents` child — Claude Code's own `memory` dir, say — contributes nothing rather than being crawled. Each subagent record takes a -**namespaced** id, `/` (`usage-index.mjs:354`), because Claude +**namespaced** id, `/` (`usage-index.mjs:440`), because Claude Code names every such file `agent-.jsonl` and that stem is not guaranteed unique across two parents; an unnamespaced id would silently collide two unrelated subagent records into one. `locateSubagent` -(`usage-index.mjs:1015-1034`) resolves that id back to the nested path when a +(`usage-index.mjs:1211-1230`) resolves that id back to the nested path when a reader opens the session, building the candidate path from the two validated capture groups rather than from raw request input. -The parsers are `parseClaude` (`usage-parsers.mjs:742-777`) and `parseCodex` -(`usage-parsers.mjs:1183-1240`). They normalize raw JSONL bytes; project evidence also consults the local filesystem. +The parsers are `parseClaude` (`usage-parsers.mjs:817-871`) and `parseCodex` +(`usage-parsers.mjs:1355-1442`). They normalize raw JSONL bytes; project evidence also consults the local filesystem. Missing-time fallback paths can consult the clock. Their output is local evidence, not a network response or an invoice. Nothing in this transcript pipeline calls a provider API or a billing endpoint; **no transcript @@ -196,22 +289,27 @@ turns`. **Formula:** ```text -sessions = count of session records with responses > 0 AND end >= cutoff -responses = Σ over included sessions of session.responses +eligible = responses > 0 OR positive Codex component usage OR retained OpenCode observations +current.sessions = count of eligible records with non-null end >= cutoff (no upper bound) +windowStart = now - days × DAY_MS +previous.sessions = count of eligible records with windowStart - days × DAY_MS <= end < windowStart +responses = Σ over included sessions of accountedResponses (else responses) ``` **Source:** -- Filter: a parsed record with zero assistant turns is dropped entirely — "no - assistant turn → not a session" (`usage-aggregate.mjs:888-899`) — and a record whose - last activity falls outside the requested window is dropped too - (`usage-aggregate.mjs:898-899`). +- Filter: `buildSessionRows` (`usage-aggregate.mjs:970-982`) accepts response-bearing records, + positive Codex component usage or retained OpenCode observations. An unknown end or an end + outside the requested window is excluded. Refused OpenCode acquisitions retain uncertainty + separately without becoming ordinary session rows. The current projection supplies no + upper bound; `previousWindow` (`usage-aggregate.mjs:1274-1281`) supplies the exclusive + `windowStart` upper bound and derives both bounds from displayed `days` and `now`. - `responses` accumulation: Claude increments once per API message id — every transcript line of one message counts once, the last line's usage winning - (`usage-parsers.mjs:661-696`); Codex increments per `agent_message` event - (`usage-parsers.mjs:1021-1025`). -- Totals: `totals.responses += s.responses` per included session -(`usage-aggregate.mjs:928`). + (`usage-parsers.mjs:708-753`); Codex increments per `agent_message` event + (`usage-parsers.mjs:1143-1147`). +- Totals: `totals.responses += s._accountedResponses` per included session +(`usage-aggregate.mjs:1050`). - Render: `kpi("sessions", fmtNum(t.sessions), fmtNum(t.responses)+" assistant turns", "")` (`dashboard/client.mjs`). @@ -332,22 +430,22 @@ same sentence fingerprints identically whichever host recorded it: | Symbol | Location | Notes | |---|---|---| -| `normalizePromptText` | `src/lib/usage-parsers.mjs:261` | lowercased, whitespace-collapsed, trailing punctuation stripped | -| `promptFingerprint` | `src/lib/usage-parsers.mjs:296` | the `{h, t, th}` hash/count/token-hash triple | -| `promptShape` | `src/lib/usage-parsers.mjs:342` | the `q`/`o` flags, anchored on the question and persona-opener rules below | -| `QUESTION_WH_RE` | `src/lib/usage-parsers.mjs:311` | one of two rules the `q` flag checks | -| `QUESTION_AUX_RE` | `src/lib/usage-parsers.mjs:312` | the other | -| `PERSONA_OPENER_RE` | `src/lib/usage-parsers.mjs:318` | what the `o` flag checks | -| `notePromptFingerprint` | `src/lib/usage-parsers.mjs:359` | records one fingerprint, or counts overflow past the caps below | -| `MAX_PROMPT_FPS` | `src/lib/usage-parsers.mjs:240` | the per-session fingerprint cap | -| `MAX_TOKEN_HASHES` | `src/lib/usage-parsers.mjs:255` | the per-fingerprint token-hash cap | +| `normalizePromptText` | `src/lib/usage-parsers.mjs:286` | lowercased, whitespace-collapsed, trailing punctuation stripped | +| `promptFingerprint` | `src/lib/usage-parsers.mjs:321` | the `{h, t, th}` hash/count/token-hash triple | +| `promptShape` | `src/lib/usage-parsers.mjs:367` | the `q`/`o` flags, anchored on the question and persona-opener rules below | +| `QUESTION_WH_RE` | `src/lib/usage-parsers.mjs:336` | one of two rules the `q` flag checks | +| `QUESTION_AUX_RE` | `src/lib/usage-parsers.mjs:337` | the other | +| `PERSONA_OPENER_RE` | `src/lib/usage-parsers.mjs:343` | what the `o` flag checks | +| `notePromptFingerprint` | `src/lib/usage-parsers.mjs:384` | records one fingerprint, or counts overflow past the caps below | +| `MAX_PROMPT_FPS` | `src/lib/usage-parsers.mjs:265` | the per-session fingerprint cap | +| `MAX_TOKEN_HASHES` | `src/lib/usage-parsers.mjs:280` | the per-fingerprint token-hash cap | | `PROVENANCE_TAGS` | `src/lib/usage-provenance.mjs:21` | the closed four-tag vocabulary | | the ordered provenance rules | `src/lib/usage-provenance.mjs:33-77` | matched against, in order, to resolve a tag | | `provenanceOf` | `src/lib/usage-provenance.mjs:93` | resolves one turn's provenance tag | Wired on the Claude path where userTurnKind is called — -`src/lib/usage-parsers.mjs:600-621`; on the Codex path inside -`handleCodexUserMessage` — `src/lib/usage-parsers.mjs:985-1006`; on the +`src/lib/usage-parsers.mjs:635-656`; on the Codex path inside +`handleCodexUserMessage` — `src/lib/usage-parsers.mjs:1107-1128`; on the opencode path inside `recordUserMessage` — `src/lib/usage-opencode.mjs:184-197` **What this does not model:** @@ -455,12 +553,12 @@ this day carried the fingerprint layer", so a zero here is *measured*. | Symbol | Location | |---|---| -| `TAP_MAX_TOKENS` | `src/lib/usage-aggregate.mjs:287` | -| the baseline window and floor | `src/lib/usage-aggregate.mjs:294` | -| `v16Projection` (per session) | `src/lib/usage-aggregate.mjs:330` | -| `foldSessionPrompts` | `src/lib/usage-aggregate.mjs:369` | -| `sealPromptHosts` | `src/lib/usage-aggregate.mjs:387` | -| `buildPromptBaselines` | `src/lib/usage-aggregate.mjs:417` | +| `TAP_MAX_TOKENS` | `src/lib/usage-aggregate.mjs:290` | +| the baseline window and floor | `src/lib/usage-aggregate.mjs:297` | +| `v16Projection` (per session) | `src/lib/usage-aggregate.mjs:333` | +| `foldSessionPrompts` | `src/lib/usage-aggregate.mjs:374` | +| `sealPromptHosts` | `src/lib/usage-aggregate.mjs:392` | +| `buildPromptBaselines` | `src/lib/usage-aggregate.mjs:422` | | `detectSupervisionTapShare` | `src/lib/usage-insights.mjs:780` | | `detectHeadlessShare` | `src/lib/usage-insights.mjs:808` | | `detectHostPromptAsymmetry` | `src/lib/usage-insights.mjs:845` | @@ -557,7 +655,8 @@ call has no per-token rate, and the unknown-model fallback would invent one (1M input + 1M output tokens would read $18). Those messages count toward `unpricedMessages`, add no dollars, and are left out of the cache-saving estimate; the gap is coverage the reader can see, not a silent $0. A local -provider that reports a cost, including 0, stays an observed figure. A +provider that reports a cost, including 0, stays an observed figure. For nonlocal or unknown +providers, reported zero with positive tokens is unpriced. A custom-named local provider cannot be recognised from its id and keeps the fallback rate. @@ -598,7 +697,7 @@ lexicographically so no `Date` parsing is involved and the module stays clock-free. `foldSessionUsageRow` passes each usage row's own `day` to `costOf` -(`usage-aggregate.mjs:739-741`), which +(`usage-aggregate.mjs:746-748`), which it already has because rows are keyed by `(day, model)`. **This is the whole point:** tokens metered in August must still read as August's rate when the panel is opened in December. Pricing by *today's* date instead would restate a @@ -668,9 +767,9 @@ tokens = input + output + cacheRead + cacheWrite (summed across all rows in wi ``` **Source:** `t.tokens` from `totals`, accumulated per row at -`usage-aggregate.mjs:765` (`rowTokens = row.input + row.output + row.cacheRead + +`usage-aggregate.mjs:775` (`rowTokens = row.input + row.output + row.cacheRead + row.cacheWrite`) and rolled into `totals.tokens` via `addTo` -(`usage-aggregate.mjs:656-665`). Rendered with `fmtTok()` +(`usage-aggregate.mjs:663-672`). Rendered with `fmtTok()` (`dashboard/client.mjs`): `≥1e9` → `"X.XB"`, `≥1e6` → `"X.XM"`, `≥1e3` → `"X.XK"`, else the rounded integer. @@ -684,7 +783,7 @@ per row is **gross input minus cached input** — Claude's parser reads `cache_read_input_tokens` and `cache_creation_input_tokens` as separate fields the provider already reports separately (`telemetry-records.mjs:216-224`); Codex's parser subtracts `cached_input_tokens` from `input_tokens` explicitly -at this call site (`usage-parsers.mjs:1132-1183`, `input: Math.max(0, gross - cacheRead)`) because +at this call site (`usage-parsers.mjs:1296-1355`, `input: Math.max(0, gross - cacheRead)`) because Codex's own `input_tokens` field **includes** cached tokens and would double-count them against the separately-reported `cacheRead` figure if left as-is. This is asserted by test: @@ -775,25 +874,25 @@ session data, and each needs its own fix: human, or genuinely idle) donates its *entire* idle stretch to the span, even though no work happened during it. Fix: split each session into active sub-intervals wherever the gap between two consecutive timestamps - exceeds `IDLE_GAP_MS` (15 minutes, `usage-parsers.mjs:29`), then union + exceeds `IDLE_GAP_MS` (15 minutes, `usage-parsers.mjs:32`), then union *those* sub-intervals — this is `engagedSeconds`. **Source:** -- `mergeIntervals()` (`usage-aggregate.mjs:40-65`) — the pure union primitive, +- `mergeIntervals()` (`usage-aggregate.mjs:43-68`) — the pure union primitive, sorts intervals and merges any two that are "overlapping OR exactly touching" - (`s <= curEnd`, `usage-aggregate.mjs:56`), returning total covered seconds + (`s <= curEnd`, `usage-aggregate.mjs:59`), returning total covered seconds rounded to the nearest second. -- `activeIntervals()` (`usage-parsers.mjs:469-487`) — splits one session's +- `activeIntervals()` (`usage-parsers.mjs:494-512`) — splits one session's sorted timestamp list into sub-intervals wherever a gap exceeds IDLE_GAP_MS; "a run of one timestamp yields a zero-length interval and so - contributes nothing" (comment, `usage-parsers.mjs:469-475`). + contributes nothing" (comment, `usage-parsers.mjs:494-500`). - Aggregation, each its own call to `mergeIntervals`: `engagedSeconds` over - every session's active sub-intervals (`usage-aggregate.mjs:1064-1069`); + every session's active sub-intervals (`usage-aggregate.mjs:1174-1179`); `spanUnionSeconds` over whole spans instead - (`usage-aggregate.mjs:1044-1068`); spanMs is a running sum of - "s._span[1] - s._span[0]" across the loop (`usage-aggregate.mjs:963-981`), - finalized into `spanMinutes` (`usage-aggregate.mjs:1064-1068`). + (`usage-aggregate.mjs:1154-1178`); spanMs is a running sum of + "s._span[1] - s._span[0]" across the loop (`usage-aggregate.mjs:1045-1076`), + finalized into `spanMinutes` (`usage-aggregate.mjs:1174-1178`). - Render: `fmtHours()` (`dashboard/client.mjs`, `≥10h` rounds to the nearest hour, else one decimal place) and `fmtMins()` (`dashboard/client.mjs`, `≥60min` rounds to hours, else whole @@ -846,12 +945,12 @@ byDay[day].sessionsActive = count of distinct sessions with any usage row that d **Source:** the day key is the row's own `row.day`, computed once at parse time as **local calendar day**, not UTC -(`usage-parsers.mjs:35`/`usage-parsers.mjs:1174` call `localDay(at)`) — so a +(`usage-parsers.mjs:38`/`usage-parsers.mjs:1332` call `localDay(at)`) — so a session that runs from 23:58 local to 00:05 local has its session count attributed to the day its *first* usage row landed on (test: `tests/kit/usage-index.test.mjs:738`, "a session that opens before midnight is counted on its first billed day"). Accumulation, at this call: `dayBucket(byDay, -row.day)` then `d.cost = round(d.cost + rowCost)` (`usage-aggregate.mjs:764-771`). Bar height: +row.day)` then `d.cost = round(d.cost + rowCost)` (`usage-aggregate.mjs:774-781`). Bar height: `h = maxDay ? max(2, cost/maxDay*100) : 2` (`dashboard/client.mjs`) — every non-empty day gets a visually nonzero bar (floor of 2%), so a very cheap day is never rendered as invisible. @@ -879,11 +978,11 @@ renders "no sessions in window" instead of zeroed figures (`dashboard/client.mjs`). **Formula:** identical aggregation to every other bucket -(`byHost[s.host]`, populated via `addTo()` (`usage-aggregate.mjs:667-676`), - called once per session at this call: `usage-aggregate.mjs:968-993`), keyed by the literal string +(`byHost[s.host]`, populated via `addTo()` (`usage-aggregate.mjs:674-683`), + called once per session at this call: `usage-aggregate.mjs:1050-1103`), keyed by the literal string `"claude"` or `"codex"` assigned at parse time (this call: `blankSession(id, 'claude')` / `blankSession(id, 'codex')`, -`usage-parsers.mjs:197-225`, `:1220`, `parseClaude`/`parseCodex` entry points). +`usage-parsers.mjs:216-250`, `:1383`, `parseClaude`/`parseCodex` entry points). OpenCode's SQLite reader builds the same record shape and contributes a third host key. @@ -895,22 +994,27 @@ The historical Codex incidents in Appendix A are examples, not an exhaustive dia **Two identity maps, with separate evidence.** The aggregate buckets window spend by two identities, and reading one as the other is the -mistake this split exists to prevent (`usage-aggregate.mjs:913`, -`usage-aggregate.mjs:941-942`): +mistake this split exists to prevent (`usage-aggregate.mjs:994`, +`usage-aggregate.mjs:1022-1023`): - **`byHost`** — the execution host: which CLI wrote the transcript (`claude`, `codex`, `opencode`). This is what the host cards render. It is a fact about the file's provenance on disk, and it proves nothing about which vendor served the tokens. -- **`byProvider`** — the inference-provider string **as recorded**, ungated: - `s.provider ?? 'unknown'`. This map keeps its historical name and its - historical shape for callers that want the raw string, whatever its - evidence. A session that recorded no provider keys to `'unknown'`. - -`byProvider` uses the served session row's recorded inference provider, or `unknown`. -Codex session metadata/turn context and OpenCode assistant `providerID` can establish -that value with observed provenance; Claude history normally leaves it absent. The -Scorecard UI does not rank this map, but session details expose provider/provenance. +- **`byProvider`** — OpenCode response, token and cost totals are split by the provider on each + assistant usage row, with missing row providers in `unknown`. `foldSessionUsageRows` + (`usage-aggregate.mjs:791-815`) accumulates these per-provider shares; the second pass applies + them at this call: `addTo(bucket(byProvider, provider), { ...s, ...usage })` + (`usage-aggregate.mjs:1083-1092`). Each response/token/cost share lands once, but one session + counts once under **each** provider it used. Provider session counts therefore need not sum + to the overall session count; they are not disjoint session populations. + +When there are no per-row provider shares, the fallback uses the session's recorded provider, +`s.provider ?? 'unknown'`, with its accounted response count at this call: `addTo` +(`usage-aggregate.mjs:1091`). +Codex session metadata/turn context and OpenCode assistant `providerID` supply observed metadata; +Claude history normally leaves the session provider absent. These facts are not network +attestation. The Scorecard UI does not rank this map, but session details expose provider/provenance. The source's former parser field is retained separately as `transcriptProvider`. **What this does not model:** a workflow that hands off between Claude and @@ -935,9 +1039,9 @@ punchcard[dow + "-" + hour] += 1 per assistant/agent_message response, at its **Source:** incremented once per Claude API message (all of a message's transcript lines are one hit) -(`usage-parsers.mjs:42`, keyed by this call: `punchKey(at)`) and once per Codex -`agent_message` (`usage-parsers.mjs:1021-1025`), merged into the window-level -`punchcard` object per session (`usage-aggregate.mjs:943-1007`). Cell intensity is +(`usage-parsers.mjs:45`, keyed by this call: `punchKey(at)`) and once per Codex +`agent_message` (`usage-parsers.mjs:1143-1147`), merged into the window-level +`punchcard` object per session (`usage-aggregate.mjs:1024-1117`). Cell intensity is linear against the single busiest cell in the window: `v = pcMax ? n/pcMax : 0` (`dashboard/client.mjs`) — this is a **relative**, not absolute, scale, so the heatmap's brightest cell is always @@ -979,9 +1083,9 @@ byModel[model].sessions = count of DISTINCT sessions whose s.models includes th ``` **Source:** cost/tokens/responses accumulate inside the usage-row loop -(`usage-aggregate.mjs:752-779`); the per-model session count is deliberately computed +(`usage-aggregate.mjs:762-790`); the per-model session count is deliberately computed **separately**, once per session over its `s.models` array -(`usage-aggregate.mjs:910-919`) rather than inside the cost loop, precisely +(`usage-aggregate.mjs:991-1000`) rather than inside the cost loop, precisely **so that a model can appear in `byModel` — with a nonzero session count — even in a session that contributed zero cost/tokens/responses for that model.** This is not an edge case invented for this document: it is the @@ -991,11 +1095,11 @@ excluded subagent-replay session still shows up as "used," at zero cost, rather than vanishing. `byModel[...].responses` is populated from each usage row's response field at -this call (`usage-aggregate.mjs:775-778`). The shared usage-row accumulator is -defined at `usage-parsers.mjs:499-515`; Claude passes one response per API -message at its call site (`usage-parsers.mjs:677`). Codex passes the session's +this call (`usage-aggregate.mjs:786-789`). The shared usage-row accumulator is +defined at `usage-parsers.mjs:524-550`; Claude passes one response per API +message at its call site (`usage-parsers.mjs:723`). Codex passes the session's whole response count once, where finalizeCodexUsage makes the corresponding -call (`usage-parsers.mjs:1136-1179`). +call (`usage-parsers.mjs:1303-1337`). The two parsers therefore hand the aggregate the same response-bearing row shape, despite their different per-turn and cumulative transcript formats. @@ -1014,12 +1118,12 @@ split `server_error` 27, `authentication_failed` 3, `rate_limit` 3 — three distinct underlying causes, one placeholder shape). The parser recognizes the decoded API-error placeholder and returns before model -or usage attribution (`usage-parsers.mjs:701-720`). The turn does **not** increment -the response count or punchcard — it is not a model response (`usage-parsers.mjs:661-696`) — it *is* real +or usage attribution (`usage-parsers.mjs:758-777`). The turn does **not** increment +the response count or punchcard — it is not a model response (`usage-parsers.mjs:708-753`) — it *is* real engaged time (its timestamp still extends the session span), someone was genuinely waiting on it — and increments the record's exception count instead. Aggregation rolls that count into the window -total (`usage-aggregate.mjs:968-979`) and keeps it on the session row beside the -delegation-source fields (`usage-aggregate.mjs:838-867`), so it remains +total (`usage-aggregate.mjs:1050-1074`) and keeps it on the session row beside the +delegation-source fields (`usage-aggregate.mjs:909-944`), so it remains inspectable in Sessions without creating a fake model row. When `totals.exceptions > 0`, the panel header shows a small `"· N dropped/errored turns excluded"` note (`dashboard/client.mjs`); @@ -1378,7 +1482,7 @@ both credential-free for ak: `windowDurationMins: 10080` (the weekly). Windows are therefore keyed and labelled by duration (`windowLabel`, `quota.mjs:52`), never by slot name. The same rule applies to the historical snapshots parsed out of rollouts: the -normalizer at `usage-parsers.mjs:866-884` keeps a flat `windows` list keyed by +normalizer at `usage-parsers.mjs:971-989` keeps a flat `windows` list keyed by `window_minutes`. **Freshness is part of the number.** Both sides carry `fetchedAt`; the view @@ -1424,7 +1528,7 @@ Codex ≥0.140 maintains its own SQLite thread ledger (`~/.codex/state_N.sqlite` globs and takes the newest). `readCodexState` (`:62`, whose own delegate call reads the db file) reads per-thread `thread_source` (`user` vs `subagent`) plus `thread_spawn_edges`, and -`applyCodexLedger` (`usage-aggregate.mjs:1280-1310`) overlays that onto parsed +`applyCodexLedger` (`usage-aggregate.mjs:1392-1465`) overlays that onto parsed sessions: a thread that ONLY the ledger identifies as a subagent has its token usage stripped — with no `thread_source` in its own rollout its parsed usage is the unsubtracted cumulative total, which replays the parent's entire token history @@ -1432,7 +1536,7 @@ unsubtracted cumulative total, which replays the parent's entire token history visible. A rollout that says `thread_source: subagent` itself is left untouched: the parser already reduced it to the subagent's own usage (§16.2). **The parser is primary, the ledger is the fallback**: `rec.threadSource ?? -t?.threadSource ?? fromEdges` (`usage-aggregate.mjs:1280`) reads the rollout's +t?.threadSource ?? fromEdges` (`usage-aggregate.mjs:1392`) reads the rollout's own `session_meta.thread_source` first, and only consults the ledger when that line is missing entirely. This is sound because `thread_source` is now the FIRST session_meta line's value (§1.2) rather than whichever meta @@ -1443,7 +1547,7 @@ migration generation its `N` reflects; a rollout the ledger cannot resolve (an older Codex build, a migrated-beyond-recognition state file) still gets a correct `threadSource` straight from its own transcript rather than falling through unclassified. Codex sessions also carry -`reasoningOutput` (`usage-parsers.mjs:1136-1183`) — reasoning tokens are a **subset** +`reasoningOutput` (`usage-parsers.mjs:1303-1355`) — reasoning tokens are a **subset** of output tokens and are annotation only, never added to any sum. ## 14. Known limitations, restated as a single checklist @@ -1468,7 +1572,7 @@ the same list: - [x] A percentile taken from the overflow bucket of a histogram is printed with `≥`, and an unmeasured one is `null` rather than `0` (§15). - [x] A latency figure is never called TTFT: it is a prompt-to-answer gap or - a host-measured turn duration, and neither transcript records TTFT (§15). + a host-measured turn duration. Codex host-reported first-token evidence is a separate metric (§15). - [x] Permission posture keeps `not-recorded` as a first-class bucket — unmapped evidence is never folded into a real posture — and the inference provider is separately reported when native evidence exists (§8, §16). @@ -1509,23 +1613,23 @@ p(q), over N samples, landing in bucket i (count n_i, running total `cum` before **Source:** -- Edges: `LAT_BUCKET_EDGES` and `LEN_BUCKET_EDGES` (`usage-aggregate.mjs:212`, `:215`). - The parsers carry their own copies (`usage-parsers.mjs:365-370`) and the +- Edges: `LAT_BUCKET_EDGES` and `LEN_BUCKET_EDGES` (`usage-aggregate.mjs:215`, `:215`). + The parsers carry their own copies (`usage-parsers.mjs:390-395`) and the browser bundle a third pair (`LAT_EDGES`/`LEN_EDGES`), because the payload ships bucket *counts* and never the edges they were binned on. -- Slotting: `bucketIndex` (`usage-parsers.mjs:379-381`) — one definition of a +- Slotting: `bucketIndex` (`usage-parsers.mjs:404-406`) — one definition of a boundary, shared by every histogram built on these edges. -- Sampling: `noteLatencySample` (`usage-parsers.mjs:372-377`) allocates +- Sampling: `noteLatencySample` (`usage-parsers.mjs:397-402`) allocates `latHist` lazily, so a session that never observed a latency keeps `latHist: null` — absent, not a fabricated row of zeroes. - Session length: `seal` derives each session's `lenSeconds` from its own - active intervals (`usage-parsers.mjs:489-496`) — the §6 engaged figure for + active intervals (`usage-parsers.mjs:514-521`) — the §6 engaged figure for one session, never its first-to-last span. This is a per-session parse result, kept distinct from the next window-level fold. -- Window merge: `buildRhythm` (`usage-aggregate.mjs:1084-1109`) adds the +- Window merge: `buildRhythm` (`usage-aggregate.mjs:1194-1219`) adds the per-session `latHist` slot-wise and buckets each session's `lenSeconds`. -- Percentiles: `percentileFromBuckets` (`usage-aggregate.mjs:241-258`). The +- Percentiles: `percentileFromBuckets` (`usage-aggregate.mjs:244-261`). The browser re-implementation `bucketPercentile` (`usage-rhythm.mjs:106-126`) is pinned to byte-identical output, and the browser's edge copies to the server constants, by `tests/kit/dashboard-usage-telemetry.test.mjs:924-942` and @@ -1558,14 +1662,14 @@ median target 50 lands in bucket 1 (running total 40, n = 25), giving **Overflow floors, and why `≥` is not decoration.** The last bucket of either histogram has no upper edge to interpolate towards, so a percentile landing in it reports that bucket's **floor** and nothing more — -`if (i >= edges.length) return round(lo, 2)` (`usage-aggregate.mjs:252`). +`if (i >= edges.length) return round(lo, 2)` (`usage-aggregate.mjs:255`). A p95 printed as `≥60s` therefore means *at least 60 seconds* — the counts cannot say whether the real figure is 61 seconds or 61 minutes, and printing a bare `60s` would state a precision they do not carry. Both renderers apply the prefix by the same rule (`v >= lastEdge`), so a value that reaches the last edge by interpolation and one that came from the overflow slot print identically — the two are the same claim. An empty histogram is `null`, never -`0` (`usage-aggregate.mjs:244`): "nothing was measured" and "measured zero" are +`0` (`usage-aggregate.mjs:247`): "nothing was measured" and "measured zero" are different statements and only the first is true, so the cards print `not measured` and the CLI prints `no samples`. @@ -1575,13 +1679,13 @@ same way, and the panel says so rather than implying a single clock: | Host | How a latency sample is produced | |---|---| -| codex | **Host-measured.** `task_started` remembers the turn's start (`usage-parsers.mjs:915-934`) and `task_complete` samples Codex's own `duration_ms` (`usage-parsers.mjs:936-954`) — but only if no prompt-gap already covered that turn (so a turn is never sampled twice) and only within the same 3600 s cap the derived paths apply. | -| codex | Also derives a prompt-gap when one is available: `handleCodexUserMessage` opens the window (`usage-parsers.mjs:976-1006`) and the next agent message closes it, clearing `turnStartedAt` so the `duration_ms` fallback cannot double-fire (`usage-parsers.mjs:1008-1019`). | -| claude | **Derived from event gaps.** A human prompt sets `latState.pendingMs` (`pendingMs`, `usage-parsers.mjs:593-608`); the first real assistant turn closes that gap into a `noteLatencySample` call (`usage-parsers.mjs:722-725`). | +| codex | **Host-measured.** `task_started` remembers the turn's start (`usage-parsers.mjs:1028-1047`) and `task_complete` samples Codex's own `duration_ms` (`usage-parsers.mjs:1049-1076`) — but only if no prompt-gap already covered that turn (so a turn is never sampled twice) and only within the same 3600 s cap the derived paths apply. | +| codex | Also derives a prompt-gap when one is available: `handleCodexUserMessage` opens the window (`usage-parsers.mjs:1098-1128`) and the next agent message closes it, clearing `turnStartedAt` so the `duration_ms` fallback cannot double-fire (`usage-parsers.mjs:1130-1141`). | +| claude | **Derived from event gaps.** A human prompt sets `latState.pendingMs` (`pendingMs`, `usage-parsers.mjs:628-643`); the first real assistant turn closes that gap into a `noteLatencySample` call (`usage-parsers.mjs:779-782`). | | opencode | Derived from its message stream, measured to **completion**: `rec.pendingPromptMs` is the user message's `time.created`, and the first assistant row closes it at that row's `time.completed` (`closeLatencyWindow`, `usage-opencode.mjs:318-324`) — OpenCode inserts the assistant row ~15 ms after the prompt and fills it in as it generates, so its own `time.created` is not a response time. A row with no completed stamp yields no sample. | **Every** path is capped: a sample above `MAX_LATENCY_SAMPLE_SECONDS` -(3600 s, `usage-parsers.mjs:445-460`) is an idle resume — the person walked away +(3600 s, `usage-parsers.mjs:470-485`) is an idle resume — the person walked away and came back — not a wait for a reply, so it is dropped from sampling entirely rather than parked in the overflow bucket beside genuinely slow turns. That includes Codex's host-measured `duration_ms`. An earlier ruling exempted @@ -1594,11 +1698,11 @@ reference corpus before the fix: 12 of 835 durations exceeded the cap, the largest 94,079,450 ms ≈ 26.1 hours, all of them landing in the `≥60s` overflow bucket and dragging `latP95` into it. An interrupted turn contributes nothing at all — `turn_aborted` clears both -pending states (`usage-parsers.mjs:1048-1089`), so a prompt that was never +pending states (`usage-parsers.mjs:1170-1218`), so a prompt that was never answered can never be timed against a later, unrelated reply. A dropped API turn is likewise never a sample: the error branch returns before the latency block and deliberately leaves `pendingMs` set, so the first real completion -that eventually follows is what gets timed (`usage-parsers.mjs:601-610`). +that eventually follows is what gets timed (`usage-parsers.mjs:636-645`). **This figure is never labeled TTFT, in any surface.** Time-to-first-token measures when a stream *starts*; every figure here measures when a turn @@ -1611,7 +1715,8 @@ TTFT" beside the per-host note it qualifies. A true TTFT exists for Claude Code, but only as a span in its opt-in OpenTelemetry beta ([monitoring](https://code.claude.com/docs/en/monitoring-usage)) — a different, non-transcript evidence class that this scorecard does not read. -Neither transcript store records it, so no panel here may borrow the name. +Codex can separately record host-reported first-token timing. That evidence is retained +independently and does not rename or replace the completion-latency metric. **What this does not model:** @@ -1639,8 +1744,8 @@ Neither transcript store records it, so no panel here may borrow the name. means a window dominated by one host is really reporting that host's instrument; - the bucketing *function* is implemented twice: the parsers export - `bucketIndex` (`usage-parsers.mjs:379-381`) and the aggregate keeps a private - copy of the same loop (`usage-aggregate.mjs:222-225`), because the dependency + `bucketIndex` (`usage-parsers.mjs:404-406`) and the aggregate keeps a private + copy of the same loop (`usage-aggregate.mjs:225-228`), because the dependency between the two modules is deliberately one-way. The *edges* they run on are pinned equal by test — `AGG_LAT_EDGES` against `LAT_BUCKET_EDGES` (`tests/kit/usage-index.test.mjs:15-19`) — but the two function bodies @@ -1670,13 +1775,13 @@ mode = normalizeMode(host, raw evidence) or 'not-recorded' **Source:** `normalizeMode` (`usage-modes.mjs:23-35`) is the whole taxonomy; `MODES` (`usage-modes.mjs:4`) is the closed four-value vocabulary. Per-day folding is "addCost(d.byMode, rec.mode ?? 'not-recorded', rowCost)" -(`usage-aggregate.mjs:752-772`) — in the usage-row pass, because only a row knows +(`usage-aggregate.mjs:762-782`) — in the usage-row pass, because only a row knows which day its dollars landed on. The window bucket is -this call: `addTo(bucket(byMode, s.mode ?? 'not-recorded'), s)` (`usage-aggregate.mjs:983-993`). +this call: `addTo(bucket(byMode, s.mode ?? 'not-recorded'), s)` (`usage-aggregate.mjs:1093-1103`). The evidence each parser reads: Claude's `permissionMode`, off the human prompt -only (`usage-parsers.mjs:593-608`); Codex's `approval_policy`/`sandbox_policy` +only (`usage-parsers.mjs:628-643`); Codex's `approval_policy`/`sandbox_policy` off each `turn_context`, last one wins since a session may renegotiate mid-run -(`usage-parsers.mjs:843-864`); OpenCode's `mode` off each assistant message +(`usage-parsers.mjs:943-964`); OpenCode's `mode` off each assistant message (`usage-opencode.mjs:344-345`). Render is `modeChart` in `src/lib/dashboard/client/usage.mjs`; the CLI table is `printScoreModeTable` (`src/commands/usage.mjs:270-272`). @@ -1708,7 +1813,7 @@ or `{"type":"workspace-write", …}` with sibling fields such as `network_access` — never the bare string the taxonomy is written against. A survey of this machine's rollouts (400 files, 2026-08-28) found 1,110 object occurrences and **zero** string ones. `handleCodexTurnContext` -(`usage-parsers.mjs:843-864`) therefore reads `sandbox_policy.type` and passes +(`usage-parsers.mjs:943-964`) therefore reads `sandbox_policy.type` and passes that to `normalizeMode`, which is unchanged and still accepts the string form. Before this extraction the object reached `normalizeMode` intact, matched no rule, and stringified into `modeRaw` as `"never/[object Object]"`: the `plan`, @@ -1731,11 +1836,11 @@ second field. (`usage-modes.mjs:25`, `:32`), so an unrecognised raw value — a future `permissionMode`, a policy this taxonomy has not been taught — yields no mode. The raw string is kept beside the normalized one as `modeRaw` -(`usage-parsers.mjs:208`) precisely because the mapping is a judgement call and +(`usage-parsers.mjs:240`) precisely because the mapping is a judgement call and a reader checking it needs the evidence it was made from. `not-recorded` is a first-class bucket key rather than a display fallback, folded at this call: `addTo(bucket(byMode, s.mode ?? 'not-recorded'), s)` -(`usage-aggregate.mjs:983-993`), it is always offered as a row by the CLI table +(`usage-aggregate.mjs:1093-1103`), it is always offered as a row by the CLI table even at zero (`printBucketTable`, `src/commands/usage.mjs:257-264`), and `segColor` (`src/lib/dashboard/client/usage-rhythm.mjs:191-192`) forces it to the de-emphasis ink rather than letting a palette give @@ -1747,17 +1852,21 @@ evidence must never read as a posture. **Formula:** ```text -source = (session.sidechain || session.threadSource == 'subagent') - ? 'subagent' : 'main' +delegated = isSubagentSession(session) + OR session.threadSource IN ['guardian_review', 'agent_created_thread'] +source = delegated ? 'subagent' : 'main' bySource[k].cost = Σ over sessions with that source of session.cost centre of the donut = round(main / (main + subagent) × 100) % ``` -**Source:** `sourceKey` (`usage-aggregate.mjs:932-936`). Both rows are created, +**Source:** `sourceKey` (`usage-aggregate.mjs:1013-1017`) uses the shared +`isSubagentSession` predicate (`usage-context.mjs:20-23`) and explicitly includes guardian reviews +and agent-created threads. The same source classification gates `humanPrompts`: only main-session +prompts enter the autonomy denominator (`usage-aggregate.mjs:1051-1054`). Both rows are created, at this call to it, before the fold -("Both source rows always exist", `usage-aggregate.mjs:962-966`) so "no subagent sessions" renders +("Both source rows always exist", `usage-aggregate.mjs:1044-1048`) so "no subagent sessions" renders as a zero rather than a row the UI silently drops. Claude's evidence is the -`isSidechain` flag on any entry in the file (`usage-parsers.mjs:758-763`, decoded at +`isSidechain` flag on any entry in the file (`usage-parsers.mjs:842-847`, decoded at `telemetry-records.mjs:267`); Codex's is the ledger-backed `thread_source` (§13c). Render is `sourceDonut` in `src/lib/dashboard/client/usage.mjs`. @@ -1768,7 +1877,7 @@ replay cannot be separated reports none. A zero cannot establish whether actual **Claude — real, priced, included.** A session's delegated work is written to its own transcript under `//subagents/`, and those files are -discovered by `listClaudeSubagents` (`usage-index.mjs:349-354`, §1) and parsed +discovered by `listClaudeSubagents` (`usage-index.mjs:435-440`, §1) and parsed like any other. `parseClaude` already prices those bytes and marks the record `sidechain` from its own `isSidechain` entries, so the cost is real, is included in `totals.cost`, and the session opens in the Sessions tab like a main-thread @@ -1856,10 +1965,10 @@ costPerSessionP90 = nearest-rank P90 of the same set cacheSavedUsd = Σ rows (costOf(1M as input) - costOf(1M as cacheRead)) × cacheRead / 1e6 ``` -**Source:** the derived block is `finishTotals` (`usage-aggregate.mjs:1042-1082`), +**Source:** the derived block is `finishTotals` (`usage-aggregate.mjs:1152-1192`), which the previous-window projection calls too so a baseline is never derived a second, drifting way. `median` and `percentile` are exact over the values -(`usage-aggregate.mjs:1029-1041`), unlike §15's bucketed percentiles. +(`usage-aggregate.mjs:1125-1151`), unlike §15's bucketed percentiles. Active days come from `byDay`'s key count and the streak from `activeStreak` in `src/lib/dashboard/client/usage.mjs`; the tiles are `cadenceCells` there, and `printScoreCadence` (`src/commands/usage.mjs:219-242`) in the CLI. @@ -1868,13 +1977,13 @@ Active days come from `byDay`'s key count and the streak from `activeStreak` in `totals.humanPrompts` is not the fingerprint-based `typedPrompts` count. It can include control records and unrecognized machine-authored prompts. It is accumulated under an explicit main-thread guard -(`usage-aggregate.mjs:953`): a subagent's prompts are written by the harness, +(`usage-aggregate.mjs:1035`): a subagent's prompts are written by the harness, so counting them would report a person as having typed work nobody asked for by hand — and would grow the denominator exactly in the windows where delegation was heaviest, making autonomy fall as automation rose. `totals.prompts` still -records every prompt beside it (`usage-aggregate.mjs:928`); the two are +records every prompt beside it (`usage-aggregate.mjs:1009`); the two are different questions and both are on the wire. Touch rate is those same human -prompts per engaged hour (`usage-aggregate.mjs:1030`), so both per-prompt figures +prompts per engaged hour (`usage-aggregate.mjs:1140`), so both per-prompt figures share one denominator. A rate whose denominator is zero is `null`, never `0` — no engaged time means the rate was never measured, which is not what "zero per hour" claims. @@ -1900,8 +2009,8 @@ presence, not verified billing. A session with no usage rows contributes to neit that map nor its per-day session count. **Cost per session is a median over priced sessions only.** A session carries -`_priced` when it had any usage rows at all (`usage-aggregate.mjs:873-879`), and -only those costs enter the distribution (`usage-aggregate.mjs:962-980`). A session +`_priced` when it had any usage rows at all (`usage-aggregate.mjs:950-956`), and +only those costs enter the distribution (`usage-aggregate.mjs:1044-1075`). A session with no usage rows costs `$0` *structurally* — nothing was ever measured for it, the common case being a Codex subagent that only its ledger row identifies, whose tokens are stripped as a double-count (§16.2, §13c) — and letting those in would report "the typical session @@ -1915,9 +2024,9 @@ positive figure that rounds away at two decimals prints `<$0.01`, never "nothing" are different claims. **What the cache saved, asked as a difference.** `cacheSavingPerMillion` -(`usage-aggregate.mjs:732-746`) prices one million tokens twice through the +(`usage-aggregate.mjs:739-756`) prices one million tokens twice through the *injected* pricer — once as fresh input, once as cache reads — and takes the -gap; `cacheSavedFor` (`usage-aggregate.mjs:732-753`) scales that to the tokens +gap; `cacheSavedFor` (`usage-aggregate.mjs:739-763`) scales that to the tokens a row actually read from cache. Nothing in that path knows what the cache multiplier is, so the saving cannot drift out of step with §3's table the way a hard-coded "0.9 × input" would the day the multiplier changed. Both probes @@ -1936,15 +2045,15 @@ this row = $4.50 × 2,000,000 / 1e6 = $9.00 ``` The window total is the sum of those per-row figures -(`usage-aggregate.mjs:943-979`), carried on each session row as `cacheSavedUsd` -(`usage-aggregate.mjs:836-857`) so it is auditable a row at a time rather than only +(`usage-aggregate.mjs:1024-1074`), carried on each session row as `cacheSavedUsd` +(`usage-aggregate.mjs:907-933`) so it is auditable a row at a time rather than only in aggregate, and rendered in the cache tile's subtitle as `saved ≈ $X vs uncached`. **Deltas: what "the previous window" is, exactly.** For a displayed window of `d` days ending at `now`, the baseline is the equal-length window immediately before it — the half-open interval `[now − 2d, now − d)` -(`previousWindow`, `usage-aggregate.mjs:1157-1181`). Both bounds are derived from +(`previousWindow`, `usage-aggregate.mjs:1267-1292`). Both bounds are derived from `now` and `d`, the window the UI is *showing*, and never from the parse cutoff: the caller widens that cutoff on purpose so older records survive to be aggregated here, and deriving the baseline from a widened bound would silently @@ -1958,26 +2067,26 @@ BASELINE_MIN_ACTIVE_DAYS of history BEFORE the displayed window and returns null without it — while this depth is a strict superset of the previous window at every supported width. A delta against an unknown-length window is not a delta. The upper bound is exclusive so a session ending exactly at the boundary belongs to the current -window and is not counted in both (`endMs`, `usage-aggregate.mjs:888-899`). Asking for +window and is not counted in both (`endMs`, `usage-aggregate.mjs:966-980`). Asking for `previous` without widening the lookback yields an all-zero baseline — the older records were never read off disk — and every chip self-suppresses against it rather than claiming a change it cannot measure. Leaving `previous` off entirely leaves `agg.previous` as `null` — "not requested", which a zeroed totals object would misreport as "measured nothing" -(`usage-aggregate.mjs:1229`). A chip self-suppresses when the baseline is null +(`usage-aggregate.mjs:1313`). A chip self-suppresses when the baseline is null or zero, and a magnitude that rounds to zero prints flat rather than drawing an arrow the printed number does not support (`deltaChip`, `usage-rhythm.mjs:36-53`; `fmtDelta`, `src/commands/usage.mjs:184-195`). **Engaged time by day is a sibling map, not a `byDay` field.** `byDay`'s presence contract is **days with retained usage rows** — a key exists exactly when tokens -landed on that day (`dayBucket`, `usage-aggregate.mjs:698-705`) — and that is +landed on that day (`dayBucket`, `usage-aggregate.mjs:705-712`) — and that is what the active-day count and the streak above are counted from. Engaged time does not share that key set: a session that runs past midnight, or a day spent reading, produces worked time on a day that billed nothing. So -`buildEngagedByDay` (`usage-aggregate.mjs:1133-1153`) keys its own map, cutting +`buildEngagedByDay` (`usage-aggregate.mjs:1243-1263`) keys its own map, cutting each active interval at every local midnight it crosses -(`splitAtLocalMidnight`, `usage-aggregate.mjs:1119-1130`) and unioning the pieces +(`splitAtLocalMidnight`, `usage-aggregate.mjs:1229-1240`) and unioning the pieces per day, which makes the map sum exactly to `totals.engagedSeconds`. Folding it into `byDay` would have forced one of two lies: inventing zero-token `byDay` rows, or dropping real worked time. The consequence is visible on the tiles — @@ -2013,8 +2122,8 @@ byDay[day].exceptions += session.exceptions attributed to the session's FIRST ``` **Source:** `exceptions` and `aborts` accumulate together onto totals -(`totals.exceptions += s.exceptions`, `usage-aggregate.mjs:954`); the per-day series lands on `byDay` itself — -`byDay[s._day].exceptions` (`usage-aggregate.mjs:977`). Render is +(`totals.exceptions += s.exceptions`, `usage-aggregate.mjs:1055`); the per-day series lands on `byDay` itself — +`byDay[s._day].exceptions` (`usage-aggregate.mjs:1106`). Render is `relRate`/`relStat`/`relTrend` in `src/lib/dashboard/client/usage.mjs` (that bundle shares a basename with the CLI command module, so cited here by name, no line); `printScoreReliability` (`src/commands/usage.mjs:270-295`) prints @@ -2025,9 +2134,9 @@ model, and each host signals that differently: | Host | What is counted, and where | |---|---| -| claude | The API-error placeholder — Claude Code synthesizes a local turn with no completion behind it when a connection drops, a rate limit rejects, or auth fails. The decoder sets `isApiError` from either `isApiErrorMessage` or the literal `` model marker (`telemetry-records.mjs:269`), because the flag is not set on every build that emits the placeholder; the parser counts it as an exception, not a response, and returns before any model or usage attribution (`usage-parsers.mjs:701-720`). | -| codex | A `task_complete` event carrying a non-null `error` (`usage-parsers.mjs:936-954`). | -| codex | `turn_aborted` is counted **separately**, into `rec.aborts` (`usage-parsers.mjs:1048-1089`) — not into exceptions. | +| claude | The API-error placeholder — Claude Code synthesizes a local turn with no completion behind it when a connection drops, a rate limit rejects, or auth fails. The decoder sets `isApiError` from either `isApiErrorMessage` or the literal `` model marker (`telemetry-records.mjs:269`), because the flag is not set on every build that emits the placeholder; the parser counts it as an exception, not a response, and returns before any model or usage attribution (`usage-parsers.mjs:758-777`). | +| codex | A `task_complete` event carrying a non-null `error` (`usage-parsers.mjs:1049-1076`). | +| codex | `turn_aborted` is counted **separately**, into `rec.aborts` (`usage-parsers.mjs:1170-1218`) — not into exceptions. | | opencode | An assistant message carrying a non-null `error` other than `MessageAbortedError` (`usage-opencode.mjs:344-348`). | | opencode | `MessageAbortedError` — how OpenCode records a turn the user stopped — is counted **separately**, into `rec.aborts` (name at `usage-opencode.mjs:307-308`), and keeps the row's tokens and cost. | @@ -2036,7 +2145,7 @@ recorded interruption; it does not independently prove who initiated it. An exce is the turn failing. Summing them would report a deliberate interruption as a reliability problem and move a number that is supposed to mean "how often did this break". They are counted, carried -(`aborts`, `usage-aggregate.mjs:809-833`) and displayed side by side, with the +(`aborts`, `usage-aggregate.mjs:836-904`) and displayed side by side, with the distinction stated on the tile rather than left to the label. **Aborts are CODEX-AND-OPENCODE normalized evidence.** This counter consumes Codex @@ -2056,7 +2165,7 @@ treatment `latHist` (§15) and the context chip (§16) already get. **Exceptions ride the session's first-billed day.** The per-day series uses the same attribution as the session count — `byDay[s._day].exceptions += s.exceptions` -(`usage-aggregate.mjs:954-957`) — which is *not* the moment a turn dropped: a session spanning midnight lands all of its +(`usage-aggregate.mjs:1106`) — which is *not* the moment a turn dropped: a session spanning midnight lands all of its exceptions on the day its tokens first billed. That keeps the reliability trend and the session trend drawn on one convention — the alternative, attributing each exception to its own timestamp, would have made the two lines disagree @@ -2073,15 +2182,12 @@ measurement behind §10 breaks 33 such placeholder turns down as `server_error` placeholder shape, which is why the panel counts them together and §10 excludes them from the model ranking rather than showing a `$0` model row. -**What this does not model:** the rate's denominator is *responses*, which -includes the exception turns themselves (they increment `rec.responses` before -the error branch returns, `usage-parsers.mjs:568`) — they were real engaged -time, someone was genuinely waiting on them. A retry that eventually succeeded -appears as one exception plus one successful response, not as a single -recovered turn; nothing in either transcript links the two. And the worst-day -flag names the day with the most exceptions without inventing a threshold for -what counts as a spike, because any constant chosen here would be a judgement -the data never made. +**What this does not model:** the rate's denominator is accounted responses. Claude API-error +placeholders increment `rec.exceptions` and return before response, usage or punchcard accounting +(`recordClaudeAssistantTurn`, `usage-parsers.mjs:749-777`); they do not enter that denominator. +A later successful retry can contribute one response alongside the earlier exception, but the +metric does not pair them into a single recovered turn. The worst-day flag names the day with +the most exceptions without inventing a threshold for what counts as a spike. --- @@ -2101,7 +2207,7 @@ byTool[name] += session.tools[name] summed across sessions byDay[day].byModelFamily[fam] += rowCost fam = modelFamily(row.model) ``` -**Source:** the tool tally is folded into `byTool` at `usage-aggregate.mjs:943-1007`; +**Source:** the tool tally is folded into `byTool` at `usage-aggregate.mjs:1024-1117`; the per-day family split is this call: `addCost(d.byModelFamily, modelFamily(row.model), rowCost)` (`usage-aggregate.mjs:776`), inside the usage-row pass because only a row knows its day. Render is `toolRows`/`modelMix` in @@ -2109,10 +2215,10 @@ row knows its day. Render is `toolRows`/`modelMix` in **Tool names are the host's own, never renamed.** Claude's tally is keyed by the `tool_use` block's own `name` (`collectClaudeToolNames`, -`usage-parsers.mjs:627-638`). Codex's five tallied item types — +`usage-parsers.mjs:662-673`). Codex's five tallied item types — `CommandExecution`, `McpToolCall`, `FileChange`, `CollabAgentToolCall`, -`DynamicToolCall` (`CODEX_TOOL_ITEM_TYPES`, `usage-parsers.mjs:1040-1046`, tallied at this -call: `CODEX_TOOL_ITEM_TYPES.has(decoded.unknownItemType)` — `usage-parsers.mjs:1092-1103`) — +`DynamicToolCall` (`CODEX_TOOL_ITEM_TYPES`, `usage-parsers.mjs:1162-1168`, tallied at this +call: `CODEX_TOOL_ITEM_TYPES.has(decoded.unknownItemType)` — `usage-parsers.mjs:1221-1235`) — keep those exact spellings in the ranking. Mapping `CommandExecution` onto `Bash`, or `FileChange` onto `Edit`, would be a claim about equivalence that neither host makes: the vocabularies are host-specific, the semantics do not @@ -2126,12 +2232,12 @@ above it is read against — and the fold row is dimmed because `Other` is a residue, not a tool. **Model-family folding, with the rules pinned.** `modelFamily` -(`usage-aggregate.mjs:272-279`) lowercases the id, keeps only the segment after +(`usage-aggregate.mjs:275-282`) lowercases the id, keeps only the segment after the last `/` so a namespaced id still ends on the same tokens, then: | Rule | Example id | Family | |---|---|---| -| contains an Anthropic family name (`CLAUDE_FAMILIES`, `usage-aggregate.mjs:264`) | `claude-opus-5-20260401` | `opus` | +| contains an Anthropic family name (`CLAUDE_FAMILIES`, `usage-aggregate.mjs:267`) | `claude-opus-5-20260401` | `opus` | | — matched by containment, not position, since the id shape has moved | `claude-3-5-sonnet-20241022` | `sonnet` | | — and after the last slash, so a namespaced id still folds | `openrouter/anthropic/claude-haiku-4-5` | `haiku` | | otherwise matches `gpt-(\d+)` | `gpt-5.6-sol` | `gpt-5` | @@ -2360,14 +2466,14 @@ commit `540be18` in the historical fix. `parseCodex`'s single `addUsage()` call never included a `responses` field — Claude's parser passes `responses: 1` per API message at this call: -`usage-parsers.mjs:677` (the current equivalent), but Codex's call +`usage-parsers.mjs:723` (the current equivalent), but Codex's call passed no such field at all. Because `byModel[model].responses` is summed -directly from each usage row's `responses` field (`usage-aggregate.mjs:775-778`, +directly from each usage row's `responses` field (`usage-aggregate.mjs:786-789`, `m.responses += row.responses`), **every** Codex model in §10's Models-in-Play list displayed `0 resp` regardless of real token/cost volume or actual `agent_message` count. **Fix:** parseCodex now passes `responses: rec.responses` (the session's own tallied response count, inside -`finalizeCodexUsage`, `usage-parsers.mjs:1136-1183`) on its `addUsage()` call. +`finalizeCodexUsage`, `usage-parsers.mjs:1303-1355`) on its `addUsage()` call. #### Bug B — subagent thread-replay could double-bill tokens @@ -2396,10 +2502,10 @@ confirmed as a real Codex rollout field by **[C7]**) and skips the `addUsage()` call entirely when its value is `'subagent'` — `finalizeCodexUsage` returns early at this call: `if (!lastUsage || rec.threadSource === 'subagent')` -(`usage-parsers.mjs:1136-1173`). The session record itself is **not** +(`usage-parsers.mjs:1303-1331`). The session record itself is **not** dropped — it remains visible in the Sessions tab with `threadSource` surfaced (mirroring the existing `sidechain` flag Claude sessions already -carry, `usage-parsers.mjs:760-763`), so a maintainer auditing the raw data can +carry, `usage-parsers.mjs:844-847`), so a maintainer auditing the raw data can still see it; it simply contributes zero tokens/cost, exactly as intended by the "models still shows up in §10's list, with zero cost" mechanism §10 describes. @@ -2461,8 +2567,8 @@ parity: it does not apply ledger fallback or allocate costs per turn/day/model. `byModel` on the first run after the change, purely because the cache predated it; every unit test still passed, since tests only exercise a fresh parse. `SCHEMA_VERSION` went to `4` specifically to force the one-time - re-parse; the constant now reads `24` (`usage-index.mjs:177`), each bump since - having forced its own re-parse the same way. + re-parse. That is the historical v4 migration; the current `SCHEMA_VERSION` is `26` + (`usage-index.mjs:198`), with the present compatibility contract stated above. Re-querying the same live server after the bump returned `totals.exceptions: 20` with `` absent from `byModel` — measured, not projected. diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index d0587a8d..2335a1d5 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -28,7 +28,8 @@ export const SUITES = { ['--test', 'tests/ui/dashboard-project-context.mjs', 'tests/ui/maintenance-projects.mjs', 'tests/ui/maintenance-host-alignment.mjs', 'tests/ui/intelligence-picker.mjs', 'tests/ui/usage-project-groups.mjs', 'tests/ui/context-coverage.mjs', 'tests/ui/host-readiness.mjs', - 'tests/ui/maintenance-focus.mjs', 'tests/ui/maintenance-guidance.mjs'], + 'tests/ui/maintenance-focus.mjs', 'tests/ui/maintenance-guidance.mjs', + 'tests/ui/session-surfaces.mjs'], ], }; diff --git a/src/commands/system.mjs b/src/commands/system.mjs index c417951d..7b583540 100644 --- a/src/commands/system.mjs +++ b/src/commands/system.mjs @@ -1,3 +1,4 @@ +import { censusDisclosure } from '../lib/census-presentation.mjs'; // ak system — the machine footprint in the terminal (ADR-0025). // // The CLI twin of the dashboard's System area, driving the SAME composed @@ -231,24 +232,25 @@ function renderRuntime(runtime) { } const rows = census?.value ?? []; if (!rows.length) { - console.log(` ${dim('no agent processes are running')}`); + console.log(` ${dim('no coding-agent or desktop-application processes are running')}`); return; } const sink = reasonSink(); console.log(''); - table(['HOST', 'PID', 'CPU', 'RSS', 'UPTIME', 'PROJECT'], rows.map((row) => [ - row.host, + table(['CODING-AGENT HOST / DESKTOP APPLICATION', 'PID', 'CPU', 'RSS', 'UPTIME', 'WORKING CONTEXT'], rows.map((row) => [ + row.application ?? row.host ?? 'Unknown process', String(row.pid), sink.cell(row.cpuPercent, fmtPercent), sink.cell(row.rssBytes, fmtBytes), sink.cell(row.uptimeMs, fmtDuration), - row.project?.status === UNKNOWN ? 'unattributed' : (row.project?.value?.label ?? 'unattributed'), + row.source?.status === UNKNOWN ? 'unattributed' + : (row.source?.value?.label ?? row.project?.value?.label ?? 'unattributed'), ])); sink.report(); - // `project` degrades per process (a cwd the platform will not disclose); its + // `source` degrades per process (a cwd the platform will not disclose); its // reason lives on the row, not in the numeric sink above. - for (const reason of new Set(rows.filter((row) => row.project?.status === UNKNOWN) - .map((row) => row.project.reason))) { + for (const reason of new Set(rows.filter((row) => (row.source ?? row.project)?.status === UNKNOWN) + .map((row) => (row.source ?? row.project).reason))) { console.log(` ${dim(`unattributed: ${reason}`)}`); } } @@ -348,6 +350,7 @@ function renderProjects(projects, now) { info(dim('not measured yet — run: ak system --refresh=machine')); return; } + info(dim(censusDisclosure(projects))); field('discovered', `${meas(projects.count)}${projects.truncated ? dim(' · list truncated') : ''}`); if (!projects.locMeasured) field('lines of code', dim('not measured in this scan')); diff --git a/src/lib/census-presentation.mjs b/src/lib/census-presentation.mjs new file mode 100644 index 00000000..dbb5c752 --- /dev/null +++ b/src/lib/census-presentation.mjs @@ -0,0 +1,8 @@ +/** An absent legacy field is unknown, never a measured zero. Shared by CLI/UI. */ +export function censusDisclosure(census = {}) { + const count = (field) => Number.isInteger(census[field]) && census[field] >= 0 ? String(census[field]) : 'Unknown number of'; + return `${count('importedExcluded')} confirmed pure imported copies excluded (no project, host or origin contribution); ` + + `${count('importedMixed')} mixed files retain proven native activity; ` + + `${count('importedUnresolved')} files have unresolved bounded ownership (not confirmed exclusions). ` + + 'The dedicated Cowork transcript source is not covered; Cowork declarations in covered transcripts remain valid observations.'; +} diff --git a/src/lib/codex-import-marker.mjs b/src/lib/codex-import-marker.mjs index 1be22e44..afa1ab0f 100644 --- a/src/lib/codex-import-marker.mjs +++ b/src/lib/codex-import-marker.mjs @@ -12,14 +12,15 @@ export const CODEX_IMPORT_TURN_PREFIX = 'external-import-turn'; -/** One decoded rollout record: is it a turn of an imported thread? Only a +/** One decoded rollout record: does it explicitly belong to an imported turn? Only a * string `payload.turn_id` counts; the marker text inside a message does not. */ export function isCodexImportedLine(e) { const turnId = e?.payload?.turn_id; return typeof turnId === 'string' && turnId.startsWith(CODEX_IMPORT_TURN_PREFIX); } -/** A rollout's bounded head (raw JSON lines): is the rollout an imported copy? +/** Does a bounded head contain an imported turn? This does not prove the + * whole rollout is imported-only; later native turns require a separate scan. * A line without the marker text is not parsed, which keeps this cheap on the * large native heads; unparseable and non-string lines are skipped. */ export function isImportedCodexRollout(headLines) { @@ -31,3 +32,69 @@ export function isImportedCodexRollout(headLines) { } return false; } + +/** Stateful per-turn ownership. Explicit turn metadata outranks adjacency; + * a foreign completion never closes the active turn. Missing IDs in a mixed + * file cannot open a native turn. No IDs or payloads escape the state. */ +export function newCodexTurnOwnership({ hasImports = true } = {}) { + return { hasImports, ownershipComplete: true, activeId: null, activeOwner: hasImports ? 'ambiguous' : 'native', + importedIds: new Set(), importedTurnCountComplete: true, importedRecords: 0, ambiguousRecords: 0, nativeRecords: 0 }; +} + +const validTurnId = (id) => typeof id === 'string' && id.length > 0 + && id.length <= 256 && !/\s/u.test(id); + +function noteBoundary(state, p, imported) { + // Absence can mean an enriching context. An explicitly invalid declaration + // cannot preserve adjacency to the prior native turn in a mixed source. + if (state.hasImports && Object.hasOwn(p, 'turn_id') && !validTurnId(p.turn_id)) { + state.activeId = null; + state.activeOwner = 'ambiguous'; + state.ownershipComplete = false; + return; + } + if (!validTurnId(p.turn_id) && p.type !== 'task_started') return; + state.activeId = validTurnId(p.turn_id) ? p.turn_id : null; + state.activeOwner = imported ? 'imported' : state.activeId ? 'native' + : state.hasImports ? 'ambiguous' : 'native'; +} + +function endsActiveTurn(record, p, activeId) { + return record?.type === 'event_msg' && ['task_complete', 'turn_aborted'].includes(p.type) + && validTurnId(p.turn_id) && p.turn_id === activeId; +} + +function noteImportedId(state, id) { + if (state.importedIds.has(id)) return; + if (validTurnId(id) && state.importedIds.size < 4096) state.importedIds.add(id); + else state.importedTurnCountComplete = false; +} + +export function codexTurnOwner(state, record) { + const p = record?.payload ?? {}; + const id = p.turn_id; + const imported = isCodexImportedLine(record); + const boundary = record?.type === 'turn_context' + || (record?.type === 'event_msg' && p.type === 'task_started'); + // An ID-less context enriches an identified turn but cannot open one. + if (boundary) noteBoundary(state, p, imported); + let owner = imported ? 'imported' : state.activeOwner; + if (state.hasImports && id !== undefined && (!validTurnId(id) || id !== state.activeId) && !imported) owner = 'ambiguous'; + if (imported) { noteImportedId(state, id); state.importedRecords++; } + else if (owner === 'imported') state.importedRecords++; + else if (owner === 'ambiguous' && record?.type !== 'session_meta') state.ambiguousRecords++; + if (owner === 'native' && record?.type === 'event_msg' + && ['user_message', 'agent_message', 'item_completed', 'token_count'].includes(p.type)) state.nativeRecords++; + if (endsActiveTurn(record, p, state.activeId)) { + state.activeId = null; + state.activeOwner = state.hasImports ? 'ambiguous' : 'native'; + } + return owner; +} + +/** Only enumerated counts are persisted; turn IDs remain local to the walk. */ +export function codexImportEvidence(state) { + return { importedTurns: state.importedIds.size, importedTurnCountComplete: state.importedTurnCountComplete, + importedRecords: state.importedRecords, + ambiguousRecords: state.ambiguousRecords, nativeRecords: state.nativeRecords }; +} diff --git a/src/lib/codex-rollout-reader.mjs b/src/lib/codex-rollout-reader.mjs index fba25d8d..989cb113 100644 --- a/src/lib/codex-rollout-reader.mjs +++ b/src/lib/codex-rollout-reader.mjs @@ -57,12 +57,16 @@ function clippedStub(head) { } /** Parse one whole line, or `null` for anything that is not a JSON object. */ -function parseLine(buf) { - if (!buf.length || buf[0] !== OPEN_BRACE) return null; +function parseLine(buf, stats) { + if (!buf.length) return null; + if (buf[0] !== OPEN_BRACE) { + if (buf.toString('utf8').trim()) stats.malformedRecords++; + return null; + } try { const obj = JSON.parse(buf.toString('utf8')); return obj && typeof obj === 'object' ? obj : null; - } catch { return null; } + } catch { stats.malformedRecords++; return null; } } /** @@ -75,6 +79,7 @@ function parseLine(buf) { */ function* passOver(file, { chunkBytes, maxLineBytes, stats }) { stats.clippedLines = 0; + stats.malformedRecords = 0; const fd = fs.openSync(file, 'r'); try { const limit = fs.fstatSync(fd).size; @@ -102,7 +107,7 @@ function* passOver(file, { chunkBytes, maxLineBytes, stats }) { stats.clippedLines++; out = clippedStub(head); } else if (partsLen) { - out = parseLine(parts.length === 1 ? parts[0] : Buffer.concat(parts, partsLen)); + out = parseLine(parts.length === 1 ? parts[0] : Buffer.concat(parts, partsLen), stats); } parts = []; partsLen = 0; oversize = false; head = null; return out; @@ -119,7 +124,7 @@ function* passOver(file, { chunkBytes, maxLineBytes, stats }) { if (nl < 0) break; let obj; if (partsLen === 0 && !oversize && nl - start <= maxLineBytes) { - obj = parseLine(view.subarray(start, nl)); // whole line inside this chunk: no copy + obj = parseLine(view.subarray(start, nl), stats); // whole line inside this chunk: no copy } else { take(Buffer.from(view.subarray(start, nl))); // `chunk` is reused, so keep a copy obj = finish(); @@ -140,7 +145,7 @@ function* passOver(file, { chunkBytes, maxLineBytes, stats }) { * Open a rollout for parsing. Returns a source `parseCodex` accepts in place of * a string: `head` (the first 256 KiB, for session-origin detection), `lines` * (re-iterable — each iteration re-reads the file, which the subagent replay - * pre-pass needs) and `stats` (`clippedLines` of the LAST pass). + * pre-pass needs) and `stats` (`clippedLines` and `malformedRecords` of the LAST pass). * * Throws (ENOENT, EACCES, …) when the file cannot be opened or its head read; * the caller decides how to report that. @@ -151,7 +156,7 @@ function* passOver(file, { chunkBytes, maxLineBytes, stats }) { export function openCodexRollout(file, limits = {}) { const chunkBytes = limits.chunkBytes ?? DEFAULT_CHUNK_BYTES; const maxLineBytes = limits.maxLineBytes ?? DEFAULT_MAX_LINE_BYTES; - const stats = { clippedLines: 0 }; + const stats = { clippedLines: 0, malformedRecords: 0 }; const fd = fs.openSync(file, 'r'); let head; try { diff --git a/src/lib/codex-usage-walk.mjs b/src/lib/codex-usage-walk.mjs index da7e8a2e..92a35a06 100644 --- a/src/lib/codex-usage-walk.mjs +++ b/src/lib/codex-usage-walk.mjs @@ -33,9 +33,12 @@ const ZERO = Object.freeze({ input_tokens: 0, cached_input_tokens: 0, output_tok const MONOTONIC = ['input_tokens', 'cached_input_tokens', 'output_tokens', 'total_tokens']; const snapshotOf = (t) => Object.fromEntries(FIELDS.map((f) => [f, Number(t?.[f]) || 0])); +const isTotalOnly = (t) => t.total_tokens > 0 && !t.input_tokens && !t.cached_input_tokens && !t.output_tokens; /** * @typedef {object} CodexUsageWalk + * @property {boolean} excludedBaseline + * @property {boolean} unknownBaseline * @property {boolean} unattributable * @property {Record|null} prev * @property {string|null} model @@ -51,6 +54,8 @@ const snapshotOf = (t) => Object.fromEntries(FIELDS.map((f) => [f, Number(t?.[f] export function newCodexUsageWalk({ unattributable = false } = {}) { return { unattributable, + excludedBaseline: false, + unknownBaseline: false, // a total-only counter hid the component baseline prev: null, // the previous cumulative snapshot, replayed ones included model: null, // the model of the turn_context in effect lastMs: null, // the last finite event time seen on a token_count @@ -94,15 +99,31 @@ export function noteCodexWalkResponse(walk, ms, dayOf) { * the running total; an own one books its delta on `ms`'s day under the current * model. */ -export function walkCodexTokenCount(walk, total, ms, replay, dayOf) { +export function walkCodexTokenCount(walk, total, ms, replay, dayOf, last = null) { if (!total) return; const cur = snapshotOf(total); const prev = walk.prev; - walk.prev = cur; if (Number.isFinite(ms)) walk.lastMs = ms; - const restarted = prev !== null && MONOTONIC.some((f) => cur[f] < prev[f]); + if (isTotalOnly(cur)) { + // A total-only snapshot has no component baseline. Treat the next full + // snapshot as a new baseline unless that call itself proves a reset. + walk.unknownBaseline = true; + walk.excludedBaseline = replay; + return; + } + // A first native call can reset ABOVE the copied baseline. Matching + // last/total counters prove that reset; an identical re-emission does not. + const lastSnapshot = last ? snapshotOf(last) : null; + const explicitReset = (walk.excludedBaseline || walk.unknownBaseline) && lastSnapshot + && FIELDS.every((f) => cur[f] === lastSnapshot[f]) + && (prev === null || FIELDS.some((f) => cur[f] !== prev[f])); + const restarted = explicitReset || (prev !== null && MONOTONIC.some((f) => cur[f] < prev[f])); + const unknownBaseline = walk.unknownBaseline; + walk.unknownBaseline = false; + walk.prev = cur; + walk.excludedBaseline = replay; if (restarted) walk.segments++; - if (replay || walk.unattributable) return; + if (replay || walk.unattributable || (unknownBaseline && !restarted)) return; const base = prev === null || restarted ? ZERO : prev; const d = Object.fromEntries(FIELDS.map((f) => [f, Math.max(0, cur[f] - base[f])])); if (!d.input_tokens && !d.output_tokens && !d.cached_input_tokens) return; diff --git a/src/lib/dashboard-server.mjs b/src/lib/dashboard-server.mjs index cef33622..b81cf2ee 100644 --- a/src/lib/dashboard-server.mjs +++ b/src/lib/dashboard-server.mjs @@ -287,7 +287,8 @@ function censusBackedDiscovery() { readCensus: () => (last ? { everSeen: last.everSeen, onDisk: last.onDisk, gitRepos: last.gitRepos, learning: last.learning, - complete: last.complete, + complete: last.complete, importedExcluded: last.importedExcluded, + importedMixed: last.importedMixed, importedUnresolved: last.importedUnresolved, } : null), }; } @@ -378,8 +379,8 @@ async function collectData({ cwd, fetchStatus, projectParam, getProjectSnapshot, intel: { selectedProjectKey: selected?.key ?? null, selectedProjectLabel: selected?.label ?? null, - projects: projects.map(({ key, label, path: projectPath, source, learningScope, learningScopeEvidence, learningOrigins, learningObservedAt }) => ( - { key, label, path: projectPath, source, + projects: projects.map(({ key, label, path: projectPath, source, hosts, sessionOrigins, sessionSurfaces, learningScope, learningScopeEvidence, learningOrigins, learningObservedAt }) => ( + { key, label, path: projectPath, source, hosts, sessionOrigins, sessionSurfaces, learningScope: ['repository', 'worktree', 'user'].includes(learningScope) ? learningScope : 'unknown', learningScopeEvidence: learningScopeEvidence ?? 'unclassified', learningObservedAt: learningObservedAt ?? null, learningOrigins: ['claude-desktop', 'codex-desktop'].filter((origin) => learningOrigins?.includes(origin)) } diff --git a/src/lib/dashboard/client.mjs b/src/lib/dashboard/client.mjs index 47c217fb..167e835e 100644 --- a/src/lib/dashboard/client.mjs +++ b/src/lib/dashboard/client.mjs @@ -1,3 +1,5 @@ +import { censusDisclosure } from '../census-presentation.mjs'; +import { SESSION_SURFACE_LABELS, SESSION_HOST_LABELS, SESSION_INITIATOR_LABELS, SESSION_PROVIDER_LABELS, sessionPresentation, sessionProviderPresentation } from '../session-surface.mjs'; import { repositoryTree } from './project-groups.mjs'; import { contextCard } from './context-card.mjs'; import { contextHostCard } from './context-host-card.mjs'; @@ -92,6 +94,8 @@ aboutSrc = inject(aboutSrc, 'var ABOUT = []; // PLACEHOLDER:ABOUT_JS', `var ABOU const datetimeSrc = readSplit('datetime.mjs'); const hostReadinessSrc = readSplit('host-readiness.mjs'); +const sessionPresentationSrc = readSplit('session-presentation.mjs'); +const sessionVocabularySrc = `const SESSION_SURFACE_LABELS=${JSON.stringify(SESSION_SURFACE_LABELS)},SESSION_HOST_LABELS=${JSON.stringify(SESSION_HOST_LABELS)},SESSION_INITIATOR_LABELS=${JSON.stringify(SESSION_INITIATOR_LABELS)},SESSION_PROVIDER_LABELS=${JSON.stringify(SESSION_PROVIDER_LABELS)};${sessionPresentation.toString()}${sessionProviderPresentation.toString()}`; const intelligenceSrc = readSplit('intelligence.mjs'); const pollSrc = readSplit('poll.mjs'); const refreshControlSrc = readSplit('refresh-control.mjs'); @@ -165,5 +169,5 @@ const bootSrc = readSplit('boot.mjs'); export const JS = ` (function(){ ${bootstrapSrc}${contextCard.toString()}${contextHostCard.toString()}${repositoryTree.toString()}${overviewSrc}${datetimeSrc}${hostReadinessSrc} -${intelligenceSrc}${pollSrc}${refreshControlSrc}${usageRhythmSrc}${usagePromptsSrc}${usageContextHooksSrc}${usageSrc}${modelLifecycleSrc}${usageOrchestratorsSrc}${rufloComponentsSrc}${aboutSrc}${systemReadoutSrc}${systemProjectsSrc}${maintenanceWorkspaceSrc}${maintenanceFiltersSrc}${maintenanceCardsSrc}${maintenanceOperationSrc}${maintenanceLanguageLogosSrc}${maintenanceFocusSrc}${maintenanceInventorySrc}${maintenanceRelationshipsSrc}${maintenanceInspectorSrc}${maintenanceGuidanceSrc}${maintenanceDiscoverySrc}${maintenanceActivitySrc}${systemMaintenanceActionsSrc}${systemMaintenanceSrc}${bootSrc}})(); +${censusDisclosure.toString()}${sessionVocabularySrc}${sessionPresentationSrc}${intelligenceSrc}${pollSrc}${refreshControlSrc}${usageRhythmSrc}${usagePromptsSrc}${usageContextHooksSrc}${usageSrc}${modelLifecycleSrc}${usageOrchestratorsSrc}${rufloComponentsSrc}${aboutSrc}${systemReadoutSrc}${systemProjectsSrc}${maintenanceWorkspaceSrc}${maintenanceFiltersSrc}${maintenanceCardsSrc}${maintenanceOperationSrc}${maintenanceLanguageLogosSrc}${maintenanceFocusSrc}${maintenanceInventorySrc}${maintenanceRelationshipsSrc}${maintenanceInspectorSrc}${maintenanceGuidanceSrc}${maintenanceDiscoverySrc}${maintenanceActivitySrc}${systemMaintenanceActionsSrc}${systemMaintenanceSrc}${bootSrc}})(); `; diff --git a/src/lib/dashboard/client/intelligence.mjs b/src/lib/dashboard/client/intelligence.mjs index 239641be..5a3e2308 100644 --- a/src/lib/dashboard/client/intelligence.mjs +++ b/src/lib/dashboard/client/intelligence.mjs @@ -1,6 +1,8 @@ // @ts-nocheck — browser bundle source (never node-imported; client.mjs // reads it as text). See src/lib/dashboard/client/**'s eslint.config.mjs // override comment for why this directory isn't run through the node lib. +import { censusDisclosure } from '../../census-presentation.mjs'; +import { projectSurfacesHtml, surfaceNames } from './session-presentation.mjs'; import { renderHostReadiness } from './host-readiness.mjs'; import { renderAbout } from './about.mjs'; import { DASH_TOKEN, activeTab, esc, overviewView, positionThumb } from './bootstrap.mjs'; @@ -54,20 +56,18 @@ import { fmtNum, kpi } from './usage.mjs'; html+='

At least one transcript could not be read, ' +"so every figure above is a lower bound.

"; } + html+='

'+esc(censusDisclosure(c))+'

'; body.innerHTML=html; box.hidden=false; } - var INTEL_SCOPE_GROUPS=[['repository','Git repositories'],['worktree','Git worktrees'],['user','User-level learning'],['unknown','Other / unclassified']]; - var machineWideDesignationFilter='all'; + var INTEL_SCOPE_GROUPS=[['repository','Git repositories'],['worktree','Git worktrees'],['user','User-level learning'],['unknown','Unknown']]; + var machineWideDesignationFilter='all',machineWideSurfaceFilter='all'; function machineWideDesignation(p){ if(p.learningScope==='repository')return 'Git repository'; if(p.learningScope==='worktree')return 'Git worktree'; - var origins=Array.isArray(p.learningOrigins)?p.learningOrigins:[]; - if(origins.includes('codex-desktop'))return 'ChatGPT Desktop'; - if(origins.includes('claude-desktop'))return 'Claude Desktop'; - if(Array.isArray(p.hosts)&&p.hosts.includes('opencode'))return 'OpenCode'; - return 'Directory'; + if(p.learningScope==='user')return 'User-level learning'; + return 'Unknown'; } function intelScopeRows(rows,scope){ return rows.filter(function(p){ @@ -86,7 +86,7 @@ import { fmtNum, kpi } from './usage.mjs'; var label=p.label||'(unlabeled)'; return '
' +''+esc(label)+storeHtml+'' - +''+esc(machineWideDesignation(p))+'' + +''+esc(machineWideDesignation(p))+''+projectSurfacesHtml(p)+'' +''+esc(fmtNum(p.patternsLearned))+'' +''+esc(fmtNum(p.patternStoreCount))+'' +''+esc(lastTxt)+'
'; @@ -110,12 +110,15 @@ import { fmtNum, kpi } from './usage.mjs'; +kpi("most active project",totals.mostActiveProject||"—","by most recent learning adaptation","accent"); var table=document.getElementById("mw-table"); if(!table)return; - if(!perProject.length){table.innerHTML='
no projects discovered on this machine.
';return;} + if(!perProject.length){machineWideSurfaceFilter='all';table.innerHTML='
no projects discovered on this machine.
';return;} var designations=['all'].concat(Array.from(new Set(perProject.map(machineWideDesignation))).sort()); - var visible=(machineWideDesignationFilter==='all'?perProject:perProject.filter(function(row){return machineWideDesignation(row)===machineWideDesignationFilter;})).sort(function(a,b){return String(a.label||'').localeCompare(String(b.label||''),undefined,{sensitivity:'base',numeric:true})||String(a.key||a.path||'').localeCompare(String(b.key||b.path||''));}); + var surfaceChoices=Array.from(new Set(perProject.flatMap(surfaceNames))).sort(); + if(machineWideSurfaceFilter!=='all'&&!surfaceChoices.includes(machineWideSurfaceFilter))machineWideSurfaceFilter='all'; + var visible=(machineWideDesignationFilter==='all'?perProject:perProject.filter(function(row){return machineWideDesignation(row)===machineWideDesignationFilter;})).filter(function(row){return machineWideSurfaceFilter==='all'||surfaceNames(row).includes(machineWideSurfaceFilter);}).sort(function(a,b){return String(a.label||'').localeCompare(String(b.label||''),undefined,{sensitivity:'base',numeric:true})||String(a.key||a.path||'').localeCompare(String(b.key||b.path||''));}); table.innerHTML='
' +designations.map(function(designation){var label=designation==='all'?'All':designation;return '';}).join('') - +'
'+machineWideTable(visible); + +''+machineWideTable(visible); + var surfaceSelect=document.getElementById('mw-surface-filter');if(surfaceSelect)surfaceSelect.onchange=function(){machineWideSurfaceFilter=surfaceSelect.value;renderMachineWide(mw);}; if(table.querySelectorAll)Array.from(table.querySelectorAll('.mw-filter-pill')).forEach(function(button){button.addEventListener('click',function(){machineWideDesignationFilter=button.getAttribute('data-designation')||'all';renderMachineWide(mw);});}); } diff --git a/src/lib/dashboard/client/maintenance-filters.mjs b/src/lib/dashboard/client/maintenance-filters.mjs index 4f854743..54a4d9aa 100644 --- a/src/lib/dashboard/client/maintenance-filters.mjs +++ b/src/lib/dashboard/client/maintenance-filters.mjs @@ -1,4 +1,5 @@ // @ts-nocheck — dashboard browser bundle. +import { surfaceFacetLabel } from './session-presentation.mjs'; import { mntIcon, mntProjectDesignation } from './maintenance-cards.mjs'; import { esc } from './bootstrap.mjs'; import { MNT, MNT_GUIDANCE_LANE_LABELS, MNT_CONFLICT_EXPLANATIONS, MNT_CREDENTIAL_READINESS_LABELS, MNT_CURATED_VIEW_LABELS, MNT_SCOPE_LABELS, mntHumanize, mntKindLabel } from './maintenance-workspace.mjs'; @@ -9,13 +10,13 @@ import { MNT, MNT_GUIDANCE_LANE_LABELS, MNT_CONFLICT_EXPLANATIONS, MNT_CREDENTIA "evidenceFields","recentlyChanged", ]; var MNT_FACET_LABEL={ - family:"Resource",scope:"Scope",environment:"Environment",project:"Project",sessionOrigin:"Session origin",projectType:"Project type",kind:"Type",adapter:"Adapters",consumer:"Hosts", + family:"Resource",scope:"Scope",environment:"Environment",project:"Project",sessionOrigin:"Session surface",projectType:"Project type",kind:"Type",adapter:"Adapters",consumer:"Hosts", carrier:"Carrier",provenance:"Source",packageManager:"Package manager",versionState:"Version state", guidance:"Guidance",dependencyRole:"Dependency role",conflict:"Conflict", credentialReadiness:"Credential",channel:"Channel",evidenceFields:"Evidence available", recentlyChanged:"Recently changed", }; - var MNT_ADAPTER_LABELS={claude:'Claude',codex:'Codex',opencode:'OpenCode',hermes:'Hermes'}; + var MNT_ADAPTER_LABELS={claude:'Claude Code',codex:'Codex',opencode:'OpenCode',hermes:'Hermes Agent'}; var MNT_DEPENDENCY_ROLE_LABEL={"depends-on":"Depends on","depended-on-by":"Depended on by","none":"No dependency role"}; function mntFacetLabelsFor(facet){ @@ -24,8 +25,8 @@ import { MNT, MNT_GUIDANCE_LANE_LABELS, MNT_CONFLICT_EXPLANATIONS, MNT_CREDENTIA } export function mntFacetValueLabel(facet,value){ if(facet==="adapter"||facet==="consumer")return MNT_ADAPTER_LABELS[value]||mntHumanize(value); - if(facet==="sessionOrigin")return ({"claude-desktop":"Claude Desktop","codex-desktop":"ChatGPT Desktop",unknown:"Unclassified"})[value]||"Unclassified"; - if(facet==="projectType")return ({git:'Git',folder:'Folder',worktree:'Worktree',unknown:'Not checked'})[value]||'Not checked'; + if(facet==="sessionOrigin")return surfaceFacetLabel(value); + if(facet==="projectType")return ({git:'Git',folder:'Folder',worktree:'Worktree',unknown:'Unknown'})[value]||'Unknown'; if(facet==="scope")return MNT_SCOPE_LABELS[value]||mntHumanize(value); if(facet==="kind")return mntKindLabel(value); if(facet==="guidance")return MNT_GUIDANCE_LANE_LABELS[value]||mntHumanize(value); diff --git a/src/lib/dashboard/client/maintenance-focus.mjs b/src/lib/dashboard/client/maintenance-focus.mjs index d4518f30..567089f2 100644 --- a/src/lib/dashboard/client/maintenance-focus.mjs +++ b/src/lib/dashboard/client/maintenance-focus.mjs @@ -1,4 +1,5 @@ // @ts-nocheck — classic browser bundle source. +import { projectSurfacesHtml } from './session-presentation.mjs'; import { mntLanguageLogo } from './maintenance-language-logos.mjs'; import { esc } from './bootstrap.mjs'; import { MNT, MNT_SCOPE_LABELS, mntKindLabel } from './maintenance-workspace.mjs'; @@ -68,7 +69,7 @@ import { mntFacetValueLabel } from './maintenance-filters.mjs'; +(level==='project'?mntProjectKindBadge(node.projectKind):'') +(level==='project'&&node.languages&&node.languages.length?mntLanguageBadges(node.languages):'') +(node.description?''+esc(node.description)+'':'') - +(note?''+esc(note)+'':'')+''+esc(node.count)+' installation'+(node.count===1?'':'s')+''+mntIcon('chevron')+''; + +(note?''+esc(note)+'':'')+''+esc(node.count)+' installation'+(node.count===1?'':'s')+''+mntIcon('chevron')+''+(level==='project'?projectSurfacesHtml(node):'')+''; } function mntFocusInstallation(row,index){ var crumbs=(row.breadcrumb||[]).slice(),scope=row.scope||{}; diff --git a/src/lib/dashboard/client/session-presentation.mjs b/src/lib/dashboard/client/session-presentation.mjs new file mode 100644 index 00000000..dedbee79 --- /dev/null +++ b/src/lib/dashboard/client/session-presentation.mjs @@ -0,0 +1,38 @@ +// @ts-nocheck — bundled with shared vocabulary by client.mjs. +import { SESSION_HOST_LABELS, SESSION_SURFACE_LABELS, sessionPresentation } from '../../session-surface.mjs'; +import { esc } from './bootstrap.mjs'; + +export function surfaceEntries(project){ + if(Array.isArray(project.sessionSurfaces))return project.sessionSurfaces.filter(function(row){return row.sessions>0;}); + return (project.sessionOrigins||[]).filter(function(row){return row.sessions>0;}); +} +export function surfaceNames(project){ + var entries=surfaceEntries(project); + return Array.from(new Set(entries.map(function(row){return sessionPresentation(row).label;}))).sort(); +} +export function surfaceRawText(origin){ + var raw=origin.rawEvidence||{},parts=[]; + ['entrypoint','originator','source','threadSource','sessionKind'].forEach(function(key){ + var values=Array.isArray(raw[key])?raw[key]:[raw[key]]; + values.slice(0,16).forEach(function(value){ + if(typeof value==='string'&&value.length<=80&&(value==='Codex Desktop'||/^[A-Za-z][A-Za-z0-9_.-]*$/.test(value)))parts.push(key+': '+value); + }); + }); + if(origin.rawEvidenceComplete===false)parts.push('additional raw declarations omitted by bound'); + return parts.join(' · '); +} +export function surfaceDetailHtml(origin){ + var p=sessionPresentation(origin),raw=surfaceRawText(origin); + return esc(p.label)+' · initiator: '+esc(p.initiator)+(p.note?' · '+esc(p.note):'')+(raw?' · '+esc(raw):'')+(Array.isArray(origin.attributes)?' · '+origin.attributes.filter(function(value){return ['on 3P','started from Claude Desktop','started from mobile','started from a project','started from web'].includes(value);}).map(esc).join(', '):''); +} +export function projectSurfacesHtml(project){ + var entries=surfaceEntries(project); + return '
Session surfaces: '+esc(surfaceNames(project).join(', ')||'Unknown')+'' + +(entries.length?entries.map(function(row){return '
Host: '+esc(Object.hasOwn(SESSION_HOST_LABELS,row.host)?SESSION_HOST_LABELS[row.host]:'Unknown')+' · '+surfaceDetailHtml(row)+' · provider: '+esc(sessionPresentation(row).provider)+' ('+esc(sessionPresentation(row).providerBasis)+')' + +' · '+esc(row.sessions)+' sessions ('+esc(row.countBasis||'legacy count basis unknown')+')
';}).join(''):'
Host, initiator and provider: Unknown
')+'
'; +} +export function surfaceFacetLabel(value){ + if(value==='unknown')return 'Legacy origin: no declared desktop origin'; + if(value==='surface-unknown')return 'Unknown session surface'; + if(value==='codex-desktop')return sessionPresentation({origin:value}).note; + return Object.hasOwn(SESSION_SURFACE_LABELS,value)?SESSION_SURFACE_LABELS[value]:'Unknown';} diff --git a/src/lib/dashboard/client/system-projects.mjs b/src/lib/dashboard/client/system-projects.mjs index d94fc149..ec77eba4 100644 --- a/src/lib/dashboard/client/system-projects.mjs +++ b/src/lib/dashboard/client/system-projects.mjs @@ -2,6 +2,8 @@ // @ts-nocheck — browser bundle source (never node-imported; client.mjs // reads it as text). See src/lib/dashboard/client/**'s eslint.config.mjs // override comment for why this directory isn't run through the node lib. +import { censusDisclosure } from '../../census-presentation.mjs'; +import { projectSurfacesHtml } from './session-presentation.mjs'; import { authHeaders, esc } from './bootstrap.mjs'; import { formatLocalDateTime, formatLocalDateTimeLong, shortSessionId } from './datetime.mjs'; import { ago } from './intelligence.mjs'; @@ -256,7 +258,7 @@ import { fmtNum, fmtTok, limAge, pct } from './usage.mjs'; if(!pm||pm.status==="unknown"||!Array.isArray(pm.value)){ procs.innerHTML=sysEmpty((pm&&pm.reason)||"the process census is unavailable."); }else if(!pm.value.length){ - procs.innerHTML=sysEmpty("no host process is running right now \u2014 a measured zero."); + procs.innerHTML=sysEmpty("no coding-agent or desktop-application process is running right now \u2014 a measured zero."); }else{ var rows=pm.value,maxRss=0,body=""; for(i=0;imaxRss)maxRss=rv;} @@ -271,7 +273,8 @@ import { fmtNum, fmtTok, limAge, pct } from './usage.mjs'; +esc(source.value.label||source.value.path)+"" : '' +esc(String((source&&source.reason)||"not attributable").split("\u2014")[0].trim())+""; - body+=''+esc(p.host)+"" + body+='' + +esc(p.application||p.host||"Unknown process")+"" +''+esc(String(p.pid))+"" +""+proj+"" +''+mhtml(p.uptimeMs,fmtDur)+"" @@ -282,7 +285,7 @@ import { fmtNum, fmtTok, limAge, pct } from './usage.mjs'; } // pid is right-aligned in the body, so its header is too — a numeric // column whose header hangs off the far side reads as a different column. - procs.innerHTML='
' + procs.innerHTML='
Host
' +'' +'' +""+body+"
Coding-agent host / desktop applicationpidWorking contextUptimeCPURSS
"; @@ -783,7 +786,7 @@ import { fmtNum, fmtTok, limAge, pct } from './usage.mjs'; var control=opts.expandable?'':''; var worktreeMark=opts.worktree?'':''; var display=opts.worktree?''+esc(pr.label||'worktree')+'':name; - return ''+worktreeMark+display+''+esc(pr.path||'not measured yet')+'' + return ''+worktreeMark+display+''+esc(pr.path||'not measured yet')+''+projectSurfacesHtml(pr)+'' +''+mhtml(pr.loc&&pr.loc.total,function(v){return "~"+fmtTok(v);})+"" +""+langCell(pr.loc)+"" +''+mhtml(pr.totalBytes,fmtBytes)+"" @@ -801,7 +804,7 @@ import { fmtNum, fmtTok, limAge, pct } from './usage.mjs'; +". This view shows "+esc(fmtNum(tree.repositories.length))+" verified repositor"+(tree.repositories.length===1?"y":"ies") +" with "+esc(fmtNum(worktrees))+" nested worktree"+(worktrees===1?"":"s")+"; "+esc(fmtNum(tree.excludedDirectories))+" non-repository directories are excluded." +" Line counts are approximate: extension-bucketed, with node_modules and vendored " - +"trees excluded. Disk is the whole project directory, .git and node_modules included."; + +"trees excluded. Disk is the whole project directory, .git and node_modules included. "+esc(censusDisclosure(p))+""; } export function renderSysProjects(d){ @@ -810,7 +813,7 @@ import { fmtNum, fmtTok, limAge, pct } from './usage.mjs'; var p=d.projects; if(!p){el.innerHTML=sysEmpty(NOT_SCANNED);return;} var all=p.projects||[]; - if(!all.length&&!(p.discoveryProjects||[]).length){el.innerHTML=sysEmpty("no repository was discovered on this machine.");return;} + if(!all.length&&!(p.discoveryProjects||[]).length){el.innerHTML=sysEmpty("no repository was discovered on this machine.")+'
'+esc(censusDisclosure(p))+"
";return;} var tree=repositoryTree({projects:all,discoveryProjects:p.discoveryProjects}); var byPath={};tree.repositories.forEach(function(group){byPath[group.repository.path]=group;}); var repositories=sortProjects(tree.repositories.map(function(group){return group.repository;}),projSort.key,projSort.dir); diff --git a/src/lib/dashboard/client/usage.mjs b/src/lib/dashboard/client/usage.mjs index 09d34e48..7c5f2359 100644 --- a/src/lib/dashboard/client/usage.mjs +++ b/src/lib/dashboard/client/usage.mjs @@ -1,6 +1,8 @@ // @ts-nocheck — browser bundle source (never node-imported; client.mjs // reads it as text). See src/lib/dashboard/client/**'s eslint.config.mjs // override comment for why this directory isn't run through the node lib. +import { SESSION_HOST_LABELS, sessionProviderPresentation } from '../../session-surface.mjs'; +import { surfaceDetailHtml } from './session-presentation.mjs'; import { formatLocalDateTime } from './datetime.mjs'; import { VIEWS, authHeaders, esc, setTab, syncHash } from './bootstrap.mjs'; import { ago } from './intelligence.mjs'; @@ -1103,23 +1105,18 @@ import { renderUsage } from './usage-orchestrators.mjs'; +''+esc(sub||resetTxt(resetSec))+""; } - // An empty Claude panel is explained by WHICH statusLine a session runs - // (#238 M3): the tee lives only in the kit footer. The server sends the - // user-level statusLine's class (claudeChannel, never its path); the copy - // states Claude Code's precedence rule, because a project's own statusLine - // overrides the user-level one — which is how a footer-carrying project - // still fills this panel when the user-level script cannot. An unknown or - // missing class (an older server) gets the generic sentence. - var CLAUDE_PRECEDENCE="a project’s own statusLine takes precedence over your user-level one"; + // The channel describes the resolved settings context; local and managed + // settings can override the project and user settings. + var CLAUDE_PRECEDENCE="local or managed settings may override the project and user settings; the effective statusLine takes precedence"; var CLAUDE_SETUP="Set a project up with ak setup --project (ak sync keeps its footer current), then run a Pro/Max session there."; var CLAUDE_EMPTY={ - "kit-footer":"your user-level statusLine carries the kit footer, so limits arrive after the first response " + "kit-footer":"the effective statusLine carries the kit footer, so limits arrive after the first response " +"of a Claude Code session on a Pro/Max plan. Run one session, then revisit.", - "custom":"your user-level statusLine runs a custom script without the kit footer, so it does not report limits " - +"to ak. They arrive only from sessions in projects whose own statusLine carries the footer: "+CLAUDE_PRECEDENCE+". "+CLAUDE_SETUP, - "none":"you have no user-level statusLine, so only sessions in projects whose own statusLine carries the kit footer " + "custom":"the effective statusLine runs a custom script without the kit footer, so it does not report limits " + +"to ak. They arrive from sessions whose effective statusLine carries the footer: "+CLAUDE_PRECEDENCE+". "+CLAUDE_SETUP, + "none":"there is no effective statusLine, so only sessions whose effective statusLine carries the kit footer " +"report limits. "+CLAUDE_SETUP, - "project-helper":"your user-level statusLine runs each project’s own ruflo helper, so limits arrive from sessions " + "project-helper":"the effective statusLine runs each project’s own ruflo helper, so limits arrive from sessions " +"in projects where that helper carries the kit footer. Run ak sync in such a project to re-inject it, " +"then run a Pro/Max session there." }; @@ -1328,7 +1325,7 @@ import { renderUsage } from './usage-orchestrators.mjs'; // not exist, when in fact it was measured and found absent (ADR-0009 §5). function dash(v){return (v==null||v==="")?"—":String(v);} function reportedIdentity(v){v=String(v==null?"":v).trim();return v&&!/^unknown$/i.test(v)?v:null;} - function identityName(v){var raw=reportedIdentity(v);if(!raw)return"Not recorded";return{claude:"Claude Code",codex:"Codex",opencode:"OpenCode",anthropic:"Anthropic",openai:"OpenAI",openrouter:"OpenRouter",bedrock:"AWS Bedrock",vertex:"Google Vertex AI",foundry:"Microsoft Foundry",gateway:"Custom gateway",ollama:"Ollama",lmstudio:"LM Studio"}[raw.toLowerCase()]||raw;} + function identityName(v){return Object.hasOwn(SESSION_HOST_LABELS,v)?SESSION_HOST_LABELS[v]:'Unknown';} // ── per-session chips ───────────────────────────────────────────────────── // Evidence the row already carries, shown only where the transcript @@ -1403,7 +1400,7 @@ import { renderUsage } from './usage-orchestrators.mjs'; ? ' (conf '+esc(sx.confidence.toFixed(2))+")" : ""; var modelList=(Array.isArray(sx.models)?sx.models:[]).filter(function(model){return reportedIdentity(model);}); var models=modelList.length?modelList.join(", "):"Not recorded"; - var providerRaw=reportedIdentity(sx.provider),provider=identityName(providerRaw),provenance=reportedIdentity(sx.providerProvenance)||"unknown",providerContext=providerRaw?provenance+" evidence":"not established by source"; + var providerPresentation=sessionProviderPresentation(sx),provider=providerPresentation.label,providerContext=providerPresentation.basis; var toks="in "+fmtTok(sx.input)+" · out "+fmtTok(sx.output) +" · cache r "+fmtTok(sx.cacheRead)+" / w "+fmtTok(sx.cacheWrite) // Codex-only detail: reasoning tokens are a SUBSET of output (they bill @@ -1431,7 +1428,7 @@ import { renderUsage } from './usage-orchestrators.mjs'; // codex or opencode transcript can record an interrupt, so a claude row // reads "not recorded" rather than a measured-looking 0. +" · aborts "+(sx.host==="codex"||sx.host==="opencode"?fmtNum(Number(sx.aborts)||0):"not recorded for this host"); - var rows=[["execution host",esc(identityName(sx.host))],["inference provider",esc(provider)+" ("+esc(providerContext)+")"],["models",esc(models)],["posture",posture],["rhythm",esc(rhythm)],["basis",esc(basis)+conf],["tokens",esc(toks)], + var rows=[["session surface",surfaceDetailHtml(sx.sessionOrigin||{})],["execution host",esc(identityName(sx.host))],["inference provider",esc(provider)+" ("+esc(providerContext)+")"],["models",esc(models)],["posture",posture],["rhythm",esc(rhythm)],["basis",esc(basis)+conf],["tokens",esc(toks)], ["tools",esc(tools)],["flags",esc(flags)]]; return '
/rollout--.jsonl // // The corpus is large (1.3 GB on the reference machine) and a finished -// transcript never changes again, so every file is parsed AT MOST ONCE: the +// transcript never changes again, so parsing is reused within its local calendar +// context: the // derived per-session record is cached in ~/.config/agentic-kit/usage-index.json -// keyed by (path, mtime, size). A warm refresh only stats. +// keyed by (path, mtime, size, localTimeContext). A warm refresh stats source files and validates +// cached Claude message claims before using them for cross-file accounting. // // Three rules this module exists to enforce: // 1. Engaged time is the UNION of ACTIVE intervals, never the sum of spans. @@ -36,12 +38,16 @@ // consumer's existing import path expects them from here. import fs from 'node:fs'; import path from 'node:path'; +import { createHash } from 'node:crypto'; import { configDir, claudeDir, codexDir } from './paths.mjs'; import { readClaudeWindowLog, statClaudeWindowLedger } from './claude-window-ledger.mjs'; import { writePrivateFileAtomic } from './file-write.mjs'; import { readCodexStateResult } from './codex-state.mjs'; +import { selectOpencodeSource } from './usage-opencode-source.mjs'; +import { reusableOpencodeObservations } from './usage-opencode-cache.mjs'; +import { opencodeStorageHealth } from './usage-opencode-health.mjs'; import { - defaultOpencodeDbPath, listSessionsResult as listOpencodeSessionsResult, + listSessionsResult as listOpencodeSessionsResult, parseSession as parseOpencodeSession, sessionExistsResult as opencodeSessionExistsResult, usageNotReportedWarnings, } from './usage-opencode.mjs'; @@ -50,6 +56,7 @@ import { MAX_TELEMETRY_UNKNOWN_KINDS, recordTelemetryUnit, } from './usage-telemetry.mjs'; import { parseClaude, parseCodex } from './usage-parsers.mjs'; +import { reconcileClaudeMessages, validClaudeMessageClaims } from './usage-claude-dedup.mjs'; import { openCodexRollout } from './codex-rollout-reader.mjs'; import { maskSecrets, applyCodexLedger, aggregate, sessionPayload } from './usage-aggregate.mjs'; @@ -183,9 +190,38 @@ export { MAX_TURN_CHARS, mergeIntervals, maskSecrets, normalizeSessionIdentity, // across providers (rows now carry `provider`); and no mark on completed // responses whose provider reported no tokens (`tokensUnreported`). None can be // corrected in place, so every cached OpenCode record re-parses. -export const SCHEMA_VERSION = 25; +// The unreleased v26 migration also records per-turn imported exclusion evidence. +// A v26 Claude entry written before cost-state support lacks `claudeCostState`; +// reparse that entry in place rather than bumping the unreleased schema again. +// Claude record coverage is also added within v26: entries without its count-only +// parseStats reparse, so a legacy cache never manufactures a zero unknown count. +export const SCHEMA_VERSION = 26; // v26 adds parse-time session surface fields; v25 records reparse. +// OpenCode cost trust and compaction/reconciliation interpretation changed +// within schema 26. This entry marker requires both semantics, independently +// of source identity and the separately enforced local-calendar context. +const OPENCODE_PARSE_SEMANTICS = 'cost-trust-v2-observations-v1'; +const compatibleOpencodeCache = (candidate, entry) => candidate.provider !== 'opencode' + || (entry?.parseSemantics === OPENCODE_PARSE_SEMANTICS + && entry?.sourceIdentity === candidate.sourceIdentity && !!candidate.sourceIdentity); + +/** Local buckets depend on the full zone's historical rules, not today's offset. + * Include the runtime rule-data version; an upgrade can change past buckets. + * Unknown zones are deliberately non-reusable, including legacy v26 entries. */ +function localTimeContext() { + try { + const zone = Intl.DateTimeFormat().resolvedOptions().timeZone; + if (!zone) return null; + return JSON.stringify([zone, process.versions.tz ?? null, process.versions.icu ?? null]); + } catch { return null; } +} +function compatibleLocalTime(entry, context) { + return context !== null && entry?.localTimeContext === context; +} const DAY_MS = 86_400_000; +// Dashboard windows stop at 365 days. One displayed window plus its equal +// previous window is therefore bounded at 730 days of Claude identity reads. +const MAX_CLAUDE_IDENTITY_DAYS = 730; // One day of slack past dashboard-server.mjs's 365-day clampDays ceiling — // see the carry-forward pruning comment in scan() below. const KEEP_MS = 366 * DAY_MS; @@ -268,9 +304,10 @@ function rootHealth(dir) { function emptyCodexDiagnostics() { return { - files: 0, cachedFiles: 0, parsedFiles: 0, unparsedFiles: 0, unparsedReasons: {}, importedExcluded: 0, - filesWithTokens: 0, filesWithResponses: 0, - legacyEvents: 0, itemCompletedEvents: 0, tokenCountEvents: 0, + files: 0, cachedFiles: 0, parsedFiles: 0, unparsedFiles: 0, unparsedReasons: {}, importedExcluded: 0, importedMixed: 0, importedTurnsExcluded: 0, importAmbiguousRecords: 0, + importOwnershipIncompleteFiles: 0, importedTurnCountIncompleteFiles: 0, + filesWithTokens: 0, filesWithResponses: 0, zeroResponseUsageFiles: 0, zeroResponseUnsupportedFiles: 0, + legacyEvents: 0, itemCompletedEvents: 0, tokenCountEvents: 0, totalOnlyTokenCountEvents: 0, prompts: 0, responses: 0, unknownItemTypes: {}, unknownItemTypeOverflow: 0, clippedLines: 0, warnings: [], }; } @@ -283,6 +320,7 @@ function addCodexParseDiagnostics(target, stats) { target.legacyEvents += stats.legacyEvents; target.itemCompletedEvents += stats.itemCompletedEvents; target.tokenCountEvents += stats.tokenCountEvents; + target.totalOnlyTokenCountEvents += stats.totalOnlyTokenCountEvents ?? 0; target.prompts += stats.prompts; target.responses += stats.responses; target.clippedLines += stats.clippedLines ?? 0; @@ -300,10 +338,10 @@ function addCodexParseDiagnostics(target, stats) { function finalizeCodexHealth(root, diagnostics) { const warnings = []; - const tokenFiles = diagnostics.filesWithTokens; const responseFiles = diagnostics.filesWithResponses; - if (tokenFiles > 0 && responseFiles === 0) warnings.push('zero-response-yield'); - else if (tokenFiles > responseFiles) warnings.push('partial-response-yield'); + if (diagnostics.zeroResponseUnsupportedFiles > 0 && responseFiles === 0) warnings.push('zero-response-yield'); + else if (diagnostics.zeroResponseUnsupportedFiles > 0) warnings.push('partial-response-yield'); + if (diagnostics.totalOnlyTokenCountEvents > 0) warnings.push('total-only-token-count'); if (Object.keys(diagnostics.unknownItemTypes).length || diagnostics.unknownItemTypeOverflow > 0) { warnings.push('unknown-item-types'); } @@ -312,11 +350,13 @@ function finalizeCodexHealth(root, diagnostics) { if (diagnostics.unparsedFiles > 0) warnings.push('unparsed-rollouts'); if (diagnostics.clippedLines > 0) warnings.push('oversized-lines-clipped'); diagnostics.warnings = warnings; - const hasYieldWarning = warnings.includes('zero-response-yield') || warnings.includes('partial-response-yield'); - const status = root.status === 'ok' && hasYieldWarning + const hasUsageWarning = warnings.includes('zero-response-yield') || warnings.includes('partial-response-yield') + || warnings.includes('total-only-token-count'); + const status = root.status === 'ok' && hasUsageWarning ? 'degraded' : root.status; const reason = status === 'degraded' && root.status === 'ok' - ? (warnings.includes('zero-response-yield') ? 'parse-yield-zero' : 'parse-yield-partial') : root.reason; + ? (warnings.includes('zero-response-yield') ? 'parse-yield-zero' + : warnings.includes('partial-response-yield') ? 'parse-yield-partial' : 'usage-total-only') : root.reason; return { ...root, status, reason, diagnostics }; } @@ -334,6 +374,42 @@ function attachTelemetryHealth(health, common, diagnostics = health.diagnostics) }; } +function claudeParseHealth(root, common) { + if (root.status !== 'ok' || common.unitsSeen === common.unitsParsed) return root; + return { ...root, status: 'degraded', reason: 'transcript-parse-incomplete' }; +} + +const CLAUDE_RECORD_COUNTERS = ['knownHandledRecords', 'knownIgnoredRecords', + 'unknownRecords', 'invalidTypeRecords', 'malformedRecords']; +function validClaudeRecordStats(stats) { + return stats && typeof stats === 'object' && !Array.isArray(stats) + && Object.keys(stats).length === CLAUDE_RECORD_COUNTERS.length + && CLAUDE_RECORD_COUNTERS.every((key) => Number.isSafeInteger(stats[key]) && stats[key] >= 0); +} +function emptyClaudeRecordDiagnostics() { + return { knownHandledRecords: 0, knownIgnoredRecords: 0, unknownRecords: 0, + invalidTypeRecords: 0, malformedRecords: 0 }; +} +function addClaudeRecordDiagnostics(target, stats, provider) { + if (provider !== 'claude' || !validClaudeRecordStats(stats)) return; + for (const key of CLAUDE_RECORD_COUNTERS) target[key] += stats[key]; +} +function claudeRecordCoverage(root, common, counts) { + const incomplete = root.status !== 'ok' || common.unitsSeen !== common.unitsParsed + || counts.unknownRecords > 0 || counts.invalidTypeRecords > 0 || counts.malformedRecords > 0; + return { ...counts, coverage: root.status === 'absent' || (root.status === 'ok' && common.unitsSeen === 0) + ? 'not-observed' : common.unitsParsed === 0 ? 'unknown' : incomplete ? 'incomplete' : 'complete' }; +} +function finalizeClaudeRecordHealth(root, common, counts) { + const base = attachTelemetryHealth(claudeParseHealth(root, common), common); + const records = claudeRecordCoverage(root, common, counts); + const incompleteRecords = root.status === 'ok' && common.unitsSeen === common.unitsParsed + && records.coverage === 'incomplete'; + return { ...base, + ...(incompleteRecords ? { status: 'degraded', reason: 'transcript-record-coverage-incomplete' } : {}), + diagnostics: { ...base.diagnostics, records } }; +} + function defaultRoots() { return { claude: path.join(claudeDir(), 'projects'), @@ -467,7 +543,8 @@ function parseCodexFile(entry, sink, limits) { function parseFile(entry, sink = {}, limits = {}) { if (entry.provider === 'opencode') { try { - const parsed = parseOpencodeSession({ dbFile: entry.dbFile, id: entry.id }); + const parsed = parseOpencodeSession({ dbFile: entry.dbFile, id: entry.id, + maxSessionBytes: limits.maxSessionBytes, maxSessionRows: limits.maxSessionRows }); // Title hygiene matches the JSONL parsers: the cached index lands on // disk, so the same secrets mask applies here. if (parsed?.session) parsed.session.title = maskSecrets(parsed.session.title); @@ -589,6 +666,7 @@ function scanKey(o = {}) { return JSON.stringify([ Number(o.days) || 14, Number(o.lookbackDays) || 0, !!o.previous, !!o.prompts, !!o.force, roots, o.cachePath || '', o.claudeWindowConfigDir || '', + localTimeContext(), selectOpencodeSource({ roots: o.roots }), ]); } @@ -612,11 +690,14 @@ function notify(onProgress, payload) { * pair this with `previous: true` (below) to actually get the * older records back out, via `previous.totals`/`previous.rhythm`, * rather than by hand-splitting a widened `sessions[]`. Undefined - * (default) behaves exactly as `days` alone: no widening. + * (default) still discovers Claude files through the equal-length + * preceding identity window; other hosts retain the `days` cutoff. * @property {boolean} [previous] also have `aggregate` project the * equal-length window immediately before the displayed one (see - * usage-aggregate.mjs's `previousWindow`); needs `lookbackDays` set - * wide enough for those older records to have been read at all. + * usage-aggregate.mjs's `previousWindow`); Claude's equal-length + * predecessor is acquired for identity accounting even without an + * explicit lookback, while other hosts need `lookbackDays` set wide + * enough for their older records to have been read. * Forwarded to `aggregate`'s own `previous` option unchanged. * @property {boolean} [prompts] also have `aggregate` build the prompt * repetition projection (`agg.promptPatterns` — recurring clusters, @@ -634,9 +715,10 @@ function notify(onProgress, payload) { * statusline's `claude-context-windows/` ledger (tests). Unset reads * the real config dir only for default-root scans; overridden `roots` * read no ledger. `null` disables ledger pairing. - * @property {{streamAboveBytes?: number, chunkBytes?: number, maxLineBytes?: number}} [readLimits] + * @property {{streamAboveBytes?: number, chunkBytes?: number, maxLineBytes?: number, maxSessionBytes?: number, maxSessionRows?: number}} [readLimits] * override where a Codex rollout switches from a whole-string read to - * the bounded streaming reader, and that reader's chunk/line limits (tests) + * the bounded streaming reader, its chunk/line limits, and OpenCode session + * acquisition byte/row limits (tests) * @property {number} [now] override "now" (tests) * @property {number} [maxAgeMs] readIndex only: memo TTL * @property {object|null} [codexState] override the Codex SQLite thread ledger @@ -671,19 +753,23 @@ export async function buildIndex(o = {}) { * with defaults — its mere presence (vs `undefined`) is what makes an * override hermetic; see the codex ledger comment below. */ function discoverOpencodeSource(rawRoots, cutoff) { - const ocDb = rawRoots === undefined ? defaultOpencodeDbPath() : (rawRoots?.opencode ?? null); - if (!ocDb || !fs.existsSync(ocDb)) return { health: { status: 'absent', reason: null }, candidates: [], ocDb }; + const selection = selectOpencodeSource({ roots: rawRoots }); + const ocDb = selection.dbFile; + const storageCoverage = opencodeStorageHealth(selection); + const sourceHealth = (health) => ({ ...health, storageCoverage }); + if (!ocDb) return { health: sourceHealth(selection.health), candidates: [], ocDb }; + if (!fs.existsSync(ocDb)) return { health: sourceHealth({ status: 'absent', reason: null }), candidates: [], ocDb }; const listed = listOpencodeSessionsResult({ dbFile: ocDb, cutoffMs: cutoff }); if (!listed.ok) { const health = listed.error.kind === 'absent' ? { status: 'absent', reason: 'absent' } : { status: 'degraded', reason: listed.error.kind }; - return { health, candidates: [], ocDb }; + return { health: sourceHealth(health), candidates: [], ocDb }; } const candidates = listed.value.map((e) => ({ - file: `opencode://${e.id}`, provider: 'opencode', id: e.id, dbFile: ocDb, + file: `opencode://${e.id}`, provider: 'opencode', id: e.id, dbFile: ocDb, sourceIdentity: selection.sourceIdentity, stat: { mtimeMs: e.mtimeMs, size: e.size, updatedMs: e.updatedMs }, })); - return { health: { status: 'ok', reason: null }, candidates, ocDb }; + return { health: sourceHealth(selection.health), candidates, ocDb }; } /** Codex's own per-file bookkeeping for one scan candidate: file counts, the @@ -695,8 +781,26 @@ function discoverOpencodeSource(rawRoots, cutoff) { function recordCodexCandidate(codexDiagnostics, { session, parseStats, cacheHit, failure }) { codexDiagnostics.files++; if (cacheHit) codexDiagnostics.cachedFiles++; - if (session?.imported === true) { codexDiagnostics.importedExcluded++; return false; } - if (session) { addCodexParseDiagnostics(codexDiagnostics, parseStats); return true; } + const imports = session?.importEvidence; + codexDiagnostics.importedTurnsExcluded += imports?.importedTurns ?? 0; + if (imports?.ownershipComplete === false) codexDiagnostics.importOwnershipIncompleteFiles++; + if (imports?.importedTurnCountComplete === false) codexDiagnostics.importedTurnCountIncompleteFiles++; + codexDiagnostics.importAmbiguousRecords += imports?.ambiguousRecords ?? 0; + if (session?.imported === true) { + codexDiagnostics.importedExcluded++; + codexDiagnostics.clippedLines += parseStats?.clippedLines ?? 0; + return false; + } + if (imports) codexDiagnostics.importedMixed++; + if (session) { + addCodexParseDiagnostics(codexDiagnostics, parseStats); + if (!session.responses && parseStats?.tokenCountEvents > 0) { + if (session.usage?.some((row) => row.input > 0 || row.output > 0 || row.cacheRead > 0 || row.cacheWrite > 0)) + codexDiagnostics.zeroResponseUsageFiles++; + else codexDiagnostics.zeroResponseUnsupportedFiles++; + } + return true; + } codexDiagnostics.unparsedFiles++; const reason = failure.reason ?? 'parse-error'; codexDiagnostics.unparsedReasons[reason] = (codexDiagnostics.unparsedReasons[reason] ?? 0) + 1; @@ -739,35 +843,60 @@ function withWindowLedger(entry, windowConfigDir) { return { ...entry, windowConfigDir, windowStat: statClaudeWindowLedger(windowConfigDir, entry.id) }; } +function compatibleCostStateCache(c, hit) { + return c.provider !== 'claude' || (Object.hasOwn(hit.session ?? {}, 'claudeCostState') + && Object.hasOwn(hit.session ?? {}, 'claudeMessageCoverage') + && validClaudeRecordStats(hit.parseStats) + && validClaudeMessageClaims(hit.session) + && (!hit.session.claudeCostState || Object.hasOwn(hit.session.claudeCostState, 'startMs'))); +} + +function parseValidatedCandidate(c, failure, readLimits) { + const parsed = parseFile(c, failure, readLimits); + const session = parsed?.session ?? null; + // A source with malformed accounting cannot enter the cache or global + // reconciliation, even when the parser could salvage other fields. + return { session: c.provider === 'claude' && session && !validClaudeMessageClaims(session) ? null : session, + parseStats: parsed?.parseStats ?? null, observationFingerprint: parsed?.observationFingerprint ?? null }; +} + +function withClaudeIdentityEligibility(session, candidate, cutoff) { + if (candidate.provider !== 'claude') return session; + return { ...session, + claudeSourceKey: createHash('sha256').update(candidate.file).digest('hex'), + claudeIdentityEligible: Number.isFinite(candidate.stat.mtimeMs) && candidate.stat.mtimeMs >= cutoff + && Number.isFinite(session.end) && session.end >= cutoff }; +} + /** Parse (or reuse the cached parse of) one scan candidate, updating the * common cross-host telemetry diagnostics and codex's extra per-file * diagnostics as side effects. Pulled out of scan()'s loop so the per-file * bookkeeping — which is genuinely provider-specific (codex tracks file * counts and yield diagnostics no other source has) — is not inlined into * the generic scan loop's own complexity. */ -function processCandidate(c, cache, commonDiagnostics, codexDiagnostics, readLimits = {}) { +function processCandidate(c, cache, commonDiagnostics, codexDiagnostics, claudeRecordDiagnostics, readLimits = {}, timeContext = localTimeContext()) { const hit = cache?.entries?.[c.file]; // `updatedMs` exists only on OpenCode candidates (its rows are rewritten in // place, so created-time and count cannot see a finished turn); file-backed - // sources key on mtime/size alone. + // sources key on mtime/size plus the local calendar context. const updated = c.stat.updatedMs === undefined ? {} : { upd: c.stat.updatedMs }; - const cacheHit = !!(hit && hit.mtime === c.stat.mtimeMs && hit.size === c.stat.size + const cacheHit = !!(compatibleLocalTime(hit, timeContext) && hit.mtime === c.stat.mtimeMs && hit.size === c.stat.size && hit.upd === updated.upd && ledgerStillValid(hit, c.windowStat) + && compatibleCostStateCache(c, hit) + && compatibleOpencodeCache(c, hit) + && reusableOpencodeObservations(c, hit, readLimits) && (c.provider !== 'codex' || hit.parseStats)); - const key = { mtime: c.stat.mtimeMs, size: c.stat.size, ...updated, ...windowKey(c.windowStat, cacheHit ? hit : null) }; - let session = cacheHit ? hit.session : null; - let parseStats = cacheHit ? hit.parseStats : null; + const key = { localTimeContext: timeContext, mtime: c.stat.mtimeMs, size: c.stat.size, ...updated, ...windowKey(c.windowStat, cacheHit ? hit : null), + ...(c.provider === 'opencode' ? { parseSemantics: OPENCODE_PARSE_SEMANTICS, sourceIdentity: c.sourceIdentity } : {}) }; const failure = {}; - if (!session) { - const parsed = parseFile(c, failure, readLimits); - session = parsed ? parsed.session : null; - parseStats = parsed?.parseStats ?? null; - } + const { session, parseStats, observationFingerprint } = cacheHit && hit.session + ? hit : parseValidatedCandidate(c, failure, readLimits); const counted = c.provider !== 'codex' || recordCodexCandidate(codexDiagnostics, { session, parseStats, cacheHit, failure }); if (counted && commonDiagnostics[c.provider]) { recordTelemetryUnit(commonDiagnostics[c.provider], session); + addClaudeRecordDiagnostics(claudeRecordDiagnostics, parseStats, c.provider); if (c.provider === 'codex') { addTelemetryDiagnostics(commonDiagnostics.codex, { unknownKinds: parseStats?.unknownItemTypes, @@ -775,7 +904,11 @@ function processCandidate(c, cache, commonDiagnostics, codexDiagnostics, readLim }); } } - return { key, session, parseStats }; + return { key: observationCacheKey(c, key, observationFingerprint), session, parseStats }; +} + +function observationCacheKey(candidate, key, observationFingerprint) { + return candidate.provider === 'opencode' ? { ...key, observationFingerprint } : key; } /** Carry forward cached entries outside the window whose source still exists, @@ -796,10 +929,14 @@ function processCandidate(c, cache, commonDiagnostics, codexDiagnostics, readLim * Mutates `entries` and `records` in place; returns the possibly-updated * opencode health (a degraded existence check discovered mid-loop must * still be visible to the NEXT entry's check and to the final report). */ -function carryForwardCachedEntries(cache, entries, records, { now, cutoff, ocDb, opencodeHealth }) { +function carryForwardCachedEntries(cache, entries, records, { now, cutoff, ocDb, opencodeHealth, attemptedFiles, timeContext }) { if (!cache?.entries) return opencodeHealth; + let legacyCacheEntriesExcluded = 0; + let timezoneCacheEntriesExcluded = 0; for (const [file, e] of Object.entries(cache.entries)) { - if (entries[file] || !e?.session) continue; + // A candidate that failed reparsing must not be revived just because its + // path still stats (it may now be unreadable or no longer be a file). + if (entries[file] || attemptedFiles.has(file) || !e?.session) continue; const lastActivity = e.session.end ?? e.session.start; // No timestamp at all → can't judge age; keep it rather than guess. if (lastActivity != null && now - lastActivity > KEEP_MS) continue; @@ -811,9 +948,15 @@ function carryForwardCachedEntries(cache, entries, records, { now, cutoff, ocDb, if (!result) continue; entries[file] = result.entry; opencodeHealth = result.health; - if (result.pushRecord && (lastActivity == null || lastActivity >= cutoff)) records.push(e.session); + if (!result.pushRecord && (lastActivity == null || lastActivity >= cutoff)) legacyCacheEntriesExcluded++; + if (result.pushRecord && (lastActivity == null || lastActivity >= cutoff)) { + // Retain the original marker so a degraded source cannot launder + // old buckets into the new timezone on the following refresh. + if (compatibleLocalTime(e, timeContext)) records.push(e.session); + else timezoneCacheEntriesExcluded++; + } } - return opencodeHealth; + return { ...opencodeHealth, legacyCacheEntriesExcluded, timezoneCacheEntriesExcluded }; } /** The opencode half of carryForwardCachedEntries — split out to keep both @@ -822,18 +965,22 @@ function carryForwardCachedEntries(cache, entries, records, { now, cutoff, ocDb, * caller to apply; see carryForwardCachedEntries for why a kept entry is * pushed back into `records` (unlike claude/codex's carry-forward). */ function carryForwardOpencodeEntry(file, e, opencodeHealth, ocDb) { - const dbFile = e.dbFile ?? ocDb; + if (!ocDb) return null; + const selection = selectOpencodeSource({ roots: { opencode: ocDb } }); + if (!selection.sourceIdentity || e.sourceIdentity !== selection.sourceIdentity) return null; + const dbFile = ocDb; const exists = opencodeHealth.status === 'degraded' ? null : (dbFile ? opencodeSessionExistsResult({ dbFile, id: file.slice('opencode://'.length) }) : null); if (opencodeHealth.status === 'degraded' || (exists?.ok && exists.value)) { - return { entry: { ...e, dbFile }, health: opencodeHealth, pushRecord: true }; + return { entry: { ...e, dbFile }, health: opencodeHealth, + pushRecord: compatibleOpencodeCache({ provider: 'opencode', sourceIdentity: selection.sourceIdentity }, e) }; } if (exists && !exists.ok && exists.error.kind !== 'absent') { return { entry: { ...e, dbFile }, - health: { status: 'degraded', reason: exists.error.kind }, - pushRecord: true, + health: { ...opencodeHealth, status: 'degraded', reason: exists.error.kind }, + pushRecord: compatibleOpencodeCache({ provider: 'opencode', sourceIdentity: selection.sourceIdentity }, e), }; } return null; @@ -871,35 +1018,44 @@ async function scan(o = {}) { const deps = await loadDeps(injected); const r = { ...defaultRoots(), ...(roots ?? {}) }; const cacheFile = cachePath ?? defaultCachePath(); - // Widened when the caller passes lookbackDays (a server wanting a - // `previous`-window projection, e.g.) — every DISCOVERY/parse use below - // (candidates, opencode listing, carry-forward) shares this ONE value, so - // widening it here is the entire discovery-side effect. It does NOT reach - // `aggregate`'s own cutoff below — see displayCutoff — so the CURRENT - // window's `sessions`/`totals` never silently widen with it; only - // `aggregate`'s `previous` projection (when requested) reads the extra - // records this pulls in. Unset, `lookbackDays ?? days` is exactly `days` — - // today's behavior, unchanged. + // The display and its equal-length comparison need ONE Claude identity + // pool even if the caller does not request the comparison. This fixed pool + // cannot depend on `previous` or an explicit, deeper `lookbackDays`, or a + // toggle would elect a different owner for the same message. Only Claude + // discovery pays the additional read; other hosts retain their original + // cutoff. The 730-day ceiling covers the dashboard's supported 365-day + // display+comparison maximum and is reported as a cap for wider callers. const cutoff = now - (lookbackDays ?? days) * DAY_MS; + const requestedDays = Number(days); + const identityDays = Math.min(MAX_CLAUDE_IDENTITY_DAYS, + 2 * Math.max(1, Number.isFinite(requestedDays) ? requestedDays : 14)); + const identityCutoff = now - identityDays * DAY_MS; + const claudeCutoff = Math.min(cutoff, identityCutoff); // Primary transcript roots: read once at root level (cheap — not the // recursive per-file walk listClaude/listCodex still do below). const claudeHealth = rootHealth(r.claude); const codexHealth = rootHealth(r.codex); const windowConfigDir = resolveWindowConfigDir(o, roots); - const candidates = [...listClaude(r.claude), ...listCodex(r.codex)] + const claudeCandidates = listClaude(r.claude) .map((e) => withWindowLedger({ ...e, stat: statSafe(e.file) }, windowConfigDir)) + .filter((e) => e.stat && e.stat.mtimeMs >= claudeCutoff); + const codexCandidates = listCodex(r.codex) + .map((e) => ({ ...e, stat: statSafe(e.file) })) .filter((e) => e.stat && e.stat.mtimeMs >= cutoff); + const candidates = [...claudeCandidates, ...codexCandidates]; const opencodeSource = discoverOpencodeSource(roots, cutoff); let opencodeHealth = opencodeSource.health; const ocDb = opencodeSource.ocDb; candidates.push(...opencodeSource.candidates); + const timeContext = localTimeContext(); const cache = force ? null : readCache(cacheFile); const entries = {}; const records = []; const codexDiagnostics = emptyCodexDiagnostics(); + const claudeRecordDiagnostics = emptyClaudeRecordDiagnostics(); const commonDiagnostics = { claude: emptyTelemetryDiagnostics(), codex: emptyTelemetryDiagnostics(), @@ -910,27 +1066,28 @@ async function scan(o = {}) { notify(onProgress, { scanned: 0, total, phase: 'scan' }); for (const c of candidates) { - const { key, session, parseStats } = processCandidate(c, cache, commonDiagnostics, codexDiagnostics, readLimits); + const { key, session, parseStats } = processCandidate(c, cache, commonDiagnostics, codexDiagnostics, claudeRecordDiagnostics, readLimits, timeContext); if (session) { entries[c.file] = { ...key, session, ...(parseStats ? { parseStats } : {}), ...(c.dbFile ? { dbFile: c.dbFile } : {}), }; - if (!session.imported) records.push(session); + if (!session.imported) records.push(withClaudeIdentityEligibility(session, c, identityCutoff)); } scanned++; if (scanned % 100 === 0) notify(onProgress, { scanned, total, phase: 'scan' }); } opencodeHealth = carryForwardCachedEntries(cache, entries, records, { - now, cutoff, ocDb, opencodeHealth, + now, cutoff, ocDb, opencodeHealth, timeContext, attemptedFiles: new Set(candidates.map((candidate) => candidate.file)), }); writeCache(cacheFile, { schemaVersion: SCHEMA_VERSION, updatedAt: new Date(now).toISOString(), entries }); // Completed OpenCode responses whose provider reported no token counts: one // informational health warning (the sessions themselves are still counted). addTelemetryDiagnostics(commonDiagnostics.opencode, { - warnings: usageNotReportedWarnings(records.filter((rec) => rec.host === 'opencode')), + warnings: [...opencodeSource.health.storageCoverage.warnings, + ...usageNotReportedWarnings(records.filter((rec) => rec.host === 'opencode'))], }); notify(onProgress, { scanned: total, total, phase: 'aggregate' }); @@ -946,7 +1103,17 @@ async function scan(o = {}) { // `previous: true` caller would find its "current" totals silently // absorbing what should have been the previous window (the bug this fixes). const displayCutoff = now - days * DAY_MS; - const result = aggregate(applyCodexLedger(records, ledger), { + const unknownEligibility = records.filter((rec) => rec?.provider === 'claude' + && typeof rec.claudeIdentityEligible !== 'boolean').length; + // A deeper historical request can reveal a Claude file whose mtime/end was + // outside the fixed identity pool. Keep its prior history, but do not let a + // newly discovered outside-pool session enter the displayed current window. + const outsideCurrent = (rec) => rec?.provider === 'claude' + && rec.claudeIdentityEligible !== true && rec.end >= displayCutoff; + const observed = applyCodexLedger(records, ledger); + const currentExcluded = observed.filter(outsideCurrent).length; + const reconciled = reconcileClaudeMessages(observed.filter((rec) => !outsideCurrent(rec))); + const result = aggregate(reconciled, { days, now, cutoff: displayCutoff, deps, previous, prompts, }); const codexSourceHealth = finalizeCodexHealth(codexHealth, codexDiagnostics); @@ -954,7 +1121,16 @@ async function scan(o = {}) { warnings: codexSourceHealth.diagnostics.warnings, }); result.sourceHealth = { - claude: attachTelemetryHealth(claudeHealth, commonDiagnostics.claude), + claude: { + ...finalizeClaudeRecordHealth(claudeHealth, commonDiagnostics.claude, claudeRecordDiagnostics), + identityCoverage: { horizonDays: identityDays, + horizonCoversComparison: unknownEligibility === 0 && 2 * requestedDays <= MAX_CLAUDE_IDENTITY_DAYS, + horizonCoversRequestedHistory: unknownEligibility === 0 && Number(lookbackDays ?? days) <= identityDays, + outOfPoolRecords: records.filter((rec) => rec?.provider === 'claude' && rec.claudeIdentityEligible !== true).length, + unknownEligibilityRecords: unknownEligibility, + outsideCurrentExcluded: currentExcluded, + basis: 'file-mtime-and-session-end' }, + }, codex: attachTelemetryHealth(codexSourceHealth, commonDiagnostics.codex), opencode: attachTelemetryHealth(opencodeHealth, commonDiagnostics.opencode), codexLedger: codexLedgerHealth, @@ -988,7 +1164,7 @@ export async function readIndex(o = {}) { // `cachePath` per test so their keys already differ. Ruled parked with that // reason rather than left implied. const key = scanKey({ ...o, days }); - if (_memo && _memo.key === key && now - _memo.at < maxAgeMs) return _memo.agg; + if (localTimeContext() !== null && _memo && _memo.key === key && now - _memo.at < maxAgeMs) return _memo.agg; const agg = await buildIndex({ ...o, days }); _memo = { key, at: now, agg }; return agg; @@ -1113,7 +1289,7 @@ export async function readSession(id, o = {}) { // opencode sessions live in the SQLite store, not a JSONL file — resolve // them before the file-locating path (pseudo-key opencode://). - const ocDb = o.roots === undefined ? defaultOpencodeDbPath() : (o.roots?.opencode ?? null); + const ocDb = selectOpencodeSource({ roots: o.roots }).dbFile; const ocExists = ocDb && fs.existsSync(ocDb) ? opencodeSessionExistsResult({ dbFile: ocDb, id }) : null; if (ocExists?.ok && ocExists.value) { diff --git a/src/lib/usage-opencode-bounds.mjs b/src/lib/usage-opencode-bounds.mjs index d672f76e..d76e14f8 100644 --- a/src/lib/usage-opencode-bounds.mjs +++ b/src/lib/usage-opencode-bounds.mjs @@ -1,3 +1,4 @@ +import { availableOpencodeMetadata } from './usage-opencode-observations.mjs'; // Bound native-to-JS materialization before any JSON bodies are selected. // SQLite octet_length can inspect stored byte lengths without materializing // long TEXT/BLOB bodies. Include ids and metadata too, not only JSON payloads. @@ -9,10 +10,11 @@ const limit = (value, ceiling) => Number.isSafeInteger(value) && value > 0 export function sessionAcquisitionCoverage(db, id, { maxSessionBytes, maxSessionRows }) { const byteLimit = limit(maxSessionBytes, MAX_BYTES); const rowLimit = limit(maxSessionRows, MAX_ROWS); + const metadataBytes = availableOpencodeMetadata(db).map(name => ` + COALESCE(octet_length(${name}), 0)`).join(''); const totals = db.prepare(` SELECT COALESCE(SUM(bytes), 0) AS bytes, COALESCE(SUM(rows), 0) AS rows, MAX(latest) AS latest FROM ( SELECT COALESCE(SUM(octet_length(id) + COALESCE(octet_length(parent_id), 0) - + COALESCE(octet_length(directory), 0) + COALESCE(octet_length(title), 0)), 0) AS bytes, + + COALESCE(octet_length(directory), 0) + COALESCE(octet_length(title), 0) ${metadataBytes}), 0) AS bytes, COUNT(*) AS rows, MAX(CASE WHEN typeof(time_created) IN ('integer', 'real') THEN time_created END) AS latest FROM session WHERE id = ? UNION ALL diff --git a/src/lib/usage-opencode-cache.mjs b/src/lib/usage-opencode-cache.mjs new file mode 100644 index 00000000..2d6154d8 --- /dev/null +++ b/src/lib/usage-opencode-cache.mjs @@ -0,0 +1,52 @@ +import { createHash } from 'node:crypto'; +import { withDb } from './sqlite.mjs'; +import { sessionAcquisitionCoverage } from './usage-opencode-bounds.mjs'; +import { availableOpencodeMetadata, hasOpencodeV2Rows } from './usage-opencode-observations.mjs'; + +/** Called only inside the parser/read-probe snapshot after the same session's + * acquisition budget passes. Hash only observation inputs, never the entire DB + * or user/assistant text. Each framed row goes straight into the hash; only its + * digest survives. Part removal/rewrite can preserve every upstream timestamp. */ +export function opencodeObservationFingerprint(db, id) { + const hash = createHash('sha256'); + const add = row => { const json = JSON.stringify(row); hash.update(`${Buffer.byteLength(json)}:`).update(json); }; + const columns = ['id', ...availableOpencodeMetadata(db)]; + add(db.prepare(`SELECT ${columns.join(', ')} FROM session WHERE id = ?`).get(id) ?? null); + add(hasOpencodeV2Rows(db, id)); + // Multi-path extraction keeps JSON type distinctions (true vs 1, null vs + // strings) and excludes prompt bodies. Malformed rows retain a validity + // marker so the parser can still salvage other rows in the same session. + for (const row of db.prepare(`SELECT id, json_valid(data) AS valid, + CASE WHEN json_valid(data) THEN json_extract(data, + '$.role', '$.parentID', '$.summary', '$.finish', '$.error', '$.time.completed', + '$.tokens', '$.cost', '$.providerID') END AS observation + FROM message WHERE session_id = ? ORDER BY id`).iterate(id)) add(row); + add('parts'); + for (const row of db.prepare(`SELECT p.message_id, p.data + FROM part p JOIN message m ON m.id = p.message_id + WHERE m.session_id = ? AND CASE WHEN json_valid(p.data) + THEN json_extract(p.data, '$.type') IN ('compaction', 'step-finish') ELSE 0 END + ORDER BY p.rowid`).iterate(id)) add(row); + return hash.digest('hex'); +} + +/** A warm-cache probe has its own read transaction and the parser's per-session + * byte/row ceilings. Failure or insufficient coverage cannot authorize reuse. */ +export function readOpencodeObservationFingerprint({ dbFile, id, maxSessionBytes, maxSessionRows }) { + const result = withDb(dbFile, db => { + db.exec('BEGIN'); + if (!sessionAcquisitionCoverage(db, id, { maxSessionBytes, maxSessionRows }).complete) return null; + return opencodeObservationFingerprint(db, id); + }); + return result.ok ? result.value : null; +} + +export function reusableOpencodeObservations(candidate, entry, limits) { + if (candidate.provider !== 'opencode') return true; + return typeof entry.observationFingerprint === 'string' + && /^[a-f0-9]{64}$/.test(entry.observationFingerprint) + && entry.observationFingerprint === readOpencodeObservationFingerprint({ + dbFile: candidate.dbFile, id: candidate.id, + maxSessionBytes: limits.maxSessionBytes, maxSessionRows: limits.maxSessionRows, + }); +} diff --git a/src/lib/usage-opencode-health.mjs b/src/lib/usage-opencode-health.mjs new file mode 100644 index 00000000..5e04bb77 --- /dev/null +++ b/src/lib/usage-opencode-health.mjs @@ -0,0 +1,18 @@ +// Source coverage is independent of V1 listing, parsing and the selected window. +import { withDb } from './sqlite.mjs'; +import { observeOpencodeStorageCoverage } from './usage-opencode-storage-coverage.mjs'; + +/** Observe only the selected store and its explicitly resolved legacy root. + * Failed database access is unknown, never evidence that V2 storage is empty. + * @param {{dbFile: string | null, legacyRoot: string | null}} selection */ +export function opencodeStorageHealth({ dbFile, legacyRoot }) { + const legacy = observeOpencodeStorageCoverage({ legacyRoot }); + if (!dbFile) return legacy; + const observed = withDb(dbFile, (db) => { + db.exec('PRAGMA query_only = ON'); + return observeOpencodeStorageCoverage({ db }); + }); + const v2 = observed.ok ? observed.value.v2 : { status: 'unknown' }; + const warnings = observed.ok ? observed.value.warnings : ['opencode-v2-observation-incomplete']; + return { v2, legacy: legacy.legacy, warnings: [...warnings, ...legacy.warnings] }; +} diff --git a/src/lib/usage-opencode-observations.mjs b/src/lib/usage-opencode-observations.mjs new file mode 100644 index 00000000..43e16ff9 --- /dev/null +++ b/src/lib/usage-opencode-observations.mjs @@ -0,0 +1,142 @@ +import { isLocalInferenceProvider } from './usage-local-provider.mjs'; +// OpenCode v1.18.33: core/session/projector.ts accumulates step-finish usage, +// while opencode/session/processor.ts retains only the last step's message +// tokens. These counters are diagnostics, never an additional billing source. +export const OPENCODE_METADATA = ['version', 'time_compacting', 'cost', 'tokens_input', + 'tokens_output', 'tokens_reasoning', 'tokens_cache_read', 'tokens_cache_write']; + +export function availableOpencodeMetadata(db) { + const columns = new Set(db.prepare('PRAGMA table_info(session)').all().map(row => row.name)); + return OPENCODE_METADATA.filter(name => columns.has(name)); +} + +const finite = v => typeof v === 'number' && Number.isFinite(v) && v >= 0; +const integer = v => Number.isSafeInteger(v) && v >= 0; +const parse = raw => { try { return JSON.parse(raw); } catch { return null; } }; +const finished = data => typeof data?.finish === 'string' && data.finish.length > 0 && data.error == null; + +function compactions(srow, messages, parts) { + const requests = new Set(messages.filter(row => row.data?.role === 'user' + && (parts.get(row.id) ?? []).some(part => part.type === 'compaction')).map(row => row.id)); + const completed = new Set(); + const failed = new Set(); + const pending = new Set(); + let orphaned = 0; + for (const { data } of messages) { + if (data?.role !== 'assistant' || data.summary !== true) continue; + if (requests.has(data.parentID)) { + if (finished(data)) completed.add(data.parentID); + else if (data.error != null) failed.add(data.parentID); + else pending.add(data.parentID); + } else if (finished(data)) orphaned++; + } + const unresolved = [...requests].filter(id => !completed.has(id) && (!failed.has(id) || pending.has(id))).length; + const lowerBound = completed.size; + const incomplete = messages.some(row => !row.data || !['user', 'assistant'].includes(row.data.role)); + const inFlight = srow.time_compacting != null; + return { + compactions: lowerBound, + compactionEvidence: { lowerBound, upperBound: incomplete || (inFlight && !unresolved) + ? null : lowerBound + unresolved + orphaned }, + opencodeCompaction: { requests: requests.size, failed: [...failed].filter(id => !completed.has(id)).length, + unresolved, orphaned, inFlight }, + }; +} + +function counters(data) { + const t = data?.tokens; + const values = [t?.input, t?.output, t?.reasoning, t?.cache?.read, t?.cache?.write]; + if (!values.every(integer) || !finite(data?.cost)) return null; + // A nonzero total inconsistent with the verified additive convention is an + // unsupported older provider basis, rather than a forced apparent mismatch. + if (t.total != null && t.total !== 0 && (!integer(t.total) || t.total !== values.reduce((a, b) => a + b, 0))) return null; + return [data.cost, ...values]; +} +const same = (a, b) => a.every((n, i) => i === 0 + ? Math.abs(n - b[i]) <= 1e-9 * Math.max(1, n, b[i]) : n === b[i]); +const unknown = reason => ({ state: 'unknown', reason, basis: 'opencode-v1.18.33-single-step' }); + +function incompleteMessages(srow, messages, assistants) { + return !assistants.length || srow.time_compacting != null || messages.some(row => !row.data + || !['user', 'assistant'].includes(row.data.role)) || assistants.some(({ data }) => !finished(data) + || !finite(data.time?.completed) || data.time.completed <= 0); +} + +function reconciliation(srow, messages, parts, hasV2) { + if (srow.version !== '1.18.33') return unknown('unsupported-version'); + if (hasV2 !== false) return unknown('unsupported-v2-scope'); + const session = [srow.cost, srow.tokens_input, srow.tokens_output, srow.tokens_reasoning, + srow.tokens_cache_read, srow.tokens_cache_write]; + if (!finite(session[0]) || !session.slice(1).every(integer)) return unknown('invalid-session-counters'); + if (session.every(n => n === 0)) return unknown('unpopulated-session-counters'); + const assistants = messages.filter(row => row.data?.role === 'assistant'); + if (incompleteMessages(srow, messages, assistants)) return unknown('incomplete-messages'); + const sums = [0, 0, 0, 0, 0, 0]; + for (const { id, data } of assistants) { + const usage = counters(data); + if (!usage) return unknown('invalid-message-counters'); + if (usage.slice(1).every(n => n === 0)) return unknown('unreported-message-usage'); + if (data.cost === 0 && !isLocalInferenceProvider(data.providerID)) return unknown('untrusted-message-cost'); + const steps = (parts.get(id) ?? []).filter(part => part.type === 'step-finish'); + const step = steps.length === 1 ? counters(steps[0]) : null; + if (!step || !same(usage, step)) return unknown('unproved-step-scope'); + for (let i = 0; i < sums.length; i++) sums[i] += usage[i]; + } + // A user-owned step or unreadable part cannot be assigned to these messages. + const assistantIds = new Set(assistants.map(row => row.id)); + for (const [id, rows] of parts) if (!assistantIds.has(id) + && rows.some(row => row.type === 'step-finish')) return unknown('unproved-step-scope'); + if (!sums.every(finite) || !sums.slice(1).every(integer)) return unknown('invalid-message-counters'); + return { state: same(session, sums) ? 'matched' : 'mismatch', reason: null, + basis: 'opencode-v1.18.33-single-step' }; +} + +export function hasOpencodeV2Rows(db, id) { + const v2Table = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'session_message'").get(); + if (!v2Table) return false; + try { return !!db.prepare('SELECT 1 FROM session_message WHERE session_id = ? LIMIT 1').get(id); } + catch { return null; } // unreadable scope is unknown, not an absent V2 stream +} + +/** Metadata only: no raw message/parent identities, text, or charge values persist. */ +export function opencodeObservations(db, srow, rows, parts) { + const messages = rows.map(row => ({ id: row.id, data: parse(row.data) })); + const hasV2 = hasOpencodeV2Rows(db, srow.id); + return { ...compactions(srow, messages, parts), + opencodeReconciliation: reconciliation(srow, messages, parts, hasV2) }; +} + +export function opencodeObservationProjection(rec) { + const lower = Number.isSafeInteger(rec.compactions) && rec.compactions >= 0 ? rec.compactions : 0; + const upper = rec.compactionEvidence?.upperBound; + const valid = rec.compactionEvidence?.lowerBound === lower && (upper === null + || (Number.isSafeInteger(upper) && upper >= lower)); + return { codexEffort: null, firstTokenMs: null, compactions: lower, + compactionEvidence: { lowerBound: lower, upperBound: valid && rec.acquisitionCoverage?.complete !== false ? upper : null }, + opencodeReconciliation: rec.opencodeReconciliation ?? unknown('missing-observation'), + opencodeCompaction: rec.opencodeCompaction ?? null }; +} + + +/** Evidence can exist without a completed/billable response. Empty, fully + * observed OpenCode sessions still have no compaction evidence to retain. */ +export function hasOpencodeObservations(rec) { + if (rec.host !== 'opencode' || rec.acquisitionCoverage?.complete === false) return false; + const bounds = opencodeObservationProjection(rec).compactionEvidence; + return bounds.lowerBound > 0 + || bounds.upperBound === null || bounds.upperBound > 0; +} + + +/** Refused acquisitions retain uncertainty without becoming ordinary zero-cost + * session rows. Fold their bounds alone into the selected current/previous window. */ +export function foldIncompleteOpencodeObservations(records, totals, cutoff, endMs = Infinity) { + for (const rec of records) { + if (rec?.host !== 'opencode' || rec.responses || rec.acquisitionCoverage?.complete !== false + || !Number.isFinite(rec.end) || rec.end < cutoff || rec.end >= endMs) continue; + const bounds = opencodeObservationProjection(rec).compactionEvidence; + totals.compactions += bounds.lowerBound; + totals.compactionEvidence.lowerBound += bounds.lowerBound; + totals.compactionEvidence.upperBound = null; + } +} diff --git a/src/lib/usage-opencode-source.mjs b/src/lib/usage-opencode-source.mjs new file mode 100644 index 00000000..af3740c1 --- /dev/null +++ b/src/lib/usage-opencode-source.mjs @@ -0,0 +1,77 @@ +// Persisted OpenCode source selection. Never infer an installation channel from +// a filename, version, or mtime, and never combine potentially copied stores. +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { createHash } from 'node:crypto'; +import { xdgBase } from './paths.mjs'; + +// Reject filesystem control bytes rather than letting path normalization hide them. +// eslint-disable-next-line no-control-regex +const validPath = (value) => typeof value === 'string' && value.length > 0 && !/[\x00-\x1f\x7f]/u.test(value); +const unavailable = (reason, status = 'degraded') => ({ dbFile: null, legacyRoot: null, sourceIdentity: null, health: { status, reason } }); + +function selected(file, selection, fsImpl) { + if (!validPath(file) || !path.isAbsolute(file)) return unavailable('database-path-invalid'); + let dbFile = path.resolve(file); + try { dbFile = fsImpl.realpathSync(dbFile); } + catch (error) { + if (error.code !== 'ENOENT') return unavailable('database-path-unreadable'); + } + return { dbFile, legacyRoot: path.join(path.dirname(dbFile), 'storage'), sourceIdentity: createHash('sha256').update(dbFile).digest('hex'), + health: { status: 'ok', reason: null, selection } }; +} + +function discover(dataRoot, fsImpl) { + let dir; + const candidates = new Set(); + try { dir = fsImpl.opendirSync(dataRoot); } + catch (error) { + return error.code === 'ENOENT' + ? selected(path.join(dataRoot, 'opencode.db'), 'discovered', fsImpl) + : unavailable('database-discovery-unreadable'); + } + try { + try { + for (let count = 0; ; count++) { + const entry = dir.readSync(); + if (!entry) break; + if (count >= 256) return unavailable('database-discovery-limit'); + if (!/^opencode(?:-[A-Za-z0-9_-]+)?\.db$/.test(entry.name)) continue; + const candidate = selected(path.join(dataRoot, entry.name), 'discovered', fsImpl); + if (!candidate.dbFile) return candidate; + if (!fsImpl.statSync(candidate.dbFile).isFile()) return unavailable('database-path-invalid'); + candidates.add(candidate.dbFile); + } + } finally { dir.closeSync(); } + } catch { + // Once the root opened, any read/stat/close failure leaves enumeration + // incomplete. A missing candidate cannot establish a unique source. + return unavailable('database-discovery-unreadable'); + } + if (candidates.size > 1) return unavailable('database-selection-ambiguous'); + return selected([...candidates][0] ?? path.join(dataRoot, 'opencode.db'), 'discovered', fsImpl); +} + +/** @param {{roots?: {opencode?: string}, env?: NodeJS.ProcessEnv, fsImpl?: typeof fs}} [options] */ +export function selectOpencodeSource({ roots, env = process.env, fsImpl = fs } = {}) { + if (roots !== undefined) return roots?.opencode === undefined + ? unavailable(null, 'absent') : selected(roots.opencode, 'explicit-root', fsImpl); + const override = env.OPENCODE_DB; + if (override && (!validPath(override) || override.split(/[\\/]/).includes('..'))) return unavailable('database-path-invalid'); + const direct = override === ':memory:' ? unavailable('database-in-memory') + : override && path.isAbsolute(override) ? selected(override, 'environment', fsImpl) : null; + const home = env.HOME ?? env.USERPROFILE ?? os.homedir(); + const base = xdgBase('XDG_DATA_HOME', null, { env }) ?? path.join(home, '.local', 'share'); + if (!validPath(base) || !path.isAbsolute(base)) return direct + ? { ...direct, legacyRoot: null } : unavailable('database-path-invalid'); + const dataRoot = path.join(base, 'opencode'); + // Upstream legacy storage stays under Path.data even when OPENCODE_DB moves. + const withLegacyRoot = (result) => ({ ...result, legacyRoot: path.join(dataRoot, 'storage') }); + if (direct) return withLegacyRoot(direct); + if (override) return withLegacyRoot(selected(path.join(dataRoot, override), 'environment', fsImpl)); + if (['1', 'true'].includes(env.OPENCODE_DISABLE_CHANNEL_DB)) { + return withLegacyRoot(selected(path.join(dataRoot, 'opencode.db'), 'channel-disabled', fsImpl)); + } + return withLegacyRoot(discover(dataRoot, fsImpl)); +} diff --git a/src/lib/usage-opencode-storage-coverage.mjs b/src/lib/usage-opencode-storage-coverage.mjs new file mode 100644 index 00000000..405205eb --- /dev/null +++ b/src/lib/usage-opencode-storage-coverage.mjs @@ -0,0 +1,87 @@ +// Read-only metadata observations for OpenCode stores that the V1 reader does +// not consume. The caller owns database selection, handle lifetime and root. +import fs from 'node:fs'; +import path from 'node:path'; + +const MAX_ENTRIES = 256; +const MAX_DEPTH = 5; +const status = (value) => ({ status: value }); + +/** @param {import('node:sqlite').DatabaseSync | null} db */ +function observeV2(db) { + if (db == null) return status('not-observed'); + try { + const schema = db.prepare("SELECT type FROM sqlite_master WHERE name = 'session_message' LIMIT 1").get(); + if (schema == null) return status('missing'); + if (schema.type !== 'table') return status('unknown'); + return status(db.prepare('SELECT 1 FROM session_message LIMIT 1').get() == null ? 'empty' : 'present'); + } catch { + return status('unknown'); + } +} + +/** @param {string} child @param {string} name @param {number} depth @param {number} maxDepth @param {Array<{directory: string, depth: number}>} pending */ +function inspectLegacyEntry(child, name, depth, maxDepth, pending) { + const info = fs.lstatSync(child); + if (info.isSymbolicLink()) return 'incomplete'; + if (info.isFile() && name.toLowerCase().endsWith('.json')) return 'present'; + if (!info.isDirectory()) return 'continue'; + if (depth >= maxDepth) return 'incomplete'; + pending.push({ directory: child, depth: depth + 1 }); + return 'continue'; +} + +/** @param {string | null} root @param {number} maxEntries @param {number} maxDepth */ +function observeLegacy(root, maxEntries, maxDepth) { + if (root == null) return status('not-observed'); + if (typeof root !== 'string' || !path.isAbsolute(root)) return status('unknown'); + try { + let info; + try { info = fs.lstatSync(root); } + catch (error) { + if (/** @type {NodeJS.ErrnoException} */ (error).code === 'ENOENT') return status('absent'); + return status('unknown'); + } + if (!info.isDirectory()) return status('unknown'); + const pending = [{ directory: root, depth: 0 }]; + let entries = 0; + let incomplete = false; + while (pending.length) { + const { directory, depth } = pending.shift(); + const handle = fs.opendirSync(directory); + try { + let entry; + while ((entry = handle.readSync()) !== null) { + if (++entries > maxEntries) return status('unknown'); + const child = path.join(directory, entry.name); + const finding = inspectLegacyEntry(child, entry.name, depth, maxDepth, pending); + if (finding === 'present') return status('present'); + if (finding === 'incomplete') incomplete = true; + } + } finally { handle.closeSync(); } + } + return status(incomplete ? 'unknown' : 'absent'); + } catch { + return status('unknown'); + } +} + +/** + * Observe unsupported storage without reading message bodies or JSON content. + * `db` must already be opened read-only by the caller. Null means unobserved. + * Limits are clamped so caller mistakes cannot turn this into an unbounded walk. + * @param {{db?: import('node:sqlite').DatabaseSync | null, legacyRoot?: string | null, maxEntries?: number, maxDepth?: number}} [options] + * @returns {{v2: {status: string}, legacy: {status: string}, warnings: string[]}} + */ +export function observeOpencodeStorageCoverage({ db = null, legacyRoot = null, maxEntries = MAX_ENTRIES, maxDepth = MAX_DEPTH } = {}) { + const entryLimit = Number.isInteger(maxEntries) && maxEntries > 0 ? Math.min(maxEntries, MAX_ENTRIES) : MAX_ENTRIES; + const depthLimit = Number.isInteger(maxDepth) && maxDepth >= 0 ? Math.min(maxDepth, MAX_DEPTH) : MAX_DEPTH; + const v2 = observeV2(db); + const legacy = observeLegacy(legacyRoot, entryLimit, depthLimit); + const warnings = []; + if (v2.status === 'present') warnings.push('opencode-v2-session-message-present'); + if (v2.status === 'unknown') warnings.push('opencode-v2-observation-incomplete'); + if (legacy.status === 'present') warnings.push('opencode-legacy-json-present'); + if (legacy.status === 'unknown') warnings.push('opencode-legacy-observation-incomplete'); + return { v2, legacy, warnings }; +} diff --git a/src/lib/usage-opencode.mjs b/src/lib/usage-opencode.mjs index abaacd05..89db25f3 100644 --- a/src/lib/usage-opencode.mjs +++ b/src/lib/usage-opencode.mjs @@ -13,17 +13,18 @@ // on bad input — an absent/corrupt db simply reads as "no opencode source". // // Two attribution rules, grounded in the store itself: -// - COST is opencode's own metered figure on each assistant message -// (data.cost). That is OBSERVED truth, so usage rows carry it as -// `costObserved` and the aggregate prefers it over the pricing table — -// never re-priced from a guessed rate (kimi/openrouter/local rates are -// exactly what ak does not know and must not invent). +// - COST is opencode's own figure on each assistant message (data.cost). +// A positive recorded cost is observed. A zero with positive tokens from +// an unverified non-local provider is unpriced: OpenCode may default a +// missing model rate to zero. Never re-price that row from a guessed rate. // - INFERENCE PROVIDER is the assistant row's providerID when observed // (provenance 'observed'), never the host. A bare `opencode` host id says // nothing about who served the model. // Subagent sessions (parent_id set) keep their tokens: opencode child sessions // record their OWN messages, not a replay of the parent's — the codex // double-count rule does not apply (different storage semantics). +import { availableOpencodeMetadata, opencodeObservations } from './usage-opencode-observations.mjs'; +import { opencodeObservationFingerprint } from './usage-opencode-cache.mjs'; import { withDb } from './sqlite.mjs'; import { sessionAcquisitionCoverage } from './usage-opencode-bounds.mjs'; // Shared record shape/accumulator with parseClaude/parseCodex — see their @@ -35,9 +36,12 @@ import { } from './usage-parsers.mjs'; import { normalizeMode } from './usage-modes.mjs'; import { xdgBase } from './paths.mjs'; +export { selectOpencodeSource } from './usage-opencode-source.mjs'; import { observeUsageProject } from './usage-project-evidence.mjs'; +import { isLocalInferenceProvider } from './usage-local-provider.mjs'; -/** The live opencode store. Overridable via roots in tests. */ +/** Conventional footprint census location, not an authoritative transcript source. + * Usage and project discovery must use selectOpencodeSource instead. */ export function defaultOpencodeDbPath() { const home = xdgBase('XDG_DATA_HOME', null); return home @@ -191,15 +195,16 @@ function recordUserMessage(rec, turns, { rowId, at, withTurns, partsByMessage }) // Opens the prompt→assistant-message latency window; closed by the next // recordAssistantMessage (mirrors parseClaude/parseCodex's latState). rec.pendingPromptMs = at; - // Every opencode user message IS a prompt-kind turn (this source carries no - // harness-injected user rows), so it always fingerprints — on BOTH paths, - // which is why the scan path now loads user text parts (see loadTextParts). - // I1: that makes this the WIDEST of the three fingerprinted populations — + // Main-session user messages are prompt-kind turns. A child session's user + // messages are agent-written, so they do not enter the prompt fingerprint + // layer, though its prompts and usage remain accounted for on BOTH paths. + // The scan path loads user text parts for main-session fingerprints. + // I1: main sessions are the widest of the three fingerprinted populations — // claude gates on userTurnKind, codex additionally on - // CODEX_MACHINE_ENVELOPE_RE, opencode on nothing. Compare per provenance tag, + // CODEX_MACHINE_ENVELOPE_RE, opencode on no further turn kind. Compare per provenance tag, // never in total. const text = messagePartsText(partsByMessage, rowId, ['text']); - notePromptFingerprint(rec, text, 'prompt'); + if (!rec.sidechain) notePromptFingerprint(rec, text, 'prompt'); if (!withTurns) return; turns.push({ role: 'user', at: new Date(at).toISOString(), text, prompt: true, kind: 'prompt' }); } @@ -251,10 +256,6 @@ function recordAssistantUsage(rec, data, at) { const model = typeof data.modelID === 'string' && data.modelID ? data.modelID : 'unknown'; if (!rec.models.includes(model)) rec.models.push(model); const provider = typeof data.providerID === 'string' && data.providerID ? data.providerID : null; - if (provider) { - rec.inferenceProvider = provider; - rec.providerProvenance = 'observed'; - } const t = data.tokens ?? {}; const cache = t.cache ?? {}; const day = localDay(at || Date.now()); @@ -272,7 +273,11 @@ function recordAssistantUsage(rec, data, at) { if (isUnreportedUsage(data, t, cache)) usageRow.tokensUnreported = (usageRow.tokensUnreported ?? 0) + 1; // Retain missing-cost tokens separately before coalescing by day/model. usageRow.costObserved ??= null; - if (typeof data.cost === 'number' && Number.isFinite(data.cost) && data.cost >= 0) { + const hasMeasuredTokens = [t.input, t.output, t.reasoning, cache.read, cache.write] + .some((value) => typeof value === 'number' && Number.isFinite(value) && value > 0); + if (data.cost === 0 && hasMeasuredTokens && !isLocalInferenceProvider(provider)) { + usageRow.costUntrustedMessages = (usageRow.costUntrustedMessages ?? 0) + 1; + } else if (typeof data.cost === 'number' && Number.isFinite(data.cost) && data.cost >= 0) { usageRow.costObserved = (usageRow.costObserved ?? 0) + data.cost; usageRow.costObservedMessages = (usageRow.costObservedMessages ?? 0) + 1; } else { @@ -369,22 +374,9 @@ function processMessageRow(rec, turns, row, { withTurns, partsByMessage }) { recordAssistantMessage(rec, turns, { data, rowId: row.id, at, withTurns, partsByMessage }); } -/** The `part` rows a parse needs. `withTurns` wants every part (text, tool and - * reasoning, for both roles) to build turn rows; the scan path wants only the - * USER text parts, which is all a prompt fingerprint reads — the assistant - * bodies it would otherwise pull in are the bulk of the store and are never - * looked at there. - * - * This is the one place the scan path reads message BODIES at all, so its cost - * was measured rather than assumed. The live store on this machine is too - * small to time (2 sessions, 2 parts; ~5 µs/session, where the two - * `json_extract` predicates cannot pay for themselves because there is nothing - * to exclude). Benchmarked instead against a synthetic store at realistic scale - * — 300 sessions, 18k messages, 63k parts, 75 MB — the filtered query runs - * **45 µs/session and materializes 0.6 MB**, against 125 µs/session and 61 MB - * for the unfiltered join the reader path uses: 2.8x faster, and ~100x less - * text pulled into memory. Only the fingerprints are retained; the text itself - * is discarded with the row. */ +/** Scan reads user text for fingerprints and compaction/step-finish metadata + * for observations. Step-finish usage is never added to message usage. + * Detail additionally reads text/reasoning/tool bodies for transcript turns. */ function loadTextParts(db, id, withTurns) { if (withTurns) { return db.prepare(` @@ -396,9 +388,10 @@ function loadTextParts(db, id, withTurns) { return db.prepare(` SELECT p.message_id AS message_id, p.data AS data FROM part p JOIN message m ON m.id = p.message_id - WHERE m.session_id = ? - AND json_extract(m.data, '$.role') = 'user' - AND json_extract(p.data, '$.type') = 'text' + WHERE m.session_id = ? AND ( + (json_extract(m.data, '$.role') = 'user' + AND json_extract(p.data, '$.type') IN ('text', 'compaction')) + OR json_extract(p.data, '$.type') = 'step-finish') ORDER BY p.rowid ASC `).all(id); } @@ -456,15 +449,22 @@ export function parseSession({ dbFile, id, withTurns = false, maxSessionBytes, m delete rec.stamps; return { session: rec, turns: [] }; } - const srow = db.prepare('SELECT id, parent_id, directory, title FROM session WHERE id = ?').get(id); + const columns = ['id', 'parent_id', 'directory', 'title', ...availableOpencodeMetadata(db)]; + const srow = db.prepare(`SELECT ${columns.join(', ')} FROM session WHERE id = ?`).get(id); if (!srow) return null; const msgRows = db.prepare('SELECT id, time_created, data FROM message WHERE session_id = ? ORDER BY time_created ASC, id ASC').all(id); const partsByMessage = buildPartsIndex(loadTextParts(db, id, withTurns)); const rec = initSessionRecord(srow); - Object.assign(rec, { acquisitionCoverage }); + Object.assign(rec, { acquisitionCoverage }, opencodeObservations(db, srow, msgRows, partsByMessage)); const turns = []; for (const row of msgRows) processMessageRow(rec, turns, row, { withTurns, partsByMessage }); + // A session can switch providers, including to a row with no providerID. + // Its usage rows retain the observed identity; the session names a provider + // only when every assistant row agrees on one. + const providers = new Set(rec.usage.map((row) => row.provider ?? null)); + rec.inferenceProvider = providers.size === 1 ? [...providers][0] : null; + rec.providerProvenance = rec.inferenceProvider ? 'observed' : 'unknown'; if (!withTurns) collectScanToolCounts(db, id, rec); if (!rec.title) rec.title = '(untitled)'; @@ -473,7 +473,7 @@ export function parseSession({ dbFile, id, withTurns = false, maxSessionBytes, m delete rec.stamps; delete rec.pendingPromptMs; delete rec.spans; - return { session: rec, turns }; + return { session: rec, turns, observationFingerprint: opencodeObservationFingerprint(db, id) }; }); return result.ok ? result.value : null; } diff --git a/src/lib/usage-parsers.mjs b/src/lib/usage-parsers.mjs index 802b4824..5be14c64 100644 --- a/src/lib/usage-parsers.mjs +++ b/src/lib/usage-parsers.mjs @@ -14,6 +14,7 @@ import { repoRoot } from './paths.mjs'; import { windowAt } from './claude-window-ledger.mjs'; import { MAX_TELEMETRY_UNKNOWN_KINDS } from './usage-telemetry.mjs'; import { decodeClaudeRecord, decodeCodexRecord } from './telemetry-records.mjs'; +import { recordClaudeCostState } from './usage-cost.mjs'; import { codexReplayPlan, isCodexReplayLine } from './codex-replay.mjs'; import { newCodexUsageWalk, noteCodexWalkModel, noteCodexWalkResponse, walkCodexTokenCount, codexWalkRows, @@ -22,8 +23,9 @@ import { toMs, maskSecrets } from './usage-aggregate.mjs'; import { normalizeMode } from './usage-modes.mjs'; import { provenanceOf } from './usage-provenance.mjs'; import { promptSemantics } from './usage-prompt-semantics.mjs'; -import { observeUsageProject, usageSessionOrigin } from './usage-project-evidence.mjs'; -import { isCodexImportedLine } from './codex-import-marker.mjs'; +import { observeUsageProject, usageRecordOrigin, importedUsageRecordOrigin } from './usage-project-evidence.mjs'; +import { isCodexImportedLine, newCodexTurnOwnership, codexTurnOwner, codexImportEvidence } from './codex-import-marker.mjs'; +import { claudeProviderFromModelId } from './session-surface.mjs'; export { promptSemantics } from './usage-prompt-semantics.mjs'; @@ -54,6 +56,12 @@ function clip(text, max = 100) { return t.length > max ? `${t.slice(0, max - 1)}…` : t; } +function boundedClaudeModel(model) { + if (typeof model !== 'string' || model.length > 100 || model.includes('//')) return 'unknown'; + return /^[A-Za-z0-9._:/-]+$/u.test(model) || /^claude-[a-z0-9-]+@20[0-9]{6}$/u.test(model) + ? model : 'unknown'; +} + /** * Directory names that mean "the thing below me is a WORKTREE of the repo above * me", not a project of its own. `path.basename(cwd)` on a worktree yields the @@ -176,8 +184,10 @@ function applyProject(rec, res) { // ── transcript parsing ────────────────────────────────────────────────────── -/** Split JSONL into parsed objects, skipping anything that will not parse. */ -function* jsonLines(raw) { +/** Split JSONL into parsed records. Codex retains its conservative object-only + * framing; Claude also observes valid non-object JSON for shape diagnostics. */ +function* jsonLines(raw, stats = null, includeNonObjects = false) { + if (stats) stats.malformedRecords = 0; // Scanned lazily, not split up front: a caller that needs only the first // line (the subagent replay pre-pass) must not pay for the whole file. let pos = 0; @@ -186,10 +196,18 @@ function* jsonLines(raw) { const end = found < 0 ? raw.length : found; const start = pos; pos = end + 1; - if (end === start || raw.charCodeAt(start) !== 123 /* '{' */) continue; + if (end === start) continue; + if (includeNonObjects && !raw.slice(start, end).trim()) continue; + if (!includeNonObjects && raw.charCodeAt(start) !== 123 /* '{' */) { + if (stats && raw.slice(start, end).trim()) stats.malformedRecords++; + continue; + } let obj; - try { obj = JSON.parse(raw.slice(start, end)); } catch { continue; } - if (obj && typeof obj === 'object') yield obj; + try { obj = JSON.parse(raw.slice(start, end)); } catch { + if (stats) stats.malformedRecords++; + continue; + } + if (includeNonObjects || (obj && typeof obj === 'object')) yield obj; } } @@ -202,12 +220,18 @@ export function blankSession(id, provider) { id, provider, host: provider, inferenceProvider: null, providerProvenance: 'unknown', title: '', project: 'unknown', start: null, end: null, projectEvidence: null, sessionOrigin: { origin: 'unknown', evidence: 'desktop-origin-not-declared' }, - prompts: 0, responses: 0, exceptions: 0, sidechain: false, threadSource: null, models: [], tools: {}, + prompts: 0, responses: 0, exceptions: 0, sidechain: false, threadSource: null, parentSessionId: null, models: [], tools: {}, skill: null, plugin: null, worktree: null, usage: [], punchcard: {}, active: [], stamps: [], // Codex-only detail (v6): reasoning tokens inside output, and the last // rate-limit snapshot the rollout carried. Claude sessions keep the zero // and the null — absent, not unknown. reasoningOutput: 0, rateLimits: null, + // Codex host observations. Missing telemetry remains null; compactions + // count completed context replacements, never extra token spend. + codexEffort: null, firstTokenMs: null, compactions: 0, + compactionEvidence: { lowerBound: 0, upperBound: 0 }, + claudeCostState: null, + claudeMessageCoverage: null, // v11: cross-host permission posture (usage-modes.normalizeMode), a // response-latency histogram, THIS session's own engaged seconds, model // context-window detail, and codex's explicit-abort count. Every field @@ -649,6 +673,18 @@ function collectClaudeToolNames(rec, toolUses) { /** Did this decoded usage carry any token evidence at all? */ const hasClaudeUsage = (u) => u.input + u.output + u.cacheRead + u.cacheWrite > 0; +function claudeCrossFileIdentity(e) { + const messageId = e.message.id; + const requestId = e.requestId; + // These are the two observed Claude API ID forms. An arbitrary/malformed + // string is not proof that two files hold one provider message. + const rawId = typeof messageId === 'string' && /^msg_[A-Za-z0-9_-]{1,252}$/u.test(messageId) + ? ['message', messageId] : typeof requestId === 'string' && /^req_[A-Za-z0-9_-]{1,252}$/u.test(requestId) + ? ['request', requestId] : null; + const model = boundedClaudeModel(e.message.model); + return rawId ? sha(JSON.stringify(['claude', claudeProviderFromModelId(model), model, ...rawId]), 64) : null; +} + /** * Stage one assistant transcript line under its API message id. Claude Code * writes ONE line per content block (thinking / text / each tool_use) and @@ -658,26 +694,36 @@ const hasClaudeUsage = (u) => u.input + u.output + u.cacheRead + u.cacheWrite > * A later line with no token evidence never displaces an earlier one that had * some (honest-absent, same rule as the context sample below). A line with no * id at all is its own message: nothing is dropped and nothing is merged with - * an unrelated line. Dedup is scoped to ONE transcript — the same id can - * reappear in a subagent's file, and that cross-file overlap is not attempted. + * an unrelated line. A validated API identity is also retained as a hash for + * scan-wide accounting after every file has been parsed or loaded from cache. */ -function stageClaudeMessage(msgState, decoded, at, model) { - const key = decoded.messageId ?? `line:${msgState.seq++}`; +function stageClaudeMessage(msgState, decoded, at, model, recordedAtMs, identity) { + const key = identity ?? `line:${msgState.seq++}`; const prior = msgState.groups.get(key); if (prior && hasClaudeUsage(prior.usage) && !hasClaudeUsage(decoded.usage)) return; // Re-set keeps the Map's first-seen insertion order, so flush order is stable. - msgState.groups.set(key, { at, model, usage: decoded.usage }); + msgState.groups.set(key, { at, model, usage: decoded.usage, recordedAtMs, identity }); } /** Account every staged message exactly once: response count, punchcard, * the per-day/model usage row and the context sample — all from the message's * last line. Runs after the whole transcript has been read. */ function flushClaudeMessages(rec, msgState, windowLog) { - for (const { at, model, usage } of msgState.groups.values()) { + for (const { at, model, usage, recordedAtMs, identity } of msgState.groups.values()) { + if (hasClaudeUsage(usage)) { + if (recordedAtMs === null) rec.claudeMessageCoverage.missingTimestampMessages++; + else { + rec.claudeMessageCoverage.firstAtMs = Math.min(rec.claudeMessageCoverage.firstAtMs ?? recordedAtMs, recordedAtMs); + rec.claudeMessageCoverage.lastAtMs = Math.max(rec.claudeMessageCoverage.lastAtMs ?? recordedAtMs, recordedAtMs); + } + } rec.responses++; const pk = punchKey(at); rec.punchcard[pk] = (rec.punchcard[pk] ?? 0) + 1; addUsage(rec, localDay(at), model, { ...usage, responses: 1 }); + if (identity) rec.claudeMessages.push({ identity, at, day: localDay(at), model, + usage: { input: usage.input, output: usage.output, cacheRead: usage.cacheRead, + cacheWrite: usage.cacheWrite, cacheWrite1h: usage.cacheWrite1h ?? 0 } }); // Context pressure: the tokens actually IN the model's window for this // message (fresh input plus what got served from cache) — the last message // wins so the field reflects the LAST completion, not a running total. @@ -700,7 +746,7 @@ function flushClaudeMessages(rec, msgState, windowLog) { * latency/model/tool accounting plus its turn row. Usage, response count, * punchcard and context sample are STAGED per message id here and accounted * once by flushClaudeMessages. */ -function recordClaudeAssistantTurn(rec, turns, latState, msgState, ms, decoded, withTurns) { +function recordClaudeAssistantTurn(rec, turns, latState, msgState, ms, decoded, withTurns, identity) { noteSpan(rec, ms); const at = Number.isFinite(ms) ? ms : (rec.start ?? Date.now()); @@ -734,10 +780,10 @@ function recordClaudeAssistantTurn(rec, turns, latState, msgState, ms, decoded, latState.pendingMs = null; } - const model = typeof decoded.model === 'string' ? decoded.model : 'unknown'; + const model = boundedClaudeModel(decoded.model); if (!rec.models.includes(model)) rec.models.push(model); - stageClaudeMessage(msgState, decoded, at, model); + stageClaudeMessage(msgState, decoded, at, model, Number.isFinite(ms) ? ms : null, identity); const tools = collectClaudeToolNames(rec, decoded.toolUses); if (withTurns) { @@ -748,14 +794,37 @@ function recordClaudeAssistantTurn(rec, turns, latState, msgState, ms, decoded, } } +// Established Claude Code bookkeeping records with no message usage. New +// record types are never added implicitly to this list. +const CLAUDE_IGNORED_RECORD_TYPES = new Set(['bridge-session', 'file-history-snapshot', 'queue-operation', + 'atis-latch', 'last-prompt', 'attachment', 'mode', 'permission-mode', + 'agent-name', 'agent-setting', 'system', 'progress', 'summary']); +function claudeRecordCounter(type) { + if (typeof type !== 'string' || !type) return 'invalidTypeRecords'; + if (type === 'user' || type === 'assistant' || type === 'cost-state' || type === 'ai-title') return 'knownHandledRecords'; + return CLAUDE_IGNORED_RECORD_TYPES.has(type) ? 'knownIgnoredRecords' : 'unknownRecords'; +} +function* knownClaudeLines(raw, stats) { + for (const e of jsonLines(raw, stats, true)) { + const counter = claudeRecordCounter(e?.type); + stats[counter]++; + if (counter !== 'invalidTypeRecords' && counter !== 'unknownRecords') yield e; + } +} + /** - * Parse one Claude transcript. Returns `{ session, turns }`; `turns` is only - * populated when `withTurns` (the reader path) — the scan path does not need - * message bodies and holding them would balloon memory over 3,000 files. + * Parse one Claude transcript. Returns `{ session, turns, parseStats }`; + * `turns` is only populated when `withTurns` (the reader path) — the scan path + * does not need message bodies and holding them would balloon memory. */ export function parseClaude(raw, { id, dirName, withTurns = false, windowLog = null }) { const rec = blankSession(id, 'claude'); - rec.sessionOrigin = usageSessionOrigin(raw, 'claude'); + const parseStats = { knownHandledRecords: 0, knownIgnoredRecords: 0, + unknownRecords: 0, invalidTypeRecords: 0, malformedRecords: 0 }; + rec.claudeMessages = []; + rec.claudeMessageCoverage = { firstAtMs: null, lastAtMs: null, missingTimestampMessages: 0 }; + rec.sessionOrigin = usageRecordOrigin(raw, 'claude'); + const observedProviders = new Set(); const turns = []; const titleState = { firstPrompt: '', aiTitle: '' }; // Open by the most recent human prompt, closed by the first real assistant @@ -764,7 +833,11 @@ export function parseClaude(raw, { id, dirName, withTurns = false, windowLog = n // Assistant lines staged per API message id — see stageClaudeMessage. const msgState = { groups: new Map(), seq: 0 }; - for (const e of jsonLines(raw)) { + for (const e of knownClaudeLines(raw, parseStats)) { + if (e.type === 'cost-state') { + rec.claudeCostState = recordClaudeCostState(rec.claudeCostState, e, id); + continue; + } const ms = toMs(e.timestamp); if (e.type === 'ai-title') { if (typeof e.aiTitle === 'string') titleState.aiTitle = e.aiTitle; continue; } if (typeof e.attributionSkill === 'string' && !rec.skill) rec.skill = e.attributionSkill; @@ -780,18 +853,28 @@ export function parseClaude(raw, { id, dirName, withTurns = false, windowLog = n } if (decoded.role !== 'assistant' || !e.message) continue; - recordClaudeAssistantTurn(rec, turns, latState, msgState, ms, decoded, withTurns); + // A transcript's assistant model is tied to this session. Current global + // settings and process.env are not historical session evidence. + if (!decoded.isApiError) observedProviders.add(claudeProviderFromModelId(boundedClaudeModel(e.message.model))); + // The hash preserves API identity across copied files without persisting + // a raw provider ID in the cache. Different ID kinds/providers cannot meet. + recordClaudeAssistantTurn(rec, turns, latState, msgState, ms, decoded, withTurns, claudeCrossFileIdentity(e)); } flushClaudeMessages(rec, msgState, windowLog); + if (observedProviders.size === 1 && !observedProviders.has(null)) { + rec.sessionOrigin.thirdPartyProvider = observedProviders.values().next().value; + rec.sessionOrigin.thirdPartyProviderBasis = 'assistant-model-id'; + } + rec.title = maskSecrets(titleState.aiTitle || clip(titleState.firstPrompt)) || '(untitled)'; if (rec.project === 'unknown') applyProject(rec, projectLabel(null, dirName)); - return { session: seal(rec), turns }; + return { session: seal(rec), turns, parseStats }; } function codexParseStats() { return { - legacyEvents: 0, itemCompletedEvents: 0, tokenCountEvents: 0, + legacyEvents: 0, itemCompletedEvents: 0, tokenCountEvents: 0, totalOnlyTokenCountEvents: 0, prompts: 0, responses: 0, unknownItemTypes: {}, unknownItemTypeOverflow: 0, // Oversized rollout lines the streaming reader clipped instead of parsing // (codex-rollout-reader.mjs); always 0 for a rollout read as a string. @@ -839,20 +922,26 @@ function recordCodexUnknownType(stats, type) { * `handleCodexTurnContext`'s own `rec.project === 'unknown'` check — a * DIFFERENT gate that coincides with this one in the common case but is * not "the same rule" as this latch. */ -function handleCodexMeta(rec, metaState, decoded) { +function handleCodexMeta(rec, metaState, decoded, payload) { if (metaState.seen) return; metaState.seen = true; if (typeof decoded.sessionId === 'string' && decoded.sessionId) rec.id = decoded.sessionId; if (typeof decoded.cwd === 'string') applyProject(rec, projectLabel(decoded.cwd, null, repoRootOf(decoded.cwd))); if (typeof decoded.cwd === 'string') rec.projectEvidence = observeUsageProject(decoded.cwd); if (typeof decoded.threadSource === 'string') rec.threadSource = decoded.threadSource; + // Observed Codex shape: source.subagent.thread_spawn.parent_thread_id. + // Accept only a UUID-shaped identifier; arbitrary source objects are never + // copied into the usage record or used to infer a parent. + const parentId = payload?.source?.subagent?.thread_spawn?.parent_thread_id; + if (typeof parentId === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(parentId) + && parentId !== rec.id) rec.parentSessionId = parentId; if (decoded.provider) { rec.inferenceProvider = decoded.provider; rec.providerProvenance = 'observed'; } } -function handleCodexTurnContext(rec, decoded, payload) { +function handleCodexTurnContext(rec, decoded, payload, captureEffort = true) { if (!rec.projectEvidence && typeof decoded.cwd === 'string') rec.projectEvidence = observeUsageProject(decoded.cwd); if (typeof decoded.model === 'string' && !rec.models.includes(decoded.model)) rec.models.push(decoded.model); if (decoded.provider) { @@ -874,6 +963,11 @@ function handleCodexTurnContext(rec, decoded, payload) { : payload.sandbox_policy; const m = normalizeMode({ host: 'codex', approvalPolicy: payload.approval_policy, sandboxPolicy: sandbox }); if (m.raw) { rec.mode = m.mode; rec.modeRaw = m.raw; } + if (captureEffort && ['none', 'minimal', 'low', 'medium', 'high', 'xhigh'].includes(payload.effort)) { + rec.codexEffort ??= { last: null, counts: {} }; + rec.codexEffort.last = payload.effort; + rec.codexEffort.counts[payload.effort] = (rec.codexEffort.counts[payload.effort] ?? 0) + 1; + } } /** Normalize one token_count event's rate-limit windows (primary/secondary), @@ -916,7 +1010,15 @@ function applyCodexRateLimit(rec, rl, ms) { * thread's own usage nor a context or rate-limit observation of it. */ function handleCodexTokenCount(rec, stats, usageState, decoded, ms, replay) { stats.tokenCountEvents++; - walkCodexTokenCount(usageState.walk, decoded.usage.total, ms, replay, localDay); + const total = decoded.usage.total; + if (!replay && Number.isFinite(Number(total?.total_tokens)) && Number(total.total_tokens) > 0 + && !['input_tokens', 'cached_input_tokens', 'output_tokens'].some((field) => + Number.isFinite(Number(total[field])) && Number(total[field]) > 0)) { + // A total alone cannot establish input, cache or output, so it cannot be + // priced or folded into a component row. Preserve the observed gap. + stats.totalOnlyTokenCountEvents++; + } + walkCodexTokenCount(usageState.walk, decoded.usage.total, ms, replay, localDay, usageState.importOwnership ? decoded.usage.last : null); if (replay) return; // Codex re-emits an identical token_count (measured: ~2.8% of events) with // the SAME cumulative total when no new model call happened; that is a @@ -956,7 +1058,16 @@ function handleCodexTaskStarted(rec, latState, payload) { * awaiting approval overnight arrives as a multi-hour "response" that the * prompt-gap path would have discarded. A non-null `error` counts as an * exception regardless of whether the fallback sample fires. */ -function handleCodexTaskComplete(rec, latState, payload) { +function handleCodexTaskComplete(rec, latState, payload, captureFirstToken = true) { + const first = payload.time_to_first_token_ms; + if (captureFirstToken && typeof first === 'number' && Number.isFinite(first) && first >= 0 + && first <= MAX_LATENCY_SAMPLE_SECONDS * 1000) { + rec.firstTokenMs ??= { count: 0, total: 0, min: first, max: first, provenance: 'host-observed' }; + rec.firstTokenMs.count++; + rec.firstTokenMs.total += first; + rec.firstTokenMs.min = Math.min(rec.firstTokenMs.min, first); + rec.firstTokenMs.max = Math.max(rec.firstTokenMs.max, first); + } const duration = Number(payload.duration_ms); if (latState.turnStartedAt !== null && Number.isFinite(duration) && duration / 1000 <= MAX_LATENCY_SAMPLE_SECONDS) { @@ -1066,8 +1177,8 @@ const CODEX_TOOL_ITEM_TYPES = new Set([ /** `item_completed` item types the host emits that are UNDERSTOOD and are * neither a message nor a tool: model reasoning, sub-agent lifecycle notes, * image views, extension calls, web searches and context compaction. They - * carry no usage or turn evidence this parser needs, so they are recognised - * and dropped. Without this list every scan raised the `unknown-item-types` + * carry no token usage; a completed compaction is separately counted as + * context evidence. Without this list every scan raised the `unknown-item-types` * warning permanently (six kinds landed in the 32-kind cap), which taught * readers to ignore the one diagnostic meant to flag a genuinely new shape. * Only a type in NEITHER set is unknown. */ @@ -1087,8 +1198,15 @@ function handleCodexEventMsg(rec, turns, stats, titleState, usageState, latState // thread's: they must not open latency windows, sample a context window or // count the parent's aborts against the child. if (replay && ['task_started', 'task_complete', 'turn_aborted'].includes(payload.type)) return; - if (payload.type === 'task_started') { handleCodexTaskStarted(rec, latState, payload); return; } - if (payload.type === 'task_complete') { handleCodexTaskComplete(rec, latState, payload); return; } + if (payload.type === 'task_started') { + noteCodexCompactionTurn(usageState, payload.turn_id); + handleCodexTaskStarted(rec, latState, payload); + return; + } + if (payload.type === 'task_complete') { + handleCodexTaskComplete(rec, latState, payload, !usageState.unprovable); + return; + } if (payload.type === 'turn_aborted') { rec.aborts++; // An interrupted turn leaves no valid latency evidence behind it: a @@ -1103,6 +1221,9 @@ function handleCodexEventMsg(rec, turns, stats, titleState, usageState, latState if (decoded.generation === 'legacy') stats.legacyEvents++; else if (decoded.generation === 'item') stats.itemCompletedEvents++; if (decoded.unknownItemType) { + if (!replay && !usageState.unprovable && decoded.unknownItemType === 'ContextCompaction') { + noteCodexCompaction(usageState, 'item', payload.turn_id); + } // A type this parser tallies is a type it UNDERSTANDS. Recording it as an // unknown kind too made the four tool items simultaneously "tools" in the // scorecard and "unknown kinds" in sourceHealth — raising the @@ -1133,17 +1254,52 @@ function rawPayload(e) { return e?.payload && typeof e.payload === 'object' ? e.payload : {}; } +/** Keep IDs transient. Top-level `compacted` has no turn ID, so its nearest + * preceding task-start segment is only pairing evidence, not proof of a + * one-to-one relationship with ContextCompaction. */ +function noteCodexCompactionTurn(state, turnId) { + const key = `turn-${++state.turnSequence}`; + state.currentCompactionTurn = key; + if (typeof turnId === 'string' && turnId.length <= 256) state.compactionTurnIds.set(turnId, key); +} + +function noteCodexCompaction(state, shape, turnId = null) { + // An item ID without a matching observed task_start cannot establish a + // separate turn from a nearby ID-less top-level compacted envelope. + const explicitKey = typeof turnId === 'string' && turnId.length <= 256 + ? state.compactionTurnIds.get(turnId) : null; + const key = explicitKey ?? state.currentCompactionTurn ?? 'unscoped'; + const counts = state.compactionTurns.get(key) ?? { completed: 0, item: 0 }; + counts[shape]++; + state.compactionTurns.set(key, counts); +} + +function finalizeCodexCompactions(rec, state) { + let lowerBound = 0; + let upperBound = 0; + for (const { completed, item } of state.compactionTurns.values()) { + lowerBound += Math.max(completed, item); + upperBound += completed + item; + } + rec.compactions = lowerBound; + rec.compactionEvidence = { lowerBound, upperBound }; +} + /** One line of a Codex rollout, dispatched on its decoded type. */ function processCodexLine(rec, turns, stats, titleState, usageState, latState, metaState, e, ms, withTurns) { const decoded = decodeCodexRecord(e); - if (decoded.type === 'meta') { handleCodexMeta(rec, metaState, decoded); return; } + const replay = isCodexReplayLine(usageState.boundary, e); + if (decoded.type === 'meta') { handleCodexMeta(rec, metaState, decoded, rawPayload(e)); return; } if (decoded.type === 'turnContext') { - handleCodexTurnContext(rec, decoded, rawPayload(e)); + if (!replay) handleCodexTurnContext(rec, decoded, rawPayload(e), !usageState.unprovable); noteCodexWalkModel(usageState.walk, decoded.model); return; } + if (e.type === 'compacted') { + if (!replay && !usageState.unprovable) noteCodexCompaction(usageState, 'completed'); + return; + } if (e.type !== 'event_msg') return; - const replay = isCodexReplayLine(usageState.boundary, e); handleCodexEventMsg(rec, turns, stats, titleState, usageState, latState, decoded, rawPayload(e), ms, withTurns, replay); } @@ -1153,12 +1309,15 @@ function processCodexLine(rec, turns, stats, titleState, usageState, latState, m * Codex activity inflated Codex responses and prompts and diluted its * coverage figures. `imported` is set ONLY on these records, and the scan * reports how many it excluded (`importedExcluded`) rather than dropping them - * silently. Parsing stops at the first imported line — nothing after it is - * read. */ + * silently. The whole source is examined for genuine later turns. */ function importedCodexSession(rec, stats) { + rec = { ...blankSession(rec.id, 'codex'), threadSource: rec.threadSource, + ...(rec.parentSessionId ? { parentSessionId: rec.parentSessionId } : {}), importEvidence: rec.importEvidence }; rec.imported = true; + rec.sessionOrigin = importedUsageRecordOrigin(); rec.title = '(imported Claude session)'; - return { session: seal(rec), turns: [], parseStats: { ...stats, imported: true } }; + return { session: seal(rec), turns: [], parseStats: { ...codexParseStats(), + importEvidence: rec.importEvidence, clippedLines: stats.clippedLines, imported: true } }; } /** The session's usage rows, from the walk (codex-usage-walk.mjs): one per @@ -1180,6 +1339,20 @@ function finalizeCodexUsage(rec, walk) { rec.reasoningOutput = reasoningOutput; } +/** Bind a mixed session only to its first declaration and native cwd evidence. */ +function finalizeMixedCodexOrigin(rec, firstMeta) { + // The first session declaration is valid origin evidence only after own + // activity is established. Replayed/later metadata never replaces it. + const p = firstMeta?.payload ?? {}; + rec.sessionOrigin = usageRecordOrigin(JSON.stringify({ type: 'session_meta', payload: { + originator: p.originator, source: p.source, thread_source: p.thread_source, + } }), 'codex'); + if (!rec.projectEvidence && typeof p.cwd === 'string') { + rec.projectEvidence = observeUsageProject(p.cwd); + applyProject(rec, projectLabel(p.cwd, null, repoRootOf(p.cwd))); + } +} + /** * Parse one Codex rollout. `total_token_usage` is CUMULATIVE, so each event's * spend is its DELTA against the previous snapshot (summing the snapshots @@ -1204,24 +1377,31 @@ function finalizeCodexUsage(rec, walk) { * so its prompts stay out of human-prompt figures. A subagent whose replay * cannot be separated (no ordinals at all) reports no usage, as before. * - * A rollout Codex imported from a Claude Code transcript - * (`external-import-turn-N`) is not Codex activity at all — see - * importedCodexSession. + * Imported turns (`external-import-turn-N`) never count. Identified native + * turns in the same file can count; missing boundaries remain unattributable. */ export function parseCodex(raw, { id, withTurns = false }) { // `raw` is the rollout text, or a streaming source (openCodexRollout) for one // too large to hold as a string: `{ head, lines, stats }`. Both feed the SAME // walk below, so the two paths cannot drift. + const readStats = { malformedRecords: 0 }; const source = typeof raw === 'string' - ? { head: raw, lines: { [Symbol.iterator]: () => jsonLines(raw) } } + ? { head: raw, stats: readStats, lines: { [Symbol.iterator]: () => jsonLines(raw, readStats) } } : raw; const rec = blankSession(id, 'codex'); - rec.sessionOrigin = usageSessionOrigin(source.head, 'codex'); + rec.sessionOrigin = usageRecordOrigin(source.head, 'codex'); const turns = []; const stats = codexParseStats(); const lines = source.lines; const plan = codexReplayPlan(lines); - const usageState = { walk: newCodexUsageWalk({ unattributable: plan.unprovable }), boundary: plan.boundary }; + let hasImports = false; + for (const e of lines) { if (isCodexImportedLine(e)) { hasImports = true; break; } } + const ownership = newCodexTurnOwnership({ hasImports }); + let firstMeta = null; + let genuineActivity = false; + const usageState = { walk: newCodexUsageWalk({ unattributable: plan.unprovable }), boundary: plan.boundary, + unprovable: plan.unprovable, importOwnership: hasImports, compactionTurns: new Map(), + compactionTurnIds: new Map(), currentCompactionTurn: null, turnSequence: 0 }; const titleState = { firstPrompt: '' }; // Opened by task_started (turn start remembered), closed either by a // prompt→agent-message gap sample or by task_complete's own duration_ms @@ -1234,13 +1414,48 @@ export function parseCodex(raw, { id, withTurns = false }) { const metaState = { seen: false }; for (const e of lines) { - if (isCodexImportedLine(e)) return importedCodexSession(rec, stats); const ms = toMs(e.timestamp); + if (hasImports) { + const nativeBefore = ownership.nativeRecords; + const owner = codexTurnOwner(ownership, e); + const replay = plan.unprovable || isCodexReplayLine(plan.boundary, e); + if (replay) ownership.nativeRecords = nativeBefore; + if (e.type === 'session_meta') { + if (!firstMeta) firstMeta = e; + // Keep the first identity, but a copied cwd is not project evidence. + const payload = { ...rawPayload(e), cwd: undefined }; + handleCodexMeta(rec, metaState, decodeCodexRecord({ ...e, payload }), payload); + continue; + } + if (owner !== 'native' || replay) { + const decoded = decodeCodexRecord(e); + if (decoded.type === 'tokenCount') { + // Excluded snapshots still advance the cumulative baseline. + walkCodexTokenCount(usageState.walk, decoded.usage.total, ms, true, localDay); + } + latState.pendingPromptMs = null; + latState.turnStartedAt = null; + continue; + } + if (e.type === 'event_msg' && ['user_message', 'agent_message', 'item_completed', 'token_count'].includes(e.payload?.type)) genuineActivity = true; + } noteSpan(rec, ms); processCodexLine(rec, turns, stats, titleState, usageState, latState, metaState, e, ms, withTurns); } + if (hasImports) { + const malformedRecords = source.stats?.malformedRecords ?? 0; + rec.importEvidence = { ...codexImportEvidence(ownership), malformedRecords, + ownershipComplete: ownership.ownershipComplete && !plan.unprovable + && (source.stats?.clippedLines ?? 0) === 0 && malformedRecords === 0 }; + if (!rec.importEvidence.ownershipComplete) rec.importEvidence.nativeRecords = 0; + stats.importEvidence = rec.importEvidence; + stats.clippedLines = source.stats?.clippedLines ?? 0; + if (!genuineActivity || !rec.importEvidence.ownershipComplete) return importedCodexSession(rec, stats); + finalizeMixedCodexOrigin(rec, firstMeta); + } finalizeCodexUsage(rec, usageState.walk); + finalizeCodexCompactions(rec, usageState); stats.clippedLines = source.stats?.clippedLines ?? 0; rec.title = maskSecrets(clip(titleState.firstPrompt)) || '(untitled)'; return { session: seal(rec), turns, parseStats: stats }; diff --git a/src/lib/usage-project-evidence.mjs b/src/lib/usage-project-evidence.mjs index 49918afc..88891389 100644 --- a/src/lib/usage-project-evidence.mjs +++ b/src/lib/usage-project-evidence.mjs @@ -7,6 +7,7 @@ import { inspectProjectIdentity } from './footprint/project-identity.mjs'; import { transcriptSessionOrigin } from './footprint/session-origin.mjs'; import { isImportedCodexRollout } from './codex-import-marker.mjs'; import { safeProjectLabel } from './live/project-label.mjs'; +import { classifySessionSurface } from './session-surface.mjs'; import { claudeDir, codexDir, opencodeDir, configDir } from './paths.mjs'; const CACHE = new Map(); @@ -56,7 +57,7 @@ export function observeUsageProject(cwd, { observedAt = Date.now(), cache = CACH return value; } -/** Same bounded head and exact origin allowlists as footprint discovery. An +/** Same bounded head and declared-origin token validation as footprint discovery. An * imported Codex copy of a Claude Code transcript declares the ChatGPT desktop * app as its originator but is not a session from it (ADR-0060 §3). */ export function usageSessionOrigin(raw, host) { @@ -65,3 +66,23 @@ export function usageSessionOrigin(raw, host) { if (host === 'codex' && isImportedCodexRollout(lines)) return { origin: 'unknown', evidence: 'imported-copy' }; return transcriptSessionOrigin(lines, host); } + +/** Serialize the classifier dimensions for usage records. The footprint + * adapter keeps them non-enumerable to preserve its legacy origin contract. */ +export function usageRecordOrigin(raw, host) { + const legacy = usageSessionOrigin(raw, host); + if (legacy.evidence === 'imported-copy') return importedUsageRecordOrigin(); + return { + ...legacy, + surface: legacy.surface, initiator: legacy.initiator, label: legacy.label, + rawEvidence: legacy.rawEvidence, attributes: legacy.attributes, + thirdPartyProvider: legacy.thirdPartyProvider, + }; +} + +/** The parser also calls this when an import marker falls beyond the bounded + * head. A copied declaration never establishes a genuine session surface. */ +export function importedUsageRecordOrigin() { + return { origin: 'unknown', evidence: 'imported-copy', + ...classifySessionSurface({ host: 'codex', importedCopy: true }) }; +} diff --git a/tests/dashboard.test.cjs b/tests/dashboard.test.cjs index 5eab2a07..eaefbe19 100644 --- a/tests/dashboard.test.cjs +++ b/tests/dashboard.test.cjs @@ -1297,7 +1297,7 @@ async function main() { const r = await get(uiSrv.url); contains(r.body, 'var prov=d.byHost||{}'); contains(r.body, 'var host=reportedIdentity(sx.host)||"unknown"'); - contains(r.body, 'var provider=reportedIdentity(sx.provider)'); + contains(r.body, 'var provider=sessionProviderPresentation(sx).label'); contains(r.body, 'Execution host: '); contains(r.body, 'Inference provider: '); contains(r.body, '"inference provider"'); diff --git a/tests/kit/dashboard-project-identity.test.mjs b/tests/kit/dashboard-project-identity.test.mjs index f00d60da..be689ea1 100644 --- a/tests/kit/dashboard-project-identity.test.mjs +++ b/tests/kit/dashboard-project-identity.test.mjs @@ -80,9 +80,10 @@ test('should_canonicalize_symlink_aliases_without_merging_same_named_repositorie assert.notEqual(inspectProjectIdentity(first).repositoryId, inspectProjectIdentity(second).repositoryId); }); test('should_attribute_only_explicit_desktop_metadata_and_ignore_names_and_ambiguous_sources', () => { - for (const entrypoint of ['claude-desktop', 'claude-desktop-3p', 'remote_desktop']) { + for (const entrypoint of ['claude-desktop', 'claude-desktop-3p']) { assert.equal(transcriptSessionOrigin(lines({ entrypoint }), 'claude').origin, 'claude-desktop'); } + assert.equal(transcriptSessionOrigin(lines({ entrypoint: 'remote_desktop' }), 'claude').surface, 'cloud-session'); for (const originator of ['Codex Desktop', 'codex_work_desktop']) { assert.equal(transcriptSessionOrigin(lines({ type: 'session_meta', payload: { originator } }), 'codex').origin, 'codex-desktop'); } @@ -150,5 +151,5 @@ test('should_qualify_encoded_directory_recovery_as_a_sighting_instead_of_a_verif }), scanOpencode: () => ({ complete: true, sightings: [] }) }); assert.deepEqual({ sessions: result.projects[0].sessions, origin: result.projects[0].sessionOrigins[0].origin, countBasis: result.projects[0].sessionOrigins[0].countBasis }, - { sessions: 1, origin: 'unknown', countBasis: 'recovered-project-sighting' }); + { sessions: 0, origin: 'unknown', countBasis: 'recovered-project-sighting' }); }); diff --git a/tests/kit/footprint-projects.test.mjs b/tests/kit/footprint-projects.test.mjs index d5466b97..3e53b6d0 100644 --- a/tests/kit/footprint-projects.test.mjs +++ b/tests/kit/footprint-projects.test.mjs @@ -55,7 +55,7 @@ function write(file, content) { /** One Claude transcript: flat `cwd` on its own records. */ const claudeTranscript = (root, dirName, file, cwd) => write( path.join(root, dirName, file), - `${JSON.stringify({ type: 'user', cwd })}\n${JSON.stringify({ type: 'assistant' })}\n`, + `${JSON.stringify({ type: 'user', cwd, sessionId: `${dirName}/${file}` })}\n${JSON.stringify({ type: 'assistant' })}\n`, ); /** One Codex rollout: `payload.cwd` on the record that opens it. */ @@ -325,7 +325,7 @@ test('OpenCode sessions come from the store, and a broken store degrades with it withDb: () => ({ ok: false, error: { kind: 'io', message: 'SQLITE_CORRUPT' } }), }); assert.equal(broken.status, 'degraded'); - assert.equal(broken.reason, 'SQLITE_CORRUPT'); + assert.equal(broken.reason, 'io', 'health exposes the error category, not raw database error text'); assert.equal(broken.complete, false); }); diff --git a/tests/kit/footprint-windows.test.mjs b/tests/kit/footprint-windows.test.mjs index 7d145449..d714ae22 100644 --- a/tests/kit/footprint-windows.test.mjs +++ b/tests/kit/footprint-windows.test.mjs @@ -460,6 +460,34 @@ test('runtime census names the process source instead of treating every cwd as a assert.match(rows[3].source.reason, /reported no working directory/); }); +test('runtime census exposes application identity separately from coding-agent host', async () => { + const census = await collectRuntimeCensus({ + platform: 'darwin', + surveyImpl: async () => ({ processes: [ + { pid: 1, host: null, application: 'Claude Desktop', controllerKind: 'desktop-app', + startedAt: new Date(WIN_NOW - 1000).toISOString(), uptimeMs: 1000, + cpuPercent: 0, rssBytes: 100, cwd: '/', cwdReason: null }, + { pid: 2, host: null, application: 'ChatGPT desktop app', controllerKind: 'desktop-app', + startedAt: new Date(WIN_NOW - 1000).toISOString(), uptimeMs: 1000, + cpuPercent: 0, rssBytes: 100, cwd: '/', cwdReason: null }, + { pid: 3, host: 'codex', application: null, controllerKind: 'project-session', + startedAt: new Date(WIN_NOW - 1000).toISOString(), uptimeMs: 1000, + cpuPercent: 0, rssBytes: 100, cwd: '/repos/work', cwdReason: null }, + ] }), + listDaemonsImpl: async () => [], + osImpl: { totalmem: () => 1000, freemem: () => 500, cpus: () => [1] }, + now: WIN_NOW, + classifyContext: () => ({ kind: 'repository', label: 'work', path: '/repos/work', projectKey: 'work' }), + }); + assert.deepEqual(census.processes.value.map(({ host, application, source }) => + ({ host, application, sourceKind: source.value.kind, sourceLabel: source.value.label })), [ + { host: null, application: 'Claude Desktop', sourceKind: 'desktop-app', sourceLabel: 'Claude Desktop' }, + { host: null, application: 'ChatGPT desktop app', sourceKind: 'desktop-app', sourceLabel: 'ChatGPT desktop app' }, + { host: 'codex', application: null, sourceKind: 'repository', sourceLabel: 'work' }, + ]); + assert.equal(census.ephemeral, true); +}); + test('a Windows survey that cannot run at all leaves the machine facts standing', async () => { const census = await collectRuntimeCensus({ platform: 'win32', diff --git a/tests/kit/hook-audit.test.mjs b/tests/kit/hook-audit.test.mjs index 27100ff3..98900965 100644 --- a/tests/kit/hook-audit.test.mjs +++ b/tests/kit/hook-audit.test.mjs @@ -25,6 +25,30 @@ function fixture() { return { root, codexHome, project, cache }; } +function cliFixture(t) { + const fx = fixture(); + // An after hook preserves the command assertion if bounded cleanup also fails. + t.after(() => fs.rmSync(fx.root, { recursive: true, force: true, maxRetries: 3 })); + const preload = path.join(fx.root, 'probes.cjs'); + const launches = path.join(fx.root, 'unexpected-launches.jsonl'); + fs.writeFileSync(launches, ''); + fs.writeFileSync(preload, `const cp = require('node:child_process'); + const fs = require('node:fs'); + for (const method of ['spawn', 'spawnSync', 'exec', 'execSync', 'execFile', 'execFileSync', 'fork']) { + cp[method] = (command, args) => { + if (method === 'spawnSync' && command === 'codex' && JSON.stringify(args) === '["--version"]') + return { status: 0, stdout: 'codex 0.151.0', stderr: '' }; + if (method === 'execFileSync' && command === 'npm' && JSON.stringify(args) === '["root","-g"]') + return ${JSON.stringify(path.join(fx.root, 'global-packages'))}; + fs.appendFileSync(${JSON.stringify(launches)}, JSON.stringify({ method, command, args }) + '\\n'); + throw new Error('unexpected child launch during hook audit'); + }; + } + require('node:module').syncBuiltinESMExports(); + `); + return { ...fx, preload, launches }; +} + test('audit keeps SessionEnd compatibility separate from trust and never proposes an automatic cache edit', () => { const fx = fixture(); try { @@ -411,36 +435,30 @@ test('audit reports malformed hook documents and remains read-only', () => { } }); -test('ak audit hooks exposes the read-only audit as a porcelain command', () => { - const fx = fixture(); - try { - const result = spawnSync(process.execPath, [path.join(repoRoot, 'bin', 'agentic-kit.mjs'), 'audit', 'hooks', '--json'], { - cwd: fx.project, - env: spawnEnv(path.join(fx.root, 'home'), { CODEX_HOME: fx.codexHome }), - encoding: 'utf8', - }); - assert.equal(result.status, 0, result.stderr || result.stdout); - const report = JSON.parse(result.stdout); - assert.equal(report.mode, 'read-only'); - assert.equal(report.summary.automaticActions, 0); - } finally { - fs.rmSync(fx.root, { recursive: true, force: true }); - } +test('ak audit hooks exposes the read-only audit as a porcelain command', (t) => { + const fx = cliFixture(t); + const result = spawnSync(process.execPath, ['--require', fx.preload, path.join(repoRoot, 'bin', 'agentic-kit.mjs'), 'audit', 'hooks', '--json'], { + cwd: fx.project, + env: spawnEnv(path.join(fx.root, 'home'), { CODEX_HOME: fx.codexHome }), + encoding: 'utf8', + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + const report = JSON.parse(result.stdout); + assert.equal(report.mode, 'read-only'); + assert.equal(report.summary.automaticActions, 0); + assert.equal(fs.readFileSync(fx.launches, 'utf8'), ''); }); -test('ak audit hooks human output is not followed by the generic network drift nudge', () => { - const fx = fixture(); - try { - const result = spawnSync(process.execPath, [path.join(repoRoot, 'bin', 'agentic-kit.mjs'), 'audit', 'hooks'], { - cwd: fx.project, - env: spawnEnv(path.join(fx.root, 'home'), { CODEX_HOME: fx.codexHome }), - encoding: 'utf8', - timeout: 3_000, - }); - assert.equal(result.error, undefined, result.error?.message); - assert.equal(result.status, 0, result.stderr || result.stdout); - assert.match(result.stdout, /trust: unchanged/); - } finally { - fs.rmSync(fx.root, { recursive: true, force: true }); - } +test('ak audit hooks human output is not followed by the generic network drift nudge', (t) => { + const fx = cliFixture(t); + const result = spawnSync(process.execPath, ['--require', fx.preload, path.join(repoRoot, 'bin', 'agentic-kit.mjs'), 'audit', 'hooks'], { + cwd: fx.project, + env: spawnEnv(path.join(fx.root, 'home'), { CODEX_HOME: fx.codexHome }), + encoding: 'utf8', + timeout: 3_000, + }); + assert.equal(result.error, undefined, result.error?.message); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /trust: unchanged/); + assert.equal(fs.readFileSync(fx.launches, 'utf8'), '', 'audit must not launch the network drift nudge'); }); diff --git a/tests/kit/host-pick-rerecord.test.mjs b/tests/kit/host-pick-rerecord.test.mjs index 72887e89..fe755f45 100644 --- a/tests/kit/host-pick-rerecord.test.mjs +++ b/tests/kit/host-pick-rerecord.test.mjs @@ -1,5 +1,6 @@ -import { test, after } from 'node:test'; +import { test, after, beforeEach } from 'node:test'; import assert from 'node:assert/strict'; +import { format } from 'node:util'; import { sandboxHome, rmrf, writeKitConfig, offlineKitConfig } from './helpers/home-sandbox.mjs'; const home = sandboxHome('ak-host-pick-rerecord'); @@ -8,6 +9,10 @@ const host = await import('../../src/commands/x/host.mjs'); const cfg = { integrations: { hosts: { claude: false, codex: true, opencode: false } } }; const cwd = '/disposable-project'; +// Node 22 can misread Unicode stdout between binary test events (nodejs/node#65934). +// Keep the messages visible, but frame them as test diagnostics; restore per test. +beforeEach(t => t.mock.method(console, 'log', (...args) => t.diagnostic(format(...args)))); + for (const [name, initial, ok, expected] of [ ['successful install', 'absent', true, 2], ['failed install', 'absent', false, 1], diff --git a/tests/kit/intel-history.test.mjs b/tests/kit/intel-history.test.mjs index 4a7bc844..603cbcfa 100644 --- a/tests/kit/intel-history.test.mjs +++ b/tests/kit/intel-history.test.mjs @@ -291,12 +291,12 @@ test('readMachineWideIntel aggregates totals and perProject rows across multiple { path: cwdAlpha, label: 'Alpha', key: null, learningScope: 'repository', patternsLearned: 10, patternStoreCount: 2, trajectoriesRecorded: 4, graphLatest: { nodes: 5, edges: 8 }, lastAdaptation: 1000, - learningState: [], + learningState: [], hosts: [], sessionOrigins: [], sessionSurfaces: null, }, { path: cwdBeta, label: 'Beta', key: null, learningScope: 'repository', patternsLearned: 20, patternStoreCount: 3, trajectoriesRecorded: 6, graphLatest: null, lastAdaptation: 2000, - learningState: [], + learningState: [], hosts: [], sessionOrigins: [], sessionSurfaces: null, }, ]); }); @@ -357,10 +357,12 @@ test('readMachineWideIntel degrades a project with missing/malformed data to nul assert.deepEqual(result.perProject[1], { path: cwdEmpty, label: 'Empty', key: null, learningScope: 'unknown', patternsLearned: null, patternStoreCount: 0, trajectoriesRecorded: null, graphLatest: null, lastAdaptation: null, learningState: [], + hosts: [], sessionOrigins: [], sessionSurfaces: null, }); assert.deepEqual(result.perProject[2], { path: cwdMalformed, label: 'Malformed', key: null, learningScope: 'unknown', patternsLearned: null, patternStoreCount: 0, trajectoriesRecorded: null, graphLatest: null, lastAdaptation: null, learningState: [], + hosts: [], sessionOrigins: [], sessionSurfaces: null, }); }); @@ -439,3 +441,10 @@ test('a project row with no learningState degrades to [] rather than undefined', assert.deepEqual(readMachineWideIntel([row]).perProject[0].learningState, []); } }); + +test('readMachineWideIntel passes through declared session presentation evidence', () => { + const evidence = { hosts: ['codex'], sessionOrigins: [{ origin: 'unknown', sessions: 2 }], + sessionSurfaces: [{ host: 'codex', surface: 'codex-cli', sessions: 2 }] }; + const [row] = readMachineWideIntel([{ path: tmp(), label: 'CLI project', ...evidence }]).perProject; + assert.deepEqual({ hosts: row.hosts, sessionOrigins: row.sessionOrigins, sessionSurfaces: row.sessionSurfaces }, evidence); +}); diff --git a/tests/kit/intelligence-picker-groups.test.mjs b/tests/kit/intelligence-picker-groups.test.mjs index b0d31bd6..c02d4d54 100644 --- a/tests/kit/intelligence-picker-groups.test.mjs +++ b/tests/kit/intelligence-picker-groups.test.mjs @@ -28,15 +28,15 @@ test('should_segment_and-alphabetize picker options with the same designations a { key: 'd', label: 'g-p-opaque', learningScope: 'unknown', learningOrigins: ['codex-desktop'] }, ] }); const html = elements['intel-project-select'].innerHTML; - for (const label of ['Git repositories', 'Git worktrees', 'User-level learning', 'Other / unclassified']) { + for (const label of ['Git repositories', 'Git worktrees', 'User-level learning', 'Unknown']) { assert.ok(html.includes(``)); } assert.ok(html.indexOf('value="a"') < html.indexOf('value="z"')); assert.match(html, /value="z" selected>Zulu — Git repository { diff --git a/tests/kit/intelligence-table-groups.test.mjs b/tests/kit/intelligence-table-groups.test.mjs index 25b02883..02d7af7a 100644 --- a/tests/kit/intelligence-table-groups.test.mjs +++ b/tests/kit/intelligence-table-groups.test.mjs @@ -1,3 +1,4 @@ +import * as vocabulary from '../../src/lib/session-surface.mjs'; import { test } from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; @@ -23,8 +24,9 @@ test('machine-wide inventory alphabetizes every retained row and preserves KPI t const source = fs.readFileSync(new URL('../../src/lib/dashboard/client/intelligence.mjs', import.meta.url), 'utf8') .replace(/^import .*;$/gm, '').replace(/\bexport /g, ''); const esc = (value) => String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"'); - const context = vm.createContext({ document: { getElementById: (id) => elements[id] }, esc, + const context = vm.createContext({ ...vocabulary, document: { getElementById: (id) => elements[id] }, esc, fmtNum: (value) => String(value ?? 0), kpi: (label, value) => `${label}:${value};` }); + vm.runInContext(fs.readFileSync(new URL('../../src/lib/dashboard/client/session-presentation.mjs', import.meta.url), 'utf8').replace(/^import .*;$/gm, '').replace(/\bexport /g, ''), context); vm.runInContext(`${source}\nglobalThis.renderTable=renderMachineWide;`, context); const perProject = Array.from({ length: 8 }, (_, i) => ({ key: `key-${i}`, label: `Repository ${8 - i}`, learningScope: 'repository', patternsLearned: i, patternStoreCount: 1 })); @@ -46,8 +48,9 @@ test('machine-wide inventory renders one designation column and filter pills for const source = fs.readFileSync(new URL('../../src/lib/dashboard/client/intelligence.mjs', import.meta.url), 'utf8') .replace(/^import .*;$/gm, '').replace(/\bexport /g, ''); const esc = (value) => String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"'); - const context = vm.createContext({ document: { getElementById: (id) => elements[id] }, esc, + const context = vm.createContext({ ...vocabulary, document: { getElementById: (id) => elements[id] }, esc, fmtNum: (value) => String(value ?? 0), kpi: (label, value) => `${label}:${value};` }); + vm.runInContext(fs.readFileSync(new URL('../../src/lib/dashboard/client/session-presentation.mjs', import.meta.url), 'utf8').replace(/^import .*;$/gm, '').replace(/\bexport /g, ''), context); vm.runInContext(`${source}\nglobalThis.renderTable=renderMachineWide;`, context); context.renderTable({ totals: { patternsLearnedLifetime: 2, projectCount: 2, mostActiveProject: 'Repository' }, perProject: [ { key: 'repo', label: 'Repository', learningScope: 'repository', patternsLearned: 1, patternStoreCount: 1 }, @@ -56,6 +59,7 @@ test('machine-wide inventory renders one designation column and filter pills for const html = elements['mw-table'].innerHTML; assert.match(html, /Designation/); assert.match(html, /Git repository/); - assert.match(html, /ChatGPT Desktop/); + assert.match(html, /Unknown/); + assert.doesNotMatch(html, /ChatGPT Desktop/); assert.match(html, /mw-filter-pill/); }); diff --git a/tests/kit/live-process-sessions.test.mjs b/tests/kit/live-process-sessions.test.mjs index 9b1bf1e4..b3cf3bc1 100644 --- a/tests/kit/live-process-sessions.test.mjs +++ b/tests/kit/live-process-sessions.test.mjs @@ -13,6 +13,12 @@ test('host process detection recognizes controllers and rejects helpers', () => assert.equal(hostFromCommand('node /opt/bin/codex'), 'codex'); assert.equal(hostFromCommand('/usr/local/bin/opencode --continue'), 'opencode'); assert.equal(hostFromCommand('codex mcp-server'), null); + assert.equal(hostFromCommand('codex -s read-only mcp-server'), null); + assert.equal(hostFromCommand('node /opt/bin/codex -c model="test" mcp-server'), null); + assert.equal(hostFromCommand('codex -c developer_instructions="say mcp-server hello"'), 'codex'); + assert.equal(hostFromCommand('codex --config=developer_instructions="say mcp-server hello"'), 'codex'); + assert.equal(hostFromCommand('codex --config developer_instructions=say mcp-server hello'), 'codex', + 'a flattened unquoted config value cannot prove an MCP subcommand'); assert.equal(hostFromCommand('/opt/bin/codex-code-mode-host'), null); assert.equal(hostFromCommand('node app.mjs codex'), null); assert.equal(hostFromCommand('python worker.py claude'), null); @@ -60,7 +66,7 @@ test('runtime survey keeps top-level sessions and folds nested host workers into test('runtime survey classifies host services and desktop apps without retaining argv', async () => { const startedAt = 'Mon Aug 3 12:00:00 2026'; const processRows = parseProcessList([ - `100 1 ${startedAt} /Applications/ChatGPT.app/Contents/Resources/codex /Applications/ChatGPT.app/Contents/Resources/codex app-server`, + `100 1 ${startedAt} /Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex /Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex app-server`, `200 1 ${startedAt} /Users/me/.codex/plugins/.plugin-appserver/codex /Users/me/.codex/plugins/.plugin-appserver/codex app-server`, `300 1 ${startedAt} /Applications/Claude.app/Contents/MacOS/Claude /Applications/Claude.app/Contents/MacOS/Claude`, `400 1 ${startedAt} /usr/local/bin/claude claude`, @@ -81,6 +87,58 @@ test('runtime survey classifies host services and desktop apps without retaining 'classification emits an enum, never the potentially sensitive argv'); }); +test('Codex global options before app-server identify a service, never a session', async () => { + const startedAt = 'Mon Aug 3 12:00:00 2026'; + const app = '/Applications/ChatGPT.app/Contents/MacOS/ChatGPT'; + const bundled = '/Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex'; + const processRows = [ + { pid: 10, ppid: 1, startedAt, executable: '/usr/local/bin/codex', + command: 'codex -s read-only -a never app-server' }, + { pid: 20, ppid: 1, startedAt, executable: '/usr/local/bin/codex', + command: 'codex --config model="test" app-server' }, + { pid: 30, ppid: 1, startedAt, executable: app, command: `${app} app-server` }, + { pid: 31, ppid: 30, startedAt, executable: bundled, + command: `${bundled} --config=model="test" --strict-config app-server` }, + { pid: 40, ppid: 1, startedAt, executable: '/usr/local/bin/codex', + command: 'codex --config app-server' }, + { pid: 50, ppid: 1, startedAt, executable: '/usr/local/bin/codex', + command: 'codex --unknown value app-server' }, + { pid: 60, ppid: 1, startedAt, executable: '/usr/local/bin/codex', + command: 'codex -- app-server' }, + { pid: 70, ppid: 1, startedAt, executable: '/usr/local/bin/codex', + command: 'codex --model app-server' }, + { pid: 80, ppid: 1, startedAt, executable: '/usr/local/bin/codex', + command: 'codex --config malformed app-server' }, + { pid: 90, ppid: 1, startedAt, executable: '/usr/local/bin/codex', + command: 'codex -c developer_instructions="say app-server hello"' }, + { pid: 91, ppid: 1, startedAt, executable: '/usr/local/bin/codex', + command: 'codex --config=developer_instructions="say app-server hello"' }, + { pid: 92, ppid: 1, startedAt, executable: '/usr/local/bin/codex', + command: 'codex --config developer_instructions=say app-server hello' }, + { pid: 93, ppid: 1, startedAt, executable: '/usr/local/bin/codex', + command: 'codex --config developer_instructions="say app-server hello" app-server' }, + ]; + const cwdByPid = new Map(processRows.map((row) => [row.pid, `/repos/${row.pid}`])); + const survey = await surveyHostProcesses({ platform: 'darwin', processRows, cwdByPid, + metricsByPid: new Map() }); + assert.deepEqual(survey.processes.map(({ pid, controllerKind }) => ({ pid, controllerKind })), [ + { pid: 10, controllerKind: 'host-service' }, + { pid: 20, controllerKind: 'host-service' }, + { pid: 30, controllerKind: 'desktop-app' }, + { pid: 40, controllerKind: 'project-session' }, + { pid: 50, controllerKind: 'project-session' }, + { pid: 60, controllerKind: 'project-session' }, + { pid: 70, controllerKind: 'project-session' }, + { pid: 80, controllerKind: 'project-session' }, + { pid: 90, controllerKind: 'project-session' }, + { pid: 91, controllerKind: 'project-session' }, + { pid: 92, controllerKind: 'project-session' }, + { pid: 93, controllerKind: 'host-service' }, + ]); + assert.deepEqual((await listActiveHostSessions({ platform: 'darwin', processRows, cwdByPid, + inspectWorkspace: async () => null })).map(({ pid }) => pid), [40, 50, 60, 70, 80, 90, 91, 92]); +}); + // macOS `ps -o comm=` prints the executable's full path, and many real paths // contain spaces (`Application Support`, `Visual Studio Code.app`). The Claude // desktop app hosts its own Claude Code CLI under such a path (#238 item 3). @@ -127,6 +185,39 @@ test('a Claude Code CLI hosted by the Claude desktop app is its own project sess .map((session) => ({ pid: session.pid, host: session.host })), [{ pid: 200, host: 'claude' }]); }); +test('both desktop applications remain applications while their bundled CLIs are sessions', async () => { + const startedAt = 'Mon Aug 3 12:00:00 2026'; + const claudeApp = '/Applications/Claude.app/Contents/MacOS/Claude'; + const chatgptApp = '/Applications/ChatGPT.app/Contents/MacOS/ChatGPT'; + const codexCli = '/Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex'; + const rows = [ + { pid: 100, ppid: 1, startedAt, executable: claudeApp, command: claudeApp }, + { pid: 110, ppid: 100, startedAt, executable: DESKTOP_CLI, command: DESKTOP_CLI }, + { pid: 120, ppid: 110, startedAt, executable: '/usr/local/bin/codex', command: 'codex exec review' }, + { pid: 200, ppid: 1, startedAt, executable: chatgptApp, command: chatgptApp }, + { pid: 210, ppid: 200, startedAt, executable: codexCli, command: `${codexCli} --model test` }, + { pid: 220, ppid: 200, startedAt, executable: codexCli, command: `${codexCli} app-server` }, + { pid: 300, ppid: 1, startedAt, executable: '/usr/local/bin/codex', command: 'codex' }, + { pid: 400, ppid: 1, startedAt, executable: '/Applications/Other.app/Contents/MacOS/codex', command: 'codex' }, + { pid: 500, ppid: 1, startedAt, executable: '/usr/local/bin/claude', command: 'claude --prompt app-server /Applications/ChatGPT.app/Contents/Resources/codex-cli/bin/codex' }, + ]; + const cwdByPid = new Map([...rows.map((row) => [row.pid, `/repos/${row.pid}`])]); + const survey = await surveyHostProcesses({ platform: 'darwin', processRows: rows, + cwdByPid, metricsByPid: new Map() }); + assert.deepEqual(survey.processes.map(({ pid, host, application, controllerKind }) => + ({ pid, host, application, controllerKind })), [ + { pid: 100, host: null, application: 'Claude Desktop', controllerKind: 'desktop-app' }, + { pid: 110, host: 'claude', application: null, controllerKind: 'project-session' }, + { pid: 200, host: null, application: 'ChatGPT desktop app', controllerKind: 'desktop-app' }, + { pid: 210, host: 'codex', application: null, controllerKind: 'project-session' }, + { pid: 300, host: 'codex', application: null, controllerKind: 'project-session' }, + { pid: 500, host: 'claude', application: null, controllerKind: 'project-session' }, + ]); + const sessions = await listActiveHostSessions({ platform: 'darwin', processRows: rows, + cwdByPid, inspectWorkspace: async () => null }); + assert.deepEqual(sessions.map(({ pid }) => pid), [110, 210, 300, 500]); +}); + test('a host CLI nested under an ordinary controller still folds into it', async () => { const startedAt = 'Mon Aug 3 12:00:00 2026'; const processRows = [ @@ -177,11 +268,34 @@ test('the POSIX survey finds a desktop-hosted CLI end to end through ps output w }); assert.deepEqual(sessions.map((session) => ({ pid: session.pid, host: session.host })), [ { pid: 200, host: 'claude' }, - { pid: 300, host: 'claude' }, ]); assert.equal(calls[1].args[1], '300,200', 'argv is still fetched only for host candidates'); }); +test('the POSIX header and targeted argv passes discover the ChatGPT bundled Codex CLI', async () => { + const startedAt = 'Mon Aug 3 12:00:00 2026'; + const app = '/Applications/ChatGPT.app/Contents/MacOS/ChatGPT'; + const cli = '/Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex'; + const calls = []; + const execFileImpl = async (_command, args) => { + calls.push(args); + if (args.includes('pid=,ppid=,lstart=,comm=')) return { stdout: [ + `100 1 ${startedAt} ${app}`, + `110 100 ${startedAt} ${cli}`, + `200 1 ${startedAt} /Applications/Other.app/Contents/MacOS/Other`, + ].join('\n') }; + if (args.includes('pid=,args=')) return { stdout: `100 ${app}\n110 ${cli} exec review\n` }; + throw new Error('unexpected process probe'); + }; + const survey = await surveyHostProcesses({ platform: 'darwin', uid: 501, execFileImpl, + cwdByPid: new Map([[100, '/'], [110, '/repos/work']]), metricsByPid: new Map() }); + assert.deepEqual(survey.processes.map(({ pid, host, application }) => ({ pid, host, application })), [ + { pid: 100, host: null, application: 'ChatGPT desktop app' }, + { pid: 110, host: 'codex', application: null }, + ]); + assert.equal(calls[1][1], '100,110', 'unknown applications never reach the argv pass'); +}); + test('workspace inspection is shared consistently across Claude, Codex, and OpenCode', async () => { const startedAt = 'Mon Aug 3 12:00:00 2026'; const processRows = parseProcessList([ diff --git a/tests/kit/maintenance-dashboard-v2-api.test.mjs b/tests/kit/maintenance-dashboard-v2-api.test.mjs index a8edec94..9e96bcca 100644 --- a/tests/kit/maintenance-dashboard-v2-api.test.mjs +++ b/tests/kit/maintenance-dashboard-v2-api.test.mjs @@ -588,7 +588,7 @@ test('v2 inventory projection keeps the nine inspector sections and the page env assert.equal(page.groups.length, 1); assert.deepEqual(Object.keys(page.groups[0].placements[0]).sort(), [ 'breadcrumb', 'carrier', 'consumerHosts', 'displayName', 'guidanceLane', 'kind', 'placementId', 'projectId', 'projectKind', - 'repositoryEvidence', 'repositoryId', 'repositoryLabel', 'repositoryObservedAt', 'rowAction', 'scope', 'sessionOrigins', 'versions', + 'repositoryEvidence', 'repositoryId', 'repositoryLabel', 'repositoryObservedAt', 'rowAction', 'scope', 'sessionOrigins', 'sessionSurfaces', 'versions', ]); const inspector = publicInspector(inspectorFor(inventory, PLACEMENT)); assert.deepEqual(Object.keys(inspector).sort(), [ diff --git a/tests/kit/maintenance-focus-client.test.mjs b/tests/kit/maintenance-focus-client.test.mjs index 6f778dca..e04c1cc3 100644 --- a/tests/kit/maintenance-focus-client.test.mjs +++ b/tests/kit/maintenance-focus-client.test.mjs @@ -1,13 +1,15 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; +import * as vocabulary from '../../src/lib/session-surface.mjs'; import { mntLanguageLogo } from '../../src/lib/dashboard/client/maintenance-language-logos.mjs'; const esc=s=>String(s).replace(/&/g,'&').replace(/s,mntFacetValueLabel:(_,v)=>v,mntIcon:()=>'',mntProjectKindBadge:kind=>esc(kind),mntAvailableTo:()=>''},['mntFocusChoose','mntFocusBack','mntFocusCrumbs','renderMntFocusResults']);} +const { projectSurfacesHtml } = load('session-presentation', { ...vocabulary, esc }, ['projectSurfacesHtml']); +function focus(state){return load('maintenance-focus',{MNT:state,esc,projectSurfacesHtml,mntLanguageLogo,MNT_SCOPE_LABELS:{user:'User',across:'All scopes',project:'Projects'},mntKindLabel:s=>s,mntFacetValueLabel:(_,v)=>v,mntIcon:()=>'',mntProjectKindBadge:kind=>esc(kind),mntAvailableTo:()=>''},['mntFocusChoose','mntFocusBack','mntFocusCrumbs','renderMntFocusResults']);} test('navigation turns User and resource type into explicit filters while retaining host refinements',()=>{ const state={scope:'across',facets:{consumer:['claude']}};const api=focus(state); api.mntFocusChoose('scope','user');api.mntFocusChoose('kind','mcp-registration');api.mntFocusChoose('resource','res_1'); diff --git a/tests/kit/maintenance-project-grouping.test.mjs b/tests/kit/maintenance-project-grouping.test.mjs index 4169a9c5..d8a30138 100644 --- a/tests/kit/maintenance-project-grouping.test.mjs +++ b/tests/kit/maintenance-project-grouping.test.mjs @@ -1,9 +1,14 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import vm from 'node:vm'; import { test } from 'node:test'; import assert from 'node:assert/strict'; import { buildManagementInventory } from '../../src/lib/maintenance/management/projection.mjs'; import { runInventoryQuery } from '../../src/lib/maintenance/management/query.mjs'; import { publicInventoryPage } from '../../src/lib/dashboard/maintenance-api.mjs'; import { validateMaintenanceV2Query } from '../../src/lib/dashboard/maintenance-security.mjs'; +import { discoverProjectSources } from '../../src/lib/footprint/project-sources.mjs'; +import { tempDir } from './helpers/temp-dir.mjs'; const options = { installationKey: 'maintenance-grouping-fixture-key', environment: { platform: 'darwin' }, now: () => 1700000000000 }; const repository = { kind: 'git', repositoryId: 'repository:0123456789abcdef0123', root: '/work/repo', @@ -62,3 +67,103 @@ test('should_report_session_counts_once_per_project_despite_multiple_installed_r assert.equal(page.navigation.nodes[0].count, 2); assert.equal(page.navigation.nodes[0].sessionOrigins[0].sessions, 7); }); +test('project census count basis survives discovery, management, and API without changing legacy meanings', (t) => { + // Catalog project roots are native absolute paths, just like discovery output. + const project = path.join(tempDir('ak-census-grouping', t), 'census-project'); + const discovered = discoverProjectSources({ + scanTranscripts: (_root, host) => ({ complete: true, sightings: host === 'claude' + ? [{ cwd: project, weight: 2, sessionOrigin: { origin: 'claude-desktop', evidence: 'declared' } }] + : [{ cwd: project, weight: 3, sessionOrigin: { origin: 'codex-desktop', evidence: 'declared' } }] }), + scanOpencode: () => ({ complete: true, sightings: [] }), + }); + const source = discovered.projects[0]; + assert.equal(source.path, project); + assert.deepEqual(source.sessionOrigins.map(({ countBasis, sessions }) => [countBasis, sessions]), + [['declared-session-ids', 2], ['transcript-files', 3]]); + const page = publicInventoryPage(runInventoryQuery(build({ projects: [], discoveryProjects: [source] }, [project]), + { scope: 'project', presentation: 'focus' })); + assert.deepEqual(page.navigation.nodes[0].sessionOrigins, + [{ origin: 'claude-desktop', sessions: 2, countBasis: 'declared-session-ids' }, + { origin: 'codex-desktop', sessions: 3, countBasis: 'transcript-files' }]); +}); +test('legacy basis and zero recovery keep their exact meaning; invalid basis is omitted', () => { + const project = '/legacy-project'; + const origins = [ + { origin: 'claude-desktop', sessions: 4, countBasis: 'transcript-files' }, + { origin: 'codex-desktop', sessions: 2, countBasis: 'not-a-basis' }, + { origin: 'unknown', sessions: 0, countBasis: 'recovered-project-sighting' }, + ]; + const page = publicInventoryPage(runInventoryQuery(build({ projects: [], discoveryProjects: [ + { path: project, sessionOrigins: origins }, + ] }, [project]), { scope: 'project', presentation: 'focus' })); + assert.deepEqual(page.navigation.nodes[0].sessionOrigins, [ + origins[0], { origin: 'codex-desktop', sessions: 2 }, origins[2], + ]); +}); +test('surface evidence survives public focus and row DTOs and facets exclude zero recovery observations', () => { + const sessionSurfaces = [{ host: 'codex', surface: 'chatgpt-desktop-work', initiator: 'agent', sessions: 2, + countBasis: 'transcript-files', rawEvidence: { originator: ['codex_work_desktop'] } }, + { host: 'claude', surface: 'cloud-session', initiator: 'automation', sessions: 0, countBasis: 'recovered-project-sighting' }]; + const inventory = build({ projects: [], discoveryProjects: [{ path: '/project', repository, sessionSurfaces }] }, ['/project']); + const page = publicInventoryPage(runInventoryQuery(inventory, { scope: 'project', presentation: 'focus', facets: { sessionOrigin: ['chatgpt-desktop-work'] } })); + assert.equal(page.total, 1); + assert.equal(page.navigation.nodes[0].sessionSurfaces[0].host, 'claude'); + assert.deepEqual(page.navigation.nodes[0].sessionSurfaces[1].rawEvidence.originator, ['codex_work_desktop']); + assert.equal(runInventoryQuery(inventory, { scope: 'project', facets: { sessionOrigin: ['cloud-session'] } }).total, 0); +}); + +for (const stateSource of ['saved preference', 'bookmarked hash']) { + test(`legacy desktop filter from ${stateSource} retains its exact membership`, () => { + const context = vm.createContext({ URLSearchParams, localStorage: { getItem: () => null } }); + const source = fs.readFileSync(new URL('../../src/lib/dashboard/client/maintenance-workspace.mjs', import.meta.url), 'utf8') + .replace(/^import\s[\s\S]*?from ['"][^'"]+['"];\s*$/gm, '').replace(/\bexport (?=(?:function|var)\b)/g, ''); + vm.runInContext(source, context); + if (stateSource === 'saved preference') context.mntApplyPreferredState({ lastView: { scope: 'project', facets: { sessionOrigin: ['codex-desktop'] } } }); + else context.mntApplyState(context.mntParseHashParts(['system', 'maintenance', 'inventory?scope=project&facet.sessionOrigin=codex-desktop'])); + const restored = JSON.parse(JSON.stringify(context.MNT)); + assert.deepEqual(restored.facets.sessionOrigin, ['codex-desktop']); + const rows = [{ path: '/desktop', sessionOrigins: [{ origin: 'codex-desktop', sessions: 3 }] }, + { path: '/unknown', sessionOrigins: [{ origin: 'unknown', sessions: 1 }] }]; + const inventory = build({ projects: [], discoveryProjects: rows }, rows.map((row) => row.path)); + const result = publicInventoryPage(runInventoryQuery(inventory, { scope: restored.scope, facets: restored.facets, presentation: 'focus' })); + assert.equal(result.total, 1); + assert.equal(result.navigation.nodes[0].sessionOrigins[0].origin, 'codex-desktop'); + assert.equal(result.navigation.nodes[0].sessionSurfaces, null); + rows[0].sessionSurfaces = [{ host: 'codex', surface: 'chatgpt-desktop-work', sessions: 3 }]; + const refreshed = build({ projects: [], discoveryProjects: rows }, rows.map((row) => row.path)); + assert.equal(runInventoryQuery(refreshed, { scope: restored.scope, facets: restored.facets }).total, 1, + 'refreshing to the richer contract must not invalidate the saved legacy filter'); + }); +} + +for (const stateSource of ['saved preference', 'bookmarked hash']) { + test(`legacy Unknown filter from ${stateSource} retains membership after surface refresh`, () => { + const context = vm.createContext({ URLSearchParams, localStorage: { getItem: () => null } }); + const source = fs.readFileSync(new URL('../../src/lib/dashboard/client/maintenance-workspace.mjs', import.meta.url), 'utf8') + .replace(/^import\s[\s\S]*?from ['"][^'"]+['"];\s*$/gm, '').replace(/\bexport (?=(?:function|var)\b)/g, ''); + vm.runInContext(source, context); + if (stateSource === 'saved preference') context.mntApplyPreferredState({ lastView: { scope: 'project', facets: { sessionOrigin: ['unknown'] } } }); + else context.mntApplyState(context.mntParseHashParts(['system', 'maintenance', 'inventory?scope=project&facet.sessionOrigin=unknown'])); + const restored = JSON.parse(JSON.stringify(context.MNT)); + assert.deepEqual(restored.facets.sessionOrigin, ['unknown']); + const rows = ['codex-cli', 'codex-ide', 'unknown'].map((surface) => ({ path: '/' + surface, + sessionOrigins: [{ origin: 'unknown', sessions: 1 }] })); + rows.push({ path: '/desktop', sessionOrigins: [{ origin: 'codex-desktop', sessions: 1 }] }); + const query = validateMaintenanceV2Query('inventory', new URLSearchParams('scope=project&facet.sessionOrigin=unknown')); + const before = build({ projects: [], discoveryProjects: rows }, rows.map((row) => row.path)); + assert.equal(runInventoryQuery(before, query).total, 3); + rows.forEach((row, i) => { row.sessionSurfaces = [{ host: 'codex', surface: ['codex-cli', 'codex-ide', 'unknown', 'chatgpt-desktop-work'][i], sessions: 1 }]; }); + const after = build({ projects: [], discoveryProjects: rows }, rows.map((row) => row.path)); + assert.equal(runInventoryQuery(after, { scope: restored.scope, facets: restored.facets }).total, 3); + assert.equal(runInventoryQuery(after, query).total, 3); + assert.equal(runInventoryQuery(after, { scope: 'project', facets: { sessionOrigin: ['surface-unknown'] } }).total, 1); + }); +} + +test('implicit legacy Unknown membership also survives richer surface evidence', () => { + const row = { path: '/no-origin' }; + const query = { scope: 'project', facets: { sessionOrigin: ['unknown'] } }; + assert.equal(runInventoryQuery(build({ projects: [], discoveryProjects: [row] }, [row.path]), query).total, 1); + row.sessionSurfaces = [{ host: 'codex', surface: 'codex-cli', sessions: 1 }]; + assert.equal(runInventoryQuery(build({ projects: [], discoveryProjects: [row] }, [row.path]), query).total, 1); +}); diff --git a/tests/kit/project-sources-claude-sessions.test.mjs b/tests/kit/project-sources-claude-sessions.test.mjs new file mode 100644 index 00000000..ea1cb034 --- /dev/null +++ b/tests/kit/project-sources-claude-sessions.test.mjs @@ -0,0 +1,107 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { tempDir } from './helpers/temp-dir.mjs'; +import { scanTranscriptCwds, discoverProjectSources } from '../../src/lib/footprint/project-sources.mjs'; + +function write(root, group, name, records) { + const file = path.join(root, group, name); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${records.map((record) => JSON.stringify(record)).join('\n')}\n`); +} + +test('Claude project census counts declared sessions and excludes bridge and subagent files', () => { + const root = tempDir('ak-claude-census'); + const claudeRoot = path.join(root, 'claude'); + const a = path.join(root, 'a'); + const b = path.join(root, 'b'); + fs.mkdirSync(a); fs.mkdirSync(b); + write(claudeRoot, 'a', '1.jsonl', [{ type: 'user', sessionId: 'one', cwd: a }]); + write(claudeRoot, 'a', '2.jsonl', [{ type: 'assistant', sessionId: 'two', cwd: a }]); + write(claudeRoot, 'a', '3.jsonl', [{ type: 'user', sessionId: 'one', cwd: b }]); + write(claudeRoot, 'a', '4.jsonl', [{ type: 'user', cwd: a }]); + write(claudeRoot, 'a', '5.jsonl', [{ type: 'user', sessionId: 12, cwd: a }]); + write(claudeRoot, 'a', '6.jsonl', [{ type: 'bridge-session', sessionId: 'bridge', cwd: b }]); + write(claudeRoot, 'a', '7.jsonl', [{ type: 'cost-state', sessionId: 'cost', cwd: b }]); + write(claudeRoot, 'a/one/subagents', 'agent-a.jsonl', [ + { type: 'assistant', sessionId: 'child', cwd: b, isSidechain: true }, + ]); + const scan = scanTranscriptCwds(claudeRoot, 'claude'); + assert.deepEqual({ files: scan.files, sessions: scan.sessions, duplicate: scan.duplicateSessionFiles, + subagents: scan.subagentExcluded, nonConversation: scan.nonConversationExcluded, + unknown: scan.unknownSessionFiles }, + { files: 8, sessions: 2, duplicate: 1, subagents: 1, nonConversation: 2, unknown: 2 }); + const result = discoverProjectSources({ claudeRoot, codexRoot: path.join(root, 'no-codex'), + scanOpencode: () => ({ sightings: [], complete: true }) }); + assert.equal(result.projects.length, 1); + assert.equal(result.projects[0].sessions, 2); + assert.equal(result.projects[0].sessionOrigins[0].countBasis, 'declared-session-ids'); +}); + +test('an incomplete head with cwd preserves project evidence but does not invent a session', () => { + const root = tempDir('ak-claude-head'); + const claudeRoot = path.join(root, 'claude'); + write(claudeRoot, 'a', 'partial.jsonl', [ + { type: 'system', cwd: root, sessionId: 'later' }, + { type: 'user', cwd: root, sessionId: 'later' }, + ]); + const scan = scanTranscriptCwds(claudeRoot, 'claude', { maxLines: 1 }); + assert.equal(scan.sessions, 0); + assert.equal(scan.unknownSessionFiles, 1); + assert.equal(scan.sightings[0].weight, 0); + assert.equal(scan.withCwd, 1); +}); + +test('excluded-only folder cannot become a project through encoded directory recovery', () => { + const root = tempDir('ak-claude-excluded'); + const claudeRoot = path.join(root, 'claude'); + write(claudeRoot, 'bridge-only', 'bridge.jsonl', [{ type: 'bridge-session', sessionId: 'bridge' }]); + write(claudeRoot, 'bridge-only/parent/subagents', 'agent-a.jsonl', + [{ type: 'assistant', sessionId: 'child', isSidechain: true }]); + const scan = scanTranscriptCwds(claudeRoot, 'claude', { decodeDir: () => root }); + assert.equal(scan.recoveredFromDirName, 0); + assert.equal(scan.unresolved, 0); + assert.deepEqual(scan.sightings, []); +}); + +test('encoded directory recovery is project evidence with zero verified sessions', () => { + const root = tempDir('ak-claude-recovery'); + const claudeRoot = path.join(root, 'claude'); + write(claudeRoot, 'legacy', 'unknown.jsonl', [{ type: 'user' }]); + const scan = scanTranscriptCwds(claudeRoot, 'claude', { decodeDir: () => root }); + assert.equal(scan.sessions, 0); + assert.equal(scan.unknownSessionFiles, 1); + assert.deepEqual(scan.sightings.map(({ origin, weight }) => [origin, weight]), [['encoded-dir', 0]]); + const discovered = discoverProjectSources({ claudeRoot, codexRoot: path.join(root, 'none'), + scanTranscripts: (source, host, options) => scanTranscriptCwds(source, host, { + ...options, decodeDir: host === 'claude' ? () => root : null, + }), scanOpencode: () => ({ sightings: [], complete: true }) }); + assert.equal(discovered.everSeen, 1); + assert.equal(discovered.projects[0].sessions, 0); + assert.equal(discovered.projects[0].sessionOrigins[0].sessions, 0); +}); + +test('bridge marker in a bounded head cannot exclude a later conversation', () => { + const root = tempDir('ak-claude-bridge-head'); + const claudeRoot = path.join(root, 'claude'); + write(claudeRoot, 'a', 'bridge.jsonl', [ + { type: 'bridge-session', sessionId: 'one', cwd: root }, + { type: 'user', sessionId: 'one', cwd: root }, + ]); + const scan = scanTranscriptCwds(claudeRoot, 'claude', { maxLines: 1 }); + assert.equal(scan.nonConversationExcluded, 0); + assert.equal(scan.unknownSessionFiles, 1); + assert.equal(scan.sessionCountComplete, false); + assert.equal(scan.sightings[0].weight, 0); +}); + +test('unreadable root cannot claim a complete zero-session count', () => { + const scan = scanTranscriptCwds('/unreadable', 'claude', { + walk: () => ({ status: 'unknown', reason: 'EACCES', complete: false }), + }); + assert.equal(scan.status, 'degraded'); + assert.equal(scan.sessions, 0); + assert.equal(scan.sessionCountComplete, false); + assert.equal(scan.complete, false); +}); diff --git a/tests/kit/quota-codex-presence.test.mjs b/tests/kit/quota-codex-presence.test.mjs index 133aef1a..75a319ff 100644 --- a/tests/kit/quota-codex-presence.test.mjs +++ b/tests/kit/quota-codex-presence.test.mjs @@ -63,7 +63,8 @@ function spawnSpy() { } const callLimits = (extra = {}) => readLimits({ - now: NOW, claudeFile, claudeSettingsFile, codexCacheFile: cacheFile, ...extra, + now: NOW, claudeFile, claudeSettingsFile, claudeManagedSettingsFile: null, + codexCacheFile: cacheFile, ...extra, }); test('found Codex, stale cache: one app-server call', async () => { diff --git a/tests/kit/quota.test.mjs b/tests/kit/quota.test.mjs index 965c38b7..3a837519 100644 --- a/tests/kit/quota.test.mjs +++ b/tests/kit/quota.test.mjs @@ -8,13 +8,18 @@ import path from 'node:path'; import { EventEmitter } from 'node:events'; import { windowLabel, normalizeClaudeLimits, normalizeCodexLimits, readClaudeLimits, - collectCodexLimits, CODEX_TTL_MS, unsupportedQuotaHosts, readLimits, - classifyClaudeTeeChannel, CLAUDE_TEE_CHANNELS, + collectCodexLimits, CODEX_TTL_MS, unsupportedQuotaHosts, readLimits as rawReadLimits, + classifyClaudeTeeChannel as rawClassifyClaudeTeeChannel, CLAUDE_TEE_CHANNELS, collectCodexLimitsDetailed, CODEX_UNAVAILABLE_REASONS, } from '../../src/lib/quota.mjs'; import { tempDir } from './helpers/temp-dir.mjs'; +import { claudeManagedSettingsPath } from '../../src/lib/paths.mjs'; const tmp = () => tempDir('ak-quota'); +const classifyClaudeTeeChannel = (options) => rawClassifyClaudeTeeChannel({ + managedSettingsFile: null, ...options, +}); +const readLimits = (options) => rawReadLimits({ claudeManagedSettingsFile: null, ...options }); // ── windowLabel — duration-derived, never slot-derived ─────────────────────── @@ -104,6 +109,206 @@ function teeFixture({ statusLine, raw, scripts = {} } = {}) { } const cmd = (command) => ({ type: 'command', command }); +test('managed statusLine overrides a user footer with a custom command', () => { + const fx = teeFixture({ statusLine: cmd('node ~/.claude/helpers/statusline.cjs'), + scripts: { '.claude/helpers/statusline.cjs': FOOTER_SCRIPT } }); + const managedSettingsFile = path.join(fx.home, 'managed-settings.json'); + fs.writeFileSync(managedSettingsFile, JSON.stringify({ statusLine: cmd('echo managed') })); + assert.equal(classifyClaudeTeeChannel({ + settingsFile: fx.settingsFile, managedSettingsFile, home: fx.home, + }), 'custom'); +}); + +test('managed footer overrides a custom user statusLine', () => { + const fx = teeFixture({ statusLine: cmd('echo user'), + scripts: { '.claude/helpers/statusline.cjs': FOOTER_SCRIPT } }); + const managedSettingsFile = path.join(fx.home, 'managed-settings.json'); + fs.writeFileSync(managedSettingsFile, JSON.stringify({ + statusLine: cmd('node ~/.claude/helpers/statusline.cjs'), + })); + assert.equal(classifyClaudeTeeChannel({ + settingsFile: fx.settingsFile, managedSettingsFile, home: fx.home, + }), 'kit-footer'); +}); + +test('missing managed file or unrelated managed keys preserve the user statusLine', () => { + const fx = teeFixture({ statusLine: cmd('echo user') }); + const managedSettingsFile = path.join(fx.home, 'managed-settings.json'); + assert.equal(classifyClaudeTeeChannel({ + settingsFile: fx.settingsFile, managedSettingsFile, home: fx.home, + }), 'custom'); + fs.writeFileSync(managedSettingsFile, JSON.stringify({ model: 'synthetic' })); + assert.equal(classifyClaudeTeeChannel({ + settingsFile: fx.settingsFile, managedSettingsFile, home: fx.home, + }), 'custom'); +}); + +test('invalid or unreadable managed file stays unknown instead of using the user footer', () => { + const fx = teeFixture({ statusLine: cmd('node ~/.claude/helpers/statusline.cjs'), + scripts: { '.claude/helpers/statusline.cjs': FOOTER_SCRIPT } }); + const managedSettingsFile = path.join(fx.home, 'managed-settings.json'); + for (const body of ['{broken', '[]', JSON.stringify({ statusLine: {} }), + JSON.stringify({ statusLine: 'invalid' })]) { + fs.writeFileSync(managedSettingsFile, body); + assert.equal(classifyClaudeTeeChannel({ + settingsFile: fx.settingsFile, managedSettingsFile, home: fx.home, + }), 'unknown'); + } + fs.rmSync(managedSettingsFile); + fs.mkdirSync(managedSettingsFile); + assert.equal(classifyClaudeTeeChannel({ + settingsFile: fx.settingsFile, managedSettingsFile, home: fx.home, + }), 'unknown'); +}); + +test('managed null or false statusLine is unknown and cannot inherit the user footer', () => { + const fx = teeFixture({ statusLine: cmd('node ~/.claude/helpers/statusline.cjs'), + scripts: { '.claude/helpers/statusline.cjs': FOOTER_SCRIPT } }); + const managedSettingsFile = path.join(fx.home, 'managed-settings.json'); + for (const statusLine of [null, false]) { + fs.writeFileSync(managedSettingsFile, JSON.stringify({ statusLine })); + assert.equal(classifyClaudeTeeChannel({ + settingsFile: fx.settingsFile, managedSettingsFile, home: fx.home, + }), 'unknown'); + } +}); + +test('managed default path selection is platform-specific and unsupported platforms skip it', () => { + const fx = teeFixture({ statusLine: cmd('echo user') }); + for (const platform of ['darwin', 'linux', 'win32']) { + const expected = claudeManagedSettingsPath(platform); + const reads = []; + const fsImpl = { readFileSync(file, encoding) { + reads.push([file, encoding]); + if (file === expected) return JSON.stringify({ statusLine: cmd('echo managed') }); + if (file === fx.settingsFile) return JSON.stringify({ statusLine: cmd('echo user') }); + throw Object.assign(new Error('unexpected read'), { code: 'ENOENT' }); + } }; + assert.equal(rawClassifyClaudeTeeChannel({ + settingsFile: fx.settingsFile, platform, fsImpl, home: fx.home, + }), 'custom'); + assert.deepEqual(reads, [[expected, 'utf8']]); + } + const reads = []; + const fsImpl = { readFileSync(file) { reads.push(file); return JSON.stringify({ statusLine: cmd('echo user') }); } }; + assert.equal(rawClassifyClaudeTeeChannel({ + settingsFile: fx.settingsFile, platform: 'unsupported', fsImpl, home: fx.home, + }), 'custom'); + assert.deepEqual(reads, [fx.settingsFile]); +}); + +test('a shell chain is custom without reading or running its footer script', () => { + const settingsFile = '/synthetic/user.json'; + const managedSettingsFile = '/synthetic/managed.json'; + const script = '/synthetic/footer.cjs'; + const reads = []; + const fsImpl = { + readFileSync(file) { + reads.push(file); + if (file === managedSettingsFile) return JSON.stringify({ + statusLine: cmd(`node ${script} && echo should-not-run`), + }); + if (file === script) return FOOTER_SCRIPT; + throw new Error('unexpected read'); + }, + statSync(file) { + assert.equal(file, script); + return { isFile: () => true, size: FOOTER_SCRIPT.length }; + }, + }; + assert.equal(rawClassifyClaudeTeeChannel({ + settingsFile, managedSettingsFile, fsImpl, home: '/synthetic', + }), 'custom'); + assert.deepEqual(reads, [managedSettingsFile]); +}); + +test('shell wrappers around a footer helper are custom', () => { + const fx = teeFixture({ scripts: { '.claude/helpers/statusline.cjs': FOOTER_SCRIPT } }); + const script = path.join(fx.home, '.claude/helpers/statusline.cjs'); + const wrapped = [ + `sh -c 'node "${script}"'`, + `bash -c 'node "${script}"'`, + `zsh -c 'node "${script}"'`, + `cmd /c node "${script}"`, + `powershell -Command "node '${script}'"`, + 'sh -c \'node "$D/.claude/helpers/statusline.cjs"\'', + ]; + for (const command of wrapped) { + fs.writeFileSync(fx.settingsFile, JSON.stringify({ statusLine: cmd(command) })); + assert.equal(classifyClaudeTeeChannel({ settingsFile: fx.settingsFile, home: fx.home }), 'custom', command); + } +}); + +test('a JavaScript path in an argument or inline program does not prove the footer runs', () => { + const fx = teeFixture({ scripts: { '.claude/helpers/statusline.cjs': FOOTER_SCRIPT } }); + const script = path.join(fx.home, '.claude/helpers/statusline.cjs'); + for (const command of [ + `echo "${script}"`, + `node -e "console.log('${script}')"`, + `node -p "'${script}'"`, + `node -r "${script}" -e '0'`, + ]) { + fs.writeFileSync(fx.settingsFile, JSON.stringify({ statusLine: cmd(command) })); + assert.equal(classifyClaudeTeeChannel({ settingsFile: fx.settingsFile, home: fx.home }), 'custom', command); + } +}); + +test('option-shaped targets and unquoted comments cannot identify the project helper', () => { + const settingsFile = '/synthetic/settings.json'; + let command; + const reads = []; + const fsImpl = { + readFileSync(file) { + reads.push(file); + if (file === settingsFile) return JSON.stringify({ statusLine: cmd(command) }); + throw new Error('unexpected script read'); + }, + statSync() { throw new Error('unexpected script stat'); }, + }; + for (command of [ + 'node --eval=./.claude/helpers/statusline.cjs', + 'node --no-warnings=./.claude/helpers/statusline.cjs', + 'node #/.claude/helpers/statusline.cjs', + 'node "--eval=./.claude/helpers/statusline.cjs"', + ]) { + assert.equal(classifyClaudeTeeChannel({ settingsFile, fsImpl, home: '/synthetic' }), 'custom', command); + } + assert.deepEqual(reads, Array(4).fill(settingsFile)); +}); + +test('a quoted footer path keeps literal shell punctuation', () => { + const fx = teeFixture({ scripts: { 'My & Tools/#statusline.cjs': FOOTER_SCRIPT } }); + const script = path.join(fx.home, 'My & Tools/#statusline.cjs'); + fs.writeFileSync(fx.settingsFile, JSON.stringify({ statusLine: cmd(`node "${script}"`) })); + assert.equal(classifyClaudeTeeChannel({ settingsFile: fx.settingsFile, home: fx.home }), 'kit-footer'); +}); + +test('direct quoted helper invocation remains a footer with a Node option', () => { + const fx = teeFixture({ scripts: { 'My Tools/status line.cjs': FOOTER_SCRIPT } }); + const script = path.join(fx.home, 'My Tools/status line.cjs'); + for (const command of [`node --no-warnings "${script}"`, `node '${script}'`]) { + fs.writeFileSync(fx.settingsFile, JSON.stringify({ statusLine: cmd(command) })); + assert.equal(classifyClaudeTeeChannel({ settingsFile: fx.settingsFile, home: fx.home }), 'kit-footer', command); + } +}); + +test('direct Windows Node invocation can read a quoted footer path', () => { + const settingsFile = '/synthetic/settings.json'; + const script = 'C:\\Users\\Example User\\statusline.cjs'; + const fsImpl = { + readFileSync(file) { + if (file === settingsFile) return JSON.stringify({ statusLine: cmd(`node.exe "${script}"`) }); + if (file === script) return FOOTER_SCRIPT; + throw new Error('unexpected read'); + }, + statSync(file) { + assert.equal(file, script); + return { isFile: () => true, size: FOOTER_SCRIPT.length }; + }, + }; + assert.equal(classifyClaudeTeeChannel({ settingsFile, fsImpl, home: '/synthetic' }), 'kit-footer'); +}); + test('classifyClaudeTeeChannel: no settings file or no statusLine is "none"', () => { const { home, settingsFile } = teeFixture(); assert.equal(classifyClaudeTeeChannel({ settingsFile, home }), 'none', 'absent settings file'); @@ -509,6 +714,19 @@ test('readLimits carries the Claude tee channel class beside an unchanged claude assert.equal(out.claudeChannel, 'custom'); }); +test('readLimits forwards a managed settings path to the Claude classifier', async () => { + const fx = teeFixture({ statusLine: cmd('echo user') }); + const managedSettingsFile = path.join(fx.home, 'managed-settings.json'); + fs.writeFileSync(managedSettingsFile, JSON.stringify({ statusLine: null })); + const out = await rawReadLimits({ + now: 1000, claudeFile: path.join(fx.home, 'absent.json'), + codexCacheFile: path.join(fx.home, 'codex.json'), codexPresence: () => 'not-found', + claudeSettingsFile: fx.settingsFile, claudeManagedSettingsFile: managedSettingsFile, + home: fx.home, + }); + assert.equal(out.claudeChannel, 'unknown'); +}); + test('readLimits carries why Codex limits are unavailable beside an unchanged codex field', async () => { // codexPresence: () => 'found' pins this test to the app-server failure-class // propagation it exercises (unaffected by this task): whether the spawn is diff --git a/tests/kit/run-tests-runner.test.mjs b/tests/kit/run-tests-runner.test.mjs index 4d7cc9d9..cfa70811 100644 --- a/tests/kit/run-tests-runner.test.mjs +++ b/tests/kit/run-tests-runner.test.mjs @@ -245,7 +245,7 @@ test('a clean command passes; concurrent-writer churn does not fail a developer assert.match(r.stderr, /concurrent writers \(not failing\)/); }); -test('SUITES keeps the exact commands package.json ran before', async () => { +test('SUITES preserves package scripts and includes the session-surfaces regression', async () => { const { SUITES } = await import('../../scripts/run-tests.mjs'); assert.deepEqual(SUITES.unit[0], ['--test', '--experimental-test-coverage', '--test-coverage-lines=70', '--test-coverage-branches=70', '--test-coverage-functions=70', 'tests/kit/*.test.mjs']); @@ -255,7 +255,7 @@ test('SUITES keeps the exact commands package.json ran before', async () => { assert.deepEqual(SUITES.ui[1], ['--test', 'tests/ui/dashboard-project-context.mjs', 'tests/ui/maintenance-projects.mjs', 'tests/ui/maintenance-host-alignment.mjs', 'tests/ui/intelligence-picker.mjs', 'tests/ui/usage-project-groups.mjs', 'tests/ui/context-coverage.mjs', 'tests/ui/host-readiness.mjs', 'tests/ui/maintenance-focus.mjs', - 'tests/ui/maintenance-guidance.mjs']); + 'tests/ui/maintenance-guidance.mjs', 'tests/ui/session-surfaces.mjs']); const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')); assert.equal(pkg.scripts.test, 'node scripts/run-tests.mjs unit'); assert.equal(pkg.scripts['test:ui'], 'node scripts/run-tests.mjs ui'); diff --git a/tests/kit/session-presentation.test.mjs b/tests/kit/session-presentation.test.mjs new file mode 100644 index 00000000..23608787 --- /dev/null +++ b/tests/kit/session-presentation.test.mjs @@ -0,0 +1,72 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import * as presentation from '../../src/lib/session-surface.mjs'; +import { discoverProjectSources } from '../../src/lib/footprint/project-sources.mjs'; +import { tempDir } from './helpers/temp-dir.mjs'; +import fs from 'node:fs'; +import path from 'node:path'; + +test('shared presentation keeps legacy desktop mode unknown and rejects raw provider claims', () => { + assert.equal(typeof presentation.sessionPresentation, 'function'); + const legacy = presentation.sessionPresentation({ origin: 'codex-desktop' }); + assert.equal(legacy.surface, 'unknown'); + assert.equal(legacy.label, 'Unknown'); + assert.match(legacy.note, /ChatGPT desktop app.*mode.*not recorded/i); + assert.equal(presentation.sessionPresentation({ surface: 'claude-desktop', thirdPartyProvider: 'amazon-bedrock' }).provider, 'Unknown'); + assert.equal(presentation.sessionPresentation({ surface: 'claude-desktop', thirdPartyProvider: 'amazon-bedrock', thirdPartyProviderBasis: 'assistant-model-id' }).provider, 'Amazon Bedrock'); +}); + +test('project discovery preserves separate surfaces and bounded raw disagreements through JSON', (t) => { + const root = tempDir('ak-session-presentation-', t); + const claude = path.join(root, 'claude'); fs.mkdirSync(claude); + for (const [i, entrypoint] of ['cli', 'sdk-cli', 'future-a', 'future-b'].entries()) { + fs.writeFileSync(path.join(claude, `${i}.jsonl`), JSON.stringify({ type: 'user', sessionId: `id-${i}`, cwd: root, entrypoint })); + } + const census = discoverProjectSources({ claudeRoot: claude, codexRoot: path.join(root, 'absent'), opencodeDbFile: path.join(root, 'absent.db') }); + const row = JSON.parse(JSON.stringify(census.projects[0])); + assert.equal(row.sessions, 4); + assert.ok(Array.isArray(row.sessionSurfaces)); + assert.deepEqual(row.sessionSurfaces.map((entry) => entry.surface).sort(), ['claude-code-cli', 'claude-noninteractive', 'other-claude']); + const other = row.sessionSurfaces.find((entry) => entry.surface === 'other-claude'); + assert.deepEqual(other.rawEvidence.entrypoint, ['future-a', 'future-b']); + assert.equal(other.sessions, 2); + assert.equal(other.countBasis, 'declared-session-ids'); + assert.equal(other.initiator, 'unknown'); +}); +test('aggregation preserves declared attributes and separates supported provider observations', async () => { + const { mergeSessionSurfaces } = await import('../../src/lib/footprint/session-surfaces.mjs'); + const common = { host: 'claude', surface: 'claude-desktop', initiator: 'person', sessions: 1, + countBasis: 'declared-session-ids', attributes: ['on 3P'] }; + const rows = mergeSessionSurfaces([{ ...common, thirdPartyProvider: 'amazon-bedrock', thirdPartyProviderBasis: 'assistant-model-id' }, common]); + assert.equal(rows.length, 2); + assert.deepEqual(rows[0].attributes, ['on 3P']); + assert.equal(rows.filter((row) => row.thirdPartyProvider === 'amazon-bedrock').length, 1); +}); +test('bounded raw disagreements preserve an explicit incomplete flag, with order-independent values', async () => { + const { mergeSessionSurfaces } = await import('../../src/lib/footprint/session-surfaces.mjs'); + const entries = Array.from({ length: 20 }, (_, i) => ({ host: 'codex', surface: 'other-openai', initiator: 'unknown', + sessions: 1, countBasis: 'transcript-files', rawEvidence: { originator: `future-${i}` } })); + const forward = mergeSessionSurfaces(entries), reverse = mergeSessionSurfaces(entries.toReversed()); + assert.deepEqual(forward, reverse); + assert.equal(forward[0].rawEvidenceComplete, false); + assert.equal(forward[0].rawEvidence.originator.length, 16); + assert.equal(forward[0].sessions, 20); +}); +test('all import count fields survive collection and the summary; absent legacy fields stay unknown', async () => { + const { collectProjects } = await import('../../src/lib/footprint/projects.mjs'); + const { systemSummaryPayload } = await import('../../src/lib/dashboard/system-summary.mjs'); + const { censusDisclosure } = await import('../../src/lib/census-presentation.mjs'); + const projects = collectProjects({ sources: { projects: [], importedExcluded: 4, importedMixed: 2, + importedUnresolved: 3, everSeen: 0, onDisk: 0, gitRepos: 0, complete: false }, loc: false }); + const summary = systemSummaryPayload({ projects }).projects; + assert.equal(summary.importedExcluded, 4); assert.equal(summary.importedMixed, 2); assert.equal(summary.importedUnresolved, 3); + assert.match(censusDisclosure({}), /Unknown number of confirmed pure imported copies/); + assert.doesNotMatch(censusDisclosure({}), /0 confirmed/); +}); +test('surface aggregation tolerates malformed attributes and orders provider groups deterministically', async () => { + const { mergeSessionSurfaces } = await import('../../src/lib/footprint/session-surfaces.mjs'); + const base = { host: 'claude', surface: 'claude-desktop', sessions: 1, attributes: 'untrusted' }; + assert.doesNotThrow(() => mergeSessionSurfaces([base])); + const entries = [{ ...base, attributes: [], thirdPartyProvider: 'amazon-bedrock', thirdPartyProviderBasis: 'assistant-model-id' }, { ...base, attributes: [] }]; + assert.deepEqual(mergeSessionSurfaces(entries), mergeSessionSurfaces(entries.toReversed())); +}); diff --git a/tests/kit/session-surface-renderers.test.mjs b/tests/kit/session-surface-renderers.test.mjs new file mode 100644 index 00000000..33cf6399 --- /dev/null +++ b/tests/kit/session-surface-renderers.test.mjs @@ -0,0 +1,104 @@ +import { censusDisclosure } from '../../src/lib/census-presentation.mjs'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import vm from 'node:vm'; +import * as vocabulary from '../../src/lib/session-surface.mjs'; +const esc = (value) => String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"'); +function renderer(name, elements = {}) { + const context = vm.createContext({ ...vocabulary, censusDisclosure, MNT_CURATED_VIEW_LABELS: {}, esc, window: {}, document: { getElementById: (id) => elements[id] ?? null }, + fmtNum: String, kpi: () => '', ago: () => 'now', formatLocalDateTime: () => null }); + const read = (file) => fs.readFileSync(new URL(`../../src/lib/dashboard/client/${file}.mjs`, import.meta.url), 'utf8').replace(/^import .*;$/gm, '').replace(/\bexport /g, ''); + const helper = new URL('../../src/lib/dashboard/client/session-presentation.mjs', import.meta.url); + if (fs.existsSync(helper)) vm.runInContext(read('session-presentation'), context); + vm.runInContext(read(name), context); + return context; +} +const sessionOrigin = { surface: 'chatgpt-desktop-work', initiator: 'agent', rawEvidence: { originator: 'future-client' } }; +test('Usage detail renders independent surface, initiator and bounded raw evidence', () => { + const context = renderer('usage'); + const html = context.sdetail({ id: 'x', host: 'codex', sessionOrigin }); + assert.match(html, /ChatGPT desktop app · ChatGPT Work \(local\)/); + assert.match(html, /initiator.*Agent/); + assert.match(html, /future-client/); + const hostile = context.sdetail({ id: 'x', sessionOrigin: { surface: '', rawEvidence: { originator: '', source: 'vscode' } } }); + assert.doesNotMatch(hostile, / { + const elements = { 'mw-table': {}, 'mw-hero': {} }; + const context = renderer('intelligence', elements); + context.renderMachineWide({ totals: {}, perProject: [{ label: 'Repository', learningScope: 'repository', + sessionSurfaces: [{ ...sessionOrigin, host: 'codex', sessions: 1 }, { surface: 'cloud-session', initiator: 'automation', host: 'claude', sessions: 1 }] }] }); + assert.match(elements['mw-table'].innerHTML, /Git repository/); + assert.match(elements['mw-table'].innerHTML, /ChatGPT desktop app · ChatGPT Work \(local\)/); + assert.match(elements['mw-table'].innerHTML, /Cloud session/); + assert.match(elements['mw-table'].innerHTML, /Session surface/); +}); +test('Maintenance origin facet shares the same surface vocabulary and honest legacy fallback', () => { + const context = renderer('maintenance-filters'); + assert.equal(context.mntFacetValueLabel('sessionOrigin', 'chatgpt-desktop-work'), 'ChatGPT desktop app · ChatGPT Work (local)'); + assert.equal(context.mntFacetValueLabel('sessionOrigin', 'codex-desktop'), 'ChatGPT desktop app observed; mode not recorded in this legacy snapshot'); + assert.equal(context.mntFacetValueLabel('sessionOrigin', 'unknown'), 'Legacy origin: no declared desktop origin'); + assert.equal(context.mntFacetValueLabel('sessionOrigin', 'surface-unknown'), 'Unknown session surface'); +}); +test('Intelligence and System disclose pure, mixed and unresolved import counts, including an empty project census', () => { + const counts = { importedExcluded: 4, importedMixed: 2, importedUnresolved: 3 }; + const elements = { 'mw-census': {}, 'mw-census-body': {}, 'sys-projects': {} }; + const intel = renderer('intelligence', elements); + intel.renderCensus({ counts }); + const system = renderer('system-projects', elements); + system.sysEmpty = (text) => text; + system.renderSysProjects({ projects: { ...counts, projects: [], discoveryProjects: [] } }); + for (const id of ['mw-census-body', 'sys-projects']) { + assert.match(elements[id].innerHTML, /4 confirmed pure imported copies excluded/); + assert.match(elements[id].innerHTML, /2 mixed files retain proven native activity/); + assert.match(elements[id].innerHTML, /3 files have unresolved bounded ownership/); + assert.match(elements[id].innerHTML, /dedicated Cowork transcript source is not covered/); + } +}); +test('Maintenance project detail exposes independent evidence outside the navigation button', () => { + const context = renderer('maintenance-focus'); + context.MNT = { facets: {} }; context.mntIcon = () => ''; context.mntProjectKindBadge = () => 'Git repository'; + const html = context.mntFocusNode({ value: 'id', label: 'Example', projectKind: 'git', count: 1, + sessionSurfaces: [{ ...sessionOrigin, host: 'codex', sessions: 2 }] }, 0, 'project', false); + assert.match(html, /<\/button>
{ + const context = renderer('maintenance-filters'); + assert.equal(context.mntFacetValueLabel('sessionOrigin', '__proto__'), 'Unknown'); + assert.equal(vocabulary.sessionPresentation({ initiator: 'toString' }).initiator, 'Unknown'); +}); +test('Usage retains explicit observed provider IDs while refusing unproven or unsupported provider claims', () => { + const context = renderer('usage'); + const observed = context.sdetail({ id: 'x', provider: 'openrouter', providerProvenance: 'observed' }); + assert.match(observed, /OpenRouter/); + assert.match(observed, /recorded provider ID; not network attestation/); + const unknown = context.sdetail({ id: 'x', provider: 'openrouter', providerProvenance: 'unknown' }); + assert.doesNotMatch(unknown, /OpenRouter/); +}); +test('Intelligence refresh resets an unavailable surface selection before rendering rows', () => { + const elements = { 'mw-table': {}, 'mw-hero': {}, 'mw-surface-filter': {} }; + const context = renderer('intelligence', elements); + const cloud = { label: 'Cloud project', sessionSurfaces: [{ surface: 'cloud-session', sessions: 1 }] }; + const local = { label: 'Local project', sessionSurfaces: [{ surface: 'claude-code-cli', sessions: 1 }] }; + context.renderMachineWide({ totals: {}, perProject: [cloud, local] }); + elements['mw-surface-filter'].value = 'Cloud session'; + elements['mw-surface-filter'].onchange(); + assert.equal(context.machineWideSurfaceFilter, 'Cloud session'); + context.renderMachineWide({ totals: {}, perProject: [local] }); + assert.equal(context.machineWideSurfaceFilter, 'all'); + assert.match(elements['mw-table'].innerHTML, /Local project/); + assert.doesNotMatch(elements['mw-table'].innerHTML, /Cloud session/); +}); +test('shared legacy Claude Desktop fallback remains known in Intelligence and Maintenance', () => { + const origin = { origin: 'claude-desktop', sessions: 3 }; + assert.deepEqual(vocabulary.sessionPresentation(origin), { surface: 'claude-desktop', label: 'Claude Desktop', + initiator: 'Unknown', provider: 'Unknown', providerBasis: 'not established', note: 'Claude Desktop observed in this legacy snapshot' }); + const context = renderer('maintenance-filters'); + assert.deepEqual(Array.from(context.surfaceNames({ sessionOrigins: [origin] })), ['Claude Desktop']); + assert.equal(context.mntFacetValueLabel('sessionOrigin', 'claude-desktop'), 'Claude Desktop'); + assert.equal(vocabulary.sessionPresentation({ origin: 'codex-desktop' }).surface, 'unknown'); +}); diff --git a/tests/kit/session-surface.test.mjs b/tests/kit/session-surface.test.mjs new file mode 100644 index 00000000..a34b3992 --- /dev/null +++ b/tests/kit/session-surface.test.mjs @@ -0,0 +1,139 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { classifySessionSurface, sessionSurfaceLabel } from '../../src/lib/session-surface.mjs'; +import { transcriptSessionOrigin } from '../../src/lib/footprint/session-origin.mjs'; + +const claudeCases = [ + ['cli', 'claude-code-cli', 'person'], ['claude-vscode', 'claude-code-vscode', 'person'], + ['claude-desktop', 'claude-desktop', 'person'], ['claude-desktop-3p', 'claude-desktop', 'person'], + ['local-agent', 'cowork', 'person'], ['local_agent', 'cowork', 'person'], + ['remote_cowork', 'cowork', 'person'], ['remote', 'cloud-session', 'person'], + ['remote_desktop', 'cloud-session', 'person'], ['remote_mobile', 'cloud-session', 'person'], + ['remote_projects', 'cloud-session', 'person'], ['remote_trigger', 'cloud-session', 'automation'], + ['remote_cowork_trigger', 'cloud-session', 'automation'], + ['sdk-py', 'claude-agent-sdk', 'automation'], ['sdk-ts', 'claude-agent-sdk', 'automation'], + ['sdk-cli', 'claude-noninteractive', 'automation'], + ['claude-code-github-action', 'github-actions', 'automation'], + ['claude_in_slack', 'claude-tag', 'person'], ['claude-in-slack', 'claude-tag', 'person'], + ['claude-in-teams', 'claude-tag', 'person'], + ['mcp', 'other-claude', 'automation'], ['ssh-remote', 'other-claude', 'person'], + ['bench', 'other-claude', 'unknown'], +]; + +test('maps every ADR-0060 Claude raw value to one surface and initiator', () => { + for (const [entrypoint, surface, initiator] of claudeCases) { + const actual = classifySessionSurface({ host: 'claude', entrypoint }); + assert.equal(actual.surface, surface, entrypoint); + assert.equal(actual.initiator, initiator, entrypoint); + assert.equal(actual.label, sessionSurfaceLabel(surface), entrypoint); + assert.deepEqual(actual.rawEvidence, { entrypoint }, entrypoint); + } +}); + +test('maps every ADR-0060 OpenAI originator, including source-dependent MCP', () => { + const cases = [ + ['Codex Desktop', undefined, 'chatgpt-desktop-codex', 'person'], + ['codex_work_desktop', undefined, 'chatgpt-desktop-work', 'person'], + ['codex-tui', undefined, 'codex-cli', 'person'], + ['codex_exec', 'exec', 'codex-cli-exec', 'automation'], + ['codex_vscode', 'vscode', 'codex-ide', 'person'], + ['codex_sdk_ts', undefined, 'codex-sdk', 'automation'], + ['codex_python_sdk', undefined, 'codex-sdk', 'automation'], + ['codex_cli_rs', 'mcp', 'codex-mcp', 'agent'], + ['codex_work_web', undefined, 'chatgpt-work-cloud', 'person'], + ['codex_work_mobile', undefined, 'chatgpt-work-cloud', 'person'], + ['codex_work_cca', undefined, 'chatgpt-work-cloud', 'person'], + ['chatgpt_cca', undefined, 'chatgpt-work-cloud', 'person'], + ['future-client', 'vscode', 'other-openai', 'unknown'], + ]; + for (const [originator, source, surface, initiator] of cases) { + const actual = classifySessionSurface({ host: 'codex', originator, source }); + assert.equal(actual.surface, surface, originator); + assert.equal(actual.initiator, initiator, originator); + assert.equal(actual.label, sessionSurfaceLabel(surface), originator); + assert.deepEqual(actual.rawEvidence, source ? { originator, source } : { originator }, originator); + } +}); + +test('keeps initiator orthogonal to surface and import state', () => { + assert.equal(classifySessionSurface({ host: 'claude', entrypoint: 'cli', sessionKind: 'bg' }).initiator, 'automation'); + assert.equal(classifySessionSurface({ host: 'codex', originator: 'Codex Desktop', threadSource: 'guardian_review' }).initiator, 'agent'); + assert.equal(classifySessionSurface({ host: 'codex', originator: 'Codex Desktop', threadSource: 'subagent' }).initiator, 'agent'); + assert.equal(classifySessionSurface({ host: 'codex', originator: 'Codex Desktop', threadSource: 'chatgpt_handoff' }).initiator, 'person'); + assert.equal(classifySessionSurface({ host: 'codex', originator: 'Codex Desktop', threadSource: 'automation' }).initiator, 'automation'); + assert.equal(classifySessionSurface({ host: 'codex', originator: 'Codex Desktop', importedCopy: true }).initiator, 'imported-copy'); + assert.equal(classifySessionSurface({ host: 'codex', originator: 'codex_exec', threadSource: 'user' }).initiator, 'automation'); + for (const threadSource of ['user', 'chatgpt_handoff']) { + assert.equal(classifySessionSurface({ host: 'codex', originator: 'codex_cli_rs', source: 'mcp', threadSource }).initiator, 'agent'); + for (const originator of ['codex_sdk_ts', 'codex_python_sdk']) { + assert.equal(classifySessionSurface({ host: 'codex', originator, threadSource }).initiator, 'automation'); + } + } + assert.equal(classifySessionSurface({ host: 'codex', originator: 'codex_sdk_ts', threadSource: 'guardian_review' }).initiator, 'agent'); +}); + +test('unknown declarations remain bounded and do not become product claims', () => { + assert.deepEqual(classifySessionSurface({ host: 'claude' }), { + surface: 'unknown', initiator: 'unknown', label: 'Unknown', rawEvidence: {}, attributes: [], thirdPartyProvider: null, + }); + const ambiguous = classifySessionSurface({ host: 'codex', source: 'vscode' }); + assert.equal(ambiguous.surface, 'unknown'); + assert.equal(ambiguous.label, 'Unknown'); + assert.deepEqual(ambiguous.rawEvidence, { source: 'vscode' }); + const prompt = 'private prompt '.repeat(100); + const unknown = classifySessionSurface({ host: 'claude', entrypoint: prompt }); + assert.equal(unknown.surface, 'unknown'); + assert.deepEqual(unknown.rawEvidence, {}); + assert.ok(!JSON.stringify(unknown).includes('private prompt')); + assert.deepEqual(classifySessionSurface({ host: 'codex', originator: 'user prompt' }).rawEvidence, {}); + for (const field of ['entrypoint', 'originator', 'source', 'threadSource', 'sessionKind']) { + const value = 'future_enum_v2'; + const candidate = classifySessionSurface({ host: field === 'entrypoint' || field === 'sessionKind' ? 'claude' : 'codex', + [field]: value }); + assert.equal(candidate.rawEvidence[field], value, field); + } + assert.deepEqual(classifySessionSurface({ host: 'claude', entrypoint: 'x'.repeat(80) }).rawEvidence, + { entrypoint: 'x'.repeat(80) }); + assert.deepEqual(classifySessionSurface({ host: 'claude', entrypoint: 'x'.repeat(81) }).rawEvidence, {}); + for (const value of ['a\nsecret', 'a secret', '', { text: 'secret' }, ['secret']]) { + assert.deepEqual(classifySessionSurface({ host: 'codex', originator: value }).rawEvidence, {}); + } + assert.deepEqual(classifySessionSurface({ host: 'claude', entrypoint: 'future_enum_v2' }), { + surface: 'other-claude', initiator: 'unknown', label: 'Other Claude surface', + rawEvidence: { entrypoint: 'future_enum_v2' }, attributes: [], thirdPartyProvider: null, + }); + assert.equal(sessionSurfaceLabel('__proto__'), 'Unknown'); +}); + +test('records observed attributes and keeps third-party provider separate', () => { + const thirdParty = classifySessionSurface({ host: 'claude', entrypoint: 'claude-desktop-3p' }); + assert.deepEqual(thirdParty.attributes, ['on 3P']); + assert.equal(thirdParty.thirdPartyProvider, null); + const remote = classifySessionSurface({ host: 'claude', entrypoint: 'remote_desktop' }); + assert.equal(remote.surface, 'cloud-session'); + assert.deepEqual(remote.attributes, ['started from Claude Desktop']); +}); + +test('footprint adapter keeps legacy origin/evidence and latches first declaration', () => { + const lines = (...rows) => rows.map((row) => JSON.stringify(row)); + const codex = transcriptSessionOrigin(lines( + { type: 'session_meta', payload: { originator: 'codex-tui', source: 'vscode', thread_source: 'user' } }, + { type: 'session_meta', payload: { originator: 'Codex Desktop' } }, + ), 'codex'); + assert.equal(codex.origin, 'unknown'); + assert.equal(codex.evidence, 'desktop-origin-not-declared'); + assert.equal(codex.surface, 'codex-cli'); + assert.equal(codex.initiator, 'person'); + assert.deepEqual(codex.rawEvidence, { originator: 'codex-tui', source: 'vscode', threadSource: 'user' }); + const claude = transcriptSessionOrigin(lines({ entrypoint: 'claude-desktop' }, { entrypoint: 'cli' }), 'claude'); + assert.deepEqual([claude.origin, claude.evidence, claude.surface], + ['claude-desktop', 'entrypoint:claude-desktop', 'claude-desktop']); + assert.equal(transcriptSessionOrigin(lines({ entrypoint: 'remote_desktop' }), 'claude').origin, 'unknown'); + const future = transcriptSessionOrigin(lines( + { entrypoint: 'future_enum_v2', sessionKind: 'future_kind', message: { content: 'private prompt' } }, + { entrypoint: 'claude-desktop' }), 'claude'); + assert.deepEqual(future.rawEvidence, { entrypoint: 'future_enum_v2', sessionKind: 'future_kind' }); + assert.equal(future.surface, 'other-claude'); + assert.equal(future.origin, 'unknown'); + assert.equal(JSON.stringify(future).includes('private prompt'), false); +}); diff --git a/tests/kit/system-command.test.mjs b/tests/kit/system-command.test.mjs index bff84153..d77828b0 100644 --- a/tests/kit/system-command.test.mjs +++ b/tests/kit/system-command.test.mjs @@ -243,3 +243,11 @@ test('ak system --help documents only the current spellings', () => { assert.match(r.stdout, /\[--refresh\[=live\|machine\]\] \[--project-trees\] \[--json\]/); assert.doesNotMatch(r.stdout, /--deep\b/, 'only the current spellings'); }); +test('system reports pure exclusions, mixed activity and unresolved ownership separately with Cowork coverage', async () => { + const collector = fakeCollector({ snapshot: { projects: { projects: [], importedExcluded: 4, importedMixed: 2, importedUnresolved: 3 } } }); + const result = await captureLog(() => system.run({ flags: {}, deps: { collector } })); + assert.match(result.out, /4 confirmed pure imported copies excluded/); + assert.match(result.out, /2 mixed files retain proven native activity/); + assert.match(result.out, /3 files have unresolved bounded ownership/); + assert.match(result.out, /dedicated Cowork transcript source is not covered/); +}); diff --git a/tests/kit/system-runtime-app-labels.test.mjs b/tests/kit/system-runtime-app-labels.test.mjs new file mode 100644 index 00000000..13ac7759 --- /dev/null +++ b/tests/kit/system-runtime-app-labels.test.mjs @@ -0,0 +1,36 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { run } from '../../src/commands/system.mjs'; +import { captureLog } from './helpers/home-sandbox.mjs'; + +const measured = (value) => ({ status: 'measured', value, partial: false }); +const unknown = (reason) => ({ status: 'unknown', reason }); + +test('ak system renders application, coding-agent host, service, and unknown runtime rows', async () => { + const row = (pid, host, application, source) => ({ pid, host, application, source, + cpuPercent: measured(2), rssBytes: measured(1000), uptimeMs: measured(2000) }); + const processes = [ + row(101, null, 'Claude Desktop', measured({ kind: 'desktop-app', label: 'Claude Desktop' })), + row(102, 'claude', null, measured({ kind: 'repository', label: 'work' })), + row(103, null, 'ChatGPT desktop app', measured({ kind: 'desktop-app', label: 'ChatGPT desktop app' })), + row(104, 'codex', null, measured({ kind: 'host-service', label: 'Codex app service' })), + row(105, null, null, unknown('not attributable — access denied')), + ]; + const snapshot = { platform: 'darwin', generatedAt: 'now', snapshot: { present: false, reason: 'not scanned' }, + runtime: { ephemeral: true, processes: measured(processes), totals: { + processCount: measured(5), rssBytes: measured(5000), cpuPercent: measured(10), + }, daemons: { count: measured(0), staleCount: measured(0) } } }; + const { result, out } = await captureLog(() => run({ flags: { json: false }, deps: { + collector: { read: async () => snapshot }, now: () => 0, + } })); + assert.equal(result, 0); + assert.match(out, /CODING-AGENT HOST \/ DESKTOP APPLICATION/); + assert.match(out, /Claude Desktop/); + assert.match(out, /ChatGPT desktop app/); + assert.match(out, /Codex app service/); + assert.match(out, /work/); + assert.match(out, /Unknown process/); + assert.match(out, /unattributed: not attributable — access denied/); + assert.match(out, /processes\s+5/); + assert.match(out, /memory \(RSS\)\s+5\.00 KB/); +}); diff --git a/tests/kit/system-summary.test.mjs b/tests/kit/system-summary.test.mjs index a8d6553e..441d92ec 100644 --- a/tests/kit/system-summary.test.mjs +++ b/tests/kit/system-summary.test.mjs @@ -1,3 +1,5 @@ +import * as sessionVocabulary from '../../src/lib/session-surface.mjs'; +import { censusDisclosure } from '../../src/lib/census-presentation.mjs'; // GET /api/system/summary (#237 M4, decision 8). The System page drew from // GET /api/system, which ships the whole persisted catalog: every presence // fact repeated in item.presence, item.consumerBindings, item.artifacts and @@ -366,7 +368,7 @@ test('the projects summary drops per-project stack detection and node_modules ro } const row = summary.projects.projects[0]; assert.deepEqual(Object.keys(row).sort(), - ['path', 'label', 'hosts', 'totalBytes', 'lastActivity', 'loc', 'remote', 'repository'].sort()); + ['path', 'label', 'hosts', 'totalBytes', 'lastActivity', 'loc', 'remote', 'repository', 'sessionOrigins'].sort()); assert.equal('stack' in row, false, 'framework/manifest detection is not rendered (system-projects.mjs langCell)'); assert.equal('nodeModulesRoots' in row, false); assert.deepEqual(Object.keys(row.loc).sort(), ['total', 'languages', 'byLanguage'].sort()); @@ -374,7 +376,7 @@ test('the projects summary drops per-project stack detection and node_modules ro assert.deepEqual(Object.keys(row.repository).sort(), ['repositoryId', 'kind', 'root'].sort()); assert.deepEqual(Object.keys(row.remote).sort(), ['status', 'webUrl', 'raw'].sort()); const discovery = summary.projects.discoveryProjects[0]; - assert.deepEqual(Object.keys(discovery).sort(), ['path', 'label', 'hosts', 'repository'].sort()); + assert.deepEqual(Object.keys(discovery).sort(), ['path', 'label', 'hosts', 'repository', 'sessionOrigins'].sort()); }); test('the projects summary keeps byLanguage for a pre-languages loc, so an old carried-forward snapshot still renders bars', () => { @@ -510,13 +512,15 @@ function systemClient({ fetchImpl } = {}) { 'storageHostTotals', 'renderSysSummary', 'renderSysConsumers', 'renderSysReclaim', 'CHART_EXCLUDED_CATEGORIES', 'transcriptIdOf', 'renderSysKpis']; const readout = load('system-readout', { esc, fmtNum, fmtTok, document, window }, readoutExports); + const surfaceHelpers = load('session-presentation', { ...sessionVocabulary, esc }, ['projectSurfacesHtml']); const projects = load('system-projects', { + ...surfaceHelpers, censusDisclosure, ...readout, esc, authHeaders: () => ({}), formatLocalDateTime: () => null, formatLocalDateTimeLong: () => null, shortSessionId: (s) => s, ago: () => 'just now', fmtNum, fmtTok, limAge, pct, repositoryTree, SYSTEM: null, systemBusy: false, systemPollTimer: null, consMode: 'ranked', document, window, fetch: fetchImpl ?? (() => Promise.reject(new Error('no fetch in this test'))), setTimeout: () => 0, clearTimeout: () => {}, - }, ['renderSysCatalog', 'loadSystem', 'renderSysStorage', 'renderSysProjects']); + }, ['renderSysCatalog', 'loadSystem', 'renderSysStorage', 'renderSysProjects', 'renderSysRuntime']); return { document, readout, projects }; } @@ -527,6 +531,29 @@ function catalogHtml(payload) { return Object.fromEntries([...client.document.elements].map(([id, el]) => [id, { html: el.innerHTML, text: el.textContent }])); } +test('Runtime process renderer names desktop applications separately from coding-agent hosts', () => { + const client = systemClient(); + const row = (pid, host, application, source) => ({ pid, host, application, source, + uptimeMs: meas(2000), cpuPercent: meas(2), rssBytes: meas(1000) }); + client.projects.renderSysRuntime({ runtime: { processes: meas([ + row(1, null, 'Claude Desktop', meas({ kind: 'desktop-app', label: 'Claude Desktop' })), + row(2, 'claude', null, meas({ kind: 'repository', label: 'work' })), + row(3, null, 'ChatGPT desktop app', meas({ kind: 'desktop-app', label: 'ChatGPT desktop app' })), + row(4, 'codex', null, meas({ kind: 'host-service', label: 'Codex app service' })), + row(5, null, '', { status: 'unknown', reason: 'not attributable — ' }), + ]) } }); + const html = client.document.getElementById('sys-procs').innerHTML; + assert.match(html, /Coding-agent host \/ desktop application/); + assert.match(html, /Claude Desktop/); + assert.match(html, /ChatGPT desktop app/); + assert.match(html, /Codex app service/); + assert.match(html, /work/); + assert.match(html, /not attributable/); + assert.match(html, /<unknown>/); + assert.match(html, /<denied>/); + assert.doesNotMatch(html, /|/); +}); + test('the KPI band and every catalog card render identically from the summary and the full payload', () => { const full = fullPayload(6); const fromFull = catalogHtml(full); diff --git a/tests/kit/usage-claude-cost-state.test.mjs b/tests/kit/usage-claude-cost-state.test.mjs new file mode 100644 index 00000000..66ca4160 --- /dev/null +++ b/tests/kit/usage-claude-cost-state.test.mjs @@ -0,0 +1,174 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseClaude } from '../../src/lib/usage-parsers.mjs'; +import { sessionCostEvidence, reconcileClaudeCostState } from '../../src/lib/usage-cost.mjs'; +import { costOf } from '../../src/lib/pricing.mjs'; +import { aggregate, sessionPayload } from '../../src/lib/usage-aggregate.mjs'; +import { buildIndex, SCHEMA_VERSION, _resetForTest } from '../../src/lib/usage-index.mjs'; +import { tempDir } from './helpers/temp-dir.mjs'; +import fs from 'node:fs'; +import path from 'node:path'; + +const line = (value) => JSON.stringify(value); +const usage = { input_tokens: 10, output_tokens: 20, cache_read_input_tokens: 30, cache_creation_input_tokens: 40 }; +const assistant = line({ type: 'assistant', timestamp: '2026-09-28T12:00:00Z', message: { + id: 'm1', role: 'assistant', model: 'claude-opus-5', usage, content: [{ type: 'text', text: 'ok' }], +} }); +const snapshot = (totalCostUSD, overrides = {}) => line({ + type: 'cost-state', sessionId: 's1', totalCostUSD, startTime: Date.parse('2026-09-28T11:00:00Z'), + modelUsage: { 'claude-opus-5': { + inputTokens: 10, outputTokens: 20, cacheReadInputTokens: 30, cacheCreationInputTokens: 40, + thinkingTokens: 0, webSearchRequests: 0, costUSD: totalCostUSD, + } }, hasUnknownModelCost: false, ...overrides, +}); +const parse = (...records) => parseClaude([assistant, ...records].join('\n'), { id: 's1' }).session; +const deps = { costOf, classify: () => ({ category: 'Unclassified', confidence: 0, basis: 'test' }), detectInsights: () => [] }; + +test('latest valid cumulative snapshot remains separate from message cost', () => { + const rec = parse(snapshot(1), snapshot(2)); + assert.equal(rec.claudeCostState.reportedUsd, 2); + assert.equal(rec.claudeCostState.currency, 'USD'); + assert.equal(rec.claudeCostState.provenance, 'claude-code-cost-state'); + assert.equal(rec.claudeCostState.validSnapshots, 2); + assert.equal(rec.claudeCostState.coverage, 'session-cumulative'); + const evidence = sessionCostEvidence(rec, deps); + assert.equal(evidence.estimatedUsd, costOf({ model: 'claude-opus-5', day: '2026-09-28', input: 10, output: 20, cacheRead: 30, cacheWrite: 40 })); + const reconciled = reconcileClaudeCostState(rec); + assert.equal(reconciled.status, 'scope-unknown'); + assert.equal(reconciled.reportedUsd, 2); + assert.equal(reconciled.estimatedUsd, null); + assert.equal(reconciled.checkpointStartMs, Date.parse('2026-09-28T11:00:00Z')); + assert.equal(reconciled.checkpointEndMs, null); + assert.equal(reconciled.messageFirstAtMs, Date.parse('2026-09-28T12:00:00Z')); + assert.equal(reconciled.messageLastAtMs, Date.parse('2026-09-28T12:00:00Z')); +}); + +test('malformed and unsupported later checkpoints cannot replace latest valid', () => { + const rec = parse(snapshot(1), snapshot(-1), snapshot('2'), + line({ type: 'cost-state', sessionId: 's1', futureCost: 3 }), snapshot(4, { sessionId: 'other' })); + assert.equal(rec.claudeCostState.reportedUsd, 1); + assert.equal(rec.claudeCostState.validSnapshots, 1); + assert.equal(rec.claudeCostState.malformedSnapshots, 2); + assert.equal(rec.claudeCostState.unsupportedSnapshots, 2); +}); + +test('scope mismatch or unknown model prevents a cost equality claim', () => { + const mismatch = parse(snapshot(1, { modelUsage: { 'claude-opus-5': { + inputTokens: 11, outputTokens: 20, cacheReadInputTokens: 30, cacheCreationInputTokens: 40, + webSearchRequests: 0, costUSD: 1, + } } })); + assert.deepEqual(reconcileClaudeCostState(mismatch).scopeReasons, ['model-token-totals-differ']); + const unknown = parse(snapshot(1, { hasUnknownModelCost: true })); + assert.equal(reconcileClaudeCostState(unknown).status, 'scope-unknown'); + const routed = parse(snapshot(1)); + routed.sessionOrigin.thirdPartyProvider = 'amazon-bedrock'; + assert.equal(reconcileClaudeCostState(routed).status, 'scope-unknown'); +}); + +test('matching amount and exact model/token totals do not attest time or provider scope', () => { + const amount = costOf({ model: 'claude-opus-5', day: '2026-09-28', input: 10, output: 20, cacheRead: 30, cacheWrite: 40 }); + const rec = parse(snapshot(amount)); + assert.equal(rec.providerProvenance, 'unknown'); + assert.ok(reconcileClaudeCostState(rec).scopeReasons.includes('serving-provider-unverified')); + rec.inferenceProvider = 'amazon-bedrock'; + rec.providerProvenance = 'observed'; + assert.ok(reconcileClaudeCostState(rec).scopeReasons.includes('serving-provider-different')); +}); + +test('a Bedrock assistant model is provider evidence, not an Anthropic serving attestation', () => { + const model = 'us.anthropic.claude-sonnet-4-6'; + const message = JSON.parse(assistant); + message.message.model = model; + const state = JSON.parse(snapshot(1)); + state.modelUsage = { [model]: state.modelUsage['claude-opus-5'] }; + const rec = parseClaude([line(message), line(state)].join('\n'), { id: 's1' }).session; + assert.equal(rec.sessionOrigin.thirdPartyProvider, 'amazon-bedrock'); + assert.ok(reconcileClaudeCostState(rec).scopeReasons.includes('serving-provider-different')); +}); + +test('missing and malformed start times stay diagnostic without replacing a valid checkpoint', () => { + const rec = parse(snapshot(1), snapshot(2, { startTime: undefined }), + snapshot(3, { startTime: 'bad' }), snapshot(4, { startTime: 1_780_000_000 })); + assert.equal(rec.claudeCostState.reportedUsd, 1); + assert.equal(rec.claudeCostState.malformedSnapshots, 3); + assert.equal(reconcileClaudeCostState(rec).status, 'scope-unknown'); +}); + +test('checkpoint starting after a charged message has mismatched time scope', () => { + const rec = parse(snapshot(1, { startTime: Date.parse('2026-09-28T12:01:00Z') })); + assert.equal(rec.claudeMessageCoverage.firstAtMs, Date.parse('2026-09-28T12:00:00Z')); + assert.deepEqual(reconcileClaudeCostState(rec).scopeReasons, ['checkpoint-start-after-message']); +}); + +test('an earlier zero-token assistant does not widen charged-message time coverage', () => { + const zero = JSON.parse(assistant); + zero.timestamp = '2026-09-28T11:00:00Z'; + zero.message.id = 'zero'; + zero.message.usage = { input_tokens: 0, output_tokens: 0 }; + const rec = parseClaude([line(zero), assistant, + snapshot(1, { startTime: Date.parse('2026-09-28T11:30:00Z') })].join('\n'), { id: 's1' }).session; + assert.equal(rec.claudeMessageCoverage.firstAtMs, Date.parse('2026-09-28T12:00:00Z')); + assert.equal(reconcileClaudeCostState(rec).status, 'scope-unknown'); +}); + +test('overlong model keys remain diagnostic and never enter the cache', () => { + const allowedModel = 'a'.repeat(100); + const longModel = 'a'.repeat(101); + const counts = { + inputTokens: 10, outputTokens: 20, cacheReadInputTokens: 30, cacheCreationInputTokens: 40, + webSearchRequests: 0, costUSD: 2, + }; + const rec = parse(snapshot(1, { modelUsage: { [allowedModel]: counts } }), + snapshot(2, { modelUsage: { [longModel]: counts } })); + assert.equal(rec.claudeCostState.reportedUsd, 1); + assert.equal(rec.claudeCostState.malformedSnapshots, 1); + assert.equal(Object.keys(rec.claudeCostState.modelUsage)[0], allowedModel); + assert.equal(JSON.stringify(rec).includes(longModel), false); +}); + +test('aggregate and session detail expose reconciliation without adding the snapshot to totals', () => { + const rec = parse(snapshot(2)); + const now = Date.parse('2026-09-29T00:00:00Z'); + const agg = aggregate([rec], { days: 7, now, cutoff: now - 7 * 86400000, deps }); + const messageCost = sessionCostEvidence(rec, deps).estimatedUsd; + assert.equal(agg.sessions[0].cost, messageCost); + assert.equal(agg.totals.cost, messageCost); + assert.equal(agg.sessions[0].claudeCostState.status, 'scope-unknown'); + assert.equal(agg.sessions[0].claudeCostState.reportedUsd, 2); + assert.equal(sessionPayload(rec, [], deps).meta.claudeCostState.status, 'scope-unknown'); +}); + +test('schema 26 cold and warm cache reads retain the checkpoint diagnostic', async () => { + assert.equal(SCHEMA_VERSION, 26); + const root = tempDir('ak-claude-cost-state'); + const project = path.join(root, 'claude', 'project'); + fs.mkdirSync(project, { recursive: true }); + const overlongModel = 'a'.repeat(101); + const invalid = JSON.parse(snapshot(3)); + invalid.modelUsage = { [overlongModel]: invalid.modelUsage['claude-opus-5'] }; + fs.writeFileSync(path.join(project, 's1.jsonl'), [assistant, snapshot(2), line(invalid)].join('\n')); + const options = { + days: 7, now: Date.parse('2026-09-29T00:00:00Z'), + roots: { claude: path.join(root, 'claude'), codex: path.join(root, 'codex') }, + cachePath: path.join(root, 'cache', 'usage-index.json'), deps, + }; + _resetForTest(); + const cold = await buildIndex(options); + assert.equal(cold.sessions[0].claudeCostState.malformedSnapshots, 1); + assert.equal(fs.readFileSync(options.cachePath, 'utf8').includes(overlongModel), false); + const cache = JSON.parse(fs.readFileSync(options.cachePath, 'utf8')); + for (const entry of Object.values(cache.entries)) { + delete entry.session.claudeCostState.startMs; + delete entry.session.claudeCostState.endMs; + delete entry.session.claudeMessageCoverage; + } + fs.writeFileSync(options.cachePath, JSON.stringify(cache)); + _resetForTest(); + const reparsed = await buildIndex(options); + assert.equal(reparsed.sessions[0].claudeCostState.status, 'scope-unknown'); + _resetForTest(); + const warm = await buildIndex(options); + assert.deepEqual(warm.sessions[0].claudeCostState, cold.sessions[0].claudeCostState); + assert.equal(warm.sessions[0].claudeCostState.status, 'scope-unknown'); + assert.equal(warm.totals.cost, cold.totals.cost); +}); diff --git a/tests/kit/usage-claude-dedup.test.mjs b/tests/kit/usage-claude-dedup.test.mjs index 36a861d9..240fec17 100644 --- a/tests/kit/usage-claude-dedup.test.mjs +++ b/tests/kit/usage-claude-dedup.test.mjs @@ -9,6 +9,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { parseClaude } from '../../src/lib/usage-parsers.mjs'; +import { reconcileClaudeMessages } from '../../src/lib/usage-claude-dedup.mjs'; import { decodeClaudeRecord } from '../../src/lib/telemetry-records.mjs'; import { buildIndex, SCHEMA_VERSION, _resetForTest } from '../../src/lib/usage-index.mjs'; import { costOf, priceFor } from '../../src/lib/pricing.mjs'; @@ -16,6 +17,10 @@ import { tempDir } from './helpers/temp-dir.mjs'; const T0 = Date.parse('2026-08-20T10:00:00.000Z'); const at = (s) => new Date(T0 + s * 1000).toISOString(); +const dayAt = (s) => { + const d = new Date(at(s)); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +}; const line = (o) => JSON.stringify(o); const USAGE = { input_tokens: 10, output_tokens: 200, cache_read_input_tokens: 5000, cache_creation_input_tokens: 700 }; @@ -185,6 +190,355 @@ test('turn rows (reader path) are still emitted per transcript line', () => { assert.equal(turns.filter((t) => t.role === 'assistant').length, 2); }); +test('cross-file copies charge one richest message while source sessions keep their response counts', () => { + const a = parse([prompt(), asst({ id: 'msg_shared', s: 5, block: text(), usage: { ...USAGE, output_tokens: 12 } })]); + const b = parse([prompt(), asst({ id: 'msg_shared', s: 6, block: text(), usage: { ...USAGE, output_tokens: 200 } })]); + a.id = 'a'; b.id = 'b'; + const [aa, bb] = reconcileClaudeMessages([a, b]); + assert.equal(a.responses + b.responses, 2, 'cached transcript observations are untouched'); + assert.equal(aa.responses + bb.responses, 2, 'session counts still describe each file'); + assert.equal(aa.accountedResponses + bb.accountedResponses, 1); + assert.equal(aa.usage[0].output + bb.usage[0].output, 200); + assert.equal(aa.usage[0].cacheRead + bb.usage[0].cacheRead, 5000); + assert.equal(aa.usage[0].responses + bb.usage[0].responses, 1); + assert.deepEqual(reconcileClaudeMessages([b, a]).map((r) => [r.id, r.usage[0].output]), + [[bb.id, bb.usage[0].output], [aa.id, aa.usage[0].output]], 'file traversal cannot elect a different charge'); +}); + +test('missing IDs and distinct same-count IDs never collapse across files', () => { + const a = parse([prompt(), asst({ s: 5, block: text() }), asst({ id: 'msg_one', s: 6, block: text() })]); + const b = parse([prompt(), asst({ s: 5, block: text() }), asst({ id: 'msg_two', s: 6, block: text() })]); + const rows = reconcileClaudeMessages([a, b]); + assert.equal(rows.reduce((n, r) => n + r.accountedResponses, 0), 4); + assert.equal(rows.reduce((n, r) => n + r.usage[0].output, 0), 800); +}); + +test('malformed repeated identifiers are not cross-file identity proof', () => { + const a = parse([asst({ id: 'not-an-api-message-id', s: 5, block: text() })]); + const b = parse([asst({ id: 'not-an-api-message-id', s: 5, block: text() })]); + const rows = reconcileClaudeMessages([a, b]); + assert.equal(rows.reduce((n, r) => n + r.accountedResponses, 0), 2); + assert.equal(rows.reduce((n, r) => n + r.usage[0].output, 0), 400); +}); + +test('message ID, request ID and serving-provider namespaces stay separate', () => { + const plain = parse([asst({ id: 'same', s: 5, block: text() })]); + const request = parse([asst({ requestId: 'same', s: 5, block: text() })]); + const bedrock = parse([asst({ id: 'same', s: 5, block: text(), model: 'us.anthropic.claude-sonnet-4-20250514-v1:0' })]); + const rows = reconcileClaudeMessages([plain, request, bedrock]); + assert.equal(rows.reduce((n, r) => n + r.accountedResponses, 0), 3); + assert.equal(rows.reduce((n, r) => n + r.usage[0].output, 0), 600); +}); + +test('partial copied snapshots reconcile component maxima once', () => { + const a = parse([asst({ id: 'msg_partial', s: 5, block: text(), usage: { ...USAGE, cache_read_input_tokens: 0 } })]); + const b = parse([asst({ id: 'msg_partial', s: 6, block: text(), usage: { ...USAGE, output_tokens: 20 } })]); + const rows = reconcileClaudeMessages([a, b]); + assert.equal(rows.reduce((n, r) => n + r.usage[0].output, 0), 200); + assert.equal(rows.reduce((n, r) => n + r.usage[0].cacheRead, 0), 5000); + assert.equal(rows.reduce((n, r) => n + r.accountedResponses, 0), 1); +}); + +test('copies entirely outside the fixed pool still have one historical charge', () => { + const a = parse([asst({ id: 'msg_old', s: 5, block: text(), usage: { ...USAGE, output_tokens: 12 } })]); + const b = parse([asst({ id: 'msg_old', s: 6, block: text(), usage: USAGE })]); + a.claudeIdentityEligible = false; b.claudeIdentityEligible = false; + const rows = reconcileClaudeMessages([a, b]); + assert.equal(rows.reduce((n, r) => n + r.accountedResponses, 0), 1); + assert.equal(rows.reduce((n, r) => n + r.usage[0].output, 0), 200); +}); + +test('equal claims have a stable accounting owner when sidechain and source differ', () => { + const main = parse([asst({ id: 'msg_tie', s: 5, block: text() })]); + const side = parse([asst({ id: 'msg_tie', s: 5, block: text() })]); + main.sidechain = false; side.sidechain = true; + main.claudeSourceKey = 'a'; side.claudeSourceKey = 'b'; + const forward = reconcileClaudeMessages([main, side]); + const reverse = reconcileClaudeMessages([side, main]); + assert.equal(forward.find((r) => r.claudeSourceKey === 'a').accountedResponses, 1); + assert.equal(reverse.find((r) => r.claudeSourceKey === 'a').accountedResponses, 1); + assert.equal(forward.find((r) => r.claudeSourceKey === 'b').accountedResponses, 0); + assert.equal(reverse.find((r) => r.claudeSourceKey === 'b').accountedResponses, 0); + const x = parse([asst({ id: 'msg_same_metadata', s: 5, block: text() })]); + const y = parse([asst({ id: 'msg_same_metadata', s: 5, block: text() })]); + x.claudeSourceKey = 'x'; y.claudeSourceKey = 'y'; + assert.equal(reconcileClaudeMessages([x, y]).find((r) => r.claudeSourceKey === 'x').accountedResponses, 1); + assert.equal(reconcileClaudeMessages([y, x]).find((r) => r.claudeSourceKey === 'x').accountedResponses, 1, + 'source identity settles a tie even when every visible session field matches'); +}); + +test('one identity is charged once across current, previous and combined windows regardless of lookback', async () => { + _resetForTest(); + const dir = tempDir('ak-cross-window'); + const root = path.join(dir, 'claude'); + const proj = path.join(root, '-Users-me-proj'); + fs.mkdirSync(proj, { recursive: true }); + const oldFile = path.join(proj, 'old.jsonl'); + const newFile = path.join(proj, 'new.jsonl'); + const write = (file, s, output) => { + fs.writeFileSync(file, [prompt(s), asst({ id: 'msg_shared', s: s + 5, block: text(), + usage: { ...USAGE, output_tokens: output } })].join('\n') + '\n'); + fs.utimesSync(file, new Date(at(s)), new Date(at(s + 5))); + }; + write(oldFile, 0, 200); + write(newFile, 86_400, 100); + const o = { days: 1, now: T0 + 2 * 86_400_000, + roots: { claude: root, codex: path.join(dir, 'codex') }, + cachePath: path.join(dir, 'usage-index.json'), codexState: null, + deps: { costOf: ({ output }) => output / 100, pricesAsOf: 'fixture', + classify: () => ({ category: 'Build', confidence: 1, basis: 'fixture' }), detectInsights: () => [] } }; + const plain = await buildIndex(o); + assert.equal(plain.totals.output, 0, 'the richer older copy is the single accounting owner'); + assert.equal(plain.totals.responses, 0); + assert.equal(plain.totals.cost, 0); + assert.deepEqual(plain.sourceHealth.claude.identityCoverage, + { horizonDays: 2, horizonCoversComparison: true, horizonCoversRequestedHistory: true, + outOfPoolRecords: 0, unknownEligibilityRecords: 0, outsideCurrentExcluded: 0, + basis: 'file-mtime-and-session-end' }); + _resetForTest(); + const widenedCurrent = await buildIndex({ ...o, lookbackDays: 2 }); + assert.equal(widenedCurrent.totals.output, plain.totals.output); + assert.equal(widenedCurrent.totals.responses, plain.totals.responses); + _resetForTest(); + const widened = await buildIndex({ ...o, lookbackDays: 2, previous: true }); + assert.equal(widened.totals.output, plain.totals.output, 'comparison cannot change the displayed charge'); + assert.equal(widened.totals.responses, plain.totals.responses); + assert.equal(widened.previous.totals.output, 200); + assert.equal(widened.previous.totals.responses, 1); + assert.equal(widened.previous.totals.cost, 2); + _resetForTest(); + const combined = await buildIndex({ ...o, days: 2 }); + assert.equal(combined.totals.output, 200); + assert.equal(combined.totals.responses, 1); + assert.equal(combined.totals.cost, 2); + assert.equal(widened.totals.output + widened.previous.totals.output, combined.totals.output); + assert.equal(widened.totals.responses + widened.previous.totals.responses, combined.totals.responses); + _resetForTest(); + const warmPlain = await buildIndex(o); + assert.equal(warmPlain.totals.output, plain.totals.output); + const outsideFile = path.join(proj, 'outside.jsonl'); + write(outsideFile, -86_400, 300); + _resetForTest(); + const longLookback = await buildIndex({ ...o, lookbackDays: 5, previous: true }); + assert.equal(longLookback.totals.output, plain.totals.output, + 'a third copy outside the fixed accounting horizon cannot change current ownership'); + assert.equal(longLookback.previous.totals.output, 200); + _resetForTest(); + assert.equal((await buildIndex(o)).totals.output, plain.totals.output, + 'cached older history cannot change the plain query'); + _resetForTest(); + const capped = await buildIndex({ ...o, days: 400 }); + assert.deepEqual(capped.sourceHealth.claude.identityCoverage, + { horizonDays: 730, horizonCoversComparison: false, horizonCoversRequestedHistory: true, + outOfPoolRecords: 0, unknownEligibilityRecords: 0, outsideCurrentExcluded: 0, + basis: 'file-mtime-and-session-end' }, + 'a caller wider than the supported dashboard window sees the identity cap'); +}); + +test('equal copied usage still yields one charge across adjacent windows', async () => { + _resetForTest(); + const dir = tempDir('ak-equal-cross-window'); + const root = path.join(dir, 'claude'); + const proj = path.join(root, '-Users-me-proj'); + fs.mkdirSync(proj, { recursive: true }); + for (const [name, seconds] of [['old', 0], ['new', 86_400]]) { + const file = path.join(proj, `${name}.jsonl`); + fs.writeFileSync(file, asst({ id: 'msg_equal', s: seconds + 5, block: text() }) + '\n'); + fs.utimesSync(file, new Date(at(seconds)), new Date(at(seconds + 5))); + } + const o = { days: 1, now: T0 + 2 * 86_400_000, + roots: { claude: root, codex: path.join(dir, 'codex') }, + cachePath: path.join(dir, 'usage-index.json'), codexState: null, + deps: { costOf: ({ output }) => output / 100, pricesAsOf: 'fixture', + classify: () => ({ category: 'Build', confidence: 1, basis: 'fixture' }), detectInsights: () => [] } }; + const split = await buildIndex({ ...o, previous: true }); + assert.equal(split.previous.totals.sessions, 1, 'comparison is acquired within the common identity horizon'); + _resetForTest(); + const combined = await buildIndex({ ...o, days: 2 }); + assert.equal(split.totals.output + split.previous.totals.output, 200); + assert.equal(split.totals.responses + split.previous.totals.responses, 1); + assert.equal(combined.totals.output, 200); + assert.equal(combined.totals.responses, 1); + assert.equal(split.totals.cost + split.previous.totals.cost, combined.totals.cost); +}); + +test('deeper lookback cannot promote a copy whose file mtime is outside the fixed identity pool', async () => { + _resetForTest(); + const dir = tempDir('ak-identity-mtime'); + const root = path.join(dir, 'claude'); + const proj = path.join(root, '-Users-me-proj'); + fs.mkdirSync(proj, { recursive: true }); + const olderMtime = path.join(proj, 'older-mtime.jsonl'); + const newerMtime = path.join(proj, 'newer-mtime.jsonl'); + const write = (file, seconds, output, mtimeSeconds, id = 'msg_same') => { + fs.writeFileSync(file, asst({ id, s: seconds, block: text(), + usage: { ...USAGE, output_tokens: output } }) + '\n'); + fs.utimesSync(file, new Date(at(mtimeSeconds)), new Date(at(mtimeSeconds))); + }; + write(olderMtime, 5, 200, -86_400); // transcript Aug 20, file mtime Aug 19 + write(newerMtime, 86_405, 100, 86_405); // transcript and mtime Aug 21 + const o = { days: 1, now: T0 + 2 * 86_400_000, + roots: { claude: root, codex: path.join(dir, 'codex') }, + cachePath: path.join(dir, 'usage-index.json'), codexState: null, + deps: { costOf: ({ output }) => output / 100, pricesAsOf: 'fixture', + classify: () => ({ category: 'Build', confidence: 1, basis: 'fixture' }), detectInsights: () => [] } }; + const plain = await buildIndex(o); + assert.equal(plain.totals.output, 100); + _resetForTest(); + const wider = await buildIndex({ ...o, lookbackDays: 5, previous: true }); + assert.equal(wider.totals.output, 100); + assert.equal(wider.totals.cost, 1); + assert.equal(wider.totals.responses, 1); + assert.equal(wider.previous.totals.output, 0, 'the observed duplicate still charges only once'); + assert.equal(wider.sourceHealth.claude.identityCoverage.horizonCoversRequestedHistory, false); + assert.equal(wider.sourceHealth.claude.identityCoverage.outOfPoolRecords, 1); + _resetForTest(); + assert.equal((await buildIndex(o)).totals.output, 100, 'cached older history cannot promote it'); + + const distinctHistory = path.join(proj, 'distinct-history.jsonl'); + write(distinctHistory, 6, 50, -86_400, 'msg_distinct_history'); + _resetForTest(); + const historical = await buildIndex({ ...o, lookbackDays: 5, previous: true }); + assert.equal(historical.totals.output, 100); + assert.equal(historical.previous.totals.output, 50, + 'a distinct outside-pool historical message remains visible when requested'); + assert.equal(historical.sourceHealth.claude.identityCoverage.outOfPoolRecords, 2); + + // Inclusive boundary: a file at the fixed mtime cutoff belongs to the + // identity pool even when its transcript timestamp is earlier. + fs.utimesSync(olderMtime, new Date(at(0)), new Date(at(0))); + _resetForTest(); + const boundary = await buildIndex(o); + assert.equal(boundary.totals.output, 0, 'the richer boundary copy is eligible'); + _resetForTest(); + const boundaryWide = await buildIndex({ ...o, lookbackDays: 5, previous: true }); + assert.equal(boundaryWide.totals.output, 0); + assert.equal(boundaryWide.previous.totals.output, 250); +}); + +test('malformed cached Claude claim is reparsed instead of crashing or trusted', async () => { + _resetForTest(); + const dir = tempDir('ak-malformed-claims'); + const root = path.join(dir, 'claude'); + const proj = path.join(root, '-Users-me-proj'); + fs.mkdirSync(proj, { recursive: true }); + const file = path.join(proj, 'one.jsonl'); + fs.writeFileSync(file, asst({ id: 'msg_one', s: 5, block: text() }) + '\n'); + const o = { days: 14, now: T0 + 86_400_000, + roots: { claude: root, codex: path.join(dir, 'codex') }, + cachePath: path.join(dir, 'usage-index.json'), codexState: null, + deps: { costOf: () => 0, pricesAsOf: 'fixture', + classify: () => ({ category: 'Build', confidence: 1, basis: 'fixture' }), detectInsights: () => [] } }; + assert.equal((await buildIndex(o)).totals.output, 200); + const original = JSON.parse(fs.readFileSync(o.cachePath, 'utf8')); + const claim = original.entries[file].session.claudeMessages[0]; + for (const malformed of [[null], [{ ...claim, usage: { ...claim.usage, output: 201 } }], + [claim, claim]]) { + const cache = structuredClone(original); + cache.entries[file].session.claudeMessages = malformed; + fs.writeFileSync(o.cachePath, JSON.stringify(cache)); + _resetForTest(); + assert.equal((await buildIndex(o)).totals.output, 200); + const repaired = JSON.parse(fs.readFileSync(o.cachePath, 'utf8')); + assert.equal(repaired.entries[file].session.claudeMessages.length, 1); + assert.equal(typeof repaired.entries[file].session.claudeMessages[0].identity, 'string'); + } +}); + +test('invalid cached claims do not mask a source that becomes unreadable before reparse', async () => { + _resetForTest(); + const dir = tempDir('ak-claim-fallback'); + const root = path.join(dir, 'claude'); + const proj = path.join(root, '-Users-me-proj'); + fs.mkdirSync(proj, { recursive: true }); + const file = path.join(proj, 'one.jsonl'); + fs.writeFileSync(file, asst({ id: 'msg_one', s: 5, block: text() }) + '\n'); + const o = { days: 14, now: T0 + 86_400_000, + roots: { claude: root, codex: path.join(dir, 'codex') }, + cachePath: path.join(dir, 'usage-index.json'), codexState: null, + deps: { costOf: () => 0, pricesAsOf: 'fixture', + classify: () => ({ category: 'Build', confidence: 1, basis: 'fixture' }), detectInsights: () => [] } }; + await buildIndex(o); + const cache = JSON.parse(fs.readFileSync(o.cachePath, 'utf8')); + cache.entries[file].session.claudeMessages = [null]; + fs.writeFileSync(o.cachePath, JSON.stringify(cache)); + _resetForTest(); + const result = await buildIndex({ ...o, onProgress: ({ phase, scanned }) => { + if (phase === 'scan' && scanned === 0) { + fs.unlinkSync(file); + fs.mkdirSync(file); // still stats, but is no longer a readable transcript + } + } }); + assert.equal(result.totals.sessions, 0, 'an invalid cache is not a fallback observation'); + assert.equal(result.sourceHealth.claude.status, 'degraded'); + assert.equal(result.sourceHealth.claude.diagnostics.common.unitsSeen, 1); + assert.equal(result.sourceHealth.claude.diagnostics.common.unitsParsed, 0); + assert.equal(Object.keys(JSON.parse(fs.readFileSync(o.cachePath, 'utf8')).entries).length, 0); +}); + +test('index keeps cross-file accounting on cold, warm, add, change and removal scans', async () => { + _resetForTest(); + const dir = tempDir('ak-cross-file'); + const root = path.join(dir, 'claude'); + const proj = path.join(root, '-Users-me-proj'); + const other = path.join(root, '-Users-me-other'); + fs.mkdirSync(proj, { recursive: true }); + fs.mkdirSync(other, { recursive: true }); + const a = path.join(proj, 'a.jsonl'); + const b = path.join(other, 'b.jsonl'); + const c = path.join(proj, 'c.jsonl'); + const write = (file, messages) => fs.writeFileSync(file, + [prompt(), ...messages].join('\n') + '\n'); + write(a, [asst({ id: 'msg_shared', s: 5, block: text(), usage: { ...USAGE, output_tokens: 12 } })]); + write(b, [asst({ id: 'msg_shared', s: 86_406, block: text(), usage: USAGE })]); + const o = { days: 14, now: T0 + 2 * 86_400_000, + roots: { claude: root, codex: path.join(dir, 'codex') }, + cachePath: path.join(dir, 'usage-index.json'), codexState: null, + deps: { costOf: ({ output }) => output / 100, pricesAsOf: 'fixture', + classify: () => ({ category: 'Build', confidence: 1, basis: 'fixture' }), detectInsights: () => [] } }; + const check = (result, count, output, responses, sharedProject) => { + assert.equal(result.totals.sessions, count); + assert.equal(result.totals.responses, responses); + assert.equal(result.totals.output, output); + assert.equal(result.totals.cost, output / 100); + assert.equal(result.totals.tokens, result.totals.input + result.totals.output + + result.totals.cacheRead + result.totals.cacheWrite); + assert.equal(Object.values(result.byDay).reduce((n, row) => n + row.tokens, 0), result.totals.tokens); + assert.ok(result.byDay[dayAt(sharedProject === 'other' ? 86_406 : 5)].tokens > 0, + 'the elected copy owns its local billing day'); + assert.equal(Object.values(result.byModel).reduce((n, row) => n + row.output, 0), output); + assert.equal(Object.values(result.byProvider).reduce((n, row) => n + row.output, 0), output); + assert.equal(Object.values(result.byProject).reduce((n, row) => n + row.output, 0), output); + assert.equal(result.byProject[sharedProject].output, + sharedProject === 'other' ? output - (count === 3 ? 200 : 0) : output); + assert.equal(Object.values(result.bySource).reduce((n, row) => n + row.responses, 0), responses); + assert.equal(Object.values(result.punchcard).reduce((n, value) => n + value, 0), responses); + assert.equal(result.projectTree.reduce((n, row) => n + row.tokens, 0), result.totals.tokens); + assert.equal(result.sessions.reduce((n, row) => n + row.output, 0), output); + assert.equal(result.sessions.reduce((n, row) => n + row.responses, 0), count, + 'each session retains its own observed response count'); + assert.equal(result.sessions.reduce((n, row) => n + row.accountedResponses, 0), responses); + }; + check(await buildIndex(o), 2, 200, 1, 'other'); + const legacy = JSON.parse(fs.readFileSync(o.cachePath, 'utf8')); + for (const entry of Object.values(legacy.entries)) delete entry.session.claudeMessages; + fs.writeFileSync(o.cachePath, JSON.stringify(legacy)); + _resetForTest(); + check(await buildIndex(o), 2, 200, 1, 'other'); // compatible schema-26 cache reparses old Claude entries + _resetForTest(); + check(await buildIndex(o), 2, 200, 1, 'other'); // genuinely warm + write(c, [asst({ id: 'msg_distinct', s: 7, block: text(), usage: USAGE })]); + _resetForTest(); + check(await buildIndex(o), 3, 400, 2, 'other'); + write(b, [asst({ id: 'msg_shared', s: 86_408, block: text(), usage: { ...USAGE, output_tokens: 300 } })]); + fs.utimesSync(b, new Date(T0), new Date(T0 + 20_000)); + _resetForTest(); + check(await buildIndex(o), 3, 500, 2, 'other'); + fs.unlinkSync(b); + _resetForTest(); + check(await buildIndex(o), 2, 212, 2, 'proj'); +}); + // ── schema version ────────────────────────────────────────────────────────── test('SCHEMA_VERSION is at least 22 and a v21 cache is discarded and re-parsed de-duplicated', async () => { diff --git a/tests/kit/usage-claude-provider.test.mjs b/tests/kit/usage-claude-provider.test.mjs new file mode 100644 index 00000000..f0870160 --- /dev/null +++ b/tests/kit/usage-claude-provider.test.mjs @@ -0,0 +1,96 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { parseClaude, parseCodex } from '../../src/lib/usage-parsers.mjs'; +import { buildIndex, _resetForTest } from '../../src/lib/usage-index.mjs'; +import { tempDir } from './helpers/temp-dir.mjs'; +import { Rollout } from './helpers/codex-rollout.mjs'; + +const line = (value) => JSON.stringify(value); +const assistant = (model, extra = {}) => line({ type: 'assistant', timestamp: '2026-09-28T10:00:00Z', + entrypoint: 'claude-desktop-3p', ...extra, + message: { id: `m-${model}`, role: 'assistant', model, + usage: { input_tokens: 3, output_tokens: 2 }, content: [{ type: 'text', text: 'ok' }] } }); +const parse = (...lines) => parseClaude(lines.join('\n'), { id: 'provider-fixture' }).session; + +test('session-bound Bedrock and Vertex model IDs establish a separate provider detail', () => { + for (const [model, provider] of [ + ['us.anthropic.claude-sonnet-4-5-20250929-v1:0', 'amazon-bedrock'], + ['anthropic.claude-haiku-4-5', 'amazon-bedrock'], + ['anthropic.claude-opus-4-6-v1', 'amazon-bedrock'], + ['anthropic.claude-fable-5-1', 'amazon-bedrock'], + ['claude-sonnet-4-5@20250929', 'google-vertex-ai'], + ['claude-sonnet-4-5@20240229', 'google-vertex-ai'], + ]) { + const rec = parse(assistant(model)); + assert.equal(rec.sessionOrigin.surface, 'claude-desktop'); + assert.deepEqual(rec.sessionOrigin.attributes, ['on 3P']); + assert.equal(rec.sessionOrigin.thirdPartyProvider, provider); + assert.equal(rec.sessionOrigin.thirdPartyProviderBasis, 'assistant-model-id'); + assert.equal(rec.inferenceProvider, null, 'existing pricing/provider axis is unchanged'); + } +}); + +test('ordinary models, unrelated current configuration, unknown gateways and malformed metadata stay unknown', () => { + for (const model of ['claude-sonnet-4-5', 'anthropic/claude-sonnet-4-5', + 'https://secret:token@private.example/model', '//private.example/model', 'claude-sonnet-4-5@bad', + 'anthropic.claude-sonnet-4-this-is-not-a-model', 'claude-sonnet-4-5@20999999', + 'claude-sonnet-4-5@20260229', 'anthropic.claude-sonnet-4-5-20260229-v1:0', + { value: 'us.anthropic.claude-sonnet-4-5' }]) { + const { session: rec, turns } = parseClaude(assistant(model, { + env: { CLAUDE_CODE_USE_BEDROCK: '1', ANTHROPIC_BASE_URL: 'https://secret:token@private.example' }, + settings: { env: { CLAUDE_CODE_USE_VERTEX: '1' } }, + }), { id: 'provider-fixture', withTurns: true }); + assert.equal(rec.sessionOrigin.thirdPartyProvider, null); + assert.equal(JSON.stringify(rec.sessionOrigin).includes('private.example'), false); + assert.equal(JSON.stringify(rec.sessionOrigin).includes('token'), false); + assert.equal(JSON.stringify(rec).includes('private.example'), false); + assert.equal(JSON.stringify(rec).includes('secret:token'), false); + assert.equal(JSON.stringify(turns).includes('private.example'), false); + } +}); + +test('a local API-error placeholder cannot erase a preceding completed provider observation', () => { + const actual = assistant('us.anthropic.claude-sonnet-4-6'); + const error = assistant('', { isApiErrorMessage: true }); + const rec = parse(actual, error); + assert.equal(rec.sessionOrigin.thirdPartyProvider, 'amazon-bedrock'); + assert.equal(rec.responses, 1); + assert.equal(rec.exceptions, 1); + assert.deepEqual(rec.models, ['us.anthropic.claude-sonnet-4-6']); +}); + +test('conflicting provider-specific assistant IDs leave session provider unknown', () => { + const rec = parse(assistant('us.anthropic.claude-sonnet-4-6'), assistant('claude-haiku-4-5@20251001')); + assert.equal(rec.sessionOrigin.thirdPartyProvider, null); + assert.equal(rec.sessionOrigin.thirdPartyProviderBasis, undefined); + assert.equal(parse(assistant('us.anthropic.claude-sonnet-4-6'), assistant('claude-opus-5')) + .sessionOrigin.thirdPartyProvider, null, 'an unmatched model may have used a different route'); +}); + +test('imported Codex copy never inherits a Claude provider claim', () => { + const copy = new Rollout({ id: 'copy' }).meta({ originator: 'Codex Desktop' }) + .taskStarted('external-import-turn-1').user('copy').agent('response'); + const rec = parseCodex(copy.toString(), { id: 'copy' }).session; + assert.equal(rec.imported, true); + assert.equal(rec.sessionOrigin.thirdPartyProvider, null); +}); + +test('provider detail survives the existing schema-26 cold and warm cache', async (t) => { + _resetForTest(); + const dir = tempDir('ak-provider-', t), project = path.join(dir, 'claude', '-synthetic-project'); + fs.mkdirSync(project, { recursive: true }); + fs.writeFileSync(path.join(project, 'provider-fixture.jsonl'), `${assistant('us.anthropic.claude-sonnet-4-6')}\n`); + const options = { days: 2, now: Date.parse('2026-09-29T12:00:00Z'), + roots: { claude: path.join(dir, 'claude'), codex: path.join(dir, 'codex') }, + cachePath: path.join(dir, 'cache', 'usage-index.json'), + deps: { costOf: () => 0, pricesAsOf: 'fixture', classify: () => ({ category: 'Build', confidence: 1, basis: 'fixture' }), detectInsights: () => [] } }; + const cold = await buildIndex(options); + assert.equal(cold.sessions[0].sessionOrigin.thirdPartyProvider, 'amazon-bedrock'); + const cache = JSON.parse(fs.readFileSync(options.cachePath, 'utf8')); + assert.equal(Object.values(cache.entries)[0].session.sessionOrigin.thirdPartyProviderBasis, 'assistant-model-id'); + _resetForTest(); + const warm = await buildIndex(options); + assert.deepEqual(warm.sessions[0].sessionOrigin, cold.sessions[0].sessionOrigin); +}); diff --git a/tests/kit/usage-claude-record-coverage.test.mjs b/tests/kit/usage-claude-record-coverage.test.mjs new file mode 100644 index 00000000..bb1f3605 --- /dev/null +++ b/tests/kit/usage-claude-record-coverage.test.mjs @@ -0,0 +1,133 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { parseClaude } from '../../src/lib/usage-parsers.mjs'; +import { buildIndex, _resetForTest } from '../../src/lib/usage-index.mjs'; +import { tempDir } from './helpers/temp-dir.mjs'; + +const at = '2026-09-28T12:00:00Z'; +const line = (value) => JSON.stringify(value); +const assistant = { type: 'assistant', timestamp: at, message: { + id: 'm1', role: 'assistant', model: 'claude-opus-5', + usage: { input_tokens: 3, output_tokens: 5 }, content: [{ type: 'text', text: 'ok' }], +} }; +const deps = { costOf: () => 2, pricesAsOf: '2026-09-01', + classify: () => ({ category: 'Build', confidence: 1, basis: 'test' }), detectInsights: () => [] }; + +const raw = [ + line({ type: 'user', timestamp: at, message: { role: 'user', content: 'hello' } }), + line(assistant), line({ type: 'ai-title', aiTitle: 'safe title' }), + line({ type: 'cost-state', sessionId: 's1', totalCostUSD: 1 }), + line({ type: 'system', timestamp: at }), line({ type: 'attachment', timestamp: at }), + line({ type: 'future-private-type', timestamp: at, message: { usage: { output_tokens: 900 } } }), + line({ type: 42, timestamp: at }), '{bad json', +].join('\n'); + +test('Claude record coverage separates handled, ignored, unknown, invalid type and malformed JSON', () => { + const { session, parseStats } = parseClaude(raw, { id: 's1' }); + assert.deepEqual(parseStats, { + knownHandledRecords: 4, knownIgnoredRecords: 2, unknownRecords: 1, + invalidTypeRecords: 1, malformedRecords: 1, + }); + assert.equal(session.responses, 1); + assert.equal(session.usage[0].input, 3); + assert.equal(session.usage[0].output, 5); + assert.equal(JSON.stringify({ session, parseStats }).includes('future-private-type'), false); +}); + +test('valid whitespace-prefixed objects and non-object JSON have separate coverage from invalid JSON', () => { + const source = [' ' + line({ type: 'user', timestamp: at, message: { role: 'user', content: 'hello' } }), + '\t' + line(assistant), ' [1,2]', '"private scalar"', 'null', '42', ' ', '{bad json'].join('\n'); + const { session, parseStats } = parseClaude(source, { id: 's1' }); + assert.deepEqual(parseStats, { + knownHandledRecords: 2, knownIgnoredRecords: 0, unknownRecords: 0, + invalidTypeRecords: 4, malformedRecords: 1, + }); + assert.equal(session.responses, 1); + assert.equal(session.usage[0].input, 3); + assert.equal(session.usage[0].output, 5); + assert.equal(JSON.stringify({ session, parseStats }).includes('private scalar'), false); +}); + +test('whitespace-prefixed known records stay healthy across cold and warm cache reads', async () => { + const root = tempDir('ak-claude-whitespace-health'); + const project = path.join(root, 'claude', 'project'); + fs.mkdirSync(project, { recursive: true }); + fs.writeFileSync(path.join(project, 's1.jsonl'), [' ' + line(assistant), '\t' + line({ type: 'system' })].join('\n')); + const options = { days: 7, now: Date.parse('2026-09-29T00:00:00Z'), + roots: { claude: path.join(root, 'claude'), codex: path.join(root, 'codex') }, + cachePath: path.join(root, 'cache.json'), deps }; + for (let i = 0; i < 2; i++) { + _resetForTest(); + const result = await buildIndex(options); + assert.equal(result.sourceHealth.claude.status, 'ok'); + assert.deepEqual(result.sourceHealth.claude.diagnostics.records, { + knownHandledRecords: 1, knownIgnoredRecords: 1, unknownRecords: 0, + invalidTypeRecords: 0, malformedRecords: 0, coverage: 'complete', + }); + assert.equal(result.totals.responses, 1); + assert.equal(result.totals.cost, 2); + } +}); + +test('cold, warm and legacy v26 cache expose count-only incomplete coverage without changing totals', async () => { + const root = tempDir('ak-claude-record-coverage'); + const project = path.join(root, 'claude', 'project'); + fs.mkdirSync(project, { recursive: true }); + const file = path.join(project, 's1.jsonl'); + fs.writeFileSync(file, raw); + const options = { days: 7, now: Date.parse('2026-09-29T00:00:00Z'), + roots: { claude: path.join(root, 'claude'), codex: path.join(root, 'codex') }, + cachePath: path.join(root, 'cache.json'), deps }; + _resetForTest(); + const cold = await buildIndex(options); + const expected = { knownHandledRecords: 4, knownIgnoredRecords: 2, unknownRecords: 1, + invalidTypeRecords: 1, malformedRecords: 1, coverage: 'incomplete' }; + assert.deepEqual(cold.sourceHealth.claude.diagnostics.records, expected); + assert.equal(cold.sourceHealth.claude.status, 'degraded'); + assert.equal(cold.totals.responses, 1); + assert.equal(cold.totals.cost, 2); + const cached = JSON.parse(fs.readFileSync(options.cachePath, 'utf8')); + assert.deepEqual(cached.entries[file].parseStats, { + knownHandledRecords: 4, knownIgnoredRecords: 2, unknownRecords: 1, + invalidTypeRecords: 1, malformedRecords: 1, + }); + assert.equal(JSON.stringify(cached).includes('future-private-type'), false); + _resetForTest(); + const warm = await buildIndex(options); + assert.deepEqual(warm.sourceHealth.claude.diagnostics.records, expected); + assert.equal(warm.totals.cost, cold.totals.cost); + + delete cached.entries[file].parseStats; + fs.writeFileSync(options.cachePath, JSON.stringify(cached)); + _resetForTest(); + const repaired = await buildIndex(options); + assert.deepEqual(repaired.sourceHealth.claude.diagnostics.records, expected); + assert.deepEqual(JSON.parse(fs.readFileSync(options.cachePath, 'utf8')).entries[file].parseStats, + { knownHandledRecords: 4, knownIgnoredRecords: 2, unknownRecords: 1, + invalidTypeRecords: 1, malformedRecords: 1 }); +}); + +test('known-only files report complete coverage and an unreadable root reports unknown coverage', async () => { + const root = tempDir('ak-claude-record-health'); + const project = path.join(root, 'claude', 'project'); + fs.mkdirSync(project, { recursive: true }); + fs.writeFileSync(path.join(project, 's1.jsonl'), [line(assistant), line({ type: 'system' })].join('\n')); + const options = { days: 7, now: Date.parse('2026-09-29T00:00:00Z'), + roots: { claude: path.join(root, 'claude'), codex: path.join(root, 'codex') }, + cachePath: path.join(root, 'cache.json'), deps }; + _resetForTest(); + const good = await buildIndex(options); + assert.deepEqual(good.sourceHealth.claude.diagnostics.records, { + knownHandledRecords: 1, knownIgnoredRecords: 1, unknownRecords: 0, + invalidTypeRecords: 0, malformedRecords: 0, coverage: 'complete', + }); + assert.equal(good.sourceHealth.claude.status, 'ok'); + const badRoot = path.join(root, 'not-a-directory'); + fs.writeFileSync(badRoot, ''); + _resetForTest(); + const bad = await buildIndex({ ...options, roots: { ...options.roots, claude: badRoot } }); + assert.equal(bad.sourceHealth.claude.status, 'degraded'); + assert.equal(bad.sourceHealth.claude.diagnostics.records.coverage, 'unknown'); +}); diff --git a/tests/kit/usage-codex-effort-timing-compaction.test.mjs b/tests/kit/usage-codex-effort-timing-compaction.test.mjs new file mode 100644 index 00000000..ef4adbad --- /dev/null +++ b/tests/kit/usage-codex-effort-timing-compaction.test.mjs @@ -0,0 +1,162 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseCodex } from '../../src/lib/usage-parsers.mjs'; +import { aggregate } from '../../src/lib/usage-aggregate.mjs'; +import { buildIndex, _resetForTest } from '../../src/lib/usage-index.mjs'; +import { Rollout, usage, codexSandbox, stubDeps } from './helpers/codex-rollout.mjs'; +import { forkedSubagent } from './helpers/codex-rollout.mjs'; + +const now = Date.parse('2026-07-25T12:00:00Z'); +const compacted = { window_number: 2, replacement_history: [], latest_token_usage_record: {} }; +const sample = () => new Rollout({ id: 'unit10' }).meta() + .taskStarted('t1').turn('gpt-5.6', { effort: 'high' }).user('work') + .tokenCount(usage({ input: 100, output: 5 })) + .raw('event_msg', { type: 'task_complete', duration_ms: 9000, time_to_first_token_ms: 1200 }) + .raw('compacted', compacted) + .item('ContextCompaction', { id: 'compaction-1' }) + .taskStarted('t2').turn('gpt-5.6', { effort: 'low' }).user('continue') + .tokenCount(usage({ input: 40, output: 3 })) + .raw('event_msg', { type: 'task_complete', duration_ms: 7000, time_to_first_token_ms: 800 }); + +test('native effort, measured first-token times and paired compaction reconcile without token replay', () => { + const rec = parseCodex(String(sample()), { id: 'fallback' }).session; + assert.deepEqual(rec.codexEffort, { last: 'low', counts: { high: 1, low: 1 } }); + assert.deepEqual(rec.firstTokenMs, { count: 2, total: 2000, min: 800, max: 1200, provenance: 'host-observed' }); + assert.equal(rec.compactions, 1); + const a = aggregate([rec], { days: 14, now, cutoff: now - 14 * 86400000, deps: stubDeps() }); + assert.equal(a.totals.tokens, 148); + assert.equal(a.sessions[0].compactions, 1); + assert.deepEqual(a.sessions[0].codexEffort, rec.codexEffort); + assert.deepEqual(a.sessions[0].firstTokenMs, rec.firstTokenMs); + assert.equal(a.totals.compactions, 1); + assert.deepEqual(a.totals.firstTokenMs, { count: 2, total: 2000, min: 800, max: 1200, provenance: 'host-observed' }); +}); + +test('missing or malformed timing and unbounded effort never become measurements', () => { + const r = new Rollout({ id: 'invalid' }).meta() + .raw('event_msg', { type: 'task_started', turn_id: 't1', started_at: '2026-07-24T09:00:00.000Z' }) + .turn('gpt-5.6', { effort: 'arbitrary provider string' }).user('work') + .tokenCount(usage({ input: 20, output: 2 })) + .raw('event_msg', { type: 'task_complete', duration_ms: 4500 }) + .turn('gpt-5.6', { effort: 'high'.repeat(100) }) + .raw('event_msg', { type: 'task_complete', duration_ms: 1, time_to_first_token_ms: -1 }); + const rec = parseCodex(String(r), { id: 'fallback' }).session; + assert.equal(rec.codexEffort, null); + assert.equal(rec.firstTokenMs, null); + assert.equal(rec.compactions, 0); + assert.equal(rec.latCount > 0, true, 'existing total-duration latency remains separate'); +}); + +test('cold and warm cache retain the same observed detail', async () => { + const sb = codexSandbox({ 'rollout-unit10.jsonl': sample() }); + const options = { days: 14, now, roots: sb.roots, cachePath: sb.cachePath, deps: stubDeps() }; + for (let i = 0; i < 2; i++) { + _resetForTest(); + const a = await buildIndex(options); + assert.equal(a.sessions[0].codexEffort.last, 'low'); + assert.equal(a.sessions[0].firstTokenMs.total, 2000); + assert.equal(a.sessions[0].compactions, 1); + assert.equal(a.totals.tokens, 148); + if (i) assert.equal(a.sourceHealth.codex.diagnostics.cachedFiles, 1); + } +}); + +test('imported turns cannot contribute effort, first-token time or compaction', () => { + const r = new Rollout({ id: 'mixed' }).meta({ originator: 'Codex Desktop' }) + .taskStarted('external-import-turn-1').turn('gpt-5.6', { turn_id: 'external-import-turn-1', effort: 'high' }) + .raw('event_msg', { type: 'task_complete', time_to_first_token_ms: 999 }) + .raw('compacted', compacted) + .taskStarted('native-1').turn('gpt-5.6', { turn_id: 'native-1', effort: 'low' }) + .tokenCount(usage({ input: 20, output: 2 })) + .raw('event_msg', { type: 'task_complete', time_to_first_token_ms: 20 }); + const rec = parseCodex(String(r), { id: 'fallback' }).session; + assert.deepEqual(rec.codexEffort, { last: 'low', counts: { low: 1 } }); + assert.deepEqual(rec.firstTokenMs, { count: 1, total: 20, min: 20, max: 20, provenance: 'host-observed' }); + assert.equal(rec.compactions, 0); +}); + +test('subagent replay contributes no parent effort, timing or compaction', () => { + const parent = new Rollout({ id: 'parent' }).meta().taskStarted('parent-turn') + .turn('gpt-5.6', { effort: 'high' }) + .raw('event_msg', { type: 'task_complete', time_to_first_token_ms: 900 }) + .raw('compacted', compacted); + const child = forkedSubagent({ id: 'child', parent, own: (r) => r + .turn('gpt-5.6', { effort: 'medium' }) + .tokenCount(usage({ input: 10, output: 2 })) + .raw('event_msg', { type: 'task_complete', time_to_first_token_ms: 30 }) }); + const rec = parseCodex(String(child), { id: 'fallback' }).session; + assert.deepEqual(rec.codexEffort, { last: 'medium', counts: { medium: 1 } }); + assert.equal(rec.firstTokenMs.total, 30); + assert.equal(rec.compactions, 0); +}); + +test('older v26 records with absent or malformed optional detail remain unmeasured', () => { + const old = parseCodex(String(new Rollout({ id: 'old' }).meta().taskStarted('t1') + .turn().tokenCount(usage({ input: 10, output: 2 }))), { id: 'fallback' }).session; + delete old.codexEffort; + delete old.firstTokenMs; + delete old.compactions; + const malformed = { ...old, id: 'malformed', codexEffort: { last: 'arbitrary', counts: { arbitrary: 1 } }, + firstTokenMs: { count: 1, total: -3, min: -3, max: -3, provenance: 'derived' }, compactions: -2 }; + const a = aggregate([old, malformed], { days: 14, now, cutoff: now - 14 * 86400000, deps: stubDeps() }); + for (const row of a.sessions) { + assert.equal(row.codexEffort, null); + assert.equal(row.firstTokenMs, null); + assert.equal(row.compactions, 0); + } + assert.equal(a.totals.compactions, 0); + assert.equal(a.totals.firstTokenMs, null); +}); + +test('compaction observations on separate turns count separately', () => { + const r = new Rollout({ id: 'separate' }).meta().taskStarted('a').turn() + .tokenCount(usage({ input: 10, output: 2 })).raw('compacted', compacted) + .taskStarted('b').turn().raw('event_msg', { + type: 'item_completed', turn_id: 'b', item: { type: 'ContextCompaction' }, + }); + const rec = parseCodex(String(r), { id: 'fallback' }).session; + assert.equal(rec.compactions, 2); + assert.deepEqual(rec.compactionEvidence, { lowerBound: 2, upperBound: 2 }); +}); + +test('partially paired compaction shapes retain an explicit uncertainty bound', () => { + const r = new Rollout({ id: 'partial' }).meta().taskStarted('a').turn() + .tokenCount(usage({ input: 10, output: 2 })).raw('compacted', compacted) + .raw('event_msg', { type: 'item_completed', turn_id: 'a', item: { type: 'ContextCompaction' } }) + .taskStarted('b').turn().raw('compacted', { ...compacted, window_number: 3 }); + const rec = parseCodex(String(r), { id: 'fallback' }).session; + assert.equal(rec.compactions, 2); + assert.deepEqual(rec.compactionEvidence, { lowerBound: 2, upperBound: 3 }); + const a = aggregate([rec], { days: 14, now, cutoff: now - 14 * 86400000, deps: stubDeps() }); + assert.equal(a.totals.compactions, 2); + assert.deepEqual(a.totals.compactionEvidence, { lowerBound: 2, upperBound: 3 }); +}); + +test('an unmatched item turn ID cannot prove it differs from an ID-less compacted record', () => { + const r = new Rollout({ id: 'unmatched' }).meta().turn() + .tokenCount(usage({ input: 10, output: 2 })).raw('compacted', compacted) + .raw('event_msg', { type: 'item_completed', turn_id: 'unseen', item: { type: 'ContextCompaction' } }); + const rec = parseCodex(String(r), { id: 'fallback' }).session; + assert.deepEqual(rec.compactionEvidence, { lowerBound: 1, upperBound: 2 }); +}); + +test('pre-ordinal subagent history cannot supply effort, first-token time or compactions', () => { + const r = new Rollout({ id: 'old-child' }).meta({ thread_source: 'subagent' }) + .taskStarted('parent-turn').turn('gpt-5.6', { effort: 'high' }) + .tokenCount(usage({ input: 100, output: 5 })) + .raw('event_msg', { type: 'task_complete', time_to_first_token_ms: 950 }) + .raw('compacted', compacted); + const preOrdinal = r.lines.map((line) => { + const entry = JSON.parse(line); + delete entry.ordinal; + return JSON.stringify(entry); + }).join('\n'); + const rec = parseCodex(preOrdinal, { id: 'fallback' }).session; + assert.deepEqual(rec.usage, []); + assert.equal(rec.codexEffort, null); + assert.equal(rec.firstTokenMs, null); + assert.equal(rec.compactions, 0); + const a = aggregate([rec], { days: 14, now, cutoff: now - 14 * 86400000, deps: stubDeps() }); + assert.equal(a.totals.compactions, 0); + assert.equal(a.totals.firstTokenMs, null); +}); diff --git a/tests/kit/usage-codex-import-gaps.test.mjs b/tests/kit/usage-codex-import-gaps.test.mjs new file mode 100644 index 00000000..a42467e8 --- /dev/null +++ b/tests/kit/usage-codex-import-gaps.test.mjs @@ -0,0 +1,115 @@ +// Regression for lost or explicitly invalid ownership boundaries in mixed files. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { parseCodex } from '../../src/lib/usage-parsers.mjs'; +import { openCodexRollout } from '../../src/lib/codex-rollout-reader.mjs'; +import { scanTranscriptCwds } from '../../src/lib/footprint/project-sources.mjs'; +import { buildIndex, _resetForTest } from '../../src/lib/usage-index.mjs'; +import { Rollout, usage, codexSandbox, stubDeps } from './helpers/codex-rollout.mjs'; + +function beforeGap({ nativeActivity = true, imports = true } = {}) { + const r = new Rollout({ id: 'mixed' }).meta({ cwd: '/copied', originator: 'Codex Desktop' }); + if (imports) r.taskStarted('external-import-turn-1').agent('copy').tokenCount(usage({ input: 1000 })); + r.taskStarted('native-1').turn('gpt-5.6', { turn_id: 'native-1', cwd: '/native' }); + if (nativeActivity) r.agent('own').tokenCount(usage({ input: 100 })); + return r; +} +function afterGap(r) { return r.agent('owner-unproved').tokenCount(usage({ input: 500 })); } +function parseBoth(r) { + const sb = codexSandbox({ 'rollout-gap.jsonl': r }); + const file = path.join(sb.roots.codex, '2026', '07', '24', 'rollout-gap.jsonl'); + return [parseCodex(String(r), { id: 'fallback' }), + parseCodex(openCodexRollout(file, { chunkBytes: 37 }), { id: 'fallback' }), + parseCodex(openCodexRollout(file), { id: 'fallback' })]; +} +function assertExcluded(result) { + assert.equal(result.session.responses, 0, 'incomplete mixed files are conservatively excluded'); + assert.deepEqual(result.session.usage, [], 'copied cumulative usage cannot enter native totals'); + assert.equal(result.session.importEvidence.ownershipComplete, false); + assert.equal(result.session.imported, true); +} + +for (const broken of ['{"type":"event_msg","payload":{"type":"task_started","turn_id":"external-import-turn-2"', + 'not-json', '[]', 'null']) { + test(`string and streaming parsing disclose a lost ownership boundary: ${broken.slice(0, 12)}`, () => { + const r = beforeGap(); + r.lines.push(broken); + afterGap(r); + for (const result of parseBoth(r)) { + assertExcluded(result); + assert.equal(result.session.importEvidence.malformedRecords, 1); + } + const sb = codexSandbox({ 'rollout-gap.jsonl': r }); + const scan = scanTranscriptCwds(sb.roots.codex, 'codex'); + assert.deepEqual(scan.sightings, []); + assert.equal(scan.importedUnresolved, 1); + assert.equal(scan.complete, false); + }); +} + +test('string and streaming Codex imports keep whitespace-prefixed records fail closed', () => { + const r = beforeGap(); + r.lines.push(' {"type":"event_msg","payload":{"type":"task_started","turn_id":"native-2"}}'); + afterGap(r); + for (const result of parseBoth(r)) { + assertExcluded(result); + assert.equal(result.session.importEvidence.malformedRecords, 1); + } +}); + +for (const turnId of [null, '', 'bad id', 1, {}, [], 'x'.repeat(257)]) { + test(`explicitly invalid context ID breaks adjacency: ${JSON.stringify(turnId).slice(0, 24)}`, () => { + const r = afterGap(beforeGap().turn('copied-model', { turn_id: turnId, cwd: '/copied' })); + for (const result of parseBoth(r)) { + assertExcluded(result); + assert.ok(result.session.importEvidence.ambiguousRecords >= 3); + } + const noOwn = afterGap(beforeGap({ nativeActivity: false }) + .turn('copied-model', { turn_id: turnId, cwd: '/copied' })); + const sb = codexSandbox({ 'rollout-gap.jsonl': noOwn }); + const scan = scanTranscriptCwds(sb.roots.codex, 'codex'); + assert.deepEqual(scan.sightings, [], 'unproved native adjacency must not establish an app or project'); + assert.equal(scan.importedMixed, 0); + assert.equal(scan.importedUnresolved, 1); + assert.equal(scan.complete, false); + assert.equal(scan.sessionCountComplete, false); + }); +} + +test('absent context ID can enrich an identified native turn without breaking ownership', () => { + const r = afterGap(beforeGap().turn('gpt-5.6', { cwd: '/native' })); + for (const result of parseBoth(r)) { + assert.equal(result.session.responses, 2); + assert.equal(result.session.usage[0].input, 600); + assert.equal(result.session.importEvidence.ownershipComplete, true); + } +}); + +test('all-native legacy files retain their permissive skip behavior', () => { + const r = beforeGap({ imports: false }); + r.lines.push('{bad-json'); + afterGap(r.turn('gpt-5.6', { turn_id: null })); + for (const result of parseBoth(r)) { + assert.equal(result.session.responses, 2); + assert.equal(result.session.usage[0].input, 600); + assert.equal(result.session.imported, undefined); + } +}); + +test('malformed mixed-file ownership remains disclosed in cold and warm index diagnostics', async () => { + const r = beforeGap(); + r.lines.push('{broken-boundary'); + afterGap(r); + for (const streamAboveBytes of [0, 1_000_000]) { + const sb = codexSandbox({ 'rollout-gap.jsonl': r }); + for (let n = 0; n < 2; n++) { + _resetForTest(); + const a = await buildIndex({ days: 14, now: Date.parse('2026-07-25T12:00:00Z'), roots: sb.roots, + cachePath: sb.cachePath, deps: stubDeps(), readLimits: { streamAboveBytes } }); + assert.equal(a.totals.responses, 0); + assert.equal(a.totals.input, 0); + assert.equal(a.sourceHealth.codex.diagnostics.importOwnershipIncompleteFiles, 1); + } + } +}); diff --git a/tests/kit/usage-codex-import-turns.test.mjs b/tests/kit/usage-codex-import-turns.test.mjs new file mode 100644 index 00000000..591b2873 --- /dev/null +++ b/tests/kit/usage-codex-import-turns.test.mjs @@ -0,0 +1,261 @@ +// Ownership is exercised through parser, aggregate/cache and bounded discovery. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { parseCodex } from '../../src/lib/usage-parsers.mjs'; +import { buildIndex, _resetForTest } from '../../src/lib/usage-index.mjs'; +import { scanTranscriptCwds, discoverProjectSources } from '../../src/lib/footprint/project-sources.mjs'; +import { Rollout, usage, codexSandbox, stubDeps, forkedSubagent } from './helpers/codex-rollout.mjs'; + +const imported = (originator = 'Codex Desktop') => new Rollout({ id: 'mixed' }) + .meta({ originator, cwd: '/copied', thread_source: undefined }) + .taskStarted('external-import-turn-1').turn('copied-model', { turn_id: 'external-import-turn-1', cwd: '/copied' }) + .user('copied secret prompt').agent('copied secret answer').item('FileChange') + .tokenCount(usage({ input: 1000, cached: 200, output: 100 })); +const own = (r) => r.taskStarted('native-1').turn('gpt-5.6', { turn_id: 'native-1', cwd: '/genuine' }) + .user('genuine prompt').agent('genuine answer').item('CommandExecution') + .tokenCount(usage({ input: 100, cached: 20, output: 10 })); +const parse = (r) => parseCodex(String(r), { id: 'fallback', withTurns: true }); + +test('mixed ownership excludes copied content and cumulative baseline but retains genuine project and surface', () => { + const { session: s, turns, parseStats } = parse(own(imported())); + assert.equal(s.imported, undefined); + assert.equal(s.id, 'mixed'); + assert.equal(s.prompts, 1); + assert.equal(s.responses, 1); + assert.deepEqual(s.models, ['gpt-5.6']); + assert.deepEqual(s.tools, { CommandExecution: 1 }); + assert.equal(s.contextEvidence.input.samples, 1); + assert.equal(s.contextEvidence.input.peak, 100); + assert.equal(s.usage[0].input, 80); + assert.equal(s.usage[0].cacheRead, 20); + assert.equal(s.usage[0].output, 10); + assert.equal(s.project, 'genuine'); + assert.equal(s.sessionOrigin.surface, 'chatgpt-desktop-codex'); + assert.equal(s.importEvidence.importedTurns, 1); + assert.equal(parseStats.prompts, 1); + assert.equal(parseStats.responses, 1); + assert.equal(JSON.stringify({ s, turns }).includes('copied secret'), false); +}); + +test('native CLI continuation keeps its declaration and handles a cumulative reset', () => { + const { session: s } = parse(own(imported('codex-tui').resetTotal())); + assert.equal(s.sessionOrigin.surface, 'codex-cli'); + assert.equal(s.usage[0].input, 80); + assert.equal(s.usage[0].output, 10); +}); + +test('native before late import stays counted and repeated metadata cannot replace identity or native origin', () => { + const r = new Rollout({ id: 'first' }).meta({ originator: 'codex-tui' }) + .taskStarted('native-1').turn().user('own').agent().tokenCount(usage({ input: 100, output: 10 })) + .meta({ id: 'parent', originator: 'Codex Desktop' }).taskStarted('external-import-turn-1') + .user('copied').agent().tokenCount(usage({ input: 2000, output: 200 })); + const { session: s } = parse(r); + assert.equal(s.id, 'first'); + assert.equal(s.responses, 1); + assert.equal(s.usage[0].input, 100); + assert.equal(s.sessionOrigin.surface, 'codex-cli'); +}); + +test('foreign imported completion does not close a native turn; a new imported start does', () => { + const r = imported().taskStarted('native-1') + .raw('event_msg', { type: 'item_completed', turn_id: 'native-1', item: { type: 'AgentMessage', text: 'own' } }) + .raw('event_msg', { type: 'task_complete', turn_id: 'external-import-turn-1' }) + .tokenCount(usage({ input: 100, output: 10 })) + .raw('event_msg', { type: 'task_complete', turn_id: 'native-1' }) + .taskStarted('external-import-turn-2').agent('copy').tokenCount(usage({ input: 500, output: 50 })); + const { session: s } = parse(r); + assert.equal(s.responses, 1); + assert.equal(s.usage[0].input, 100); + assert.equal(s.importEvidence.importedTurns, 2); +}); + +test('missing or mismatched turn IDs cannot establish native ownership after import', () => { + const r = imported().taskStarted(undefined); + // Builder has a default ID; replace this boundary with a truly missing ID. + r.lines.pop(); + r.raw('event_msg', { type: 'task_started' }).user('ambiguous').agent('ambiguous') + .tokenCount(usage({ input: 100, output: 10 })); + const { session: s } = parse(r); + assert.equal(s.imported, true); + assert.equal(s.responses, 0); + assert.ok(s.importEvidence.ambiguousRecords > 0); + const mismatch = own(imported()).raw('event_msg', { type: 'agent_message', turn_id: 'unopened', message: 'ambiguous' }); + assert.equal(parse(mismatch).session.responses, 1); +}); + +test('a marker embedded in prompt text changes no ownership', () => { + const r = new Rollout({ id: 'ordinary' }).meta().turn().user('external-import-turn-1').agent() + .tokenCount(usage({ input: 100, output: 10 })); + assert.equal(parse(r).session.responses, 1); + assert.equal(parse(r).session.imported, undefined); +}); + +test('replayed imports do not replace child identity or count parent native activity', () => { + const parent = own(imported()); + const child = forkedSubagent({ id: 'child', parent, + own: (r) => r.user('child').agent().tokenCount(usage({ input: 50, output: 5 })) }); + const { session: s } = parse(child); + assert.equal(s.id, 'child'); + assert.equal(s.threadSource, 'subagent'); + assert.equal(s.responses, 1); + assert.equal(s.usage[0].input, 50); +}); + +test('cold and warm aggregate retain mixed usage and exclusion diagnostics', async () => { + const sb = codexSandbox({ 'rollout-mixed.jsonl': own(imported()), 'rollout-copy.jsonl': imported() }); + const options = { days: 14, now: Date.parse('2026-07-25T12:00:00Z'), roots: sb.roots, + cachePath: sb.cachePath, deps: stubDeps() }; + for (let n = 0; n < 2; n++) { + _resetForTest(); + const a = await buildIndex(options); + assert.equal(a.totals.sessions, 1); + assert.equal(a.totals.responses, 1); + assert.equal(a.totals.input, 80); + assert.equal(a.sessions[0].importEvidence.importedTurns, 1); + assert.equal(a.sessions[0].cost, 1); + assert.equal(a.sourceHealth.codex.diagnostics.importedExcluded, 1); + assert.equal(a.sourceHealth.codex.diagnostics.importedMixed, 1); + assert.equal(a.sourceHealth.codex.diagnostics.importedTurnsExcluded, 2); + if (n) assert.equal(a.sourceHealth.codex.diagnostics.cachedFiles, 2); + } +}); + +test('bounded tail discovery finds interleaved native Desktop activity beyond the head', () => { + const r = imported().raw('response_item', { type: 'message', content: 'x'.repeat(300_000) }); + own(r).taskStarted('external-import-turn-2').agent('later copied answer'); + const sb = codexSandbox({ 'rollout-mixed.jsonl': r }); + const scan = scanTranscriptCwds(sb.roots.codex, 'codex'); + assert.equal(scan.sightings.length, 1); + assert.equal(scan.sightings[0].cwd, '/genuine'); + assert.equal(scan.sightings[0].sessionOrigin.surface, 'chatgpt-desktop-codex'); + assert.equal(scan.importedExcluded, 0); + assert.equal(scan.importedMixed, 1); +}); + +test('bounded observations cannot label a large unseen middle imported-only or recover its encoded directory', () => { + const r = imported().raw('response_item', { type: 'message', content: 'x'.repeat(3_000_000) }); + const sb = codexSandbox({ 'rollout-copy.jsonl': r }); + const scan = scanTranscriptCwds(sb.roots.codex, 'codex', { decodeDir: () => '/not-evidence' }); + assert.equal(scan.sightings.length, 0); + assert.equal(scan.importedExcluded, 0); + assert.equal(scan.importedUnresolved, 1); + assert.equal(scan.complete, false); + assert.equal(scan.sessionCountComplete, false); +}); + +test('a pure import whose whole bounded file is read remains excluded', () => { + const sb = codexSandbox({ 'rollout-copy.jsonl': imported() }); + const scan = scanTranscriptCwds(sb.roots.codex, 'codex'); + assert.equal(scan.importedExcluded, 1); + assert.equal(scan.sightings.length, 0); + assert.equal(scan.complete, true); + assert.equal(fs.existsSync(path.join(sb.dir, 'cache')), false); +}); + +// A reset can restart above the old imported total; last===total is explicit +// first-call evidence even when no monotonic field decreased. +test('a native counter reset above the imported baseline books the whole first call', () => { + const r = imported().resetTotal().taskStarted('native-1').turn() + .agent().tokenCount(usage({ input: 2000, cached: 400, output: 200 })); + const s = parse(r).session; + assert.equal(s.usage[0].input, 1600); + assert.equal(s.usage[0].cacheRead, 400); + assert.equal(s.usage[0].output, 200); +}); + +test('imported discovery excludes replayed native parent activity in a child with no own work', () => { + const r = forkedSubagent({ parent: own(imported()), own: () => {} }); + const sb = codexSandbox({ 'rollout-child.jsonl': r }); + const scan = scanTranscriptCwds(sb.roots.codex, 'codex'); + assert.equal(scan.sightings.length, 0); +}); + +test('mixed streaming and string parsing agree without leaking copied context', async () => { + const { openCodexRollout } = await import('../../src/lib/codex-rollout-reader.mjs'); + const r = own(imported()); + const sb = codexSandbox({ 'rollout-mixed.jsonl': r }); + const file = path.join(sb.roots.codex, '2026', '07', '24', 'rollout-mixed.jsonl'); + const source = openCodexRollout(file); + { + const streamed = parseCodex(source, { id: 'fallback', withTurns: true }); + assert.deepEqual(streamed, parse(r)); + assert.equal(JSON.stringify(streamed).includes('copied-model'), false); + } +}); + +test('discovery reports ambiguous missing-ID activity as unresolved even at EOF', () => { + const r = imported().raw('event_msg', { type: 'task_started' }).agent('unknown owner'); + const sb = codexSandbox({ 'rollout-ambiguous.jsonl': r }); + const scan = scanTranscriptCwds(sb.roots.codex, 'codex'); + assert.equal(scan.importedExcluded, 0); + assert.equal(scan.importedUnresolved, 1); + assert.equal(scan.complete, false); +}); + +test('discovery summary carries mixed and unresolved counts for consumers', () => { + const sb = codexSandbox({ 'rollout-mixed.jsonl': own(imported()) }); + const result = discoverProjectSources({ claudeRoot: sb.roots.claude, codexRoot: sb.roots.codex, + opencodeDbFile: path.join(sb.dir, 'absent.db') }); + assert.equal(result.importedMixed, 1); + assert.equal(result.importedUnresolved, 0); +}); + +test('bounded import inspection honors a shared byte budget without reading payload bytes', async () => { + const { inspectCodexImport } = await import('../../src/lib/footprint/codex-import-discovery.mjs'); + const sb = codexSandbox({ 'rollout-copy.jsonl': imported() }); + const file = path.join(sb.roots.codex, '2026', '07', '24', 'rollout-copy.jsonl'); + let reads = 0; + const fsImpl = { ...fs, readSync: (...args) => { reads++; return fs.readSync(...args); } }; + const result = inspectCodexImport(file, { fsImpl, headBytes: 262144, budget: { remaining: 0 } }); + assert.equal(result.kind, 'unresolved'); + assert.equal(reads, 0); +}); + +test('a native interval wholly inside an unread gap cannot establish a project', () => { + const r = imported().raw('response_item', { content: 'x'.repeat(300_000) }); + own(r).taskStarted('external-import-turn-2').raw('response_item', { content: 'y'.repeat(3_000_000) }); + const sb = codexSandbox({ 'rollout-gap.jsonl': r }); + const scan = scanTranscriptCwds(sb.roots.codex, 'codex'); + assert.equal(scan.importedMixed, 0); + assert.equal(scan.importedUnresolved, 1); + assert.deepEqual(scan.sightings, []); +}); + +test('streaming mixed files with clipped ownership evidence are excluded and disclosed', async () => { + const { openCodexRollout } = await import('../../src/lib/codex-rollout-reader.mjs'); + const r = own(imported()).raw('event_msg', { type: 'item_completed', turn_id: 'external-import-turn-2', + item: { type: 'FileChange', output: 'x'.repeat(9000) } }).agent('uncertain ownership'); + const sb = codexSandbox({ 'rollout-clipped.jsonl': r }); + const file = path.join(sb.roots.codex, '2026', '07', '24', 'rollout-clipped.jsonl'); + const result = parseCodex(openCodexRollout(file, { maxLineBytes: 4096 }), { id: 'fallback' }); + assert.equal(result.session.responses, 0); + assert.equal(result.session.importEvidence.ownershipComplete, false); + assert.equal(result.parseStats.clippedLines, 1); +}); + +test('import turn diagnostics remain bounded and disclose a truncated unique-turn count', () => { + const r = imported(); + for (let i = 2; i <= 4097; i++) r.taskStarted(`external-import-turn-${i}`); + const { session: s } = parse(r); + assert.equal(s.importEvidence.importedTurns, 4096); + assert.equal(s.importEvidence.importedTurnCountComplete, false); + assert.equal(s.imported, true); +}); + +test('clipped mixed-file exclusion remains visible in cold and warm source-health diagnostics', async () => { + const r = own(imported()).raw('response_item', { content: 'x'.repeat(9000) }); + const sb = codexSandbox({ 'rollout-clipped.jsonl': r }); + for (let i = 0; i < 2; i++) { + _resetForTest(); + const a = await buildIndex({ days: 14, now: Date.parse('2026-07-25T12:00:00Z'), + roots: sb.roots, cachePath: sb.cachePath, deps: stubDeps(), + readLimits: { streamAboveBytes: 0, maxLineBytes: 4096 } }); + const d = a.sourceHealth.codex.diagnostics; + assert.equal(a.totals.responses, 0); + assert.equal(d.importOwnershipIncompleteFiles, 1); + assert.equal(d.clippedLines, 1); + assert.ok(d.warnings.includes('oversized-lines-clipped')); + } +}); diff --git a/tests/kit/usage-codex-thread-source.test.mjs b/tests/kit/usage-codex-thread-source.test.mjs new file mode 100644 index 00000000..03c650a4 --- /dev/null +++ b/tests/kit/usage-codex-thread-source.test.mjs @@ -0,0 +1,150 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { classifySessionSurface } from '../../src/lib/session-surface.mjs'; +import { parseCodex } from '../../src/lib/usage-parsers.mjs'; +import { applyCodexLedger } from '../../src/lib/usage-aggregate.mjs'; +import { rowCostEvidence } from '../../src/lib/usage-cost.mjs'; +import { costOf } from '../../src/lib/pricing.mjs'; +import { buildIndex, _resetForTest } from '../../src/lib/usage-index.mjs'; +import { Rollout, usage, codexSandbox, stubDeps } from './helpers/codex-rollout.mjs'; + +test('enumerated Codex thread sources classify without guessing an unknown source', () => { + for (const [threadSource, initiator] of [ + ['user', 'person'], ['chatgpt_handoff', 'person'], ['guardian_review', 'agent'], + ['subagent', 'agent'], ['agent_created_thread', 'agent'], ['automation', 'automation'], + ['future_source', 'unknown'], + ]) { + assert.equal(classifySessionSurface({ host: 'codex', originator: 'Codex Desktop', threadSource }).initiator, + initiator, threadSource); + } + assert.equal(classifySessionSurface({ host: 'codex', originator: 'codex_exec', threadSource: 'user' }).initiator, 'automation'); + assert.equal(classifySessionSurface({ host: 'codex', originator: 'codex_cli_rs', source: 'mcp', threadSource: 'user' }).initiator, 'agent'); + for (const threadSource of [{}, 'future source']) { + const result = classifySessionSurface({ host: 'codex', originator: 'Codex Desktop', threadSource }); + assert.equal(result.initiator, 'unknown'); + assert.deepEqual(result.rawEvidence, { originator: 'Codex Desktop' }); + } + assert.equal(classifySessionSurface({ host: 'codex', originator: 'Codex Desktop' }).initiator, 'person'); + for (const originator of ['codex_exec', 'codex_sdk_ts']) { + assert.equal(classifySessionSurface({ host: 'codex', originator, threadSource: {} }).initiator, 'automation'); + } + assert.equal(classifySessionSurface({ host: 'codex', originator: 'codex_cli_rs', source: 'mcp', + threadSource: {} }).initiator, 'agent'); +}); + +test('malformed first thread source remains unknown through parser and ledger overlay', () => { + for (const threadSource of [{}, 'future source']) { + const rollout = new Rollout({ id: 'malformed' }).meta({ originator: 'Codex Desktop', thread_source: threadSource }) + .turn().agent().tokenCount(usage({ input: 10, output: 2 })); + const rec = parseCodex(rollout.toString(), { id: 'malformed' }).session; + assert.equal(rec.sessionOrigin.initiator, 'unknown'); + assert.deepEqual(rec.sessionOrigin.rawEvidence, { originator: 'Codex Desktop' }); + const ledger = { threads: new Map([['malformed', { threadSource: 'future source' }]]), parents: new Map() }; + const overlaid = applyCodexLedger([rec], ledger)[0]; + assert.equal(overlaid.sessionOrigin.initiator, 'unknown'); + assert.deepEqual(overlaid.sessionOrigin.rawEvidence, { originator: 'Codex Desktop' }); + } +}); + +test('structured source proves parent only with the observed thread_spawn shape', () => { + const parentId = '123e4567-e89b-42d3-a456-426614174000'; + const child = new Rollout({ id: 'child' }).meta({ originator: 'Codex Desktop', thread_source: 'subagent', + source: { subagent: { thread_spawn: { parent_thread_id: parentId, depth: 1 } } } }) + .turn('gpt-5.6-sol').agent().tokenCount(usage({ input: 100, output: 20 })); + const parsed = parseCodex(child.toString(), { id: 'child' }).session; + assert.equal(parsed.parentSessionId, parentId); + assert.equal(parsed.threadSource, 'subagent'); + assert.equal(parsed.sessionOrigin.initiator, 'agent'); + assert.ok(parsed.usage.length > 0, 'own usage is retained'); + const malformed = new Rollout({ id: 'other' }).meta({ source: { subagent: { thread_spawn: { parent_thread_id: '../secret' } } } }); + assert.equal(parseCodex(malformed.toString(), { id: 'other' }).session.parentSessionId, null); +}); + +test('ledger backfill updates session origin and safely rolls child under its observed parent surface', () => { + const origin = (threadSource, surface) => ({ ...classifySessionSurface({ host: 'codex', + originator: surface === 'codex-ide' ? 'codex_vscode' : 'Codex Desktop', threadSource }), + origin: 'unknown', evidence: 'desktop-origin-not-declared' }); + const parent = { id: 'parent', provider: 'codex', threadSource: 'user', sessionOrigin: origin('user', 'codex-ide') }; + const child = { id: 'child', provider: 'codex', threadSource: null, sessionOrigin: origin(null, 'desktop'), + usage: [{ input: 20 }], reasoningOutput: 2 }; + const ledger = { threads: new Map([['child', { threadSource: 'guardian_review' }]]), + parents: new Map([['child', 'parent']]) }; + const [p, c] = applyCodexLedger([parent, child], ledger); + assert.equal(p, parent); + assert.equal(c.threadSource, 'guardian_review'); + assert.equal(c.sessionOrigin.initiator, 'agent'); + assert.equal(c.sessionOrigin.surface, 'codex-ide'); + assert.equal(c.parentSessionId, 'parent'); + assert.deepEqual(c.usage, child.usage, 'reviewer own tokens are not stripped'); + const noParent = applyCodexLedger([child], ledger)[0]; + assert.equal(noParent.parentSessionId, null); + assert.equal(noParent.sessionOrigin.surface, 'chatgpt-desktop-codex'); + const declared = { ...child, threadSource: 'subagent', parentSessionId: 'parent', + sessionOrigin: origin('subagent', 'desktop') }; + assert.equal(applyCodexLedger([parent, declared], null)[1].sessionOrigin.surface, 'codex-ide', + 'first declaration supplies a parent even when the optional ledger is absent'); + const a = { ...declared, id: 'a', parentSessionId: 'b' }; + const b = { ...parent, id: 'b', threadSource: null, parentSessionId: 'a' }; + assert.equal(applyCodexLedger([a, b], null)[0].parentSessionId, null, + 'cyclic parent declarations supply no rollup evidence'); +}); + +test('Auto-review tokens remain counted but the unpublished model is unpriced', () => { + const row = { model: 'codex-auto-review', provider: 'openai', day: '2026-09-29', + input: 100, output: 20, cacheRead: 0, cacheWrite: 0, responses: 1 }; + const evidence = rowCostEvidence(row, { provider: 'codex' }, { costOf }); + assert.equal(evidence.estimatedUsd, 0); + assert.equal(evidence.unpricedMessages, 1); + assert.equal(rowCostEvidence({ ...row, model: 'gpt-5.6-sol' }, { provider: 'codex' }, { costOf }).unpricedMessages, 0); +}); + +test('ledger enrichment cannot relabel an imported copy as a reviewer', () => { + const copy = new Rollout({ id: 'imported' }).meta({ originator: 'Codex Desktop', thread_source: null }) + .taskStarted('external-import-turn-1').user('synthetic copied text'); + const rec = parseCodex(copy.toString(), { id: 'imported' }).session; + const ledger = { threads: new Map([['imported', { threadSource: 'guardian_review' }]]), parents: new Map() }; + const enriched = applyCodexLedger([rec], ledger)[0]; + assert.equal(enriched.sessionOrigin.initiator, 'imported-copy'); + assert.equal(enriched.sessionOrigin.evidence, 'imported-copy'); + assert.deepEqual(enriched.sessionOrigin.rawEvidence, {}); + assert.deepEqual(enriched.usage, []); +}); + +test('cold and warm aggregates retain child own usage and unpriced reviewer coverage under parent surface', async () => { + const parentId = '123e4567-e89b-42d3-a456-426614174000'; + const childId = '123e4567-e89b-42d3-a456-426614174001'; + const reviewId = '123e4567-e89b-42d3-a456-426614174002'; + const parent = new Rollout({ id: parentId }).meta({ originator: 'codex_vscode' }) + .turn('gpt-5.6-sol').user().agent().tokenCount(usage({ input: 100, output: 20 })); + const child = new Rollout({ id: childId }).meta({ originator: 'Codex Desktop', thread_source: 'subagent', + source: { subagent: { thread_spawn: { parent_thread_id: parentId, depth: 1 } } } }) + .turn('gpt-5.6-sol').agent().tokenCount(usage({ input: 50, output: 20 })); + const reviewer = new Rollout({ id: reviewId }).meta({ originator: 'Codex Desktop', thread_source: 'guardian_review', + source: { subagent: { other: 'guardian' } } }) + .turn('codex-auto-review').agent().tokenCount(usage({ input: 25, output: 5 })); + const sb = codexSandbox({ + 'rollout-2026-07-24T09-00-00-parent.jsonl': parent, + 'rollout-2026-07-24T09-01-00-child.jsonl': child, + 'rollout-2026-07-24T09-02-00-review.jsonl': reviewer, + }); + const options = { days: 14, now: Date.parse('2026-07-25T12:00:00Z'), roots: sb.roots, + cachePath: sb.cachePath, codexState: { threads: new Map(), parents: new Map([[reviewId, parentId]]) }, + deps: { ...stubDeps(), costOf } }; + _resetForTest(); + const cold = await buildIndex(options); + _resetForTest(); + const warm = await buildIndex(options); + for (const agg of [cold, warm]) { + assert.equal(agg.totals.tokens, 220); + assert.equal(agg.totals.humanPrompts, 1); + assert.equal(agg.bySource.subagent.tokens, 100); + assert.equal(agg.sessions.find((s) => s.id === childId).tokens, 70); + const review = agg.sessions.find((s) => s.id === reviewId); + assert.equal(review.tokens, 30); + assert.equal(review.cost, 0); + assert.equal(review.costEvidence.unpricedMessages, 1); + assert.equal(review.sessionOrigin.surface, 'codex-ide'); + } + assert.deepEqual(warm.totals, cold.totals); + assert.deepEqual(warm.sessions.map((s) => s.sessionOrigin), cold.sessions.map((s) => s.sessionOrigin)); +}); diff --git a/tests/kit/usage-codex-zero-response.test.mjs b/tests/kit/usage-codex-zero-response.test.mjs new file mode 100644 index 00000000..d5465f7b --- /dev/null +++ b/tests/kit/usage-codex-zero-response.test.mjs @@ -0,0 +1,98 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseCodex } from '../../src/lib/usage-parsers.mjs'; +import { aggregate } from '../../src/lib/usage-aggregate.mjs'; +import { buildIndex, _resetForTest } from '../../src/lib/usage-index.mjs'; +import { Rollout, usage, codexSandbox, stubDeps } from './helpers/codex-rollout.mjs'; + +const now = Date.parse('2026-07-25T12:00:00Z'); +const native = () => new Rollout({ id: 'tool-only' }).meta().taskStarted('native-1').turn() + .item('CommandExecution').tokenCount(usage({ input: 120, cached: 20, output: 7 })) + .raw('event_msg', { type: 'turn_aborted' }); +const totalOnly = () => new Rollout({ id: 'total-only' }).meta().taskStarted('native-1').turn() + .raw('event_msg', { type: 'token_count', info: { + total_token_usage: { total_tokens: 127 }, last_token_usage: { total_tokens: 127 }, + } }); + +test('native tool-only component usage counts with zero normalized responses', () => { + const parsed = parseCodex(String(native()), { id: 'fallback' }); + assert.equal(parsed.session.responses, 0); + assert.deepEqual(parsed.session.usage.map(({ input, cacheRead, output }) => ({ input, cacheRead, output })), + [{ input: 100, cacheRead: 20, output: 7 }]); + const result = aggregate([parsed.session], { days: 14, now, cutoff: now - 14 * 86400000, deps: stubDeps() }); + assert.equal(result.totals.sessions, 1); + assert.equal(result.totals.responses, 0); + assert.equal(result.totals.input, 100); + assert.equal(result.totals.cacheRead, 20); + assert.equal(result.totals.output, 7); + assert.equal(result.totals.tokens, 127); +}); + +test('cold and warm cache count component usage without responses and retain missing-breakdown diagnostics', async () => { + const sb = codexSandbox({ 'rollout-native.jsonl': native(), 'rollout-total.jsonl': totalOnly() }); + const options = { days: 14, now, roots: sb.roots, cachePath: sb.cachePath, deps: stubDeps() }; + for (let i = 0; i < 2; i++) { + _resetForTest(); + const result = await buildIndex(options); + assert.equal(result.totals.sessions, 1); + assert.equal(result.totals.responses, 0); + assert.equal(result.totals.tokens, 127); + assert.equal(result.sourceHealth.codex.diagnostics.zeroResponseUsageFiles, 1); + assert.equal(result.sourceHealth.codex.diagnostics.totalOnlyTokenCountEvents, 1); + assert.equal(result.sourceHealth.codex.diagnostics.zeroResponseUnsupportedFiles, 1); + assert.ok(result.sourceHealth.codex.diagnostics.warnings.includes('total-only-token-count')); + assert.equal(result.sourceHealth.codex.status, 'degraded'); + if (i) assert.equal(result.sourceHealth.codex.diagnostics.cachedFiles, 2); + } +}); + +test('total-only counter cannot create a priced row or fabricated components', () => { + const parsed = parseCodex(String(totalOnly()), { id: 'fallback' }); + assert.equal(parsed.session.responses, 0); + assert.deepEqual(parsed.session.usage, []); + assert.equal(parsed.parseStats.totalOnlyTokenCountEvents, 1); + const result = aggregate([parsed.session], { days: 14, now, cutoff: now - 14 * 86400000, deps: stubDeps() }); + assert.equal(result.totals.sessions, 0); + assert.equal(result.totals.tokens, 0); + assert.equal(result.totals.cost, 0); +}); + +test('a total-only gap cannot make a later component snapshot double count earlier usage', () => { + const r = native(); + r.raw('event_msg', { type: 'token_count', info: { total_token_usage: { total_tokens: 200 } } }); + r.tokenCount(usage({ input: 50, output: 5 })); + const parsed = parseCodex(String(r), { id: 'fallback' }); + assert.equal(parsed.parseStats.totalOnlyTokenCountEvents, 1); + assert.deepEqual(parsed.session.usage.map(({ input, cacheRead, output }) => ({ input, cacheRead, output })), + [{ input: 100, cacheRead: 20, output: 7 }]); +}); + +test('import copies and incomplete mixed ownership cannot become zero-response billable sessions', async () => { + const copy = new Rollout({ id: 'copy' }).meta({ originator: 'Codex Desktop' }) + .taskStarted('external-import-turn-1').tokenCount(usage({ input: 500 })); + const mixed = new Rollout({ id: 'mixed' }).meta({ originator: 'Codex Desktop' }) + .taskStarted('external-import-turn-1').tokenCount(usage({ input: 500 })) + .taskStarted('native-1').tokenCount(usage({ input: 50 })); + mixed.lines.push('{broken-boundary'); + const sb = codexSandbox({ 'rollout-copy.jsonl': copy, 'rollout-mixed.jsonl': mixed }); + const result = await buildIndex({ days: 14, now, roots: sb.roots, cachePath: sb.cachePath, deps: stubDeps() }); + assert.equal(result.totals.sessions, 0); + assert.equal(result.totals.tokens, 0); + assert.equal(result.sourceHealth.codex.diagnostics.importedExcluded, 2); + assert.equal(result.sourceHealth.codex.diagnostics.importOwnershipIncompleteFiles, 1); +}); + +test('an imported total-only baseline cannot bill the next native cumulative snapshot', () => { + const r = new Rollout({ id: 'mixed-total' }).meta({ originator: 'Codex Desktop' }) + .taskStarted('external-import-turn-1') + .raw('event_msg', { type: 'token_count', info: { total_token_usage: { total_tokens: 500 } } }) + .taskStarted('native-1').turn('gpt-5.6', { turn_id: 'native-1' }) + .raw('event_msg', { type: 'token_count', info: { + total_token_usage: usage({ input: 550, output: 5 }), + last_token_usage: usage({ input: 50, output: 5 }), + } }); + const parsed = parseCodex(String(r), { id: 'fallback' }); + assert.equal(parsed.session.responses, 0); + assert.deepEqual(parsed.session.usage, []); + assert.equal(parsed.session.importEvidence.ownershipComplete, true); +}); diff --git a/tests/kit/usage-index-opencode-source.test.mjs b/tests/kit/usage-index-opencode-source.test.mjs new file mode 100644 index 00000000..344f9e59 --- /dev/null +++ b/tests/kit/usage-index-opencode-source.test.mjs @@ -0,0 +1,265 @@ +// usage-index × opencode — the third transcript source through scan(), +// aggregate(), and readSession(). Hermetic: fixture claude/codex corpora and a +// fixture opencode.db, all in tmp; injected pricing/classification stubs so +// the arithmetic is exact. The real stores are never touched (the roots seam +// is also what is under test: overridden roots must NOT read the real db). +import { test, after } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; + +const NOW = Date.parse('2026-07-29T12:00:00Z'); +const DAY = 86_400_000; +const tmp = (p) => fs.mkdtempSync(path.join(os.tmpdir(), p)); +const rm = (d) => fs.rmSync(d, { recursive: true, force: true }); + +const { sandboxHome } = await import('./helpers/home-sandbox.mjs'); +const testHome = sandboxHome('ak-oc-source'); +after(() => rm(testHome)); + +const { buildIndex, readIndex, readSession, _resetForTest } = await import('../../src/lib/usage-index.mjs'); + +/** Pricing stub: prices EVERY token at 1/1000 — deliberately different from + * the fixture's observed costs so the preference is provable. */ +const deps = () => ({ + costOf: ({ input, output, cacheRead, cacheWrite }) => (input + output + cacheRead + cacheWrite) / 1000, + pricesAsOf: '2026-07-01', + classify: ({ title }) => (title + ? { category: 'Build', confidence: 0.9, basis: 'title+tools' } + : { category: 'Unclassified', confidence: 0, basis: 'no signal' }), + detectInsights: () => [], +}); + +const assistantMsg = (id, sessionId, at, { model = 'kimi-k3', provider = 'opencode', cost = null, tokens = {} } = {}) => ({ + id, sessionId, at, + data: { + role: 'assistant', agent: 'build', modelID: model, providerID: provider, + tokens: { input: 1000, output: 100, reasoning: 10, cache: { read: 200, write: 10 }, ...tokens }, + ...(cost != null ? { cost } : {}), + time: { created: at, completed: at + 1000 }, finish: 'stop', + }, +}); + +function buildDb(file, { sessions = [], messages = [] } = {}) { + const db = new DatabaseSync(file); + db.exec(` + CREATE TABLE session (id text PRIMARY KEY, project_id text NOT NULL, workspace_id text, + parent_id text, slug text NOT NULL, directory text NOT NULL, path text, title text NOT NULL, + version text NOT NULL, share_url text, summary_additions integer, summary_deletions integer, + summary_files integer, summary_diffs text, metadata text, cost real DEFAULT 0 NOT NULL, + tokens_input integer DEFAULT 0 NOT NULL, tokens_output integer DEFAULT 0 NOT NULL, + tokens_reasoning integer DEFAULT 0 NOT NULL, tokens_cache_read integer DEFAULT 0 NOT NULL, + tokens_cache_write integer DEFAULT 0 NOT NULL, revert text, permission text, agent text, + model text, time_created integer NOT NULL, time_updated integer NOT NULL, + time_compacting integer, time_archived integer); + CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL, + time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL); + CREATE INDEX message_session_time_created_id_idx ON message (session_id, time_created, id); + CREATE TABLE part (id text PRIMARY KEY, message_id text NOT NULL, session_id text NOT NULL, + time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL); + `); + const insS = db.prepare('INSERT INTO session (id, project_id, parent_id, slug, directory, title, version, time_created, time_updated) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'); + const insM = db.prepare('INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)'); + for (const s of sessions) insS.run(s.id, 'proj-1', s.parentId ?? null, 'slug-x', s.directory, s.title, '1.18.8', s.timeCreated ?? NOW - DAY, s.timeUpdated ?? NOW - DAY); + for (const m of messages) insM.run(m.id, m.sessionId, m.at, m.at, JSON.stringify(m.data)); + db.close(); + return file; +} + +/** A sandbox: empty claude/codex corpora + a fixture opencode.db + cache path. */ +function sandbox({ sessions = [], messages = [] } = {}) { + const dir = tmp('ak-uio-'); + fs.mkdirSync(path.join(dir, 'corpus', 'claude'), { recursive: true }); + fs.mkdirSync(path.join(dir, 'corpus', 'codex'), { recursive: true }); + const dbFile = buildDb(path.join(dir, 'corpus', 'opencode.db'), { sessions, messages }); + return { + dir, dbFile, + roots: { + claude: path.join(dir, 'corpus', 'claude'), + codex: path.join(dir, 'corpus', 'codex'), + opencode: dbFile, + }, + cachePath: path.join(dir, 'cache', 'usage-index.json'), + }; +} + +const opts = (sb, extra = {}) => ({ days: 14, now: NOW, roots: sb.roots, cachePath: sb.cachePath, deps: deps(), ...extra }); + +// O9: equal session IDs and stamps in separate stores must never share parses. +test('OpenCode database switches isolate warm and degraded cache entries', async () => { + const at = NOW - DAY; + const data = (title, cost) => ({ sessions: [{ id: 'ses_shared', directory: '/x', title, timeCreated: at }], + messages: [assistantMsg('a1', 'ses_shared', at + 1000, { cost })] }); + const sb = sandbox(data('first', 0.2)); + try { + await buildIndex(opts(sb)); + const second = buildDb(path.join(sb.dir, 'second.db'), data('second', 0.8)); + const options = opts(sb, { roots: { ...sb.roots, opencode: second } }); + const switched = await buildIndex(options); + assert.equal(switched.sessions[0].title, 'second'); + assert.equal(switched.totals.cost, 0.8); + assert.equal((await readSession('ses_shared', options)).meta.title, 'second'); + fs.writeFileSync(sb.dbFile, 'corrupt'); + const degraded = await buildIndex(opts(sb)); + assert.equal(degraded.sessions.length, 0, 'second database cannot supply first database fallback'); + } finally { _resetForTest(); rm(sb.dir); } +}); + +test('OpenCode legacy source identity reparses and cannot carry through a degraded store', async () => { + const at = NOW - DAY; + const sb = sandbox({ sessions: [{ id: 'ses_identity', directory: '/x', title: 'real' }], + messages: [assistantMsg('a1', 'ses_identity', at, { cost: 0.2 })] }); + try { + await buildIndex(opts(sb)); + const cache = JSON.parse(fs.readFileSync(sb.cachePath, 'utf8')); + delete cache.entries['opencode://ses_identity'].sourceIdentity; + cache.entries['opencode://ses_identity'].session.title = 'old'; + fs.writeFileSync(sb.cachePath, JSON.stringify(cache)); _resetForTest(); + assert.equal((await buildIndex(opts(sb))).sessions[0].title, 'real'); + fs.writeFileSync(sb.cachePath, JSON.stringify(cache)); + fs.writeFileSync(sb.dbFile, 'corrupt'); _resetForTest(); + assert.equal((await buildIndex(opts(sb))).sessions.length, 0); + } finally { _resetForTest(); rm(sb.dir); } +}); + +test('ambiguous default stores expose health, drop cache fallback, and agree with selected-session reads', async () => { + const sb = sandbox({ sessions: [{ id: 'ses_choice', directory: '/x', title: 'chosen' }], + messages: [assistantMsg('a1', 'ses_choice', NOW - DAY, { cost: 0.4 })] }); + const keys = ['XDG_DATA_HOME', 'OPENCODE_DB', 'OPENCODE_DISABLE_CHANNEL_DB']; + const before = Object.fromEntries(keys.map((key) => [key, process.env[key]])); + try { + process.env.XDG_DATA_HOME = sb.dir; + delete process.env.OPENCODE_DB; delete process.env.OPENCODE_DISABLE_CHANNEL_DB; + const root = path.join(sb.dir, 'opencode'); fs.mkdirSync(root); + fs.copyFileSync(sb.dbFile, path.join(root, 'opencode-preview.db')); + const options = opts(sb, { roots: undefined }); + assert.equal((await readIndex(options)).sessions[0].title, 'chosen'); + assert.equal((await readSession('ses_choice', options)).meta.title, 'chosen'); + fs.copyFileSync(sb.dbFile, path.join(root, 'opencode.db')); + const ambiguous = await readIndex(options); + assert.equal(ambiguous.sourceHealth.opencode.reason, 'database-selection-ambiguous'); + assert.equal(ambiguous.sessions.length, 0); + assert.equal(await readSession('ses_choice', options), null); + const { discoverProjectSources } = await import('../../src/lib/footprint/project-sources.mjs'); + const projects = discoverProjectSources({ claudeRoot: sb.roots.claude, codexRoot: sb.roots.codex }); + assert.equal(projects.sources.opencode.reason, 'database-selection-ambiguous'); + assert.equal(projects.complete, false); + process.env.OPENCODE_DB = 'opencode-preview.db'; + assert.equal((await readIndex(options)).sessions[0].title, 'chosen'); + assert.equal((await readSession('ses_choice', options)).meta.title, 'chosen'); + process.env.OPENCODE_DB = ':memory:'; + assert.equal((await buildIndex(options)).sourceHealth.opencode.reason, 'database-in-memory'); + assert.equal(await readSession('ses_choice', options), null); + assert.equal(fs.existsSync(path.join(root, ':memory:')), false); + assert.equal((await buildIndex(opts(sb))).sessions[0].title, 'chosen', 'explicit roots ignore environment'); + assert.equal((await buildIndex(opts(sb, { roots: {} }))).sessions.length, 0); + } finally { + for (const key of keys) { if (before[key] === undefined) delete process.env[key]; else process.env[key] = before[key]; } + _resetForTest(); rm(sb.dir); + } +}); + +// O10: coverage belongs to the source, independent of V1 candidate/cache yield. +for (const state of ['missing', 'empty', 'present', 'unknown']) { + test(`OpenCode unsupported V2 ${state} is observed with zero V1 candidates`, async () => { + const sb = sandbox(); + try { + const db = new DatabaseSync(sb.dbFile); + if (state === 'unknown') db.exec('CREATE VIEW session_message AS SELECT 1'); + else if (state !== 'missing') { + db.exec('CREATE TABLE session_message (payload text)'); + if (state === 'present') db.exec("INSERT INTO session_message VALUES ('private content')"); + } + db.close(); + const result = await buildIndex(opts(sb)); + const health = result.sourceHealth.opencode; + assert.equal(health.status, 'ok'); + assert.equal(health.storageCoverage.v2.status, state); + assert.deepEqual(health.diagnostics.common.warnings, state === 'present' + ? ['opencode-v2-session-message-present'] : state === 'unknown' + ? ['opencode-v2-observation-incomplete'] : []); + assert.equal(result.sessions.length, 0); + assert.equal(JSON.stringify(health).includes('private content'), false); + assert.equal(JSON.stringify(health).includes(sb.dir), false); + } finally { _resetForTest(); rm(sb.dir); } + }); +} + +test('OpenCode storage warnings refresh on all cache hits and survive an empty window without changing usage', async () => { + const sb = sandbox({ sessions: [{ id: 'ses_coverage', directory: '/x', title: 'known' }], + messages: [assistantMsg('a1', 'ses_coverage', NOW - DAY, { cost: 0.2 })] }); + try { + const first = await buildIndex(opts(sb)); + const cache = JSON.parse(fs.readFileSync(sb.cachePath, 'utf8')); + cache.entries['opencode://ses_coverage'].session.title = 'cache witness'; + fs.writeFileSync(sb.cachePath, JSON.stringify(cache)); _resetForTest(); + const db = new DatabaseSync(sb.dbFile); + // Source-level V2 activity elsewhere must not invalidate this unchanged V1 session. + db.exec("CREATE TABLE session_message (session_id text, payload text); INSERT INTO session_message VALUES ('unrelated', NULL)"); db.close(); + fs.mkdirSync(path.join(path.dirname(sb.dbFile), 'storage')); + fs.writeFileSync(path.join(path.dirname(sb.dbFile), 'storage', 'legacy.json'), 'not read'); + const warm = await buildIndex(opts(sb)); + assert.equal(warm.sessions[0].title, 'cache witness', 'all V1 parses came from cache'); + assert.deepEqual(warm.totals, first.totals); + const expected = ['opencode-v2-session-message-present', 'opencode-legacy-json-present']; + assert.deepEqual(warm.sourceHealth.opencode.diagnostics.common.warnings, expected); + const empty = await buildIndex(opts(sb, { now: NOW + 100 * DAY })); + assert.equal(empty.sessions.length, 0); + assert.deepEqual(empty.sourceHealth.opencode.diagnostics.common.warnings, expected); + } finally { _resetForTest(); rm(sb.dir); } +}); + +for (const condition of ['missing', 'corrupt', 'v1-missing', 'legacy-inaccessible']) { + test(`OpenCode ${condition} preserves independent legacy coverage and availability`, async () => { + const sb = sandbox(); + try { + const legacy = path.join(path.dirname(sb.dbFile), 'storage'); + if (condition === 'legacy-inaccessible') fs.writeFileSync(legacy, 'not a directory'); + else { fs.mkdirSync(legacy); fs.writeFileSync(path.join(legacy, 'record.json'), 'private'); } + if (condition === 'missing') fs.unlinkSync(sb.dbFile); + if (condition === 'corrupt') fs.writeFileSync(sb.dbFile, 'invalid'); + if (condition === 'v1-missing') { + const db = new DatabaseSync(sb.dbFile); + db.exec('DROP TABLE message; CREATE TABLE session_message (payload text); INSERT INTO session_message VALUES (NULL)'); db.close(); + } + const health = (await buildIndex(opts(sb))).sourceHealth.opencode; + assert.equal(health.status, condition === 'missing' ? 'absent' : condition === 'legacy-inaccessible' ? 'ok' : 'degraded'); + assert.equal(health.storageCoverage.legacy.status, condition === 'legacy-inaccessible' ? 'unknown' : 'present'); + assert.ok(health.diagnostics.common.warnings.includes(condition === 'legacy-inaccessible' + ? 'opencode-legacy-observation-incomplete' : 'opencode-legacy-json-present')); + if (condition === 'corrupt') assert.equal(health.storageCoverage.v2.status, 'unknown'); + if (condition === 'v1-missing') assert.ok(health.diagnostics.common.warnings.includes('opencode-v2-session-message-present')); + assert.equal(fs.existsSync(sb.dbFile), condition !== 'missing', 'missing DB stays missing'); + const isolated = (await buildIndex(opts(sb, { roots: {} }))).sourceHealth.opencode; + assert.equal(isolated.storageCoverage.legacy.status, 'not-observed'); + assert.deepEqual(isolated.diagnostics.common.warnings, []); + } finally { _resetForTest(); rm(sb.dir); } + }); +} + +test('OpenCode default legacy root stays observable during ambiguous database selection', async () => { + const sb = sandbox(); + const keys = ['XDG_DATA_HOME', 'OPENCODE_DB', 'OPENCODE_DISABLE_CHANNEL_DB']; + const before = Object.fromEntries(keys.map((key) => [key, process.env[key]])); + try { + process.env.XDG_DATA_HOME = sb.dir; + delete process.env.OPENCODE_DB; delete process.env.OPENCODE_DISABLE_CHANNEL_DB; + const root = path.join(sb.dir, 'opencode'); fs.mkdirSync(path.join(root, 'storage'), { recursive: true }); + fs.writeFileSync(path.join(root, 'storage', 'legacy.json'), 'never parsed'); + fs.copyFileSync(sb.dbFile, path.join(root, 'opencode.db')); + fs.copyFileSync(sb.dbFile, path.join(root, 'opencode-preview.db')); + const health = (await buildIndex(opts(sb, { roots: undefined }))).sourceHealth.opencode; + assert.equal(health.reason, 'database-selection-ambiguous'); + assert.equal(health.storageCoverage.v2.status, 'not-observed'); + assert.ok(health.diagnostics.common.warnings.includes('opencode-legacy-json-present')); + assert.deepEqual((await buildIndex(opts(sb))).sourceHealth.opencode.diagnostics.common.warnings, [], 'explicit source never observes the global legacy root'); + process.env.OPENCODE_DB = sb.dbFile; + const overridden = (await buildIndex(opts(sb, { roots: undefined }))).sourceHealth.opencode; + assert.ok(overridden.diagnostics.common.warnings.includes('opencode-legacy-json-present'), 'DB override does not relocate upstream legacy storage'); + } finally { + for (const key of keys) { if (before[key] === undefined) delete process.env[key]; else process.env[key] = before[key]; } + _resetForTest(); rm(sb.dir); + } +}); diff --git a/tests/kit/usage-index-opencode.test.mjs b/tests/kit/usage-index-opencode.test.mjs index 9bb4f4ad..1e8ea9ad 100644 --- a/tests/kit/usage-index-opencode.test.mjs +++ b/tests/kit/usage-index-opencode.test.mjs @@ -86,6 +86,66 @@ function sandbox({ sessions = [], messages = [] } = {}) { const opts = (sb, extra = {}) => ({ days: 14, now: NOW, roots: sb.roots, cachePath: sb.cachePath, deps: deps(), ...extra }); +test('cached OpenCode child fingerprints cannot enter current or previous prompt projections', async () => { + const current = NOW - DAY; + const prior = NOW - 15 * DAY; + const sb = sandbox({ + sessions: [ + { id: 'parent', directory: '/x', title: 'parent', timeCreated: current }, + { id: 'child', directory: '/x', title: 'child', parentId: 'parent', timeCreated: current }, + { id: 'prior-child', directory: '/x', title: 'prior child', parentId: 'parent', timeCreated: prior }, + ], + messages: [ + userMsg('pu', 'parent', current), assistantMsg('pa', 'parent', current + 1000, { cost: 0.2 }), + userMsg('cu', 'child', current), assistantMsg('ca', 'child', current + 1000, { provider: 'alpha', cost: 0.3 }), + userMsg('ou', 'prior-child', prior), assistantMsg('oa', 'prior-child', prior + 1000, { cost: 0.4 }), + ], + }); + try { + const db = new DatabaseSync(sb.dbFile); + const insert = db.prepare('INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)'); + for (const [id, messageId, sessionId, at] of [ + ['p1', 'pu', 'parent', current], ['p2', 'cu', 'child', current], ['p3', 'ou', 'prior-child', prior], + ]) insert.run(id, messageId, sessionId, at, at, JSON.stringify({ type: 'text', text: 'Run the tests' })); + db.close(); + const options = opts(sb, { lookbackDays: 28, previous: true, prompts: true }); + const cold = await buildIndex(options); + assert.equal(cold.totals.typedPrompts, 1); + assert.equal(cold.totals.humanPrompts, 1); + assert.equal(cold.totals.cost, 0.5); + assert.equal(cold.sessions.find((s) => s.id === 'child').typedPrompts, 0); + assert.equal(cold.previous.totals.typedPrompts, 0); + assert.deepEqual(cold.promptPatterns.corpus, { fingerprints: 1, typed: 1 }); + assert.deepEqual(Object.keys(cold.promptBaselines), [], 'a prior child alone does not define an operator baseline'); + const selected = await readSession('child', { roots: sb.roots, deps: deps() }); + assert.equal(selected.meta.sidechain, true); + assert.equal(selected.meta.cost, 0.3); + assert.equal(selected.turns[0].text, 'Run the tests'); + const cache = JSON.parse(fs.readFileSync(sb.cachePath, 'utf8')); + const parentFP = cache.entries['opencode://parent'].session.promptFPs[0]; + for (const id of ['child', 'prior-child']) { + cache.entries[`opencode://${id}`].session.promptFPs = [parentFP]; + } + fs.writeFileSync(sb.cachePath, JSON.stringify(cache)); + _resetForTest(); + const warm = await buildIndex(options); + assert.equal(warm.totals.typedPrompts, 1); + assert.equal(warm.sessions.find((s) => s.id === 'child').typedPrompts, 0); + assert.equal(warm.previous.totals.typedPrompts, 0); + assert.deepEqual(warm.promptPatterns.corpus, { fingerprints: 1, typed: 1 }); + assert.deepEqual(warm.promptPatterns.exactRepeats, []); + assert.deepEqual(Object.keys(warm.promptBaselines), []); + assert.equal(warm.totals.cost, 0.5); + assert.equal(warm.byProvider.alpha.cost, 0.3); + assert.equal(warm.byProvider.alpha.tokens, 1320); + assert.equal(warm.byProvider.opencode.cost, 0.2); + assert.equal(warm.totals.tokens, 2640); + assert.equal(warm.sessions.find((s) => s.id === 'child').threadSource, 'subagent'); + assert.equal(JSON.parse(fs.readFileSync(sb.cachePath, 'utf8')).entries['opencode://child'].session.promptFPs.length, 1, + 'the warm scan reused the old cached record, so aggregation must defend itself'); + } finally { _resetForTest(); rm(sb.dir); } +}); + test('scan aggregates opencode sessions: host bucket, provider bucket, tokens, and OBSERVED cost preferred over the pricing stub', async () => { const at = NOW - DAY; const sb = sandbox({ @@ -113,11 +173,108 @@ test('scan aggregates opencode sessions: host bucket, provider bucket, tokens, a assert.ok(agg.byHost.opencode, 'byHost gains the opencode bucket'); assert.equal(agg.byHost.opencode.cost, 0.5); assert.ok(agg.byProvider.opencode, 'byProvider gains the observed provider bucket'); + assert.ok(s.minutes > 0, 'the fixture has measured duration'); + assert.equal(agg.byProvider.opencode.minutes, s.minutes, 'single-provider duration follows the session'); + assert.equal(agg.byProvider.opencode.confidence, 0.9, 'classifier confidence survives provider folding'); assert.equal(agg.totals.cost, 0.5); assert.equal(agg.byModel['kimi-k3'].cost, 0.5); rm(sb.dir); }); +test('one OpenCode session partitions provider usage on cold and warm scans without changing global totals', async () => { + const at = NOW - DAY; + const sb = sandbox({ + sessions: [{ id: 'ses_switch', directory: '/x', title: 'switch', timeCreated: at }], + messages: [ + assistantMsg('a1', 'ses_switch', at + 1000, { model: 'shared', provider: 'alpha', cost: 0.25, + tokens: { input: 10, output: 2, reasoning: 0, cache: { read: 3, write: 1 } } }), + assistantMsg('a2', 'ses_switch', at + 2000, { model: 'shared', provider: 'beta', + tokens: { input: 20, output: 4, reasoning: 0, cache: { read: 5, write: 2 } } }), + assistantMsg('a3', 'ses_switch', at + 3000, { model: 'shared', provider: 'alpha', cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } }), + ], + }); + try { + const cold = await buildIndex(opts(sb)); + const cache = JSON.parse(fs.readFileSync(sb.cachePath, 'utf8')); + for (const entry of Object.values(cache.entries)) { + if (entry.session.host === 'opencode') { + entry.session.inferenceProvider = 'alpha'; // pre-repair v26 last-wins cache + entry.session.providerProvenance = 'observed'; + } + } + fs.writeFileSync(sb.cachePath, JSON.stringify(cache)); + _resetForTest(); + const warm = await buildIndex(opts(sb)); + const reused = JSON.parse(fs.readFileSync(sb.cachePath, 'utf8')); + assert.equal(Object.values(reused.entries).find((entry) => entry.session.host === 'opencode') + .session.inferenceProvider, 'alpha', 'warm scan reused the old record'); + for (const agg of [cold, warm]) { + assert.equal(agg.sessions.length, 1); + assert.equal(agg.sessions[0].provider, null, 'mixed providers have no unique session provider'); + assert.equal(agg.sessions[0].providerProvenance, 'unknown'); + assert.equal(agg.totals.sessions, 1); + assert.equal(agg.totals.responses, 3); + assert.equal(agg.totals.tokens, 47); + assert.equal(agg.totals.cost, 0.281); + assert.deepEqual(agg.sessions[0].costEvidence, { + observedUsd: 0.25, estimatedUsd: 0.031, + observedMessages: 2, estimatedMessages: 1, unpricedMessages: 0, + }); + assert.equal(agg.byHost.opencode.sessions, 1); + assert.equal(agg.byHost.opencode.responses, 3); + assert.ok(agg.sessions[0].minutes > 0, 'the fixture has measured duration'); + assert.deepEqual(Object.keys(agg.byProvider).sort(), ['alpha', 'beta']); + for (const provider of ['alpha', 'beta']) { + assert.equal(agg.byProvider[provider].minutes, agg.sessions[0].minutes, + 'each provider session count carries the session duration'); + assert.equal(agg.byProvider[provider].confidence, 0.9, + 'each provider session count carries classifier confidence'); + } + assert.deepEqual( + ['sessions', 'responses', 'input', 'output', 'cacheRead', 'cacheWrite', 'tokens', 'cost'] + .map((key) => agg.byProvider.alpha[key]), + [1, 2, 10, 2, 3, 1, 16, 0.25], + ); + assert.deepEqual( + ['sessions', 'responses', 'input', 'output', 'cacheRead', 'cacheWrite', 'tokens', 'cost'] + .map((key) => agg.byProvider.beta[key]), + [1, 1, 20, 4, 5, 2, 31, 0.031], + ); + assert.equal(agg.byModel.shared.responses, 3); + assert.equal(agg.byModel.shared.tokens, 47); + } + } finally { rm(sb.dir); } +}); + +test('an unreported zero-token completion belongs to its observed provider and an absent provider stays unknown', async () => { + const at = NOW - DAY; + const sb = sandbox({ + sessions: [{ id: 'ses_unknown', directory: '/x', title: 'unknown', timeCreated: at }], + messages: [ + assistantMsg('a1', 'ses_unknown', at + 1000, { provider: 'alpha', cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } }), + assistantMsg('a2', 'ses_unknown', at + 2000, { provider: null, cost: 0.1, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } }), + ], + }); + try { + const agg = await buildIndex(opts(sb)); + assert.equal(agg.sessions[0].provider, null); + assert.equal(agg.totals.responses, 2); + assert.equal(agg.totals.tokens, 0); + assert.equal(agg.totals.cost, 0.1); + assert.equal(agg.byHost.opencode.responses, 2); + assert.deepEqual(Object.keys(agg.byProvider).sort(), ['alpha', 'unknown']); + assert.equal(agg.byProvider.alpha.sessions, 1); + assert.equal(agg.byProvider.alpha.responses, 1); + assert.equal(agg.byProvider.alpha.tokens, 0); + assert.equal(agg.byProvider.unknown.sessions, 1); + assert.equal(agg.byProvider.unknown.responses, 1); + assert.equal(agg.byProvider.unknown.cost, 0.1); + } finally { rm(sb.dir); } +}); + test('sessions with NO observed cost fall back to the pricing table (never a fabricated $0)', async () => { const at = NOW - DAY; const sb = sandbox({ @@ -152,6 +309,96 @@ test('the incremental cache: a warm scan reuses unchanged sessions and picks up rm(sb.dir); }); +test('unchanged schema 26 OpenCode rows without parse semantics are reparsed, including mixed untrusted zero cost', async () => { + const at = NOW - DAY; + const sb = sandbox({ + sessions: [{ id: 'ses_legacy26', directory: '/x', title: 'real title', timeCreated: at }], + messages: [ + assistantMsg('a1', 'ses_legacy26', at + 1000, { cost: 0.4 }), + assistantMsg('a2', 'ses_legacy26', at + 2000, { cost: 0 }), + ], + }); + try { + const cold = await buildIndex(opts(sb)); + const file = `opencode://ses_legacy26`; + const cache = JSON.parse(fs.readFileSync(sb.cachePath, 'utf8')); + assert.equal(cache.schemaVersion, 26); + assert.ok(cache.entries[file].parseSemantics, 'every new OpenCode entry identifies its parser semantics'); + delete cache.entries[file].parseSemantics; + cache.entries[file].session.title = 'FORGED-LEGACY'; + cache.entries[file].session.usage[0].costObserved = 0.4; + delete cache.entries[file].session.usage[0].costUntrustedMessages; + fs.writeFileSync(sb.cachePath, JSON.stringify(cache)); + _resetForTest(); + const repaired = await buildIndex(opts(sb)); + const session = repaired.sessions.find((s) => s.id === 'ses_legacy26'); + assert.equal(session.title, 'real title'); + assert.equal(session.responses, 2); + assert.equal(session.costEvidence.observedMessages, 1); + assert.equal(session.costEvidence.unpricedMessages, 1); + assert.equal(session.cost, cold.sessions[0].cost); + assert.equal(repaired.totals.cost, repaired.byHost.opencode.cost); + assert.equal(repaired.totals.cost, repaired.byProvider.opencode.cost); + assert.equal(repaired.totals.responses, repaired.byProvider.opencode.responses); + assert.equal(repaired.totals.tokens, repaired.byProvider.opencode.tokens); + assert.ok(JSON.parse(fs.readFileSync(sb.cachePath, 'utf8')).entries[file].parseSemantics); + } finally { _resetForTest(); rm(sb.dir); } +}); + +test('schema 26 OpenCode rows lacking a marker reparse even when every cost was trusted; marked rows reuse warm', async () => { + const at = NOW - DAY; + const sb = sandbox({ sessions: [{ id: 'ses_trusted26', directory: '/x', title: 'real', timeCreated: at }], + messages: [assistantMsg('a1', 'ses_trusted26', at + 1000, { cost: 0.2 })] }); + try { + await buildIndex(opts(sb)); + const file = 'opencode://ses_trusted26'; + const cache = JSON.parse(fs.readFileSync(sb.cachePath, 'utf8')); + delete cache.entries[file].parseSemantics; + cache.entries[file].session.title = 'OLD'; + fs.writeFileSync(sb.cachePath, JSON.stringify(cache)); + _resetForTest(); + await buildIndex(opts(sb)); + const reparsed = JSON.parse(fs.readFileSync(sb.cachePath, 'utf8')); + assert.equal(reparsed.entries[file].session.title, 'real'); + reparsed.entries[file].session.title = 'WARM-MARKED'; + fs.writeFileSync(sb.cachePath, JSON.stringify(reparsed)); + _resetForTest(); + const warm = await buildIndex(opts(sb)); + assert.equal(warm.sessions[0].title, 'WARM-MARKED'); + } finally { _resetForTest(); rm(sb.dir); } +}); + +test('degraded OpenCode store excludes legacy cache accounting but retains compatible cache accounting', async () => { + const at = NOW - DAY; + const sb = sandbox({ sessions: [ + { id: 'ses_old', directory: '/x', title: 'old', timeCreated: at }, + { id: 'ses_new', directory: '/x', title: 'new', timeCreated: at }, + ], messages: [ + assistantMsg('a1', 'ses_old', at + 1000, { cost: 0.3 }), + assistantMsg('a2', 'ses_new', at + 1000, { cost: 0.4 }), + ] }); + try { + await buildIndex(opts(sb)); + const cache = JSON.parse(fs.readFileSync(sb.cachePath, 'utf8')); + delete cache.entries['opencode://ses_old'].parseSemantics; + fs.writeFileSync(sb.cachePath, JSON.stringify(cache)); + fs.rmSync(sb.dbFile); + fs.writeFileSync(sb.dbFile, 'not a sqlite database'); + _resetForTest(); + const degraded = await buildIndex(opts(sb)); + assert.equal(degraded.sourceHealth.opencode.status, 'degraded'); + assert.equal(degraded.sourceHealth.opencode.reason, 'corrupt'); + assert.equal(degraded.sourceHealth.opencode.legacyCacheEntriesExcluded, 1); + assert.deepEqual(degraded.sessions.map((s) => s.id), ['ses_new']); + assert.equal(degraded.totals.cost, 0.4); + assert.equal(degraded.byProvider.opencode.cost, 0.4); + assert.equal(degraded.totals.responses, 1); + const retained = JSON.parse(fs.readFileSync(sb.cachePath, 'utf8')); + assert.ok(retained.entries['opencode://ses_old']); + assert.equal(retained.entries['opencode://ses_old'].parseSemantics, undefined); + } finally { _resetForTest(); rm(sb.dir); } +}); + test('a corrupt OpenCode store preserves last-good usage and surfaces degraded source health', async () => { const at = NOW - DAY; const sb = sandbox({ diff --git a/tests/kit/usage-index-v6.test.mjs b/tests/kit/usage-index-v6.test.mjs index ab71807d..6066bf83 100644 --- a/tests/kit/usage-index-v6.test.mjs +++ b/tests/kit/usage-index-v6.test.mjs @@ -138,9 +138,10 @@ test('parseCodex normalizes current item_completed messages and exposes bounded // genuinely new shapes. assert.equal(s.tools.CommandExecution, 1, 'tallied as the tool it is'); assert.deepEqual(agg.sourceHealth.codex.diagnostics, { - files: 1, cachedFiles: 0, parsedFiles: 1, unparsedFiles: 0, unparsedReasons: {}, importedExcluded: 0, - filesWithTokens: 1, filesWithResponses: 1, - legacyEvents: 0, itemCompletedEvents: 3, tokenCountEvents: 1, + files: 1, cachedFiles: 0, parsedFiles: 1, unparsedFiles: 0, unparsedReasons: {}, importedExcluded: 0, importedMixed: 0, importedTurnsExcluded: 0, importAmbiguousRecords: 0, + importOwnershipIncompleteFiles: 0, importedTurnCountIncompleteFiles: 0, + filesWithTokens: 1, filesWithResponses: 1, zeroResponseUsageFiles: 0, zeroResponseUnsupportedFiles: 0, + legacyEvents: 0, itemCompletedEvents: 3, tokenCountEvents: 1, totalOnlyTokenCountEvents: 0, prompts: 1, responses: 1, unknownItemTypes: {}, unknownItemTypeOverflow: 0, clippedLines: 0, warnings: [], common: { @@ -169,7 +170,7 @@ test('parseCodex accepts a mixed legacy/current rollout without dropping either assert.equal(agg.sourceHealth.codex.diagnostics.itemCompletedEvents, 3); }); -test('Codex source health degrades when token-bearing files yield zero normalized responses', async () => { +test('Codex source health counts component usage with zero normalized responses', async () => { _resetForTest(); const id = 'item-completed-zero'; const line = (o) => `${JSON.stringify(o)}\n`; @@ -180,13 +181,14 @@ test('Codex source health degrades when token-bearing files yield zero normalize } }); const sb = sandbox({ [`rollout-2026-07-24T09-00-00-${id}.jsonl`]: raw }); const agg = await buildIndex(opts(sb)); - assert.equal(agg.totals.sessions, 0); - assert.equal(agg.sourceHealth.codex.status, 'degraded'); - assert.equal(agg.sourceHealth.codex.reason, 'parse-yield-zero'); - assert.deepEqual(agg.sourceHealth.codex.diagnostics.warnings, ['zero-response-yield']); + assert.equal(agg.totals.sessions, 1); + assert.equal(agg.totals.tokens, 120); + assert.equal(agg.sourceHealth.codex.status, 'ok'); + assert.equal(agg.sourceHealth.codex.diagnostics.zeroResponseUsageFiles, 1); + assert.deepEqual(agg.sourceHealth.codex.diagnostics.warnings, []); }); -test('Codex source health exposes partial response yield across token-bearing files', async () => { +test('Codex source health counts a zero-response component file alongside responses', async () => { _resetForTest(); const id = 'item-completed-partial'; const line = (o) => `${JSON.stringify(o)}\n`; @@ -199,10 +201,9 @@ test('Codex source health exposes partial response yield across token-bearing fi 'rollout-2026-07-24T09-00-01-zero-yield.jsonl': zero, }); const agg = await buildIndex(opts(sb)); - assert.equal(agg.totals.sessions, 1); - assert.equal(agg.sourceHealth.codex.status, 'degraded'); - assert.equal(agg.sourceHealth.codex.reason, 'parse-yield-partial'); - assert.deepEqual(agg.sourceHealth.codex.diagnostics.warnings, ['partial-response-yield']); + assert.equal(agg.totals.sessions, 2); + assert.equal(agg.sourceHealth.codex.status, 'ok'); + assert.deepEqual(agg.sourceHealth.codex.diagnostics.warnings, []); assert.equal(agg.sourceHealth.codex.diagnostics.filesWithTokens, 2); assert.equal(agg.sourceHealth.codex.diagnostics.filesWithResponses, 1); }); diff --git a/tests/kit/usage-index.test.mjs b/tests/kit/usage-index.test.mjs index 07e32530..b52f5e3d 100644 --- a/tests/kit/usage-index.test.mjs +++ b/tests/kit/usage-index.test.mjs @@ -947,12 +947,13 @@ test('an empty corpus yields a zeroed Aggregate rather than throwing', async () assert.equal(agg.sourceHealth.codex.diagnostics.files, 0); }); -test('buildIndex reports ok claude/codex root health when the transcript roots exist', async () => { +test('buildIndex reports malformed Claude coverage while Codex root health remains ok', async () => { _resetForTest(); const sb = sandbox(); const agg = await buildIndex(opts(sb)); - assert.equal(agg.sourceHealth.claude.status, 'ok'); - assert.equal(agg.sourceHealth.claude.reason, null); + assert.equal(agg.sourceHealth.claude.status, 'degraded'); + assert.equal(agg.sourceHealth.claude.reason, 'transcript-record-coverage-incomplete'); + assert.equal(agg.sourceHealth.claude.diagnostics.records.malformedRecords, 1); // Source health reports what was READ, never what the parser could report: // the old per-host capability matrix is not part of this payload. assert.ok(!Object.hasOwn(agg.sourceHealth.claude, 'capabilities'), diff --git a/tests/kit/usage-limits-empty-state.test.mjs b/tests/kit/usage-limits-empty-state.test.mjs index 6d882681..069eb943 100644 --- a/tests/kit/usage-limits-empty-state.test.mjs +++ b/tests/kit/usage-limits-empty-state.test.mjs @@ -33,17 +33,17 @@ const text = (html) => html.replace(/<[^>]+>/g, '').replace(/—/g, '—').r test('a custom user-level statusLine is named, with the project precedence that still fills the panel', () => { const html = text(renderLimitsWith(empty({ claudeChannel: 'custom' }))['u-lim-claude'].innerHTML); - assert.match(html, /user-level statusLine runs a custom script/); + assert.match(html, /effective statusLine runs a custom script/); assert.match(html, /does not report limits to ak/); - assert.match(html, /project’s own statusLine takes precedence over your user-level one/); + assert.match(html, /local or managed settings may override the project and user settings/); assert.match(html, /ak setup --project/); assert.doesNotMatch(html, /Run one session, then revisit/, 'running more sessions is not the fix when the effective statusline cannot tee'); }); -test('no user-level statusLine says only footer-carrying projects report limits', () => { +test('no effective statusLine says only footer-carrying projects report limits', () => { const html = text(renderLimitsWith(empty({ claudeChannel: 'none' }))['u-lim-claude'].innerHTML); - assert.match(html, /no user-level statusLine/); + assert.match(html, /no effective statusLine/); assert.match(html, /ak setup --project/); }); diff --git a/tests/kit/usage-opencode-compaction.test.mjs b/tests/kit/usage-opencode-compaction.test.mjs new file mode 100644 index 00000000..0d6e8c51 --- /dev/null +++ b/tests/kit/usage-opencode-compaction.test.mjs @@ -0,0 +1,236 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { tempDir } from './helpers/temp-dir.mjs'; +import { parseSession } from '../../src/lib/usage-opencode.mjs'; +import { aggregate } from '../../src/lib/usage-aggregate.mjs'; +import { buildIndex, _resetForTest } from '../../src/lib/usage-index.mjs'; + +const NOW = Date.parse('2026-09-29T12:00:00Z'); +const TOKENS = { input: 100, output: 20, reasoning: 5, cache: { read: 40, write: 3 } }; +const assistant = (extra = {}) => ({ role: 'assistant', summary: true, parentID: 'u', finish: 'stop', + providerID: 'openrouter', modelID: 'test', tokens: TOKENS, cost: 0.25, + time: { created: NOW - 2000, completed: NOW - 1000 }, ...extra }); +function fixture(t, { messages = [['u', { role: 'user' }], ['a', assistant()]], + parts = [['u', { type: 'compaction' }], ['a', { type: 'step-finish', tokens: TOKENS, cost: 0.25 }]], + metadata = {} } = {}) { + const dir = tempDir('ak-oc-compaction-'); + t.after(() => { _resetForTest(); fs.rmSync(dir, { recursive: true, force: true }); }); + const dbFile = path.join(dir, 'opencode.db'); + const db = new DatabaseSync(dbFile); + db.exec(`CREATE TABLE session (id TEXT PRIMARY KEY, parent_id TEXT, directory TEXT, title TEXT, + version TEXT, time_created INTEGER, time_updated INTEGER, time_compacting INTEGER, + cost REAL, tokens_input INTEGER, tokens_output INTEGER, tokens_reasoning INTEGER, + tokens_cache_read INTEGER, tokens_cache_write INTEGER); + CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, time_created INTEGER, time_updated INTEGER, data TEXT); + CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, session_id TEXT, data TEXT);`); + db.prepare('INSERT INTO session VALUES (?,NULL,?,?,?,?,?,NULL,?,?,?,?,?,?)') + .run('s', dir, 'fixture', '1.18.33', NOW - 3000, NOW, 0.25, 100, 20, 5, 40, 3); + for (const [key, value] of Object.entries(metadata)) db.prepare(`UPDATE session SET ${key} = ?`).run(value); + for (const [id, data] of messages) db.prepare('INSERT INTO message VALUES (?,?,?,?,?)') + .run(id, 's', NOW - 2000, NOW, JSON.stringify(data)); + parts.forEach(([id, data], i) => db.prepare('INSERT INTO part VALUES (?,?,?,?)').run(`p${i}`, id, 's', JSON.stringify(data))); + db.close(); + const roots = { claude: path.join(dir, 'claude'), codex: path.join(dir, 'codex'), opencode: dbFile }; + fs.mkdirSync(roots.claude); fs.mkdirSync(roots.codex); + return { dbFile, id: 's', roots, cachePath: path.join(dir, 'cache.json'), days: 14, now: NOW, + deps: { costOf: () => 99, classify: () => ({ category: 'Build', confidence: 1 }), detectInsights: () => [] } }; +} + +test('linked completed compaction counts once on scan/detail without charging its parts', t => { + const opts = fixture(t, { parts: [['u', { type: 'compaction' }], ['u', { type: 'compaction' }], + ['a', { type: 'step-finish', tokens: TOKENS, cost: 0.25 }]] }); + for (const withTurns of [false, true]) { + const { session } = parseSession({ ...opts, withTurns }); + assert.equal(session.compactions, 1); + assert.deepEqual(session.compactionEvidence, { lowerBound: 1, upperBound: 1 }); + assert.equal(session.usage[0].input, 100); + assert.equal(session.usage[0].output, 25); + assert.equal(session.usage[0].costObserved, 0.25); + assert.equal(session.opencodeReconciliation.state, 'matched'); + } +}); + +for (const [name, data, parts, meta, bounds] of [ + ['failed', assistant({ error: { name: 'APIError' } }), [['u', { type: 'compaction' }]], {}, [0, 0]], + ['aborted', assistant({ error: { name: 'MessageAbortedError' } }), [['u', { type: 'compaction' }]], {}, [0, 0]], + ['inflight', assistant({ finish: undefined, time: { created: NOW - 2000 } }), [['u', { type: 'compaction' }]], { time_compacting: NOW }, [0, 1]], + ['orphan summary', assistant({ parentID: 'absent' }), [], {}, [0, 1]], + ['request only', { role: 'user' }, [['u', { type: 'compaction' }]], {}, [0, 1]], +]) test(`${name} cannot claim a completed compaction`, t => { + const { session } = parseSession(fixture(t, { messages: [['u', { role: 'user' }], ['a', data]], parts, metadata: meta })); + assert.equal(session.compactions, bounds[0]); + assert.deepEqual(session.compactionEvidence, { lowerBound: bounds[0], upperBound: bounds[1] }); +}); + +test('time_compacting alone is incomplete evidence, never completion', t => { + const { session } = parseSession(fixture(t, { parts: [], messages: [], metadata: { time_compacting: NOW } })); + assert.equal(session.compactions, 0); + assert.deepEqual(session.compactionEvidence, { lowerBound: 0, upperBound: null }); +}); + +for (const [name, changes, state, reason] of [ + ['mismatch', { metadata: { tokens_input: 101 } }, 'mismatch', null], + ['default zero', { metadata: { cost: 0, tokens_input: 0, tokens_output: 0, tokens_reasoning: 0, tokens_cache_read: 0, tokens_cache_write: 0 } }, 'unknown', 'unpopulated-session-counters'], + ['malformed session', { metadata: { tokens_input: -1 } }, 'unknown', 'invalid-session-counters'], + ['partial session counters', { metadata: { tokens_reasoning: null } }, 'unknown', 'invalid-session-counters'], + ['unsupported inclusive token basis', { messages: [['a', assistant({ tokens: { ...TOKENS, total: 163 } })]] }, 'unknown', 'invalid-message-counters'], + ['unsupported version', { metadata: { version: '0.9.10' } }, 'unknown', 'unsupported-version'], + ['compacting', { metadata: { time_compacting: NOW } }, 'unknown', 'incomplete-messages'], + ['missing steps', { parts: [] }, 'unknown', 'unproved-step-scope'], + ['multiple steps', { parts: [['a', { type: 'step-finish', tokens: TOKENS, cost: 0.25 }], ['a', { type: 'step-finish', tokens: TOKENS, cost: 0.25 }]] }, 'unknown', 'unproved-step-scope'], + ['inflight message', { messages: [['a', assistant({ time: { created: NOW - 2000 } })]] }, 'unknown', 'incomplete-messages'], + ['invalid message counters', { messages: [['a', assistant({ tokens: { ...TOKENS, input: '100' } })]] }, 'unknown', 'invalid-message-counters'], +]) test(`reconciliation ${name} preserves message usage`, t => { + const { session } = parseSession(fixture(t, changes)); + assert.equal(session.opencodeReconciliation.state, state); + if (reason) assert.equal(session.opencodeReconciliation.reason, reason); + assert.equal(session.usage[0].input, 100); + assert.equal(session.usage[0].costObserved, 0.25); +}); + +test('aggregate current/previous, warm cache and old parse marker retain observations', async t => { + const opts = fixture(t); + const cold = await buildIndex(opts); + assert.equal(cold.sessions[0].compactions, 1); + assert.equal(cold.totals.compactions, 1); + assert.equal(cold.sessions[0].opencodeReconciliation.state, 'matched'); + _resetForTest(); + const warm = await buildIndex(opts); + assert.equal(warm.totals.compactions, 1); + const cache = JSON.parse(fs.readFileSync(opts.cachePath, 'utf8')); + const entry = Object.values(cache.entries).find(e => e.session?.host === 'opencode'); + entry.parseSemantics = 'cost-trust-v2'; entry.session.compactions = 0; + fs.writeFileSync(opts.cachePath, JSON.stringify(cache)); _resetForTest(); + assert.equal((await buildIndex(opts)).totals.compactions, 1); + _resetForTest(); + const later = await buildIndex({ ...opts, now: NOW + 15 * 86400000, previous: true, lookbackDays: 28 }); + assert.equal(later.totals.compactions, 0); + assert.equal(later.previous.totals.compactions, 1); +}); + + +test('untrusted zero cost and unsupported V2 scope cannot claim matched reconciliation', t => { + const opts = fixture(t, { messages: [['a', assistant({ cost: 0 })]], + parts: [['a', { type: 'step-finish', tokens: TOKENS, cost: 0 }]], metadata: { cost: 0 } }); + assert.equal(parseSession(opts).session.opencodeReconciliation.reason, 'untrusted-message-cost'); + const db = new DatabaseSync(opts.dbFile); + db.exec("CREATE TABLE session_message (session_id TEXT); INSERT INTO session_message VALUES ('s')"); db.close(); + assert.equal(parseSession(opts).session.opencodeReconciliation.reason, 'unsupported-v2-scope'); +}); + +test('oversized selected metadata is bounded before acquisition', t => { + const opts = fixture(t, { metadata: { version: 'x'.repeat(5000) } }); + const { session } = parseSession({ ...opts, maxSessionBytes: 4000 }); + assert.equal(session.acquisitionCoverage.reason, 'session-byte-limit'); +}); + + +test('duplicate summary evidence is counted once per actual request parent', t => { + const opts = fixture(t, { messages: [['u', { role: 'user' }], ['a', assistant()], ['b', assistant()]], + parts: [['u', { type: 'compaction' }], ['u', { type: 'compaction' }]] }); + const { session } = parseSession(opts); + assert.equal(session.compactions, 1); + assert.deepEqual(session.compactionEvidence, { lowerBound: 1, upperBound: 1 }); + assert.equal(session.responses, 2, 'distinct assistant rows keep their recorded usage'); + assert.equal(session.usage[0].costObserved, 0.5); +}); + + +function mutateDb(opts, action) { + const db = new DatabaseSync(opts.dbFile); + try { action(db); } finally { db.close(); } + _resetForTest(); +} + +for (const [name, mutation, reason] of [ + ['upstream part removal and counter subtraction', db => db.exec(`DELETE FROM part WHERE id = 'p1'; + UPDATE session SET cost = 0, tokens_input = 0, tokens_output = 0, tokens_reasoning = 0, + tokens_cache_read = 0, tokens_cache_write = 0`), 'unpopulated-session-counters'], + ['same-count step rewrite', db => db.prepare('UPDATE part SET data = ? WHERE id = ?') + .run(JSON.stringify({ type: 'step-finish', tokens: { ...TOKENS, input: 101 }, cost: 0.25 }), 'p1'), 'unproved-step-scope'], + ['part addition', db => db.exec("INSERT INTO part SELECT 'extra', message_id, session_id, data FROM part WHERE id = 'p1'"), 'unproved-step-scope'], + ['metadata-only rewrite', db => db.exec("UPDATE session SET time_compacting = 123"), 'incomplete-messages'], + ['new V2 scope', db => db.exec("CREATE TABLE session_message (session_id TEXT); INSERT INTO session_message VALUES ('s')"), 'unsupported-v2-scope'], +]) test(`warm observation cache invalidates after ${name} without timestamp changes`, async t => { + const opts = fixture(t); + assert.equal((await buildIndex(opts)).sessions[0].opencodeReconciliation.state, 'matched'); + mutateDb(opts, mutation); + const warm = await buildIndex(opts); + assert.equal(warm.sessions[0].opencodeReconciliation.reason, reason); + assert.deepEqual(warm.sessions[0].opencodeReconciliation, parseSession(opts).session.opencodeReconciliation); + assert.equal(warm.totals.responses, 1); + assert.equal(warm.totals.cost, 0.25); +}); + +test('same-size compaction part rewrite invalidates a current marker while unchanged evidence reuses it', async t => { + const opts = fixture(t); + await buildIndex(opts); + const cache = JSON.parse(fs.readFileSync(opts.cachePath, 'utf8')); + cache.entries['opencode://s'].session.title = 'cache reuse sentinel'; + fs.writeFileSync(opts.cachePath, JSON.stringify(cache)); _resetForTest(); + assert.equal((await buildIndex(opts)).sessions[0].title, 'cache reuse sentinel', 'unchanged source reuses the parse'); + mutateDb(opts, db => db.prepare('UPDATE part SET data = ? WHERE id = ?') + .run(JSON.stringify({ type: 'xxxxxxxxxx' }), 'p0')); + const warm = await buildIndex(opts); + assert.equal(warm.sessions[0].title, 'fixture'); + assert.equal(warm.totals.compactions, 0); + assert.deepEqual(warm.totals.compactionEvidence, { lowerBound: 0, upperBound: 1 }); +}); + +for (const [name, maxSessionBytes, upperBound] of [ + ['request-only', undefined, 1], ['acquisition-incomplete', 64, null], +]) test(`${name} observation uncertainty survives current and previous aggregate windows`, t => { + const opts = fixture(t, { messages: [['u', { role: 'user' }]], parts: [['u', { type: 'compaction' }]] }); + const { session } = parseSession({ ...opts, maxSessionBytes }); + for (const offset of [0, 15]) { + const now = NOW + offset * 86400000; + const result = aggregate([session], { days: 14, now, cutoff: now - 14 * 86400000, previous: true, deps: opts.deps }); + const totals = offset ? result.previous.totals : result.totals; + assert.deepEqual(totals.compactionEvidence, { lowerBound: 0, upperBound }); + assert.equal(totals.sessions, maxSessionBytes ? 0 : 1, 'refused acquisition is not a normal session'); + assert.equal(totals.responses, 0); + assert.equal(totals.tokens, 0); + assert.equal(totals.cost, 0); + if (offset) assert.equal(result.totals.sessions, 0); + } +}); + +test('a tighter acquisition budget cannot reuse a cached full observation', async t => { + const opts = fixture(t); + await buildIndex(opts); _resetForTest(); + const restricted = await buildIndex({ ...opts, readLimits: { maxSessionBytes: 64 } }); + assert.equal(restricted.totals.responses, 0); + assert.deepEqual(restricted.totals.compactionEvidence, { lowerBound: 0, upperBound: null }); + assert.equal(restricted.totals.cost, 0); +}); + + +test('unknown V2 schema preserves V1 usage and refuses a matched reconciliation', async t => { + const opts = fixture(t); + await buildIndex(opts); + mutateDb(opts, db => db.exec('CREATE TABLE session_message (payload TEXT)')); + const warm = await buildIndex(opts); + assert.equal(warm.totals.responses, 1); + assert.equal(warm.totals.cost, 0.25); + assert.equal(warm.sessions[0].opencodeReconciliation.reason, 'unsupported-v2-scope'); +}); + +test('an observation entry without its input digest reparses rather than laundering stale evidence', async t => { + const opts = fixture(t); + await buildIndex(opts); + const cache = JSON.parse(fs.readFileSync(opts.cachePath, 'utf8')); + delete cache.entries['opencode://s'].observationFingerprint; + cache.entries['opencode://s'].session.title = 'unbound cache'; + fs.writeFileSync(opts.cachePath, JSON.stringify(cache)); _resetForTest(); + assert.equal((await buildIndex(opts)).sessions[0].title, 'fixture'); +}); + +test('read-limit controls cannot override the explicitly selected database', async t => { + const opts = fixture(t); + const unrelated = fixture(t, { metadata: { title: 'other source' } }); + const result = await buildIndex({ ...opts, readLimits: { dbFile: unrelated.dbFile, id: unrelated.id } }); + assert.equal(result.sessions[0].title, 'fixture'); +}); diff --git a/tests/kit/usage-opencode-selection.test.mjs b/tests/kit/usage-opencode-selection.test.mjs new file mode 100644 index 00000000..b77bfac7 --- /dev/null +++ b/tests/kit/usage-opencode-selection.test.mjs @@ -0,0 +1,125 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { tempDir } from './helpers/temp-dir.mjs'; +import * as source from '../../src/lib/usage-opencode.mjs'; +import { scanOpencodeDirectories } from '../../src/lib/footprint/project-sources.mjs'; + +test('OpenCode selection honors explicit authority, detects ambiguity, and rejects unsafe paths', () => { + const dir = tempDir('ak-oc-selection-'); + try { + const root = path.join(dir, 'opencode'); fs.mkdirSync(root); + const channel = path.join(root, 'opencode-review_42.db'); fs.writeFileSync(channel, ''); + const env = { HOME: dir, XDG_DATA_HOME: dir }; + const select = (extra = {}) => source.selectOpencodeSource({ env, ...extra }); + assert.equal(typeof source.selectOpencodeSource, 'function'); + assert.equal(select().dbFile, fs.realpathSync(channel)); + fs.writeFileSync(path.join(root, 'opencode.db'), ''); + assert.equal(select().health.reason, 'database-selection-ambiguous'); + assert.equal(select().dbFile, null); + assert.equal(select({ env: { ...env, OPENCODE_DISABLE_CHANNEL_DB: 'true' } }).dbFile, path.join(root, 'opencode.db')); + assert.equal(select({ env: { ...env, OPENCODE_DB: 'opencode-review_42.db' } }).dbFile, fs.realpathSync(channel)); + assert.equal(select({ env: { ...env, OPENCODE_DB: channel } }).dbFile, fs.realpathSync(channel)); + assert.equal(select({ roots: {} }).dbFile, null); + assert.equal(select({ roots: { opencode: channel }, env: { OPENCODE_DB: ':memory:' } }).dbFile, fs.realpathSync(channel)); + assert.equal(select({ env: { ...env, OPENCODE_DB: ':memory:' } }).health.reason, 'database-in-memory'); + for (const value of ['../outside.db', 'bad\0.db', 'bad\n.db']) { + assert.equal(select({ env: { ...env, OPENCODE_DB: value } }).health.status, 'degraded'); + } + assert.equal(select({ roots: { opencode: 'relative.db' } }).health.status, 'degraded'); + const project = scanOpencodeDirectories({ selection: select(), withDb: () => { throw Error('must not open ambiguous source'); } }); + assert.equal(project.reason, 'database-selection-ambiguous'); + assert.equal(project.complete, false); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); + +test('source discovery is bounded and unreadable candidates never trigger a fallback', () => { + const dir = tempDir('ak-oc-bound-'); + try { + const root = path.join(dir, 'opencode'); fs.mkdirSync(root); + const env = { HOME: dir, XDG_DATA_HOME: dir }; + for (let i = 0; i < 257; i++) fs.writeFileSync(path.join(root, `other-${i}`), ''); + assert.equal(source.selectOpencodeSource({ env }).health.reason, 'database-discovery-limit'); + const denied = { ...fs, opendirSync: () => { throw Object.assign(Error('private path'), { code: 'EACCES' }); } }; + assert.equal(source.selectOpencodeSource({ env, fsImpl: denied }).health.reason, 'database-discovery-unreadable'); + const loop = { ...fs, realpathSync: () => { throw Object.assign(Error('private path'), { code: 'ELOOP' }); } }; + const result = source.selectOpencodeSource({ roots: { opencode: path.join(root, 'chosen.db') }, fsImpl: loop }); + assert.equal(result.dbFile, null); + assert.equal(result.health.reason, 'database-path-unreadable'); + assert.ok(!JSON.stringify(result.health).includes(dir)); + assert.equal(source.selectOpencodeSource({ env: { HOME: dir, XDG_DATA_HOME: 'relative', OPENCODE_DB: 'selected.db' } }).dbFile, + path.join(dir, '.local', 'share', 'opencode', 'selected.db')); + const missing = source.selectOpencodeSource({ roots: { opencode: path.join(root, 'missing.db') } }); + assert.equal(scanOpencodeDirectories({ selection: missing }).status, 'absent'); + assert.equal(fs.existsSync(missing.dbFile), false, 'read-only discovery does not create missing stores'); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); + +test('project source health reports unreadable database categories without private error text', () => { + const result = scanOpencodeDirectories({ dbFile: path.resolve('unreadable.db'), + withDb: () => ({ ok: false, error: { kind: 'permission', message: 'private database location' } }) }); + assert.equal(result.status, 'degraded'); + assert.equal(result.reason, 'permission'); +}); + +// The deterministic iterator orders an actual dangling link between two actual +// stores. Production must not treat a candidate's ENOENT as a missing root. +test('a dangling eligible candidate cannot hide a second real database', { skip: process.platform === 'win32' }, () => { + const dir = tempDir('ak-oc-dangling-'); + try { + const root = path.join(dir, 'opencode'); fs.mkdirSync(root); + fs.writeFileSync(path.join(root, 'opencode-a.db'), ''); + fs.symlinkSync(path.join(root, 'missing.db'), path.join(root, 'opencode-b.db')); + fs.writeFileSync(path.join(root, 'opencode-c.db'), ''); + const entries = fs.readdirSync(root, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)); + let closed = false; + const fsImpl = { ...fs, opendirSync: () => ({ readSync: () => entries.shift() ?? null, closeSync: () => { closed = true; } }) }; + const selection = source.selectOpencodeSource({ env: { HOME: dir, XDG_DATA_HOME: dir }, fsImpl }); + assert.equal(selection.dbFile, null); + assert.equal(selection.health.status, 'degraded'); + assert.equal(closed, true); + const projects = scanOpencodeDirectories({ selection, withDb: () => { throw Error('uncertain source must not be opened'); } }); + assert.equal(projects.complete, false); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); + +for (const failure of ['stat-ENOENT', 'stat-EACCES', 'read-ENOENT', 'read-EIO', 'close-EIO', 'limit']) { + test(`partial discovery never establishes uniqueness after ${failure}`, () => { + const dir = tempDir('ak-oc-partial-'); + try { + const root = path.join(dir, 'opencode'); fs.mkdirSync(root); + const first = path.join(root, 'opencode-a.db'); fs.writeFileSync(first, ''); + const second = path.join(root, 'opencode-b.db'); fs.writeFileSync(second, ''); + let count = 0; + const [operation, code] = failure.split('-'); + const fail = () => { throw Object.assign(Error('private path'), { code }); }; + const fsImpl = { ...fs, + opendirSync: () => ({ + readSync: () => { + count++; + if (count === 1) return { name: 'opencode-a.db' }; + if (operation === 'read') return fail(); + if (operation === 'limit') return { name: `unrelated-${count}` }; + return count === 2 ? { name: 'opencode-b.db' } : null; + }, + closeSync: () => { if (operation === 'close') fail(); }, + }), + statSync: (file) => file === second && operation === 'stat' ? fail() : fs.statSync(file), + }; + const result = source.selectOpencodeSource({ env: { HOME: dir, XDG_DATA_HOME: dir }, fsImpl }); + assert.equal(result.dbFile, null); + assert.equal(result.health.status, 'degraded'); + assert.ok(!JSON.stringify(result.health).includes(dir)); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }); +} + +test('absolute OpenCode authority survives invalid ambient home when legacy root cannot be resolved', () => { + const dir = tempDir('ak-oc-absolute'); + const dbFile = path.join(dir, 'explicit.db'); + const selected = source.selectOpencodeSource({ env: { HOME: 'relative', OPENCODE_DB: dbFile } }); + assert.equal(selected.dbFile, dbFile); + assert.equal(selected.legacyRoot, null); + assert.equal(source.selectOpencodeSource({ env: { HOME: 'relative', OPENCODE_DB: ':memory:' } }).health.reason, 'database-in-memory'); +}); diff --git a/tests/kit/usage-opencode-storage-coverage.test.mjs b/tests/kit/usage-opencode-storage-coverage.test.mjs new file mode 100644 index 00000000..78af4053 --- /dev/null +++ b/tests/kit/usage-opencode-storage-coverage.test.mjs @@ -0,0 +1,103 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; +import { DatabaseSync } from 'node:sqlite'; +import { observeOpencodeStorageCoverage } from '../../src/lib/usage-opencode-storage-coverage.mjs'; + +function fixture(fn) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-oc-coverage-')); + const file = path.join(root, 'opencode.db'); + const db = new DatabaseSync(file); + try { return fn({ root, file, db }); } + finally { if (db.isOpen) db.close(); fs.rmSync(root, { recursive: true, force: true }); } +} + +const digest = (file) => createHash('sha256').update(fs.readFileSync(file)).digest('hex'); + +test('null inputs remain not observed, with no completeness claim', () => { + assert.deepEqual(observeOpencodeStorageCoverage(), { + v2: { status: 'not-observed' }, legacy: { status: 'not-observed' }, warnings: [], + }); +}); + +test('older schema, empty V2 table, and populated V2 table have distinct states', () => fixture(({ db }) => { + assert.equal(observeOpencodeStorageCoverage({ db }).v2.status, 'missing'); + db.exec('CREATE TABLE session_message (id TEXT, data TEXT)'); + assert.equal(observeOpencodeStorageCoverage({ db }).v2.status, 'empty'); + db.prepare('INSERT INTO session_message VALUES (?, ?)').run('one', 'private body'); + const result = observeOpencodeStorageCoverage({ db }); + assert.deepEqual(result.v2, { status: 'present' }); + assert.deepEqual(result.warnings, ['opencode-v2-session-message-present']); + assert.equal(JSON.stringify(result).includes('private body'), false); +})); + +test('non-table schema and failed query stay unknown without leaking error detail', () => fixture(({ db }) => { + db.exec('CREATE VIEW session_message AS SELECT 1 AS id'); + assert.deepEqual(observeOpencodeStorageCoverage({ db }).v2, { status: 'unknown' }); + db.exec('DROP VIEW session_message; CREATE TABLE session_message (id TEXT)'); + db.close(); + const result = observeOpencodeStorageCoverage({ db }); + assert.deepEqual(result.v2, { status: 'unknown' }); + assert.deepEqual(result.warnings, ['opencode-v2-observation-incomplete']); +})); + +test('legacy absent and regular JSON presence are distinguished without reading content', () => fixture(({ root, db, file }) => { + const missing = path.join(root, 'missing'); + assert.equal(observeOpencodeStorageCoverage({ legacyRoot: missing }).legacy.status, 'absent'); + const storage = path.join(root, 'storage'); + fs.mkdirSync(path.join(storage, 'session'), { recursive: true }); + const json = path.join(storage, 'session', 'one.json'); + fs.writeFileSync(json, '{"private":"body"}'); + const before = [digest(file), digest(json)]; + const result = observeOpencodeStorageCoverage({ db, legacyRoot: storage }); + assert.deepEqual(result.legacy, { status: 'present' }); + assert.deepEqual(result.warnings, ['opencode-legacy-json-present']); + assert.equal(JSON.stringify(result).includes('private'), false); + assert.deepEqual([digest(file), digest(json)], before); +})); + +test('unreadable shape, entry cap, depth cap, and symlink-only tree are unknown', () => fixture(({ root }) => { + const fileRoot = path.join(root, 'file'); + fs.writeFileSync(fileRoot, 'not a directory'); + assert.equal(observeOpencodeStorageCoverage({ legacyRoot: fileRoot }).legacy.status, 'unknown'); + const storage = path.join(root, 'storage'); + fs.mkdirSync(storage); + fs.writeFileSync(path.join(storage, 'a.txt'), 'a'); + fs.writeFileSync(path.join(storage, 'b.txt'), 'b'); + assert.equal(observeOpencodeStorageCoverage({ legacyRoot: storage, maxEntries: 1 }).legacy.status, 'unknown'); + fs.mkdirSync(path.join(storage, 'nested')); + fs.writeFileSync(path.join(storage, 'nested', 'one.json'), '{}'); + assert.equal(observeOpencodeStorageCoverage({ legacyRoot: storage, maxDepth: 0 }).legacy.status, 'unknown'); + const linkOnly = path.join(root, 'links'); + fs.mkdirSync(linkOnly); + fs.symlinkSync(path.join(storage, 'nested'), path.join(linkOnly, 'nested')); + assert.equal(observeOpencodeStorageCoverage({ legacyRoot: linkOnly }).legacy.status, 'unknown'); +})); + +test('established presence survives incomplete traversal, and combined warnings are fixed shape', () => fixture(({ root, db }) => { + db.exec('CREATE TABLE session_message (id TEXT)'); + db.prepare('INSERT INTO session_message VALUES (?)').run('one'); + const storage = path.join(root, 'storage'); + fs.mkdirSync(storage); + fs.writeFileSync(path.join(storage, 'a.json'), '{}'); + fs.symlinkSync(path.join(root, 'outside'), path.join(storage, 'z-link')); + assert.deepEqual(observeOpencodeStorageCoverage({ db, legacyRoot: storage }), { + v2: { status: 'present' }, legacy: { status: 'present' }, + warnings: ['opencode-v2-session-message-present', 'opencode-legacy-json-present'], + }); +})); + +test('caller-owned read-only handle remains open and database bytes remain unchanged', () => fixture(({ db, file }) => { + db.exec('CREATE TABLE session_message (id TEXT); INSERT INTO session_message VALUES (1)'); + db.close(); + const before = digest(file); + const reader = new DatabaseSync(file, { readOnly: true }); + try { + assert.equal(observeOpencodeStorageCoverage({ db: reader }).v2.status, 'present'); + assert.equal(reader.isOpen, true); + assert.equal(digest(file), before); + } finally { reader.close(); } +})); diff --git a/tests/kit/usage-opencode.test.mjs b/tests/kit/usage-opencode.test.mjs index 8327587d..9826a10b 100644 --- a/tests/kit/usage-opencode.test.mjs +++ b/tests/kit/usage-opencode.test.mjs @@ -10,6 +10,8 @@ import path from 'node:path'; import { DatabaseSync } from 'node:sqlite'; import { listSessions, parseSession, sessionExists } from '../../src/lib/usage-opencode.mjs'; import { promptFingerprint } from '../../src/lib/usage-parsers.mjs'; +import { sessionCostEvidence } from '../../src/lib/usage-cost.mjs'; +import { buildIndex, _resetForTest } from '../../src/lib/usage-index.mjs'; const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'ak-uo-')); const rm = (d) => fs.rmSync(d, { recursive: true, force: true }); @@ -73,6 +75,88 @@ const assistantMsg = (id, sessionId, at, { model = 'kimi-k3', provider = 'openco }, }); +test('positive-token reported zero is unpriced for hosted and unknown providers, but observed for local providers', () => { + const d = tmp(); + try { + const dbFile = buildDb(path.join(d, 'opencode.db'), { + sessions: [{ id: 'cost-zero', directory: '/x', title: 'cost zero' }], + messages: [ + assistantMsg('hosted', 'cost-zero', T, { provider: 'openrouter', model: 'unknown-model', cost: 0 }), + assistantMsg('unknown', 'cost-zero', T + 1000, { provider: '', model: 'unknown-model', cost: 0 }), + assistantMsg('local', 'cost-zero', T + 2000, { provider: 'lmstudio', model: 'unknown-model', cost: 0 }), + ], + }); + const { session } = parseSession({ dbFile, id: 'cost-zero' }); + const evidence = sessionCostEvidence(session, { costOf: () => { throw Error('reported zero must not be estimated'); } }); + assert.deepEqual(evidence, { observedUsd: 0, estimatedUsd: 0, observedMessages: 1, estimatedMessages: 0, unpricedMessages: 2 }); + assert.equal(session.usage.length, 3, 'provider attribution remains separate'); + assert.equal(session.usage.find(r => r.provider === 'openrouter').costObserved, null); + assert.equal(session.usage.find(r => r.provider === 'lmstudio').costObserved, 0); + } finally { rm(d); } +}); + +test('reported zero without measured tokens and positive observed cost preserve their own evidence', () => { + const d = tmp(); + try { + const dbFile = buildDb(path.join(d, 'opencode.db'), { + sessions: [{ id: 'cost-mixed', directory: '/x', title: 'mixed' }], + messages: [ + assistantMsg('positive', 'cost-mixed', T, { provider: 'openrouter', cost: 0.25 }), + assistantMsg('untrusted', 'cost-mixed', T + 1000, { provider: 'openrouter', cost: 0 }), + assistantMsg('missing', 'cost-mixed', T + 2000, { provider: 'openrouter' }), + assistantMsg('zero-tokens', 'cost-mixed', T + 3000, { provider: 'openrouter', cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } }), + ], + }); + const { session } = parseSession({ dbFile, id: 'cost-mixed' }); + const evidence = sessionCostEvidence(session, { costOf: () => 0.5 }); + assert.deepEqual(evidence, { observedUsd: 0.25, estimatedUsd: 0.5, observedMessages: 2, estimatedMessages: 1, unpricedMessages: 1 }); + } finally { rm(d); } +}); + +test('malformed recorded costs are missing coverage, not trusted charges', () => { + const d = tmp(); + try { + const dbFile = buildDb(path.join(d, 'opencode.db'), { + sessions: [{ id: 'cost-bad', directory: '/x', title: 'bad' }], + messages: [ + assistantMsg('negative', 'cost-bad', T, { cost: -1 }), + assistantMsg('string', 'cost-bad', T + 1000, { cost: '0' }), + assistantMsg('nan', 'cost-bad', T + 2000, { cost: Number.NaN }), + ], + }); + const { session } = parseSession({ dbFile, id: 'cost-bad' }); + const evidence = sessionCostEvidence(session, { costOf: () => 0.1 }); + assert.deepEqual(evidence, { observedUsd: 0, estimatedUsd: 0.1, observedMessages: 0, estimatedMessages: 3, unpricedMessages: 0 }); + } finally { rm(d); } +}); + +test('a cold and warm OpenCode scan conserve unpriced zero-cost coverage', async () => { + const d = tmp(); + _resetForTest(); + try { + const dbFile = buildDb(path.join(d, 'opencode.db'), { + sessions: [{ id: 'cost-cache', directory: '/x', title: 'cache' }], + messages: [assistantMsg('remote-zero', 'cost-cache', T, { provider: 'openrouter', model: 'unknown-model', cost: 0 })], + }); + const opts = { now: T + DAY, days: 14, cachePath: path.join(d, 'cache', 'index.json'), + roots: { claude: path.join(d, 'claude'), codex: path.join(d, 'codex'), opencode: dbFile } }; + const cold = await buildIndex(opts); + _resetForTest(); + const warm = await buildIndex(opts); + for (const agg of [cold, warm]) { + const session = agg.sessions.find((row) => row.id === 'cost-cache'); + assert.equal(session.costEvidence.unpricedMessages, 1); + assert.equal(session.costEvidence.observedMessages, 0); + assert.equal(session.costEvidence.estimatedMessages, 0); + assert.equal(session.cost, 0); + assert.equal(agg.totals.cost, 0); + } + assert.deepEqual(warm.sessions.find((row) => row.id === 'cost-cache').costEvidence, + cold.sessions.find((row) => row.id === 'cost-cache').costEvidence); + } finally { _resetForTest(); rm(d); } +}); + test('listSessions filters by the latest message time and keys on mtime+count', () => { const d = tmp(); const dbFile = buildDb(path.join(d, 'opencode.db'), { @@ -117,17 +201,18 @@ test('parseSession maps a session to the index record: identity, usage rows with assert.equal(rec.exceptions, 0); assert.equal(rec.sidechain, false); assert.equal(rec.threadSource, null); - // provider is the LAST observed assistant providerID — never the host - assert.equal(rec.inferenceProvider, 'openrouter'); - assert.equal(rec.providerProvenance, 'observed'); + assert.equal(rec.inferenceProvider, null, 'a session spanning providers has no single inference provider'); + assert.equal(rec.providerProvenance, 'unknown'); // usage rows per (day, model) with summed observed cost const day1 = rec.usage.find((r) => r.model === 'kimi-k3'); + assert.equal(day1.provider, 'opencode'); assert.deepEqual( { input: day1.input, output: day1.output, cacheRead: day1.cacheRead, cacheWrite: day1.cacheWrite, responses: day1.responses, costObserved: day1.costObserved }, // output = 2 x (20 text + 5 reasoning): OpenCode stores output NET of reasoning { input: 200, output: 50, cacheRead: 80, cacheWrite: 6, responses: 2, costObserved: 0.03 }, ); const day2 = rec.usage.find((r) => r.model === 'moonshotai/kimi-k3'); + assert.equal(day2.provider, 'openrouter'); assert.equal(day2.costObserved, 0.03); assert.equal(day2.day !== day1.day, true, 'rows keyed by day'); assert.deepEqual(rec.models, ['kimi-k3', 'moonshotai/kimi-k3']); @@ -312,6 +397,59 @@ test('parseSession fingerprints user messages on the scan path, not only withTur rm(d); }); +test('an OpenCode child keeps its prompt and usage but never fingerprints its user turns', () => { + const d = tmp(); + try { + const dbFile = buildDb(path.join(d, 'opencode.db'), { + sessions: [ + { id: 'parent', directory: '/x', title: 'parent' }, + { id: 'child', directory: '/x', title: 'child', parentId: 'parent' }, + { id: 'different-child', directory: '/x', title: 'other', parentId: 'parent' }, + ], + messages: [ + userMsg('pu', 'parent', T), assistantMsg('pa', 'parent', T + 1000, { cost: 0.2 }), + userMsg('cu', 'child', T), assistantMsg('ca', 'child', T + 1000, { cost: 0.3 }), + userMsg('du', 'different-child', T), + ], + parts: [ + { id: 'pp', messageId: 'pu', sessionId: 'parent', at: T, data: { type: 'text', text: 'Run the tests' } }, + { id: 'cp', messageId: 'cu', sessionId: 'child', at: T, data: { type: 'text', text: 'Run the tests' } }, + { id: 'dp', messageId: 'du', sessionId: 'different-child', at: T, data: { type: 'text', text: 'Review the database migration' } }, + ], + }); + for (const withTurns of [false, true]) { + const parent = parseSession({ dbFile, id: 'parent', withTurns }).session; + const child = parseSession({ dbFile, id: 'child', withTurns }).session; + assert.equal(parent.promptFPs.length, 1); + assert.deepEqual(child.promptFPs, []); + assert.deepEqual(parseSession({ dbFile, id: 'different-child', withTurns }).session.promptFPs, []); + assert.equal(child.prompts, 1); + assert.equal(child.sidechain, true); + assert.equal(child.threadSource, 'subagent'); + assert.equal(child.usage[0].costObserved, 0.3); + } + } finally { rm(d); } +}); + +test('only a nonempty parent_id is child evidence, even when the parent row is absent', () => { + const d = tmp(); + try { + const dbFile = buildDb(path.join(d, 'opencode.db'), { + sessions: [ + { id: 'orphan', directory: '/x', title: 'orphan', parentId: 'missing' }, + { id: 'blank', directory: '/x', title: 'blank', parentId: '' }, + ], + messages: [userMsg('ou', 'orphan', T), userMsg('bu', 'blank', T)], + }); + const orphan = parseSession({ dbFile, id: 'orphan' }).session; + const blank = parseSession({ dbFile, id: 'blank' }).session; + assert.equal(orphan.sidechain, true); + assert.deepEqual(orphan.promptFPs, []); + assert.equal(blank.sidechain, false); + assert.equal(blank.promptFPs.length, 1); + } finally { rm(d); } +}); + test('a user message with no text part fingerprints as an attachment-only control turn', () => { const d = tmp(); const dbFile = buildDb(path.join(d, 'opencode.db'), { @@ -562,7 +700,7 @@ test('the same modelID under two providers stays two usage rows, each carrying i assert.equal(local.costObserved, null, 'the local turn recorded no cost and is not charged with the cloud turn\'s'); assert.equal(cloud.input, 75); assert.ok(Math.abs(cloud.costObserved - 0.3) < 1e-9); - assert.equal(session.inferenceProvider, 'openrouter', 'the session-level provider stays the last observed one'); + assert.equal(session.inferenceProvider, null, 'two observed providers cannot be a single session provider'); } finally { rm(d); } }); diff --git a/tests/kit/usage-session-surface.test.mjs b/tests/kit/usage-session-surface.test.mjs new file mode 100644 index 00000000..b19510f6 --- /dev/null +++ b/tests/kit/usage-session-surface.test.mjs @@ -0,0 +1,142 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { parseClaude, parseCodex } from '../../src/lib/usage-parsers.mjs'; +import { buildIndex, SCHEMA_VERSION, _resetForTest } from '../../src/lib/usage-index.mjs'; +import { Rollout, usage, codexSandbox, stubDeps } from './helpers/codex-rollout.mjs'; + +const NOW = Date.parse('2026-07-25T12:00:00.000Z'); +const options = (sandbox) => ({ days: 14, now: NOW, roots: sandbox.roots, + cachePath: sandbox.cachePath, deps: stubDeps() }); + +test('Claude declarations persist the first known SDK classification without changing legacy origin', () => { + const line = (value) => `${JSON.stringify(value)}\n`; + const raw = line({ type: 'user', timestamp: '2026-07-24T09:00:00Z', sessionId: 'sdk', + entrypoint: 'sdk-py', message: { role: 'user', content: 'synthetic request' } }) + + line({ type: 'assistant', timestamp: '2026-07-24T09:01:00Z', sessionId: 'sdk', + entrypoint: 'claude-desktop', message: { id: 'a', role: 'assistant', model: 'claude-opus-5', + usage: { input_tokens: 5, output_tokens: 2 }, content: [{ type: 'text', text: 'synthetic response' }] } }); + const { session } = parseClaude(raw, { id: 'sdk' }); + assert.deepEqual(session.sessionOrigin, { + origin: 'unknown', evidence: 'desktop-origin-not-declared', surface: 'claude-agent-sdk', + initiator: 'automation', label: 'Claude Agent SDK', rawEvidence: { entrypoint: 'sdk-py' }, + attributes: [], thirdPartyProvider: null, + }); +}); + +test('Codex MCP declaration and imported copy use factory fields with existing accounting', () => { + const native = new Rollout({ id: 'mcp' }).meta({ originator: 'codex_cli_rs', source: 'mcp' }) + .meta({ originator: 'Codex Desktop', source: 'vscode' }).turn().user().agent() + .tokenCount(usage({ input: 100, output: 20 })); + const parsed = parseCodex(native.toString(), { id: 'mcp' }).session; + assert.deepEqual(parsed.sessionOrigin, { + origin: 'unknown', evidence: 'desktop-origin-not-declared', surface: 'codex-mcp', + initiator: 'agent', label: 'Codex MCP server', + rawEvidence: { originator: 'codex_cli_rs', source: 'mcp', threadSource: 'user' }, + attributes: [], thirdPartyProvider: null, + }); + assert.equal(parsed.prompts, 1); + assert.equal(parsed.responses, 1); + + const imported = new Rollout({ id: 'copy' }).meta({ originator: 'Codex Desktop' }) + .taskStarted('external-import-turn-1').user('copied request').agent('copied response'); + const copy = parseCodex(imported.toString(), { id: 'copy' }).session; + assert.equal(copy.imported, true); + assert.equal(copy.sessionOrigin.origin, 'unknown'); + assert.equal(copy.sessionOrigin.evidence, 'imported-copy'); + assert.equal(copy.sessionOrigin.surface, 'unknown'); + assert.equal(copy.sessionOrigin.initiator, 'imported-copy'); + assert.deepEqual(copy.sessionOrigin.rawEvidence, {}); + assert.equal(copy.prompts, 0); + assert.equal(copy.responses, 0); + assert.deepEqual(copy.usage, []); +}); + +test('a late import marker still removes copied Desktop classification', () => { + const copied = new Rollout({ id: 'late-copy' }).meta({ originator: 'Codex Desktop' }); + for (let i = 0; i < 45; i++) copied.raw('event_msg', { type: 'task_complete' }); + copied.taskStarted('external-import-turn-1'); + const session = parseCodex(copied.toString(), { id: 'late-copy' }).session; + assert.equal(session.imported, true); + assert.deepEqual(session.sessionOrigin, { + origin: 'unknown', evidence: 'imported-copy', surface: 'unknown', initiator: 'imported-copy', + label: 'Unknown', rawEvidence: {}, attributes: [], thirdPartyProvider: null, + }); +}); + +test('malformed declaration metadata cannot copy prompt text into classification', () => { + const privateText = 'synthetic private prompt content'; + const raw = `${JSON.stringify({ type: 'session_meta', payload: { + id: 'bad', originator: { text: privateText }, source: ['mcp'], thread_source: privateText, + } })}\n`; + const session = parseCodex(raw, { id: 'bad' }).session; + assert.equal(JSON.stringify(session.sessionOrigin).includes(privateText), false); + assert.deepEqual(session.sessionOrigin.rawEvidence, {}); +}); + +test('unfamiliar bounded origin survives parser, aggregate and warm cache without a product guess', async () => { + _resetForTest(); + const native = new Rollout({ id: 'future' }).meta({ originator: 'future_client_v2', + source: 'future_transport', thread_source: 'future_trigger' }) + .turn().user().agent().tokenCount(usage({ input: 100, output: 20 })); + const sandbox = codexSandbox({ 'rollout-2026-07-24T09-00-00-future.jsonl': native.toString() }); + const parsed = parseCodex(native.toString(), { id: 'future' }).session; + assert.equal(parsed.sessionOrigin.surface, 'other-openai'); + assert.equal(parsed.sessionOrigin.initiator, 'unknown'); + assert.deepEqual(parsed.sessionOrigin.rawEvidence, { originator: 'future_client_v2', + source: 'future_transport', threadSource: 'future_trigger' }); + const cold = await buildIndex(options(sandbox)); + assert.equal(cold.sessions[0].sessionOrigin.surface, 'other-openai'); + assert.deepEqual(cold.sessions[0].sessionOrigin.rawEvidence, parsed.sessionOrigin.rawEvidence); + const cache = JSON.parse(fs.readFileSync(sandbox.cachePath, 'utf8')); + assert.deepEqual(Object.values(cache.entries)[0].session.sessionOrigin.rawEvidence, + parsed.sessionOrigin.rawEvidence); + _resetForTest(); + const warm = await buildIndex(options(sandbox)); + assert.equal(warm.sourceHealth.codex.diagnostics.cachedFiles, 1); + assert.deepEqual(warm.sessions[0].sessionOrigin.rawEvidence, parsed.sessionOrigin.rawEvidence); + assert.deepEqual(warm.totals, cold.totals); +}); + +test('schema 25 reparses unchanged files into schema 26 and warm cache preserves classification and accounting', async () => { + _resetForTest(); + const native = new Rollout({ id: 'sdk' }).meta({ originator: 'codex_sdk_ts' }) + .turn().user().agent().tokenCount(usage({ input: 100, output: 20 })); + const imported = new Rollout({ id: 'copy' }).meta({ originator: 'Codex Desktop' }) + .taskStarted('external-import-turn-1').user('copied request'); + const sandbox = codexSandbox({ + 'rollout-2026-07-24T09-00-00-sdk.jsonl': native.toString(), + 'rollout-2026-07-24T09-00-00-copy.jsonl': imported.toString(), + }); + const before = await buildIndex(options(sandbox)); + assert.deepEqual({ sessions: before.totals.sessions, prompts: before.totals.prompts, + responses: before.totals.responses, tokens: before.totals.tokens, cost: before.totals.cost, + importedExcluded: before.sourceHealth.codex.diagnostics.importedExcluded }, + { sessions: 1, prompts: 1, responses: 1, tokens: 120, cost: 1, importedExcluded: 1 }); + const old = JSON.parse(fs.readFileSync(sandbox.cachePath, 'utf8')); + old.schemaVersion = 25; + for (const entry of Object.values(old.entries)) { + entry.session.sessionOrigin = { origin: entry.session.sessionOrigin.origin, + evidence: entry.session.sessionOrigin.evidence }; + } + fs.writeFileSync(sandbox.cachePath, JSON.stringify(old)); + + _resetForTest(); + const cold = await buildIndex(options(sandbox)); + assert.equal(SCHEMA_VERSION, 26); + assert.equal(cold.sourceHealth.codex.diagnostics.cachedFiles, 0); + assert.equal(cold.sourceHealth.codex.diagnostics.importedExcluded, 1); + assert.deepEqual(cold.totals, before.totals); + assert.equal(cold.sessions[0].sessionOrigin.surface, 'codex-sdk'); + assert.equal(cold.sessions[0].sessionOrigin.initiator, 'automation'); + const cache = JSON.parse(fs.readFileSync(sandbox.cachePath, 'utf8')); + assert.equal(cache.schemaVersion, 26); + assert.ok(Object.values(cache.entries).some((entry) => entry.session.sessionOrigin.surface === 'codex-sdk')); + + _resetForTest(); + const warm = await buildIndex(options(sandbox)); + assert.equal(warm.sourceHealth.codex.diagnostics.cachedFiles, 2); + assert.equal(warm.sourceHealth.codex.diagnostics.importedExcluded, 1); + assert.deepEqual(warm.totals, cold.totals); + assert.deepEqual(warm.sessions[0].sessionOrigin, cold.sessions[0].sessionOrigin); +}); diff --git a/tests/kit/usage-timezone-cache.test.mjs b/tests/kit/usage-timezone-cache.test.mjs new file mode 100644 index 00000000..b1fbf6c6 --- /dev/null +++ b/tests/kit/usage-timezone-cache.test.mjs @@ -0,0 +1,191 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { spawnSync } from 'node:child_process'; +import { tempDir } from './helpers/temp-dir.mjs'; +import { selectOpencodeSource } from '../../src/lib/usage-opencode-source.mjs'; +import { spawnEnv } from './helpers/home-sandbox.mjs'; + +const indexUrl = new URL('../../src/lib/usage-index.mjs', import.meta.url).href; +const zones = ['America/Los_Angeles', 'Asia/Tokyo']; +// Local midnight, spring DST gap, and autumn repeated hour. Also spans a +// synthetic dated price boundary, preserving the existing local-row-day basis. +const stamps = ['2026-03-08T07:59:00Z', '2026-03-08T08:01:00Z', + '2026-03-08T09:59:00Z', '2026-03-08T10:01:00Z', + '2026-11-01T08:30:00Z', '2026-11-01T09:30:00Z']; +function fixture(t) { + const dir = tempDir('ak-timezone'); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const claude = path.join(dir, 'claude', 'project'); + const codex = path.join(dir, 'codex'); + fs.mkdirSync(claude, { recursive: true }); + fs.mkdirSync(path.join(codex, '2026', '03', '08'), { recursive: true }); + const rows = stamps.map((timestamp, i) => ({ type: 'assistant', timestamp, + message: { id: `message-${i}`, role: 'assistant', model: 'claude-opus-5', + usage: { input_tokens: 100, output_tokens: 20, cache_read_input_tokens: 50, + cache_creation_input_tokens: 10 }, content: [] } })); + fs.writeFileSync(path.join(claude, 'fixture.jsonl'), rows.map(JSON.stringify).join('\n')); + const cx = [{ type: 'session_meta', timestamp: stamps[0], payload: { id: 'codex-fixture', model_provider: 'openai' } }, + { type: 'turn_context', timestamp: stamps[0], payload: { model: 'gpt-5.6', effort: 'high' } }, + ...stamps.map((timestamp, i) => ({ type: 'event_msg', timestamp, payload: { type: 'token_count', info: { + total_token_usage: { input_tokens: (i + 1) * 100, cached_input_tokens: (i + 1) * 50, output_tokens: (i + 1) * 20 }, + } } }))]; + fs.writeFileSync(path.join(codex, '2026', '03', '08', 'rollout-fixture.jsonl'), cx.map(JSON.stringify).join('\n')); + return dir; +} +function openCodeFixture(dir) { + const db = new DatabaseSync(path.join(dir, 'broken.db')); + db.exec(`CREATE TABLE session (id TEXT, parent_id TEXT, directory TEXT, title TEXT, time_created INTEGER, time_updated INTEGER); + CREATE TABLE message (id TEXT, session_id TEXT, time_created INTEGER, time_updated INTEGER, data TEXT); + CREATE TABLE part (message_id TEXT, data TEXT);`); + db.prepare('INSERT INTO session VALUES (?, NULL, ?, ?, ?, ?)').run('oc-native', '/fixture', 'fixture', Date.parse(stamps[0]), Date.parse(stamps.at(-1))); + const insert = db.prepare('INSERT INTO message VALUES (?, ?, ?, ?, ?)'); + stamps.forEach((timestamp, i) => { + const at = Date.parse(timestamp); + insert.run(`a${i}`, 'oc-native', at, at, JSON.stringify({ role: 'assistant', + modelID: 'gpt-5.6', providerID: 'openai', cost: 0.25, + tokens: { input: 100, output: 20, reasoning: 5, cache: { read: 50, write: 10 } }, + time: { created: at, completed: at + 1000 }, finish: 'stop' })); + }); + db.close(); +} +function run(dir, zone, extra = '', days = 240, runtimeSetup = '') { + const script = ` + import fs from 'node:fs'; + import path from 'node:path'; + import { buildIndex, readIndex } from ${JSON.stringify(indexUrl)}; + const dir = process.argv[1]; + const cachePath = path.join(dir, 'cache.json'); + const options = { roots: { claude: path.join(dir, 'claude'), codex: path.join(dir, 'codex'), + opencode: path.join(dir, 'broken.db') }, cachePath, + now: Date.parse('2026-11-02T12:00:00Z'), days: ${days}, lookbackDays: 240, previous: true, + deps: { costOf: (u) => (u.input + u.output + u.cacheRead + u.cacheWrite) / 1000 + * (u.day < '2026-03-08' ? 2 : 1), + classify: () => ({category:'Build', confidence:1, basis:'fixture'}), detectInsights: () => [] } }; + // Filesystem provenance uses Date.now independently of the query clock. + Date.now = () => options.now; + ${runtimeSetup} + const first = await readIndex(options); + ${extra} + const agg = ${extra ? 'await readIndex(options)' : 'first'}; + const cache = JSON.parse(fs.readFileSync(cachePath, 'utf8')); + const {sourceHealth, ...stable} = agg; + console.log(JSON.stringify({ stable, sourceHealth, entries: Object.values(cache.entries) })); + `; + const out = spawnSync(process.execPath, ['--input-type=module', '-e', script, dir], { + env: spawnEnv(path.join(dir, 'home'), { TZ: zone }), encoding: 'utf8', timeout: 30000, + }); + assert.equal(out.status, 0, out.stderr); + return JSON.parse(out.stdout); +} +function poisonContext(dir, context) { + const file = path.join(dir, 'cache.json'); + const cache = JSON.parse(fs.readFileSync(file, 'utf8')); + for (const entry of Object.values(cache.entries)) { + if (context === undefined) delete entry.localTimeContext; + else entry.localTimeContext = context; + entry.session.usage.forEach((row) => { row.day = '1900-01-01'; }); + } + fs.writeFileSync(file, JSON.stringify(cache)); +} + +test('cold and warm indexes agree after timezone changes, including DST and dated pricing', (t) => { + const dir = fixture(t); + openCodeFixture(dir); + const la = run(dir, zones[0]); + assert.equal(la.stable.sessions.length, 3); + const warm = run(dir, zones[0]); + assert.deepEqual(warm.stable, la.stable); + assert.ok(warm.sourceHealth.codex.diagnostics.cachedFiles > 0); + const changed = run(dir, zones[1]); + fs.unlinkSync(path.join(dir, 'cache.json')); + const cold = run(dir, zones[1]); + assert.deepEqual(changed.stable, cold.stable); + assert.deepEqual(changed.entries, cold.entries); + assert.equal(changed.sourceHealth.codex.diagnostics.cachedFiles, 0); + assert.notDeepEqual(la.stable.byDay, cold.stable.byDay); + assert.equal(la.stable.totals.tokens, cold.stable.totals.tokens); + assert.notEqual(la.stable.totals.cost, cold.stable.totals.cost, + 'existing local day pricing intentionally changes at a dated rate boundary'); + const back = run(dir, zones[0]); + assert.deepEqual(back.stable, la.stable); +}); + +for (const context of [undefined, null, 'Invalid/Zone', {}, { zone: 'UTC' }]) { + test(`missing or invalid cached timezone reparses: ${JSON.stringify(context)}`, (t) => { + const dir = fixture(t); + const expected = run(dir, zones[0]); + poisonContext(dir, context); + const actual = run(dir, zones[0]); + assert.deepEqual(actual.stable, expected.stable); + assert.equal(actual.sourceHealth.codex.diagnostics.cachedFiles, 0); + }); +} + +test('in-process memo observes a child-only TZ change', (t) => { + const dir = fixture(t); + const changed = run(dir, zones[0], `process.env.TZ = ${JSON.stringify(zones[1])};`); + fs.unlinkSync(path.join(dir, 'cache.json')); + assert.deepEqual(changed.stable, run(dir, zones[1]).stable); +}); + +for (const unavailable of ['return { timeZone: undefined };', 'throw new Error("zone unavailable");']) { + test(`unknown runtime timezone declines persistent cache reuse: ${unavailable}`, (t) => { + const dir = fixture(t); + const expected = run(dir, zones[0]); + // Invalid TZ strings can resolve to a fallback zone on Windows. Model the + // unavailable Intl boundary directly, inside this disposable child only. + const setup = `Intl.DateTimeFormat.prototype.resolvedOptions = function () { ${unavailable} };`; + for (let refresh = 0; refresh < 2; refresh++) { + const actual = run(dir, zones[0], '', 240, setup); + assert.deepEqual(actual.stable, expected.stable); + assert.equal(actual.sourceHealth.codex.diagnostics.cachedFiles, 0); + assert.ok(actual.entries.length > 0); + assert.ok(actual.entries.every((entry) => entry.localTimeContext === null)); + } + }); +} + +test('degraded OpenCode retains old timezone evidence without contributing stale buckets', (t) => { + const dir = fixture(t); + const initial = run(dir, zones[0]); + const file = path.join(dir, 'cache.json'); + const cache = JSON.parse(fs.readFileSync(file, 'utf8')); + const entry = structuredClone(initial.entries[0]); + entry.session.id = 'oc-fixture'; + entry.session.host = entry.session.provider = 'opencode'; + entry.parseSemantics = 'cost-trust-v2-observations-v1'; + entry.dbFile = path.join(dir, 'broken.db'); + cache.entries['opencode://oc-fixture'] = entry; + fs.writeFileSync(entry.dbFile, 'not a database'); + entry.sourceIdentity = selectOpencodeSource({ roots: { opencode: entry.dbFile } }).sourceIdentity; + fs.writeFileSync(file, JSON.stringify(cache)); + const same = run(dir, zones[0]); + assert.ok(same.stable.sessions.some((s) => s.id === 'oc-fixture')); + const changed = run(dir, zones[1]); + assert.equal(changed.sourceHealth.opencode.status, 'degraded'); + assert.equal(changed.sourceHealth.opencode.timezoneCacheEntriesExcluded, 1); + assert.ok(!changed.stable.sessions.some((s) => s.id === 'oc-fixture')); + assert.deepEqual(changed.entries.find((e) => e.session.id === 'oc-fixture').localTimeContext, + entry.localTimeContext, 'carry forward must not relabel the old evidence'); + assert.ok(run(dir, zones[0]).stable.sessions.some((s) => s.id === 'oc-fixture')); +}); + +for (const days of [1, 120]) { + test(`timezone refresh preserves current and previous ${days}-day windows`, (t) => { + const dir = fixture(t); + openCodeFixture(dir); + run(dir, zones[0], '', days); + const changed = run(dir, zones[1], '', days); + fs.unlinkSync(path.join(dir, 'cache.json')); + assert.deepEqual(changed.stable, run(dir, zones[1], '', days).stable); + }); +} + +test('unset TZ uses the resolved machine zone and remains incremental', (t) => { + const dir = fixture(t); + run(dir, undefined); + assert.equal(run(dir, undefined).sourceHealth.codex.diagnostics.cachedFiles, 1); +}); diff --git a/tests/ui/intelligence-picker.mjs b/tests/ui/intelligence-picker.mjs index 719670d1..ece6e6ec 100644 --- a/tests/ui/intelligence-picker.mjs +++ b/tests/ui/intelligence-picker.mjs @@ -51,7 +51,7 @@ test('Intelligence picker labels every learning location with the inventory desi values: Array.from(node.children, option => option.value), labels: Array.from(node.children, option => option.textContent) }))); assert.deepEqual(groups.map(group => [group.label, group.values]), [ ['Git repositories', ['repo-a', 'repo-z']], ['Git worktrees', ['tree-a', 'tree-z']], - ['User-level learning', ['user-a', 'user-z']], ['Other / unclassified', ['unknown-a', 'unknown-z']], + ['User-level learning', ['user-a', 'user-z']], ['Unknown', ['unknown-a', 'unknown-z']], ]); assert.equal(groups[0].labels[0], 'alpha — Git repository'); assert.equal(groups[1].labels[0], 'Alpha tree — Git worktree'); @@ -117,7 +117,7 @@ test('Intelligence table keeps every learning location in one filterable invento assert.equal(await table.getByRole('columnheader').count(), 5); assert.deepEqual(await table.getByRole('columnheader').allTextContents(), ['Name', 'Designation', 'Patterns learned', 'Pattern store', 'Last active']); - assert.deepEqual(await table.locator('.mw-filter-pill').allTextContents(), ['All', 'Directory', 'Git repository', 'Git worktree']); + assert.deepEqual(await table.locator('.mw-filter-pill').allTextContents(), ['All', 'Git repository', 'Git worktree', 'Unknown', 'User-level learning']); const shots = process.env.AK_UI_ARTIFACTS; if (shots) fs.mkdirSync(shots, { recursive: true }); for (const width of [1360, 1100, 390]) { @@ -127,7 +127,7 @@ test('Intelligence table keeps every learning location in one filterable invento header: region.querySelector('.mw-head').getBoundingClientRect().height, row: region.querySelector('.mw-data-row').getBoundingClientRect().height, }))); - for (const size of sizes) { assert.equal(size.row, 34); assert.ok(size.scroll > size.height); } + for (const size of sizes) { assert.ok(size.row >= 34); assert.ok(size.scroll > size.height); } assert.ok(await table.evaluate(el => el.getBoundingClientRect().height) <= 520); assert.equal(await page.evaluate(() => globalThis.document.documentElement.scrollWidth <= globalThis.innerWidth), true, `no horizontal overflow at ${width}px`); assert.equal(await page.locator('#mw-hero').innerText(), hero); @@ -143,6 +143,6 @@ test('Intelligence table keeps every learning location in one filterable invento assert.equal(await table.locator('.mw-data-row').count(), 32); await table.getByRole('button', { name: 'Git repository', exact: true }).click(); assert.equal(await table.locator('.mw-data-row').count(), 8); - assert.deepEqual(await table.locator('.mw-designation').allTextContents(), Array(8).fill('Git repository')); + assert.deepEqual(await table.locator('.mw-scope-value').allTextContents(), Array(8).fill('Git repository')); assert.deepEqual(errors, []); }); diff --git a/tests/ui/maintenance-focus.mjs b/tests/ui/maintenance-focus.mjs index 22b82fb9..f8e11e22 100644 --- a/tests/ui/maintenance-focus.mjs +++ b/tests/ui/maintenance-focus.mjs @@ -1,3 +1,4 @@ +import { SESSION_SURFACE_LABELS, SESSION_HOST_LABELS, SESSION_INITIATOR_LABELS, SESSION_PROVIDER_LABELS, sessionPresentation } from '../../src/lib/session-surface.mjs'; // Real focus queries, public DTOs, page markup and browser modules. import { test } from 'node:test'; import assert from 'node:assert/strict'; @@ -36,7 +37,9 @@ test('focus browser progressively narrows to exact installations and preserves f function esc(v){return String(v).replace(/&/g,'&').replace(/

Projects

'); await page.addScriptTag({content:'var MNT='+JSON.stringify(state)+';var MNT_SCOPE_LABELS={};function esc(s){return String(s).replace(/&/g,"&").replace(/ ({ loc: { languages: (name === 'ampel' ? ['javascript', 'python', 'rust', 'java', 'ada'] : ['typescript']).map(id => ({ id })) }, repository: ['ampel','ampel-feature'].includes(name)?{repositoryId:'repository:0123456789abcdef0123',kind:name==='ampel-feature'?'worktree':'git',root:'/fixture/projects/ampel',evidence:name==='ampel-feature'?'git-common-directory-and-backlink':'git-directory',observedAt:Date.parse('2026-09-09T12:00:00Z')}:null, + sessionSurfaces: [{host:'codex',surface:name==='ampel-feature'?'chatgpt-desktop-work':'unknown',initiator:'person',sessions:1}], sessionOrigins: [{origin:name==='ampel-feature'?'codex-desktop':name==='ampel'?'claude-desktop':'unknown',sessions:1}], path: '/fixture/projects/'+name, label: name, hosts: ['claude', 'codex'], projectKind: name==='ampel-feature'?'worktree':'git', })); @@ -68,7 +70,9 @@ test('project worktree visibility and all-installations navigation work on deskt function authHeaders(){return {};} function esc(value){return String(value).replace(/[&<>"']/g,function(c){return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];});} function ago(){return '';} - ${['maintenance-workspace', 'maintenance-cards', 'maintenance-filters', 'maintenance-guidance', 'maintenance-relationships', 'maintenance-inspector', 'maintenance-language-logos','maintenance-focus', 'maintenance-inventory'].map(clientSource).join('\n')} + const SESSION_SURFACE_LABELS=${JSON.stringify(SESSION_SURFACE_LABELS)},SESSION_HOST_LABELS=${JSON.stringify(SESSION_HOST_LABELS)},SESSION_INITIATOR_LABELS=${JSON.stringify(SESSION_INITIATOR_LABELS)},SESSION_PROVIDER_LABELS=${JSON.stringify(SESSION_PROVIDER_LABELS)}; + ${sessionPresentation.toString()} + ${['session-presentation','maintenance-workspace', 'maintenance-cards', 'maintenance-filters', 'maintenance-guidance', 'maintenance-relationships', 'maintenance-inspector', 'maintenance-language-logos','maintenance-focus', 'maintenance-inventory'].map(clientSource).join('\n')} MNT.scope='project';wireMntInventory();wireMntInspector();loadMntInventory(); ` }); await page.locator('#mnt-results [data-mnt-focus]').first().waitFor(); @@ -82,11 +86,11 @@ test('project worktree visibility and all-installations navigation work on deskt assert.equal(await page.locator('[data-mnt-focus="'+worktreeId+'"]').count(), 1); const sharedGroup=page.locator('.mnt-repository-group').filter({has:page.locator('[data-mnt-focus="'+worktreeId+'"]')}); assert.equal(await sharedGroup.locator('[data-mnt-level="project"]').count(),2); - const originFilter=page.locator('#mnt-facets input[data-mnt-facet="sessionOrigin"][value="codex-desktop"]'); + const originFilter=page.locator('#mnt-facets input[data-mnt-facet="sessionOrigin"][value="chatgpt-desktop-work"]'); await originFilter.check();await page.waitForFunction(()=>!globalThis.mntInventoryBusy); assert.equal(await page.locator('#mnt-results [data-mnt-level="project"]').count(),1); - assert.match(await page.locator('#mnt-facets').innerText(),/ChatGPT Desktop/); - assert.doesNotMatch(await page.locator('#mnt-results').innerText(),/ChatGPT Desktop/); + assert.match(await page.locator('#mnt-facets').innerText(),/ChatGPT desktop app · ChatGPT Work/); + assert.match(await page.locator('#mnt-results').innerText(),/ChatGPT desktop app · ChatGPT Work/); await originFilter.uncheck();await page.waitForFunction(()=>!globalThis.mntInventoryBusy); await page.locator('#mnt-facets [data-mnt-include-worktrees]').uncheck(); diff --git a/tests/ui/session-surfaces.mjs b/tests/ui/session-surfaces.mjs new file mode 100644 index 00000000..7bd22283 --- /dev/null +++ b/tests/ui/session-surfaces.mjs @@ -0,0 +1,64 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { launchChrome } from './helpers/launch-chrome.mjs'; +import { renderPage } from '../../src/lib/dashboard/page.mjs'; +const sessionSurfaces = [{ host: 'codex', surface: 'chatgpt-desktop-work', initiator: 'agent', sessions: 2, + countBasis: 'transcript-files', rawEvidence: { originator: ['codex_work_desktop'], source: ['vscode'] } }, +{ host: 'claude', surface: 'cloud-session', initiator: 'automation', sessions: 1 }]; +const project = { key: 'example', path: '/fixture/example', label: '', learningScope: 'repository', sessionSurfaces, + repository: { kind: 'git', repositoryId: 'fixture-repo', root: '/fixture/example' } }; +const counts = { everSeen: 1, onDisk: 1, gitRepos: 1, learning: 1, importedExcluded: 4, importedMixed: 2, importedUnresolved: 3 }; +test('served dashboard renders independent session evidence and preserves observed surface filters', async t => { + const browser = await launchChrome(); t.after(() => browser.close()); + const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); + let currentProject = project; + const errors = []; page.on('pageerror', error => errors.push(error.message)); + await page.route('http://surfaces.test/**', async route => { + const url = new URL(route.request().url()); + if (url.pathname === '/') return route.fulfill({ contentType: 'text/html', body: renderPage({ name: 'Surfaces', version: 'test' }) }); + const body = url.pathname === '/api/status' ? { overall: 'ok', rows: [], intel: { projects: [currentProject], + census: { counts }, machineWide: { totals: { projectCount: 1 }, perProject: [currentProject] } } } + : url.pathname === '/api/usage' ? { totals: { sessions: 1 }, sessions: [], projectTree: [{ project: 'Example', sessions: 1, rows: [{ id: 'fixture-session', host: 'claude', sessionOrigin: { surface: 'claude-desktop', initiator: 'person', thirdPartyProvider: 'amazon-bedrock', thirdPartyProviderBasis: 'assistant-model-id', rawEvidence: { entrypoint: 'claude-desktop-3p' } } }] }] } + : url.pathname === '/api/system/summary' ? { projects: { ...counts, projects: [project], discoveryProjects: [project] } } : {}; + return route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) }); + }); + await page.goto('http://surfaces.test/#token=fixture'); + await page.click('[data-overview-view="intel"]'); + const filter = page.locator('#mw-surface-filter'); await filter.waitFor(); + assert.deepEqual(await filter.locator('option').allTextContents(), ['All', 'ChatGPT desktop app · ChatGPT Work (local)', 'Cloud session']); + await filter.selectOption({ label: 'Cloud session' }); + await page.locator('#mw-table .session-surface-detail summary').click(); + assert.match(await page.locator('#mw-table').innerText(), /Git repository/); + assert.match(await page.locator('#mw-table').innerText(), /initiator: Agent/); + assert.match(await page.locator('#mw-table').innerText(), /source: vscode/); + assert.doesNotMatch(await page.locator('#mw-table').innerText(), /Codex IDE extension/); + await page.locator('#mw-census summary').click(); + assert.match(await page.locator('#mw-census-body').innerText(), /3 files have unresolved bounded ownership/); + for (const width of [1440, 390]) { + await page.setViewportSize({ width, height: 1000 }); + assert.equal(await page.evaluate(() => globalThis.document.documentElement.scrollWidth <= globalThis.innerWidth), true); + } + await page.click('#tab-system'); await page.click('[data-system-view="projects"]'); + await page.locator('#sys-projects .session-surface-detail summary').click(); + assert.match(await page.locator('#sys-projects').innerText(), /ChatGPT desktop app · ChatGPT Work \(local\)/); + assert.match(await page.locator('#sys-projects').innerText(), /4 confirmed pure imported copies excluded/); + assert.match(await page.locator('#sys-projects').innerText(), /dedicated Cowork transcript source is not covered/); + assert.equal(await page.locator('#sys-projects img').count(), 0); + await page.click('#tab-overview'); await page.click('[data-overview-view="intel"]'); + assert.equal(await filter.inputValue(), 'Cloud session'); + currentProject = { ...project, sessionSurfaces: undefined, sessionOrigins: [{ origin: 'claude-desktop', sessions: 3 }] }; + await page.click('#poll-now'); + await page.waitForFunction(() => globalThis.document.querySelector('#mw-surface-filter').value === 'all'); + assert.deepEqual(await filter.locator('option').allTextContents(), ['All', 'Claude Desktop']); + assert.equal(await page.locator('#mw-table .mw-data-row').count(), 1); + assert.match(await page.locator('#mw-table').innerText(), /Claude Desktop/); + await page.setViewportSize({ width: 1440, height: 1000 }); + await page.click('#tab-usage'); await page.click('#usage-tab-sessions'); + await page.locator('#u-tree .phead').click(); + await page.locator('#u-tree .s-exp').click(); + const detail = await page.locator('#sd-fixture-session').innerText(); + assert.match(detail, /Claude Desktop/); assert.match(detail, /Amazon Bedrock/); + assert.match(detail, /assistant-model-id; not network attestation/); + assert.match(detail, /claude-desktop-3p/); + assert.deepEqual(errors, []); +});