diff --git a/README.md b/README.md index 6d4de648c..4c9ea7ce5 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@

Token Optimizer MCP

- Your coding agent wastes most of its context window. This stops it — by default, on install, across 15 clients. + Spend less context, keep the conclusions, and audit every claim across 16 coding clients.

@@ -15,17 +15,17 @@

Enforced by default - 15 clients - Measured against a control arm + 16 clients + Direct savings measured before and after No telemetry MIT, commercial use allowed

- The knowledge graph: findings, decisions and dead ends accreted from real agent work, with a token balance measured against a withheld control arm + Live Token Optimizer dashboard separating verified net MCP transport savings, excluded reports, per-agent attribution, and graph evidence

-

Everything your agent worked out, kept — and a savings figure measured against a withheld control arm, not estimated.

+

One local ledger for optimizer tools, live-graph substitutions, every agent, and the graph's own cost.

--- @@ -36,7 +36,7 @@ that have not changed, dumping a whole file to see three lines, running unbounded searches, and re-deriving conclusions it reached last session and then forgot. -Token Optimizer attacks that on three fronts. +Token Optimizer attacks that on four fronts. **1. It makes the expensive call impossible.** Install the plugin and a built-in `Read` of a 200 KB file is **denied**, with the refusal naming the cached, @@ -51,13 +51,43 @@ feeds them back the moment the agent touches the relevant file. A finding costs ~150 tokens to carry. Re-deriving it costs 5k–50k. **3. It measures itself, in public, and tells you when it is losing.** A -randomized control arm for savings. A forecast that keeps its own accuracy -record. Cache economics read from your client's own transcript rather than -modelled. Every number here is measured or absent — never estimated and -presented as fact. +materialized before/actual-return measurement for MCP progressive disclosure, +with later expansions debited from the same net. Modeled graph substitutions +and the randomized control arm for downstream graph effects remain separate. +Every number is measured, visibly collecting, excluded, or absent. + +**4. It attributes the traffic.** Returned context, optional cost equivalents, +and net transport avoided are grouped by operation and MCP handshake identity. Codex, Claude Code, +Gemini, and any other connected client get separate rows. Old records without +identity remain explicitly unattributed instead of being assigned to whichever +agent happens to be open now. No account, no telemetry, no hosted service. MIT, so it is usable at work. +### What the dashboard proves on a real machine + +The screenshots in this README come from the shipped server reading persisted +local data, not a design mockup. In the capture above it reports: + +- **43,491 net verified MCP transport tokens avoided** in the current live + proof: 54,037 gross reduction minus a deliberate 10,546-token expansion; +- **486,074,740 historical/tool-reported tokens quarantined**, dominated by + repository scan volume that never entered model context; +- a live Codex / Claude Code / Gemini stdio smoke against AiDotNet, with each + client attributed independently; +- **2,648 graph nodes, 6,527 edges, and 58 findings** across 11 local projects; +- **more than 1,000 hook runs with zero failures and zero timeouts** across six + active CLI clients in the selected rolling 24-hour window; +- **6,332 tokens of modeled graph-substitution potential**, excluded from the + verified headline while the causal graph-reuse study remains `Collecting`. + +The dashboard now reads native CLI usage receipts and prices uncached input, +cache reads, cache writes, and output with the exact captured provider, model, +route, request-time tier, and versioned official source. Ambiguous model ids stay +**Not priced** instead of receiving a blended guess. API/list-price equivalents +are kept separate from provider-reported charges and are never labeled as a +subscription invoice. See the [token accounting contract](./docs/TOKEN_ACCOUNTING.md). + ## Quick start **Claude Code** — install the plugin, not the bare MCP server. The plugin is @@ -69,7 +99,7 @@ what enforces; adding the server alone just gives the model tools it can ignore. /reload-plugins ``` -That is the entire installation. [All fifteen clients →](#installation) +That is the entire installation. [All sixteen clients →](#installation) Then, whenever you want to know what to do next: @@ -77,8 +107,9 @@ Then, whenever you want to know what to do next: token_audit ``` -One ranked queue: what is costing the most, priced per session and per month, -each line naming how to fix it. Not a dashboard, not six reports — a queue. +One ranked queue: what is costing the most per session, with an optional monthly +cost equivalent only after you configure your own effective rate. Each line +names how to fix it. Not a dashboard, not six reports — a queue. --- @@ -135,48 +166,93 @@ output never enters context at all. Not compressed. Absent. ## The dashboard ```bash +npm install +npm run build npm run dashboard # http://localhost:3100 ```

- Knowledge graph browser with focus view, token balance and audit tab + Per-agent token accounting with historical rows left unattributed and live Codex, Claude Code, and Gemini rows measured separately

- - -
- -**Token balance** -Earned, spent and net — with the control-arm method stated up front rather than -buried. When the experiment cannot yet support a headline figure, it says so. - -**Knowledge graph browser** -Focus view for one node and its neighbourhood; constellation view for the whole -project. Click a finding to see what it was derived from, what superseded it, -and what contradicts it. +The overview answers the questions a token optimizer should answer first: + +1. **How much MCP context did it avoid?** The headline is gross materialized + payload reduction minus every later linked expansion. Graph estimates are + intentionally separate. +2. **How much context still reached the agents?** Every successful current MCP + result records its actual returned text, even when no valid before-state + exists. That row is context-accounted but savings-unmeasured. +3. **Which agent and action spent it?** The client ledger and action table show + operations, returned context, optional cost equivalent, and net tokens + avoided. Lifecycle-only clients say `Not measured`; no zero is invented. +4. **Did remembering cost more than it saved?** Delivery and semantic-harvest + tokens are charged to the graph. A causal benefit is added only after the + treated/holdout evidence gate passes. + +### Walkthrough: get useful data, not an empty dashboard + +1. Install the MCP server and the native adapter for your CLI. The MCP handshake + provides per-client accounting; native lifecycle hooks provide automatic + routing, capture, health, and delivery where the client protocol permits it. +2. Use `smart_read`, `smart_grep`, `smart_glob`, `smart_edit`, or any other MCP + operation normally. Every successful result records returned context; tools + with a comparable materialized before-state also record a gross reduction; + later `expand` calls debit that reduction. +3. Let the active model record durable conclusions with `wiki_write`. Before a + new agent re-derives work, call `wiki_read` for the project or the files it is + about to touch. Native clients can also deliver matching knowledge + automatically. +4. Open `http://localhost:3100`. Use **Overview** for combined accounting and + **What it knows** for capture health, graph exploration, audits, and causal + evidence. +5. To register existing local repositories without reading their source, run + `npm run projects:discover -- /absolute/path/to/repos`. This makes coverage + gaps explicit; it does not fabricate findings. + +For maintainers, this live smoke exercises the shipped stdio transport and +creates separately attributed rows without seeding the analytics database: - +```bash +npm run dashboard:attribution-smoke -- /absolute/path/to/project /absolute/path/to/large-file +npm run dashboard:verify-live -- http://localhost:3100 +``` -**Audit tab** -Contradictions, stale findings, and anything the graph believes that the code no -longer supports — each with the diff that invalidated it. +

+ Structured cross-client hook and MCP health cards from live local data +

-**Evidence console** -Client/model/task cohorts, matched four-arm effects with 95% intervals, live -injection-to-outcome traces, harm feedback, and the honest capability tier for -all sixteen supported CLIs. Insufficient evidence stays visibly insufficient. +The health panel is deliberately operational rather than a raw text dump. Each +client has activity, runtime failures/timeouts, policy outcomes, and observed +surface coverage. Diagnostics keep no prompts, commands, paths, or tool output. -**One-click Markdown export** -Your agent's accumulated knowledge as documentation you can review, edit and -commit. The graph stops being a black box the moment you can read it as prose. +

+ Direct graph savings, remembering cost, holdouts, and an honest collecting causal study +

-
+Modeled substitution potential and causal graph effects are different claims. +The first is a full-file counterfactual and is never promoted to the verified +MCP headline. The second asks whether delivered knowledge prevented later reads; +it uses a control arm and remains `Collecting` until there are at least 20 +treated file touches and 5 holdouts with valid downstream joins.

- Audit tab surfacing contradictions, stale findings and low-confidence claims + Interactive 3D knowledge graph spanning eleven local projects

-

The audit tab: two findings that contradict each other, surfaced automatically.

+Drag to orbit, scroll to zoom, click a node for provenance, or switch to the +bounded one-hop focus view. The default **All known projects** scope pools local +graphs through opaque project IDs; filesystem paths never reach the browser. + +**Audit tab.** Contradictions, stale findings, and low-confidence claims remain +reviewable instead of silently becoming model truth. + +**Evidence console.** Client/model/task cohorts, matched effects with 95% +intervals, live outcome joins, harm feedback, and capability tiers for all 16 +clients. Release and superiority claims fail closed while evidence is missing. + +**One-click Markdown export.** The accumulated graph becomes documentation you +can inspect, edit, and commit. Server-side by design: the browser asks for a neighbourhood, a search result or a page. A mature graph holds thousands of nodes, and shipping it wholesale would @@ -198,9 +274,9 @@ whose capture has not started. The balance cards are backed by persisted events: **Memory deliveries** counts graph context actually supplied to an agent, **Kept back for comparison** counts randomized control touches, and **Cost of remembering** combines delivered-context tokens with measured semantic-write -payload cost. **Reading avoided** stays “Not enough comparisons” until at least -20 deliveries and 5 holdouts exist; the dashboard does not manufacture a -savings estimate from missing data. +payload cost. **Reading avoided** stays `Collecting` or `Not measured` until at +least 20 treated file touches and 5 holdouts exist with a downstream join; the +dashboard does not manufacture a savings estimate from missing data. See [the causal evidence protocol](docs/EVIDENCE_PROTOCOL.md), the [cross-client capability contract](docs/CLIENT_SUPPORT.md), and the @@ -276,10 +352,13 @@ never happens. ### Prompt-cache economics, measured from your own transcript -A cache write costs 1.25× a plain token and a read costs 0.1×, so a prefix that -keeps invalidating can cost more than every saving elsewhere. This reads the -real numbers your client already recorded — then does the part a hit rate -cannot: +Provider caches are billable and provider-specific; a cache hit is not a free +input token. For example, Anthropic publishes separate cache-read and +cache-write multipliers, while OpenAI and Gemini expose their own cached-input +usage and pricing rules. Token Optimizer reads native cache fields when the +client supplies them and keeps reads, writes, uncached input, and output +separate. It never applies one provider's cache multiplier to another client. +The attribution view then does the part a hit rate cannot: ``` ! CLAUDE.md:2 has an embedded timestamp, invalidating everything after it @@ -308,7 +387,7 @@ has actually saved: ``` generated/schema.d.ts: read in 9/9 sessions, never the source of a finding - 3,400 tokens/session (~$3.06/month); apply: waste_audit action="apply" + 3,400 tokens/session; cost equivalent not priced; apply: waste_audit action="apply" ``` Anything that touches **your** files is proposed as a diff and never applied. @@ -374,15 +453,21 @@ npm run uninstall-hooks -- --apply # carry it out --- -## It proves its own savings against a control arm +## It separates direct savings from causal graph evidence Every tool in this space reports "tokens saved" computed from its own assumptions. That number cannot be wrong, because nothing checks it. -This runs a **randomized holdout**: injection is silently withheld on a slice of -file touches, stratified by file so the comparison is within-file, and the -saving is the measured difference between the arms. It refuses to print a -headline figure until the experiment can support one, and it will tell you +For optimizer results, this records the materialized before-state and the text +actually returned to the client, then subtracts any linked expansion responses. +Native graph-substitution counterfactuals stay labeled as modeled and outside +the main headline. + +The graph's broader claim—whether delivered knowledge prevented later +re-reading—is causal, so it runs a **randomized holdout**. Delivery is silently +withheld on a slice of file touches, stratified by file, and the effect is the +difference in downstream reads between arms. That effect is not added to the +combined net until the experiment can support it, and the page will tell you plainly: > the graph is NOT yet paying for itself @@ -394,14 +479,21 @@ silent false negative, and dollars get quoted to other people. ## It runs everywhere, and says which tier -**Enforcing** — a pre-execution veto exists: Claude Code, Codex, OpenCode. +The tier is a protocol guarantee, not a preference: -**Directive** — MCP plus an always-applied rules file: Cursor, Windsurf, Cline, -Roo, Kilo, Zed, Amp, Continue, Crush, Droid, Copilot, Gemini, Qwen. +- **Lifecycle continuation:** Claude Code, Codex, GitHub Copilot CLI, Gemini CLI, + Qwen Code, and Cursor. Native routing/capture/delivery plus one active-model + completion reflection. +- **Native observation:** Cline, OpenCode, Kilo, and Windsurf. Native + routing/capture/delivery; the active model performs semantic writes. +- **MCP + rules:** Roo Code, Zed, Amp, Continue, Crush, and Droid. MCP-visible + activity and explicit graph tools, with no claim over hidden built-in calls. -Gemini and Qwen sit in the second tier **despite having hooks**, because their -only tool hook fires _after_ the read is paid for. Claiming enforcement there -would be a lie you would discover on your first large file. +The exact surfaces still differ. For example, a protocol that can replace a +large read before it reaches the model provides a stronger token guarantee than +one that can only observe it. The generated registry, adapters, dashboard, and +certification report all read the same capability source so those claims cannot +drift independently. Every config shape is confirmed against that client's published documentation, with the source URL recorded in its README. @@ -414,7 +506,7 @@ npm run verify:all | Suite | Checks | What it proves | | ---------------- | ------ | ----------------------------------------------------------------------------------------------------------------------- | -| `test` | 416 | Enforcement, staleness, injection, consolidation, disclosure, cache, routing, trust — driving the real hooks over stdin | +| `test` | 2,340 | Enforcement, staleness, injection, consolidation, disclosure, cache, routing, trust — driving the real hooks over stdin | | `verify:clients` | 129 | Every client config matches its documented schema | | `verify:harvest` | 26 | Request shape, response parsing, and that **no secret from a tool result crosses the wire** | | `verify:ui` | 22 | Real headless Chromium: layout, label collisions, legibility | @@ -427,19 +519,19 @@ a plan and deleted nothing. ## Honest comparison -| | Token Optimizer | Typical alternatives | -| ---------------------------- | -------------------------------------------------- | ---------------------------------------------------- | -| **License** | MIT — commercial use fine | Often noncommercial-only; check before using at work | -| Default behaviour | Refuses the wasteful call | Suggests a better tool | -| Re-read of an unchanged file | Returns a diff | Returns the file again | -| Savings figure | Measured against a withheld control | Computed from the tool's own assumptions | -| Cross-session memory | Findings, decisions, dead ends | Usually none | -| Compaction | Consolidation, ranked by cost-to-rederive | Checkpoint and replay | -| Cache economics | Measured from the transcript, attributed to a line | Rarely addressed | -| Model routing | Measured from episode outcomes | Guessed from task size | -| Cross-project | Fixes transfer by content hash | Per-project only | -| Clients | 15 | 3–6 typical | -| Telemetry | None | Varies | +| | Token Optimizer | Typical alternatives | +| ---------------------------- | -------------------------------------------------------------------- | ---------------------------------------------------- | +| **License** | MIT — commercial use fine | Often noncommercial-only; check before using at work | +| Default behaviour | Refuses the wasteful call | Suggests a better tool | +| Re-read of an unchanged file | Returns a diff | Returns the file again | +| Savings figure | Direct before/return ledger; causal graph effect separately held out | Computed from the tool's own assumptions | +| Cross-session memory | Findings, decisions, dead ends | Usually none | +| Compaction | Consolidation, ranked by cost-to-rederive | Checkpoint and replay | +| Cache economics | Measured from the transcript, attributed to a line | Rarely addressed | +| Model routing | Measured from episode outcomes | Guessed from task size | +| Cross-project | Fixes transfer by content hash | Per-project only | +| Clients | 16 | 3–6 typical | +| Telemetry | None | Varies | --- @@ -450,7 +542,7 @@ but they differ in what they let a hook _do_, and that difference is the whole product. A client with a pre-execution veto can have the wasteful call refused; one without can only be told. Both are listed honestly below. -Ready-made configuration for all fifteen lives in +Ready-made configuration for all sixteen lives in [`integrations/`](./integrations), generated from one source and validated by `npm run verify:clients`. Full matrix: [`docs/CLIENT_SUPPORT.md`](./docs/CLIENT_SUPPORT.md). @@ -803,12 +895,6 @@ Any stdio-capable MCP client can launch Token Optimizer with: Additional ready-made integration files are available for [Claude Desktop](./examples/claude_desktop_config.json), [Codex](./integrations/codex/config.toml), [Gemini CLI](./integrations/gemini/), [OpenCode](./integrations/opencode/), and [GitHub Copilot](./integrations/copilot/mcp-config.json). -## See it in action - -![Token Optimizer savings report showing 117,915 tokens saved and a 53 percent reduction across three operations](./docs/assets/token-savings-report.png) - -_Real `get_optimization_report` output from an MCP smoke run over this repository's tool reference, server source, and dependency lockfile. Savings vary with content and workflow._ - ## Use it You normally use Token Optimizer by asking your agent in plain language: @@ -875,11 +961,12 @@ For clients that expose direct MCP tool calls, the core inputs are small JSON ob } ``` -`get_optimization_report` aggregates recorded operations into: +`get_optimization_report` applies the same versioned measurement contract as +the dashboard and aggregates qualifying operations into: -- original, optimized, and saved token totals; -- overall reduction percentage; -- operations tracked; +- gross preview reduction, linked expansion debits, and net transport avoided; +- observed returned context and the net reduction percentage; +- verified operations tracked, with legacy and tool-reported claims excluded; - breakdowns by action/tool, hook phase, and MCP server; - optional date-range and session filters. @@ -1330,18 +1417,14 @@ When global hooks are installed, token-optimizer-mcp runs automatically on **eve └─────────────────────────────────────────────────────────────┘ ``` -### Production Performance - -Based on 38,000+ operations in real-world usage: - -| Tool Category | Avg Token Reduction | Cache Hit Rate | -| ----------------- | ------------------- | -------------- | -| File Operations | 60-90% | >80% | -| API Responses | 83-95% | >75% | -| Database Queries | 83-90% | >70% | -| Build/Test Output | 70-85% | >65% | +### Performance evidence -**Per-Session Savings**: 300K-700K tokens (worth $0.90-$2.10 at $3/M tokens) +Reduction targets in tool descriptions are design goals, not production +measurements. They never enter the verified ledger. Use the dashboard or +`get_optimization_report` for request-level measurements, and +`npm run dashboard:audit-savings` to inspect both qualifying and excluded rows. +No universal dollar value is claimed: configure an effective rate only when it +reflects your own provider, model, cache, route, plan, tier, and credits. ### Usage Examples diff --git a/docs/COMPETITIVE_GAPS.md b/docs/COMPETITIVE_GAPS.md index 200a297b8..835f9f0fe 100644 --- a/docs/COMPETITIVE_GAPS.md +++ b/docs/COMPETITIVE_GAPS.md @@ -5,8 +5,8 @@ from its README and documentation site rather than from memory. ## A correction I owe first -On issue #201 I wrote that they "audit waste in your *setup*" while we "change -what the *operations* cost", and that those compose. **That was wrong.** +On issue #201 I wrote that they "audit waste in your _setup_" while we "change +what the _operations_ cost", and that those compose. **That was wrong.** They ship active compression that changes operation cost directly: @@ -24,14 +24,14 @@ model choosing to cooperate. Ours requires the model to reissue the call against ## Where we are genuinely ahead -| | Us | Them | -|---|---|---| -| **License** | MIT | PolyForm **Noncommercial** — unusable at work | -| Clients | 15 | 6 | -| Savings measurement | **withheld control arm** | counterfactual baseline + metered receipts, no control | -| Operational tooling | ~100 MCP tools (git, build, db, API, search) | none — it is not an MCP server | -| Cross-session knowledge | anchored graph with computed staleness | decision extraction, capped at 10/session | -| Enforcement | refusal with the replacement named | nudges and transparent rewriting | +| | Us | Them | +| ----------------------- | -------------------------------------------- | ------------------------------------------------------ | +| **License** | MIT | PolyForm **Noncommercial** — unusable at work | +| Clients | 15 | 6 | +| Savings measurement | **withheld control arm** | counterfactual baseline + metered receipts, no control | +| Operational tooling | ~100 MCP tools (git, build, db, API, search) | none — it is not an MCP server | +| Cross-session knowledge | anchored graph with computed staleness | decision extraction, capped at 10/session | +| Enforcement | refusal with the replacement named | nudges and transparent rewriting | The licence is still the single most decisive line for anyone using this at work, and the control-arm measurement is a claim they cannot make. The MCP tool @@ -61,7 +61,7 @@ decision density, agent efficiency), letter grades S–F, a status-line integrat with colour transitions, and degradation tracking framed against MRCR falling 93% → 76% as context grows. -We have nothing here. It is also the most *visible* feature they have — it is on +We have nothing here. It is also the most _visible_ feature they have — it is on screen constantly, which is worth more than its accuracy. ### 3. Progressive disclosure of tool results @@ -108,10 +108,14 @@ per-message cost breakdown. We have `get_optimization_report`. The gap is less about capability than about there being an obvious thing to run that tells you what to do next. -### 8. Dollars, not tokens +### 8. Billing evidence, not a universal token price -They price against four tiers and report `$/month` with receipts. We report -tokens. Same data, and the unit people actually feel. +Provider/model tables alone cannot reconstruct a CLI user's bill because cache +reads and writes, routing, long-context tiers, subscriptions, included credits, +and enterprise agreements change marginal cost. Token Optimizer now reports +verified transport tokens and fails closed on dollars unless the user supplies +an effective blended rate. Provider-native billing exports remain the source of +truth for actual spend. ### 9. Skills and configuration health diff --git a/docs/HOOKS-INSTALLATION.md b/docs/HOOKS-INSTALLATION.md index 04c6e86c1..f25525ae8 100644 --- a/docs/HOOKS-INSTALLATION.md +++ b/docs/HOOKS-INSTALLATION.md @@ -32,11 +32,13 @@ The global hooks system provides **7-phase token optimization** that runs automa ## Prerequisites ### All Platforms + - **Claude Code** CLI installed globally - **Node.js** 18+ and npm - **token-optimizer-mcp** package (will be installed by installer) ### Platform-Specific + - **Windows**: PowerShell 5.1+ - **macOS**: Bash 4.0+, Homebrew recommended - **Linux**: Bash 4.0+ @@ -434,18 +436,21 @@ chmod +x ~/.claude-global/hooks/helpers/*.sh ### 1. Check Hooks Are Firing **Windows:** + ```powershell # View dispatcher log Get-Content "$env:USERPROFILE\.claude-global\hooks\logs\dispatcher.log" -Tail 20 ``` **macOS / Linux:** + ```bash # View dispatcher log tail -20 ~/.claude-global/hooks/logs/dispatcher.log ``` You should see entries like: + ``` [2025-10-26 10:00:00] [PreToolUse] DISPATCHER INVOKED [2025-10-26 10:00:01] [PreToolUse] Tool: Read @@ -455,18 +460,21 @@ You should see entries like: ### 2. Check MCP Tools Are Being Called **Windows:** + ```powershell # View MCP invocation log Get-Content "$env:USERPROFILE\.claude-global\hooks\logs\mcp-invocation.log" -Tail 20 ``` **macOS / Linux:** + ```bash # View MCP invocation log tail -20 ~/.claude-global/hooks/logs/mcp-invocation.log ``` You should see: + ``` [2025-10-26 10:00:00] [DEBUG] Invoking MCP: token-optimizer -> smart_read [2025-10-26 10:00:01] [DEBUG] Request: {"jsonrpc":"2.0",...} @@ -475,18 +483,21 @@ You should see: ### 3. Check Token Optimization **Windows:** + ```powershell # View token optimizer log Get-Content "$env:USERPROFILE\.claude-global\hooks\logs\token-optimizer.log" -Tail 10 ``` **macOS / Linux:** + ```bash # View token optimizer log tail -10 ~/.claude-global/hooks/logs/token-optimizer.log ``` You should see savings like: + ``` [2025-10-26 10:00:00] [SUGGEST] Read File (~5000 tokens) → Token Optimizer (~1850 tokens) | Savings: 3150 tokens (63%) ``` @@ -494,6 +505,7 @@ You should see savings like: ### 4. Check Operations Tracking **Windows:** + ```powershell # View most recent operations CSV Get-ChildItem "$env:USERPROFILE\.claude-global\hooks\data\operations-*.csv" | @@ -503,6 +515,7 @@ Get-ChildItem "$env:USERPROFILE\.claude-global\hooks\data\operations-*.csv" | ``` **macOS / Linux:** + ```bash # View most recent operations CSV ls -t ~/.claude-global/hooks/data/operations-*.csv | head -1 | xargs tail -10 @@ -515,6 +528,7 @@ ls -t ~/.claude-global/hooks/data/operations-*.csv | head -1 | xargs tail -10 **Symptom**: No log entries in dispatcher.log **Solutions**: + 1. Check workspace trust is accepted (see Manual Installation Step 5 for your platform) 2. Verify settings.json hook commands have correct paths 3. Restart Claude Code @@ -527,14 +541,17 @@ ls -t ~/.claude-global/hooks/data/operations-*.csv | head -1 | xargs tail -10 **Symptom**: dispatcher.log shows hooks firing but no MCP calls **Solutions**: + 1. Verify token-optimizer MCP server is running: **Windows:** + ```powershell Get-Process | Where-Object { $_.Name -like "*node*" -and $_.CommandLine -like "*token-optimizer*" } ``` **macOS / Linux:** + ```bash ps aux | grep token-optimizer | grep -v grep ``` @@ -552,6 +569,7 @@ ls -t ~/.claude-global/hooks/data/operations-*.csv | head -1 | xargs tail -10 **Symptom**: "File cannot be loaded because running scripts is disabled" **Solution**: + ```powershell Set-ExecutionPolicy RemoteSigned -Scope CurrentUser ``` @@ -561,6 +579,7 @@ Set-ExecutionPolicy RemoteSigned -Scope CurrentUser **Symptom**: "Permission denied" errors when running hooks **Solution**: + ```bash # Make all hook scripts executable chmod +x ~/.claude-global/hooks/dispatcher.sh @@ -576,6 +595,7 @@ ls -la ~/.claude-global/hooks/dispatcher.sh **Symptom**: "Bad substitution" or syntax errors **Solution**: macOS ships with Bash 3.x, but hooks require Bash 4+ + ```bash # Install Bash 4+ via Homebrew brew install bash @@ -731,23 +751,17 @@ C:\Users\YOUR_USERNAME\ └── token-optimizer-mcp # MCP server ``` -## Token Reduction Metrics - -Based on production usage across 38,000+ operations: - -| Tool | Average Tokens Before | Average Tokens After | Reduction % | -|------|----------------------|---------------------|-------------| -| Read | 5,000 | 1,850 | 63% | -| Grep | 2,000 | 740 | 63% | -| Glob | 1,500 | 555 | 63% | -| Edit | 3,500 | 1,295 | 63% | -| **Overall** | **3,000** | **1,110** | **63%** | +## Token reduction measurement -### Session-Level Impact +Static reduction percentages and repository scan sizes are not production +savings evidence. Current releases require a materialized MCP payload before +progressive disclosure and the actual returned payload, then debit every linked +`expand` response. Run `get_optimization_report` for the verified net or +`npm run dashboard:audit-savings` to inspect both qualifying and excluded rows. -- **Operations per session**: ~200-500 -- **Token savings per session**: 300,000-700,000 tokens -- **Cost savings (at $3/M tokens)**: $0.90-$2.10 per session +Dollar values are **Not priced** unless the user configures an effective blended +input-token rate that reflects their provider, model, cache read/write mix, +route, tier, plan, and credits. See [Token accounting](./TOKEN_ACCOUNTING.md). ## Advanced Configuration diff --git a/docs/TOKEN_ACCOUNTING.md b/docs/TOKEN_ACCOUNTING.md new file mode 100644 index 000000000..a2c414233 --- /dev/null +++ b/docs/TOKEN_ACCOUNTING.md @@ -0,0 +1,101 @@ +# Token accounting contract + +Token Optimizer reports four different quantities because combining them would +turn assumptions into evidence: + +1. **Observed returned context** is the local tokenizer count of text that + crossed the MCP response boundary. It is not a provider invoice. +2. **Gross verified transport reduction** is the difference between two + materialized payloads: the tool result before progressive disclosure and + the smaller payload actually returned. +3. **Expansion debit** is every later `expand` response linked to that preview. +4. **Net verified MCP transport avoided** is gross reduction minus expansion + debits. It may be negative. + +Graph substitutions, causal graph-reuse estimates, repository bytes scanned, +tool-reported compression fields, and legacy rows without provenance never +enter the verified MCP headline. + +## Qualification rules + +A reduction qualifies only when its durable row contains all of the following: + +- measurement schema version 2; +- a unique MCP request identity; +- exact UTF-8 byte counts for both materialized payloads and a consistent byte + delta; +- SHA-256 fingerprints for both payloads without storing their contents in the + analytics row; +- locally counted tokens with `saved = before - returned`; +- the content-addressed expansion reference printed in the preview. + +Request retries with the same process/session/RPC identity are stored once. +Historical rows remain available through `npm run dashboard:audit-savings` but +cannot be promoted by a boolean migration or a similarly named tool field. + +## What a token means here + +The MCP boundary does not expose the provider's final billing tokenizer. The +ledger therefore uses a GPT-4-compatible local tokenizer estimate so the same +transport bytes can be compared consistently across Codex, Claude Code, +Gemini, Copilot, and other clients. The dashboard says this explicitly. + +Provider-native study receipts keep uncached input, cache creation, cache read, +and output fields separate when a client supplies them. Those receipt fields +are not substituted into MCP transport measurements because they answer a +different question. + +## Provider usage and cost + +The dashboard reads native local usage receipts from supported CLI transcripts +and keeps their billable dimensions separate: uncached input, cached reads, +5-minute/1-hour/generic cache writes, and output (including thinking tokens when +the provider bills them as output). + +Adapters preserve each provider's semantics. Codex/OpenAI and Gemini prompt +totals include cached input, while Anthropic `input_tokens` excludes cache reads +and cache creation. Claude response fragments are deduplicated by request id, +and repeated Codex usage snapshots are deduplicated by cumulative state. + +A versioned price catalog selects an exact provider, model, route, request +timestamp, and long-context tier. Every priced result carries an official source +link and a catalog verification date. An ambiguous id such as `claude-sonnet`, +an unknown route, or a captured dimension without a published rate remains +**Not priced**; the nearest model is never guessed. + +The dashboard distinguishes two money quantities: + +1. **API/list-price equivalent** applies published rates to native observed + usage. A subscription, enterprise agreement, included credits, processing + tier, or cloud marketplace may make the user's invoice different. +2. **Provider-reported actual charge** appears only when the native receipt + contains a charge. Token Optimizer does not reconstruct an invoice from list + prices. + +For a verified MCP payload reduction, the conservative direct saving is one +immediate uncached-input equivalent for that operation's exact model. The ledger +does not multiply that saving by hypothetical future cache reads or writes. +Paired experiments can separately measure downstream effects from native usage. + +Current provider references are the authoritative source behind catalog rows: + +- [OpenAI model pricing and cached-input rates](https://developers.openai.com/api/docs/models/compare) +- [Anthropic API pricing](https://platform.claude.com/docs/en/about-claude/pricing) +- [Anthropic prompt-cache usage and read/write multipliers](https://platform.claude.com/docs/en/build-with-claude/prompt-caching) +- [Gemini API model pricing](https://ai.google.dev/gemini-api/docs/pricing) +- [GitHub Copilot models and token pricing](https://docs.github.com/en/copilot/reference/copilot-billing/models-and-pricing) + +The separate `analyze_project_tokens` report still accepts an explicit +`costPerMillionTokens` because repository analysis has no model request receipt. +That caller-supplied value remains labeled as a configured equivalent. + +## Auditing an existing ledger + +```bash +npm run dashboard:audit-savings +npm run dashboard:audit-savings -- /absolute/path/to/analytics.db +``` + +The audit is read-only. It reports verified net savings, gross reductions, +expansion debits, observed returned context, excluded historical/tool claims, +per-tool concentration, and the largest excluded rows. diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 2f9d04f81..4e70b5ef9 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -21,11 +21,13 @@ This document provides comprehensive documentation for all 61 tools available in Compress and cache text to reduce token usage. This is the primary tool for removing content from your context window. **Parameters:** + - `text` (string, required) - Text to optimize - `key` (string, required) - Cache key for storing - `quality` (number, optional) - Compression quality 0-11 (default: 11) **Returns:** + - `success` (boolean) - Operation status - `key` (string) - Cache key - `originalTokens` (number) - Tokens before optimization @@ -37,12 +39,13 @@ Compress and cache text to reduce token usage. This is the primary tool for remo **Token Reduction:** 60-90% average **Example:** + ```typescript optimize_text({ - text: "Large API response or file content...", - key: "api-response-key", - quality: 11 -}) + text: 'Large API response or file content...', + key: 'api-response-key', + quality: 11, +}); ``` --- @@ -52,17 +55,20 @@ optimize_text({ Retrieve previously cached and optimized text. **Parameters:** + - `key` (string, required) - Cache key to retrieve **Returns:** + - `success` (boolean) - Operation status - `key` (string) - Cache key - `text` (string) - Original cached text - `fromCache` (boolean) - Whether data was found in cache **Example:** + ```typescript -get_cached({ key: "api-response-key" }) +get_cached({ key: 'api-response-key' }); ``` --- @@ -72,10 +78,12 @@ get_cached({ key: "api-response-key" }) Compress text using Brotli compression. Returns base64-encoded compressed data. **Parameters:** + - `text` (string, required) - Text to compress - `quality` (number, optional) - Compression quality 0-11 (default: 11) **Returns:** + - `compressed` (string) - Base64-encoded compressed data **Note:** Base64 encoding adds ~33% overhead. Use `optimize_text` for better token reduction. @@ -87,9 +95,11 @@ Compress text using Brotli compression. Returns base64-encoded compressed data. Decompress base64-encoded Brotli-compressed text. **Parameters:** + - `compressed` (string, required) - Base64-encoded compressed text **Returns:** + - `text` (string) - Original decompressed text --- @@ -99,15 +109,18 @@ Decompress base64-encoded Brotli-compressed text. Count tokens in text using tiktoken (GPT-4 tokenizer). **Parameters:** + - `text` (string, required) - Text to count tokens for **Returns:** + - `tokens` (number) - Token count - `characters` (number) - Character count **Example:** + ```typescript -count_tokens({ text: "Your text here" }) +count_tokens({ text: 'Your text here' }); // Result: { tokens: 150, characters: 500 } ``` @@ -118,9 +131,11 @@ count_tokens({ text: "Your text here" }) Analyze text and provide recommendations for optimization including compression benefits and token savings. **Parameters:** + - `text` (string, required) - Text to analyze **Returns:** + - `tokens` - Token analysis - `current` (number) - Current token count - `afterCompression` (number) - Estimated tokens after compression @@ -144,6 +159,7 @@ Get comprehensive cache statistics including hit rate, compression ratio, and to **Parameters:** None **Returns:** + - `totalEntries` (number) - Number of cached entries - `totalSize` (number) - Total cache size in bytes - `hits` (number) - Cache hits @@ -159,9 +175,11 @@ Get comprehensive cache statistics including hit rate, compression ratio, and to Clear all cached data. Use with caution. **Parameters:** + - `confirm` (boolean, required) - Must be true to confirm **Returns:** + - `success` (boolean) - Operation status --- @@ -173,6 +191,7 @@ Clear all cached data. Use with caution. Read files with 80% token reduction through intelligent caching and diff-based updates. **Parameters:** + - `path` (string, required) - Path to file - `diffMode` (boolean, optional) - Return only diff if previously read (default: true) - `maxSize` (number, optional) - Max content size in bytes (default: 100000) @@ -182,12 +201,13 @@ Read files with 80% token reduction through intelligent caching and diff-based u **Token Reduction:** 80% on subsequent reads (diff-only) **Example:** + ```typescript // First read: full content -smart_read({ path: "/src/app.ts" }) +smart_read({ path: '/src/app.ts' }); // Subsequent reads: only changes -smart_read({ path: "/src/app.ts" }) +smart_read({ path: '/src/app.ts' }); ``` --- @@ -197,6 +217,7 @@ smart_read({ path: "/src/app.ts" }) Write files with verification, atomic operations, and change tracking. **Parameters:** + - `path` (string, required) - Path to file - `content` (string, required) - Content to write - `atomic` (boolean, optional) - Use atomic write with temp file (default: true) @@ -213,6 +234,7 @@ Write files with verification, atomic operations, and change tracking. Line-based file editing with diff-only output. **Parameters:** + - `path` (string, required) - Path to file - `operations` (array, required) - Edit operations - `type` - "replace", "insert", or "delete" @@ -234,6 +256,7 @@ Line-based file editing with diff-only output. Search file contents with match-only output and smart filtering. **Parameters:** + - `pattern` (string, required) - Search pattern (regex) - `cwd` (string, optional) - Working directory for search - `files` (array, optional) - File patterns to search (glob patterns) @@ -256,7 +279,8 @@ Search file contents with match-only output and smart filtering. File pattern matching with path-only results and smart filtering. **Parameters:** -- `pattern` (string, required) - Glob pattern (e.g., "src/**/*.ts") + +- `pattern` (string, required) - Glob pattern (e.g., "src/\*_/_.ts") - `cwd` (string, optional) - Working directory - `extensions` (array, optional) - Filter by file extensions - `includeMetadata` (boolean, optional) - Include file metadata (default: false) @@ -273,6 +297,7 @@ File pattern matching with path-only results and smart filtering. Git diffs with diff-only output and smart filtering. **Parameters:** + - `source` (string, optional) - Source commit/branch (default: HEAD) - `target` (string, optional) - Target commit/branch (default: working directory) - `files` (array, optional) - Specific files to diff @@ -292,10 +317,11 @@ Git diffs with diff-only output and smart filtering. Git branch listing with structured JSON output and smart filtering. **Parameters:** + - `cwd` (string, optional) - Working directory - `all` (boolean, optional) - Include local and remote branches (default: false) - `remote` (boolean, optional) - Include remote branches (default: false) -- `pattern` (string, optional) - Filter branches by pattern (e.g., "feature/*") +- `pattern` (string, optional) - Filter branches by pattern (e.g., "feature/\*") - `merged` (boolean, optional) - Only show merged branches (default: false) - `unmerged` (boolean, optional) - Only show unmerged branches (default: false) - `includeCommit` (boolean, optional) - Include last commit info (default: false) @@ -313,6 +339,7 @@ Git branch listing with structured JSON output and smart filtering. Git commit history with smart filtering and structured output. **Parameters:** + - `cwd` (string, optional) - Working directory - `limit` (number, optional) - Maximum commits to return (default: 50) - `offset` (number, optional) - Skip first N commits (default: 0) @@ -335,6 +362,7 @@ Git commit history with smart filtering and structured output. Git merge management with structured status and conflict analysis. **Parameters:** + - `mode` (string, optional) - "status", "merge", "abort", or "continue" (default: "status") - `branch` (string, optional) - Branch to merge from (for merge mode) - `commit` (string, optional) - Specific commit to merge (for merge mode) @@ -358,6 +386,7 @@ Git merge management with structured status and conflict analysis. Git status with status-only output and smart filtering. **Parameters:** + - `cwd` (string, optional) - Repository directory - `summaryOnly` (boolean, optional) - Return counts only (default: false) - `staged` (boolean, optional) - Only staged files @@ -379,6 +408,7 @@ Git status with status-only output and smart filtering. HTTP requests with intelligent caching and retry logic. **Parameters:** + - `method` (string, required) - HTTP method: "GET", "POST", "PUT", "DELETE", "PATCH" - `url` (string, required) - Request URL - `headers` (object, optional) - Request headers @@ -393,19 +423,20 @@ HTTP requests with intelligent caching and retry logic. **Token Reduction:** 83% on cache hits, 95% on cached responses **Example:** + ```typescript // First request: fetch and cache smart_api_fetch({ - method: "GET", - url: "https://api.example.com/data", - ttl: 300 -}) + method: 'GET', + url: 'https://api.example.com/data', + ttl: 300, +}); // Subsequent requests: cached (95% reduction) smart_api_fetch({ - method: "GET", - url: "https://api.example.com/data" -}) + method: 'GET', + url: 'https://api.example.com/data', +}); ``` --- @@ -415,6 +446,7 @@ smart_api_fetch({ API response caching with TTL, ETag, and event-based invalidation strategies. **Parameters:** + - `action` (string, required) - "get", "set", "invalidate", "analyze", or "warm" - `request` (object, optional) - API request data (for get/set) - `url` (string) - Request URL @@ -427,7 +459,7 @@ API response caching with TTL, ETag, and event-based invalidation strategies. - `ttl` (number, optional) - Time-to-live in seconds (default: 3600) - `tags` (array, optional) - Tags for grouping cached entries - `invalidationPattern` (string, optional) - "time", "pattern", "tag", "manual", "event" -- `pattern` (string, optional) - URL pattern for invalidation (e.g., "/api/users/*") +- `pattern` (string, optional) - URL pattern for invalidation (e.g., "/api/users/\*") - `endpoints` (array, optional) - Endpoints to warm (for warm action) - `normalizeQuery` (boolean, optional) - Normalize query parameters (default: true) - `ignoreHeaders` (array, optional) - Headers to exclude from cache key @@ -445,6 +477,7 @@ API response caching with TTL, ETag, and event-based invalidation strategies. Database queries with connection pooling, circuit breaking, and intelligent caching. **Parameters:** + - `query` (string, optional) - SQL query to execute (required for query/explain/analyze/optimize) - `action` (string, optional) - "query", "explain", "analyze", "optimize", "health", "pool", "slow", "batch" (default: "query") - `engine` (string, optional) - "postgresql", "mysql", "sqlite", "mongodb", "redis", "generic" (default: "generic") @@ -473,6 +506,7 @@ Database queries with connection pooling, circuit breaking, and intelligent cach SQL query analysis with optimization suggestions and execution plan analysis. **Parameters:** + - `query` (string, required) - SQL query to analyze - `action` (string, optional) - "analyze", "explain", "validate", "optimize", "history" (default: "analyze") - `database` (string, optional) - "postgresql", "mysql", "sqlite", "sqlserver" @@ -490,6 +524,7 @@ SQL query analysis with optimization suggestions and execution plan analysis. Database schema analysis with intelligent caching and 83% token reduction. **Parameters:** + - `connectionString` (string, required) - Database connection string - PostgreSQL: `postgresql://user:pass@host:port/db` - MySQL: `mysql://user:pass@host/db` @@ -510,6 +545,7 @@ Database schema analysis with intelligent caching and 83% token reduction. GraphQL query optimization with complexity analysis and N+1 detection. **Parameters:** + - `query` (string, required) - GraphQL query to analyze - `operationName` (string, optional) - Operation name - `variables` (object, optional) - Query variables @@ -529,6 +565,7 @@ GraphQL query optimization with complexity analysis and N+1 detection. REST API analysis with endpoint discovery and health scoring. **Parameters:** + - `specContent` (string, optional) - OpenAPI/Swagger spec content (JSON string) - `specUrl` (string, optional) - OpenAPI/Swagger spec URL (not yet supported) - `baseUrl` (string, optional) - Base API URL @@ -550,6 +587,7 @@ REST API analysis with endpoint discovery and health scoring. ORM query optimization with N+1 detection and eager loading suggestions. **Parameters:** + - `ormCode` (string, required) - ORM query code to analyze - `ormType` (string, required) - "prisma", "sequelize", "typeorm", "mongoose", "generic" - `modelDefinitions` (string, optional) - Schema/model definitions for enhanced analysis @@ -569,6 +607,7 @@ ORM query optimization with N+1 detection and eager loading suggestions. Database migration tracking with status monitoring and 83% token reduction. **Parameters:** + - `action` (string, optional) - "list", "status", "pending", "history", "rollback", "generate" (default: "list") - `migrationId` (string, optional) - Migration ID (required for rollback and generate) - `direction` (string, optional) - "up" or "down" for rollback (default: "down") @@ -585,6 +624,7 @@ Database migration tracking with status monitoring and 83% token reduction. WebSocket connection management with message tracking and 83% token reduction. **Parameters:** + - `url` (string, required) - WebSocket URL (ws:// or wss://) - `action` (string, required) - "connect", "disconnect", "send", "history", "analyze" - `message` (any, optional) - Message to send (for send action) @@ -608,6 +648,7 @@ WebSocket connection management with message tracking and 83% token reduction. TypeScript builds with intelligent caching and diff-based change detection. **Parameters:** + - `projectRoot` (string, optional) - Project root directory - `tsconfig` (string, optional) - TypeScript config file path - `force` (boolean, optional) - Force full rebuild, ignore cache (default: false) @@ -624,6 +665,7 @@ TypeScript builds with intelligent caching and diff-based change detection. Test execution with intelligent caching and incremental test selection. **Parameters:** + - `projectRoot` (string, optional) - Project root directory - `pattern` (string, optional) - Pattern to match test files - `onlyChanged` (boolean, optional) - Run only tests that changed (default: false) @@ -641,6 +683,7 @@ Test execution with intelligent caching and incremental test selection. ESLint with intelligent caching, incremental analysis, and auto-fix suggestions. **Parameters:** + - `files` (string/array, required) - Files or pattern to lint - `projectRoot` (string, optional) - Project root directory - `fix` (boolean, optional) - Auto-fix issues (default: false) @@ -658,6 +701,7 @@ ESLint with intelligent caching, incremental analysis, and auto-fix suggestions. TypeScript type checking with intelligent caching and categorized error reporting. **Parameters:** + - `projectRoot` (string, optional) - Project root directory - `tsconfig` (string, optional) - TypeScript config file path - `watch` (boolean, optional) - Watch mode for continuous type checking (default: false) @@ -673,6 +717,7 @@ TypeScript type checking with intelligent caching and categorized error reportin Package installation with dependency analysis, conflict detection, and smart caching. **Parameters:** + - `packages` (array, optional) - Packages to install (if empty, installs all from package.json) - `projectRoot` (string, optional) - Project root directory - `dev` (boolean, optional) - Install as dev dependency (default: false) @@ -689,6 +734,7 @@ Package installation with dependency analysis, conflict detection, and smart cac Docker operations with build/run/stop/logs support, image layer analysis, and optimization suggestions. **Parameters:** + - `operation` (string, required) - "build", "run", "stop", "logs", "ps" - `projectRoot` (string, optional) - Project root directory - `imageName` (string, optional) - Image name for build/run @@ -711,6 +757,7 @@ Docker operations with build/run/stop/logs support, image layer analysis, and op System log aggregation and analysis with multi-source support, pattern filtering, error detection, and insights. **Parameters:** + - `sources` (array, optional) - Log sources to aggregate (file paths or system logs) - `projectRoot` (string, optional) - Project root directory - `level` (string, optional) - Filter by log level: "error", "warn", "info", "debug", "all" (default: "all") @@ -729,6 +776,7 @@ System log aggregation and analysis with multi-source support, pattern filtering Network diagnostics and monitoring with connectivity testing, port scanning, DNS resolution, and anomaly detection. **Parameters:** + - `operation` (string, required) - "ping", "port-scan", "dns", "traceroute", "all" - `hosts` (array, optional) - Hosts to test (for ping/port-scan operations) - `ports` (array, optional) - Ports to scan (for port-scan operation) @@ -747,6 +795,7 @@ Network diagnostics and monitoring with connectivity testing, port scanning, DNS Monitor and analyze system processes with anomaly detection and resource tracking. **Parameters:** + - `projectRoot` (string, optional) - Project root directory - `filter` (string, optional) - Filter processes by name pattern - `cpuThreshold` (number, optional) - Show only high CPU usage processes (> threshold %) @@ -764,6 +813,7 @@ Monitor and analyze system processes with anomaly detection and resource trackin System resource monitoring with CPU, memory, disk usage tracking, anomaly detection, and performance recommendations. **Parameters:** + - `projectRoot` (string, optional) - Project root directory - `includeDisk` (boolean, optional) - Include disk metrics (default: true) - `diskPaths` (array, optional) - Disk paths to monitor (default: root partition) @@ -782,6 +832,7 @@ System resource monitoring with CPU, memory, disk usage tracking, anomaly detect Advanced multi-tier cache (L1/L2/L3) with 6 eviction strategies, stampede prevention, and automatic tier management. **Parameters:** + - `operation` (string, required) - "get", "set", "delete", "clear", "stats", "configure", "promote", "demote", "batch-get", "batch-set", "export", "import" - `key` (string, optional) - Cache key (for get/set/delete/promote/demote operations) - `value` (string, optional) - Value to store (for set operation) @@ -809,8 +860,9 @@ Advanced multi-tier cache (L1/L2/L3) with 6 eviction strategies, stampede preven Intelligent cache pre-warming with 87%+ token reduction, schedule-based warming, pattern analysis, dependency resolution, and progressive warming strategies. **Parameters:** + - `operation` (string, required) - "schedule", "immediate", "pattern-based", "dependency-based", "selective", "status", "cancel", "pause", "resume", "configure" -- `schedule` (string, optional) - Cron expression for scheduled warmup (e.g., '0 * * * *') +- `schedule` (string, optional) - Cron expression for scheduled warmup (e.g., '0 \* \* \* \*') - `keys` (array, optional) - Keys to warm (for immediate/selective operations) - `pattern` (string, optional) - Regex pattern for key matching - `accessHistory` (array, optional) - Access history for pattern-based warmup @@ -845,6 +897,7 @@ Intelligent cache pre-warming with 87%+ token reduction, schedule-based warming, Comprehensive cache analytics with 88%+ token reduction. Real-time dashboards, trend analysis, alerting, heatmaps, bottleneck detection, and cost optimization. **Parameters:** + - `operation` (string, required) - "dashboard", "metrics", "trends", "alerts", "heatmap", "bottlenecks", "cost-analysis", "export-data" - `metricTypes` (array, optional) - Types of metrics: "performance", "usage", "efficiency", "cost", "health" - `timeRange` (object, optional) - Time range for analysis @@ -872,6 +925,7 @@ Comprehensive cache analytics with 88%+ token reduction. Real-time dashboards, t Cache performance benchmarking with 89% token reduction through comprehensive testing and analysis. **Parameters:** + - `operation` (string, required) - "run-benchmark", "compare", "load-test", "latency-test", "throughput-test", "report" - `config` (object, optional) - Cache configuration for single benchmark - `name` (string) - Configuration name @@ -908,6 +962,7 @@ Cache performance benchmarking with 89% token reduction through comprehensive te Advanced compression strategies with 89%+ token reduction. Supports 6 algorithms (gzip, brotli, lz4, zstd, snappy, custom), adaptive selection, dictionary-based compression, and delta compression. **Parameters:** + - `operation` (string, required) - "compress", "decompress", "analyze", "optimize", "benchmark", "configure" - `data` (any, required for compress/decompress/analyze) - Data to process - `algorithm` (string, optional) - "gzip", "brotli", "lz4", "zstd", "snappy", "custom" @@ -933,10 +988,11 @@ Advanced compression strategies with 89%+ token reduction. Supports 6 algorithms Comprehensive cache invalidation with 88%+ token reduction, dependency tracking, pattern matching, scheduled invalidation, and distributed coordination. **Parameters:** + - `operation` (string, required) - "invalidate", "invalidate-pattern", "invalidate-tag", "invalidate-dependency", "schedule-invalidation", "cancel-scheduled", "audit-log", "set-dependency", "remove-dependency", "validate", "configure", "stats", "clear-audit" - `key` (string, optional) - Cache key to invalidate - `keys` (array, optional) - Array of cache keys to invalidate -- `pattern` (string, optional) - Pattern for matching keys (wildcards: * for any chars, ? for single char) +- `pattern` (string, optional) - Pattern for matching keys (wildcards: \* for any chars, ? for single char) - `tag` (string, optional) - Tag to invalidate all associated keys - `tags` (array, optional) - Array of tags to invalidate - `parentKey` (string, optional) - Parent key for dependency relationship @@ -967,6 +1023,7 @@ Comprehensive cache invalidation with 88%+ token reduction, dependency tracking, Advanced cache optimization with 89%+ token reduction. Analyzes performance, benchmarks strategies, provides ML-based recommendations, detects bottlenecks, and performs cost-benefit analysis. **Parameters:** + - `operation` (string, required) - "analyze", "benchmark", "optimize", "recommend", "simulate", "tune", "detect-bottlenecks", "cost-benefit", "configure", "report" - `workloadPattern` (string, optional) - "uniform", "skewed", "temporal", "burst", "predictable", "unknown" - `objective` (string, optional) - "hit-rate", "latency", "memory", "throughput", "balanced" (default: "balanced") @@ -986,6 +1043,7 @@ Advanced cache optimization with 89%+ token reduction. Analyzes performance, ben Advanced cache partitioning and sharding with 87%+ token reduction through consistent hashing, automatic rebalancing, and partition isolation. **Parameters:** + - `operation` (string, required) - "create-partition", "delete-partition", "list-partitions", "migrate", "rebalance", "configure-sharding", "stats" - `partitionId` (string, optional) - Partition identifier (required for create/delete) - `strategy` (string, optional) - "hash", "range", "category", "geographic", "custom" (default: "hash") @@ -1011,6 +1069,7 @@ Advanced cache partitioning and sharding with 87%+ token reduction through consi Distributed cache replication with 88%+ token reduction. Supports primary-replica and multi-primary modes, strong/eventual consistency, automatic conflict resolution, failover, incremental sync, and health monitoring. **Parameters:** + - `operation` (string, required) - "configure", "add-replica", "remove-replica", "promote-replica", "sync", "status", "health-check", "resolve-conflicts", "snapshot", "restore", "rebalance" - `mode` (string, optional) - "primary-replica", "multi-primary", "master-slave", "peer-to-peer" (for configure) - `consistency` (string, optional) - "eventual", "strong", "causal" (for configure) @@ -1040,6 +1099,7 @@ Distributed cache replication with 88%+ token reduction. Supports primary-replic ML-based predictive caching with 91%+ token reduction using ARIMA, exponential smoothing, LSTM, and collaborative filtering. **Parameters:** + - `operation` (string, required) - "train", "predict", "auto-warm", "evaluate", "retrain", "export-model", "import-model", "record-access", "get-patterns" - `modelType` (string, optional) - "arima", "exponential", "lstm", "hybrid" (default: "hybrid") - `trainData` (array, optional) - Training data (for train operation) @@ -1070,6 +1130,7 @@ ML-based predictive caching with 91%+ token reduction using ARIMA, exponential s Comprehensive alerting system with multi-channel notifications (email, Slack, webhook), intelligent routing, and 89% token reduction. **Parameters:** + - `operation` (string, required) - "create-alert", "update-alert", "delete-alert", "list-alerts", "trigger", "get-history", "configure-channels", "silence" - `alertName` (string, optional) - Alert name (required for create, optional for others) - `alertId` (string, optional) - Alert identifier (required for update, delete, trigger, silence) @@ -1117,6 +1178,7 @@ Comprehensive alerting system with multi-channel notifications (email, Slack, we Comprehensive metrics collection and aggregation with multi-source support, time-series compression, and 88% token reduction. **Parameters:** + - `operation` (string, required) - "collect", "query", "aggregate", "export", "list-sources", "configure-source", "get-stats", "purge" - `sourceId` (string, optional) - Source identifier - `sourceName` (string, optional) - Source name @@ -1151,6 +1213,7 @@ Comprehensive metrics collection and aggregation with multi-source support, time External monitoring platform integration with 87% token reduction through data compression and intelligent caching. **Parameters:** + - `operation` (string, required) - "connect", "disconnect", "list-connections", "sync-metrics", "sync-alerts", "push-data", "get-status", "configure-mapping" - `connectionId` (string, optional) - Connection identifier - `connectionName` (string, optional) - Connection name @@ -1169,6 +1232,7 @@ External monitoring platform integration with 87% token reduction through data c Create and manage custom dashboard widgets with 88% token reduction through template caching and configuration compression. **Parameters:** + - `operation` (string, required) - "create", "update", "delete", "list", "render", "create-template", "validate", "get-schema" - `widgetId` (string, optional) - Widget ID (required for update, delete, render) - `widgetName` (string, optional) - Widget name (required for create) @@ -1192,6 +1256,7 @@ Create and manage custom dashboard widgets with 88% token reduction through temp Create and manage interactive data visualizations with 92% token reduction through SVG/Canvas optimization and configuration caching. **Parameters:** + - `operation` (string, required) - "create-chart", "update-chart", "delete-chart", "list-charts", "render", "export", "create-heatmap", "create-timeline", "create-network", "create-sankey", "create-animation" - `chartId` (string, optional) - Chart ID (required for update, delete, render) - `chartType` (string, optional) - "line", "bar", "pie", "scatter", "area", "radar", "bubble" @@ -1218,6 +1283,7 @@ Create and manage interactive data visualizations with 92% token reduction throu Monitor system and application health with 91% token reduction through health state compression and metric aggregation. **Parameters:** + - `operation` (string, required) - "register-endpoint", "unregister-endpoint", "check-health", "list-endpoints", "get-history", "set-threshold", "get-summary", "run-diagnostic" - `endpointId` (string, optional) - Endpoint ID - `endpointName` (string, optional) - Endpoint name @@ -1239,6 +1305,7 @@ Monitor system and application health with 91% token reduction through health st Interactive log analysis dashboard with filtering, searching, pattern detection, and 90% token reduction. **Parameters:** + - `operation` (string, required) - "create", "update", "query", "aggregate", "detect-anomalies", "create-filter", "export", "tail" - `dashboardId` (string, optional) - Dashboard identifier - `dashboardName` (string, optional) - Dashboard name (required for create) @@ -1277,9 +1344,10 @@ Interactive log analysis dashboard with filtering, searching, pattern detection, Intelligent scheduled task management with smart caching (85%+ token reduction). Manage cron jobs (Linux/macOS) and Windows Task Scheduler with validation, history tracking, and next run predictions. **Parameters:** + - `operation` (string, required) - "list", "add", "remove", "enable", "disable", "history", "predict-next", "validate" - `taskName` (string, optional) - Name of the scheduled task -- `schedule` (string, optional) - Cron expression (e.g., "0 2 * * *") or Windows schedule +- `schedule` (string, optional) - Cron expression (e.g., "0 2 \* \* \*") or Windows schedule - `command` (string, optional) - Command to execute - `description` (string, optional) - Task description - `enabled` (boolean, optional) - Whether the task is enabled @@ -1294,25 +1362,26 @@ Intelligent scheduled task management with smart caching (85%+ token reduction). **Token Reduction:** 85% through smart caching **Example:** + ```typescript // List all scheduled tasks -smart_cron({ operation: "list" }) +smart_cron({ operation: 'list' }); // Add a new cron job smart_cron({ - operation: "add", - taskName: "daily-backup", - schedule: "0 2 * * *", // Daily at 2am - command: "/path/to/backup.sh", - description: "Daily backup job" -}) + operation: 'add', + taskName: 'daily-backup', + schedule: '0 2 * * *', // Daily at 2am + command: '/path/to/backup.sh', + description: 'Daily backup job', +}); // Predict next 5 runs smart_cron({ - operation: "predict-next", - taskName: "daily-backup", - predictCount: 5 -}) + operation: 'predict-next', + taskName: 'daily-backup', + predictCount: 5, +}); ``` --- @@ -1322,6 +1391,7 @@ smart_cron({ Intelligent user and permission management with smart caching (86%+ token reduction). Manage users, groups, permissions, ACLs, and perform security audits across Windows, Linux, and macOS. **Parameters:** + - `operation` (string, required) - "list-users", "list-groups", "check-permissions", "audit-security", "get-acl", "get-user-info", "get-group-info", "check-sudo" - `username` (string, optional) - Username for user-specific operations - `groupname` (string, optional) - Group name for group-specific operations @@ -1334,24 +1404,25 @@ Intelligent user and permission management with smart caching (86%+ token reduct **Token Reduction:** 86% through smart caching **Example:** + ```typescript // List all users (excluding system users) -smart_user({ operation: "list-users" }) +smart_user({ operation: 'list-users' }); // Get detailed user information smart_user({ - operation: "get-user-info", - username: "alice" -}) + operation: 'get-user-info', + username: 'alice', +}); // Check file permissions smart_user({ - operation: "check-permissions", - path: "/etc/config.json" -}) + operation: 'check-permissions', + path: '/etc/config.json', +}); // Perform security audit -smart_user({ operation: "audit-security" }) +smart_user({ operation: 'audit-security' }); ``` --- @@ -1361,10 +1432,11 @@ smart_user({ operation: "audit-security" }) Structural code search with 83% token reduction through AST indexing and caching. **Parameters:** + - `pattern` (string, required) - AST pattern to search for (e.g., "function $NAME($ARGS) { $BODY }") - `projectPath` (string, required) - Root directory of the project - `language` (string, optional) - Programming language: "ts", "tsx", "js", "jsx", "py", "go", "rs", "java", "c", "cpp" (auto-detected if not provided) -- `filePattern` (string, optional) - Specific directory or file pattern (e.g., "src/**/*.ts") +- `filePattern` (string, optional) - Specific directory or file pattern (e.g., "src/\*_/_.ts") - `excludePatterns` (array, optional) - Patterns to exclude from search - `contextLines` (number, optional) - Number of context lines around matches (default: 3) - `maxMatches` (number, optional) - Maximum number of matches to return (default: 100) @@ -1373,21 +1445,22 @@ Structural code search with 83% token reduction through AST indexing and caching **Token Reduction:** 83% through AST indexing **Example:** + ```typescript // Find all function definitions smart_ast_grep({ - pattern: "function $NAME($ARGS) { $BODY }", - projectPath: "/path/to/project", - language: "ts", - filePattern: "src/**/*.ts" -}) + pattern: 'function $NAME($ARGS) { $BODY }', + projectPath: '/path/to/project', + language: 'ts', + filePattern: 'src/**/*.ts', +}); // Find all class definitions with specific methods smart_ast_grep({ - pattern: "class $CLASS { $METHODS }", - projectPath: "/path/to/project", - contextLines: 5 -}) + pattern: 'class $CLASS { $METHODS }', + projectPath: '/path/to/project', + contextLines: 5, +}); ``` --- @@ -1397,9 +1470,11 @@ smart_ast_grep({ Get comprehensive session-level token usage statistics. **Parameters:** + - `sessionId` (string, optional) - Optional session ID to query (uses current session if not provided) **Returns:** + - Session-level breakdown of token usage by tool - Operation counts and types - Token savings and reduction percentages @@ -1407,47 +1482,51 @@ Get comprehensive session-level token usage statistics. - Cost estimation **Example:** + ```typescript // Get stats for current session -get_session_stats({}) +get_session_stats({}); // Get stats for specific session -get_session_stats({ sessionId: "session-123" }) +get_session_stats({ sessionId: 'session-123' }); ``` --- ### analyze_project_tokens -Analyze token usage and estimate costs across multiple sessions within a project. Aggregates data from all operations files, provides project-level statistics, identifies top contributing sessions and tools. +Analyze observed token usage across multiple sessions within a project. Aggregates data from all operations files, provides project-level statistics, and identifies top contributing sessions and tools. Cost is not priced unless the caller supplies an effective rate. **Parameters:** + - `projectPath` (string, optional) - Path to project directory (uses hooks data directory if not provided) - `startDate` (string, optional) - Optional start date filter (YYYY-MM-DD format) - `endDate` (string, optional) - Optional end date filter (YYYY-MM-DD format) -- `costPerMillionTokens` (number, optional) - Cost per million tokens in USD (default: 30) +- `costPerMillionTokens` (number, optional) - Explicit effective USD cost per million input tokens. No provider rate is assumed when omitted. **Returns:** + - Project-wide token usage statistics -- Cost estimation +- Cost equivalent when an explicit rate is supplied; otherwise `Not priced` - Top contributing sessions - Top contributing tools - Optimization opportunities **Example:** + ```typescript // Analyze entire project analyze_project_tokens({ - projectPath: "/path/to/project" -}) + projectPath: '/path/to/project', +}); // Analyze specific date range analyze_project_tokens({ - projectPath: "/path/to/project", - startDate: "2024-01-01", - endDate: "2024-01-31", - costPerMillionTokens: 25 -}) + projectPath: '/path/to/project', + startDate: '2024-01-01', + endDate: '2024-01-31', + costPerMillionTokens: 25, +}); ``` --- @@ -1457,30 +1536,33 @@ analyze_project_tokens({ Compress large file operations from the current session to reduce future token usage. Analyzes operations in the current session, identifies large text blocks from file-based tools, compresses them, and stores them in cache. **Parameters:** + - `sessionId` (string, optional) - Optional session ID to optimize (uses current session if not provided) - `min_token_threshold` (number, optional) - Minimum token count for a file operation to be considered for compression (default: 30) **Returns:** + - Number of operations optimized - Total token savings - Compression statistics - List of compressed operations **Example:** + ```typescript // Optimize current session -optimize_session({}) +optimize_session({}); // Optimize with custom threshold optimize_session({ - min_token_threshold: 50 -}) + min_token_threshold: 50, +}); // Optimize specific session optimize_session({ - sessionId: "session-123", - min_token_threshold: 100 -}) + sessionId: 'session-123', + min_token_threshold: 100, +}); ``` --- @@ -1490,36 +1572,43 @@ optimize_session({ ### When to Use Each Tool Category **Core Caching & Optimization** + - Use `optimize_text` for large content that will be referenced multiple times - Use `analyze_optimization` before caching to determine if compression is worthwhile - Use `get_cache_stats` to monitor cache performance **Smart File Operations** + - Use `smart_read` instead of Read for all file operations (automatic 80% reduction) - Use `smart_edit` for line-based changes (90% reduction through diff-only output) - Use `smart_grep` when searching codebase (80% reduction through match-only output) **API & Database** + - Use `smart_api_fetch` for all HTTP requests (95% reduction on cache hits) - Use `smart_database` for database queries (83% reduction on cached results) - Use `smart_cache_api` for API response caching with advanced strategies **Build & Test** + - Use `smart_test` to run only changed tests (incremental test selection) - Use `smart_lint` for incremental linting with auto-fix suggestions - Use `smart_build` for TypeScript builds with diff-based change detection **Advanced Caching** + - Use `smart_cache` for multi-tier caching with custom eviction strategies - Use `predictive_cache` for ML-based cache warming - Use `cache_analytics` to monitor cache performance and identify optimization opportunities **Monitoring & Dashboards** + - Use `alert_manager` to set up alerting for critical metrics - Use `metric_collector` to aggregate metrics from multiple sources - Use `data_visualizer` to create interactive charts and visualizations **System Operations** + - Use `smart_cron` to manage scheduled tasks - Use `smart_user` for user and permission management - Use `get_session_stats` and `analyze_project_tokens` to monitor token usage @@ -1527,7 +1616,7 @@ optimize_session({ ### Performance Tips 1. **Enable Caching**: Most tools default to caching enabled - keep it enabled for best performance -2. **Use Smart Tools**: Always use smart_* variants instead of standard tools (60-90% reduction) +2. **Use Smart Tools**: Always use smart\_\* variants instead of standard tools (60-90% reduction) 3. **Monitor Performance**: Use `get_session_stats` regularly to identify optimization opportunities 4. **Cache Large Content**: Use `optimize_text` for content >500 characters 5. **Use Batch Operations**: Many tools support batch operations for better performance @@ -1535,31 +1624,34 @@ optimize_session({ ### Common Patterns **File Reading Pattern**: + ```typescript // First read: full content -smart_read({ path: "/src/app.ts" }) +smart_read({ path: '/src/app.ts' }); // Subsequent reads: only changes (80% reduction) -smart_read({ path: "/src/app.ts" }) +smart_read({ path: '/src/app.ts' }); ``` **API Caching Pattern**: + ```typescript // First request: fetch and cache smart_api_fetch({ - method: "GET", - url: "https://api.example.com/data", - ttl: 300 -}) + method: 'GET', + url: 'https://api.example.com/data', + ttl: 300, +}); // Subsequent requests: cached (95% reduction) smart_api_fetch({ - method: "GET", - url: "https://api.example.com/data" -}) + method: 'GET', + url: 'https://api.example.com/data', +}); ``` **Session Monitoring Pattern**: + ```typescript // At the end of each session const stats = await get_session_stats({}); @@ -1577,20 +1669,25 @@ console.log(`Total project cost: $${projectStats.estimatedCost}`); ### Common Issues **Issue: Low cache hit rate** + - Solution: Use `cache_analytics` to identify cache misses and adjust TTL values **Issue: Compression not providing expected savings** + - Solution: Use `analyze_optimization` to check if content is suitable for compression **Issue: Tools returning stale data** + - Solution: Use `force: true` parameter to bypass cache and fetch fresh data **Issue: Session stats not tracking operations** + - Solution: Ensure global hooks are installed and enabled ### Getting Help For detailed troubleshooting and support: + - Check the main [README](../README.md) for installation and configuration - Review [Installation Guide](./HOOKS-INSTALLATION.md) for platform-specific issues - File an issue on GitHub with detailed error messages and reproduction steps diff --git a/docs/media/live/agent-accounting-live.png b/docs/media/live/agent-accounting-live.png new file mode 100644 index 000000000..e4341ec42 Binary files /dev/null and b/docs/media/live/agent-accounting-live.png differ diff --git a/docs/media/live/capture-health-live.png b/docs/media/live/capture-health-live.png new file mode 100644 index 000000000..1e31d84f4 Binary files /dev/null and b/docs/media/live/capture-health-live.png differ diff --git a/docs/media/live/evidence-console-live.png b/docs/media/live/evidence-console-live.png new file mode 100644 index 000000000..3da7cfc73 Binary files /dev/null and b/docs/media/live/evidence-console-live.png differ diff --git a/docs/media/live/graph-balance-live.png b/docs/media/live/graph-balance-live.png new file mode 100644 index 000000000..8742c06ed Binary files /dev/null and b/docs/media/live/graph-balance-live.png differ diff --git a/docs/media/live/graph-explorer-live.png b/docs/media/live/graph-explorer-live.png new file mode 100644 index 000000000..55802306d Binary files /dev/null and b/docs/media/live/graph-explorer-live.png differ diff --git a/docs/media/live/overview-live.png b/docs/media/live/overview-live.png new file mode 100644 index 000000000..5ff9684ea Binary files /dev/null and b/docs/media/live/overview-live.png differ diff --git a/evals/ucr/results/mcp-context-audit-v1.json b/evals/ucr/results/mcp-context-audit-v1.json index 876709f4b..7a3cb241c 100644 --- a/evals/ucr/results/mcp-context-audit-v1.json +++ b/evals/ucr/results/mcp-context-audit-v1.json @@ -1,8 +1,8 @@ { "schemaVersion": "ucr.mcp-context-audit/1", "evidenceClass": "native-tokenized-real-tools-list-conformance-not-model-effectiveness", - "executedAt": "2026-08-10T04:42:33.220Z", - "serverSourceHash": "1753baaa743ad320f491ac0d2d7a19354e23f664eb9e84a4a96b7a6895d89550", + "executedAt": "2026-08-12T13:32:02.466Z", + "serverSourceHash": "4795aae9db9dcbc7813618193220d01a641961a84940554b3c908c52a494650f", "surfaces": [ { "name": "baseline", @@ -13,7 +13,7 @@ "schemaBytes": 2, "schemaTokens": 1, "tokenAccounting": "tiktoken:cl100k_base", - "reductionVsFull": 0.9999668049792532, + "reductionVsFull": 0.9999669825337604, "shareOf128kContext": 0.0000078125, "shareOf1mContext": 0.000001 }, @@ -28,7 +28,7 @@ "schemaBytes": 382, "schemaTokens": 71, "tokenAccounting": "tiktoken:cl100k_base", - "reductionVsFull": 0.997643153526971, + "reductionVsFull": 0.9976557598969855, "shareOf128kContext": 0.0005546875, "shareOf1mContext": 0.000071 }, @@ -44,7 +44,7 @@ "schemaBytes": 2393, "schemaTokens": 480, "tokenAccounting": "tiktoken:cl100k_base", - "reductionVsFull": 0.9840663900414938, + "reductionVsFull": 0.9841516162049724, "shareOf128kContext": 0.00375, "shareOf1mContext": 0.00048 }, @@ -63,7 +63,7 @@ "schemaBytes": 3519, "schemaTokens": 694, "tokenAccounting": "tiktoken:cl100k_base", - "reductionVsFull": 0.9769626556016597, + "reductionVsFull": 0.9770858784296893, "shareOf128kContext": 0.005421875, "shareOf1mContext": 0.000694 }, @@ -92,12 +92,12 @@ "smart_grep", "get_optimization_report" ], - "schemaBytes": 23367, - "schemaTokens": 4815, + "schemaBytes": 24067, + "schemaTokens": 4977, "tokenAccounting": "tiktoken:cl100k_base", - "reductionVsFull": 0.8401659751037345, - "shareOf128kContext": 0.0376171875, - "shareOf1mContext": 0.004815 + "reductionVsFull": 0.8356720705253079, + "shareOf128kContext": 0.0388828125, + "shareOf1mContext": 0.004977 }, { "name": "full", @@ -209,12 +209,12 @@ "optimization_storage", "context_delta" ], - "schemaBytes": 146134, - "schemaTokens": 30125, + "schemaBytes": 146834, + "schemaTokens": 30287, "tokenAccounting": "tiktoken:cl100k_base", "reductionVsFull": 0, - "shareOf128kContext": 0.2353515625, - "shareOf1mContext": 0.030125 + "shareOf128kContext": 0.2366171875, + "shareOf1mContext": 0.030287 } ], "findings": { @@ -227,10 +227,10 @@ "fullCatalogTools": 103, "graphCaptureSchemaTokens": 480, "extendedCognitiveSchemaTokens": 694, - "defaultCoreSchemaTokens": 4815, - "fullCatalogSchemaTokens": 30125, - "graphCaptureReductionVsFull": 0.9840663900414938, - "graphCaptureReductionVsCore": 0.9003115264797508, + "defaultCoreSchemaTokens": 4977, + "fullCatalogSchemaTokens": 30287, + "graphCaptureReductionVsFull": 0.9841516162049724, + "graphCaptureReductionVsCore": 0.9035563592525617, "appliesToRegisteredClients": 16, "userMcpServersAreAdditive": "Every enabled MCP server adds its own tool schemas and instructions; client isolation must disable unrelated servers during evals." }, @@ -240,5 +240,5 @@ "client hosts may add wrappers, instructions, resources, or other MCP servers beyond this server response", "model effectiveness and empty-session p95 overhead require paired live provider runs" ], - "reportHash": "3841fd34a174c1b9e1822dbc21b570d1050ff2c2ff5b976252b95287d0b967ca" + "reportHash": "520517a3d28c3a0fef9eeb971e269a800e47f775134018cd1752be27a5d39e6a" } diff --git a/hooks-core/adapter.mjs b/hooks-core/adapter.mjs index 2d98bed73..fbad78edf 100644 --- a/hooks-core/adapter.mjs +++ b/hooks-core/adapter.mjs @@ -186,8 +186,143 @@ function contextOutput(client, eventName, additionalContext) { }; } +/** Convert one static JavaScript string literal without evaluating code. */ +function codexStringLiteral(literal) { + if (typeof literal !== 'string' || literal.length < 2) return null; + const quote = literal[0]; + if (!['"', "'", '`'].includes(quote) || literal.at(-1) !== quote) return null; + + let decoded = ''; + for (let index = 1; index < literal.length - 1; index += 1) { + const character = literal[index]; + if (quote === '`' && character === '$' && literal[index + 1] === '{') + return null; + if (character !== '\\') { + decoded += character; + continue; + } + + index += 1; + if (index >= literal.length - 1) return null; + const escaped = literal[index]; + const simple = { + b: '\b', f: '\f', n: '\n', r: '\r', t: '\t', v: '\v', 0: '\0', + '\\': '\\', "'": "'", '"': '"', '`': '`', '$': '$', + }; + if (Object.hasOwn(simple, escaped)) { + decoded += simple[escaped]; + continue; + } + if (escaped === '\n') continue; + if (escaped === '\r') { + if (literal[index + 1] === '\n') index += 1; + continue; + } + if (escaped === 'x') { + const digits = literal.slice(index + 1, index + 3); + if (!/^[0-9a-f]{2}$/i.test(digits)) return null; + decoded += String.fromCodePoint(Number.parseInt(digits, 16)); + index += 2; + continue; + } + if (escaped === 'u') { + const braced = literal[index + 1] === '{'; + const close = braced ? literal.indexOf('}', index + 2) : index + 5; + const digits = braced + ? literal.slice(index + 2, close) + : literal.slice(index + 1, close); + if ( + close < 0 || + !(braced ? /^[0-9a-f]{1,6}$/i : /^[0-9a-f]{4}$/i).test(digits) + ) return null; + const codePoint = Number.parseInt(digits, 16); + if (codePoint > 0x10ffff) return null; + decoded += String.fromCodePoint(codePoint); + index = close; + continue; + } + // JavaScript identity escapes drop the slash (for example, '\q' is 'q'). + decoded += escaped; + } + return decoded; +} + /** Convert client-specific lifecycle envelopes into the common tool shape. */ +function codexStringBindings(source) { + const values = new Map(); + const declaration = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*((?:"(?:\\.|[^"\\])*")|(?:'(?:\\.|[^'\\])*')|(?:`(?:\\.|[^`\\])*`))/gs; + for (const match of String(source || '').matchAll(declaration)) { + try { + const value = codexStringLiteral(match[2]); + if (value !== null) values.set(match[1], value); + } catch { + // A computed or malformed JavaScript expression is not safe to execute + // merely to understand a hook envelope. Leave it opaque. + } + } + return values; +} + +function codexCallArguments(source, method) { + const calls = []; + const pattern = new RegExp(`(?:tools\\.)?${method}\\s*\\(\\s*([^),]+)`, 'gi'); + for (const match of String(source || '').matchAll(pattern)) + calls.push(match[1].trim()); + return calls; +} + +function codexLiteral(value, bindings) { + if (bindings.has(value)) return String(bindings.get(value)); + return codexStringLiteral(value); +} + +function codexExecCommands(source, bindings) { + const commands = []; + for (const call of String(source || '').matchAll( + /(?:tools\.)?exec_command\s*\(\s*\{([\s\S]*?)\}\s*\)/gi + )) { + const property = /\bcmd\s*:\s*((?:"(?:\\.|[^"\\])*")|(?:'(?:\\.|[^'\\])*')|(?:`(?:\\.|[^`\\])*`)|(?:[A-Za-z_$][\w$]*))/s.exec(call[1]); + if (!property) continue; + const command = codexLiteral(property[1], bindings); + if (command) commands.push(command); + } + return commands; +} + export function normalizeClientPayload(clientName, event, raw) { + if (clientName === 'codex') { + const outerName = String(raw.tool_name ?? raw.toolName ?? raw.tool ?? ''); + if (/^(?:functions\.)?exec$/i.test(outerName)) { + const envelope = raw.tool_input ?? raw.toolInput ?? raw.arguments ?? raw.args; + const source = + typeof envelope === 'string' + ? envelope + : envelope?.code ?? envelope?.input ?? envelope?.source ?? ''; + const bindings = codexStringBindings(source); + const patchCalls = codexCallArguments(source, 'apply_patch'); + const patch = patchCalls + .map((argument) => codexLiteral(argument, bindings)) + .find(Boolean); + const mutation = patchCalls.length > 0; + const commands = codexExecCommands(source, bindings); + + // `functions.exec` is also the orchestration envelope for web, image and + // other non-filesystem tools. Do not reinterpret those as shell commands: + // doing so produced irrelevant cross-project advice and fabricated graph + // activity for operations the filesystem hooks never observed. + if (!mutation && !commands.length) return raw; + return { + ...raw, + tool_name: mutation ? 'apply_patch' : 'run_command', + tool_input: { + ...(envelope && typeof envelope === 'object' ? envelope : {}), + command: String(patch || commands.join('\n') || source || ''), + code_mode_envelope: true, + }, + }; + } + } + if (clientName === 'cline') { const body = event === 'post-tool' ? raw.postToolUse : raw.preToolUse; if (!body) return raw; @@ -383,6 +518,22 @@ function projectBriefing() { function observeAndInject(payload, state, episode, features) { const touched = touchedFiles(payload); const dirFor = (path) => wikiDir(projectRootFor(path, payload.cwd)); + const registerRoot = (root) => { + const dir = wikiDir(root); + registerProject({ + root, + graphDir: dir, + client: episode.client || 'unknown', + }); + return dir; + }; + + // SessionStart is not delivered consistently by every host/version. The + // graph was still written in that case, but the dashboard registry never + // learned it existed. Register every repository observed by a real tool call + // as a second, idempotent production path. + for (const { path } of touched) + registerRoot(projectRootFor(path, payload.cwd)); const bytes = readCostBytes(payload); if (bytes && payload.tool_input.file_path) { @@ -423,7 +574,7 @@ function observeAndInject(payload, state, episode, features) { const command = payload.tool_input?.command; if (command) { const root = commandProjectRoot(payload, payload.cwd); - const dir = wikiDir(root); + const dir = registerRoot(root); const local = forCommand(dir, load(dir), command, { sessionId: payload.session_id, alreadyInjected, @@ -748,7 +899,14 @@ async function runHook(clientName, event, invocation) { client.canDeny && event === 'pre-tool' && !repeat && - mode() !== MODE_ADVISE + mode() !== MODE_ADVISE && + // Codex code mode presents an orchestration program as one outer + // `functions.exec` call. Refusing that envelope is rendered by the host + // as a failed script and can discard several otherwise-safe nested + // operations. Observe it, capture it and advise, but reserve a hard veto + // for legacy one-operation Codex tool calls where the denied operation is + // unambiguous. + !(clientName === 'codex' && payload.tool_input?.code_mode_envelope) ); if (canRefuse) { @@ -757,19 +915,23 @@ async function runHook(clientName, event, invocation) { // Every client's refusal carries the off switch, for the same reason Claude // Code's does: enforcement that hides its own disable is coercive. if (client.denyStyle === 'top-level') { + invocation.block('policy_denied'); emit({ decision: 'deny', reason: withEscape(verdict.reason) }); } else if (client.denyStyle === 'top-level-permission') { + invocation.block('policy_denied'); emit({ permissionDecision: 'deny', permissionDecisionReason: withEscape(verdict.reason), }); } else if (client.denyStyle === 'cline') { + invocation.block('policy_denied'); emit({ cancel: true, contextModification: '', errorMessage: withEscape(verdict.reason), }); } else if (client.denyStyle === 'cursor') { + invocation.block('policy_denied'); emit({ continue: true, permission: 'deny', @@ -778,9 +940,11 @@ async function runHook(clientName, event, invocation) { 'Token Optimizer redirected an expensive built-in operation.', }); } else if (client.denyStyle === 'exit-2') { + invocation.block('policy_denied'); process.stderr.write(withEscape(verdict.reason)); process.exit(2); } else { + invocation.block('policy_denied'); emit({ hookSpecificOutput: { hookEventName: eventName, diff --git a/hooks-core/audit.mjs b/hooks-core/audit.mjs index 62f95126a..87c54a6cc 100644 --- a/hooks-core/audit.mjs +++ b/hooks-core/audit.mjs @@ -48,15 +48,17 @@ export function decline(dir, id) { export function declines(dir, { events = readMetrics(dir) } = {}) { const counts = new Map(); for (const event of events) { - if (event.kind !== 'advice' || event.action !== 'declined' || !event.id) continue; + if (event.kind !== 'advice' || event.action !== 'declined' || !event.id) + continue; counts.set(event.id, (counts.get(event.id) || 0) + 1); } return counts; } -const idOf = (finding) => finding.remedy - ? `${finding.remedy.type}:${finding.remedy.anchor || finding.remedy.file || (finding.remedy.anchors || []).join(',')}` - : `${finding.id}:${finding.anchor || finding.file || ''}`; +const idOf = (finding) => + finding.remedy + ? `${finding.remedy.type}:${finding.remedy.anchor || finding.remedy.file || (finding.remedy.anchors || []).join(',')}` + : `${finding.id}:${finding.anchor || finding.file || ''}`; /** * Whether a habit improved after we said something about it. @@ -66,12 +68,18 @@ const idOf = (finding) => finding.remedy * -- a trend computed from one session on each side is a coin flip with a * percentage sign. */ -export function habitTrend(dir, detector, { events = readMetrics(dir), minSessions = 2, anchors = null } = {}) { +export function habitTrend( + dir, + detector, + { events = readMetrics(dir), minSessions = 2, anchors = null } = {} +) { // SPLIT BY POSITION, NOT BY TIMESTAMP. The log is append-only, so its order is // authoritative -- while `at` has millisecond granularity, and a burst of // events inside one millisecond would land on the wrong side of the split or // collapse one side to nothing. Position is exact where the clock is not. - const raisedAt = events.findIndex((e) => e.kind === 'advice' && e.detector === detector); + const raisedAt = events.findIndex( + (e) => e.kind === 'advice' && e.detector === detector + ); if (raisedAt === -1) return null; // SCOPED TO THE FILES THIS DETECTOR NAMED. Counting every read in the window @@ -112,7 +120,12 @@ export function habitTrend(dir, detector, { events = readMetrics(dir), minSessio /** Marks a recommendation as having been raised, so its effect can be scored. */ export function raise(dir, finding) { - record(dir, { kind: 'advice', action: 'raised', id: idOf(finding), detector: finding.id }); + record(dir, { + kind: 'advice', + action: 'raised', + id: idOf(finding), + detector: finding.id, + }); } /** @@ -121,7 +134,11 @@ export function raise(dir, finding) { * `findings` comes from the detectors; `extra` lets the cache and routing * modules contribute without this file importing the world. */ -export function buildQueue(dir, findings = [], { events = readMetrics(dir) } = {}) { +export function buildQueue( + dir, + findings = [], + { events = readMetrics(dir) } = {} +) { const declined = declines(dir, { events }); const ledger = remedyLedger(dir); const applied = new Set(ledger.filter((r) => !r.revertedAt).map((r) => r.id)); @@ -144,7 +161,9 @@ export function buildQueue(dir, findings = [], { events = readMetrics(dir) } = { } return { - queue: queue.sort((a, b) => (b.costPerSession ?? 0) - (a.costPerSession ?? 0)), + queue: queue.sort( + (a, b) => (b.costPerSession ?? 0) - (a.costPerSession ?? 0) + ), suppressed, done: ledger.filter((r) => !r.revertedAt), }; @@ -159,7 +178,11 @@ export function buildQueue(dir, findings = [], { events = readMetrics(dir) } = { * many were withheld -- and what they are collectively worth -- keeps that from * being a silent cap. */ -export function renderAudit(dir, findings = [], { tier = 'opus', full = false, sessionsPerMonth = 60 } = {}) { +export function renderAudit( + dir, + findings = [], + { tier = 'opus', full = false, sessionsPerMonth = 60 } = {} +) { const { queue, suppressed, done } = buildQueue(dir, findings); const lines = []; @@ -173,22 +196,29 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s // read as "this costs nothing" beside a finding whose cost we simply do not // know -- the same unknown-becomes-zero error the rest of this project // corrects, in the one unit that gets quoted to other people. - const priced = item.costPerSession == null ? null : monthly(cost, { tier, sessionsPerMonth }); + const priced = + item.costPerSession == null + ? null + : monthly(cost, { tier, sessionsPerMonth }); // The id appears on EVERY actionable line, not just the appliable ones. It is the only // handle `decline` accepts and it cannot be guessed from the title, so omitting it made the // advertised decline path unreachable for precisely the findings most likely to be // unwanted -- model-routing findings carry no remedy, so they could never be suppressed no // matter how many times a user declined them. - const action = item.remedy?.kind === 'ours' ? `apply: waste_audit action="apply" id="${item.id}"` - : item.remedy?.kind === 'yours' ? `proposed edit -- needs your yes, nothing changed; decline: ${item.id}` - : `advice only -- no automatic fix; decline: ${item.id}`; + const action = + item.remedy?.kind === 'ours' + ? `apply: waste_audit action="apply" id="${item.id}"` + : item.remedy?.kind === 'yours' + ? `proposed edit -- needs your yes, nothing changed; decline: ${item.id}` + : `advice only -- no automatic fix; decline: ${item.id}`; // MEASURED ZERO IS NOT UNMEASURED. Testing `cost` for truthiness sent a real 0 down the // "not yet measurable" branch while `priced` above, which tests for null, still rendered // $0.00 beside it -- producing `cost not yet measurable (~$0.00/month)`, the exact string // the test suite declares must never appear. waste.mjs defaults every finding to 0 and // hard-sets it on the co-occurrence detector, so this is reachable, not theoretical. - const text = ` ${item.title}\n ${item.costPerSession != null ? `${cost.toLocaleString()} tokens/session` : 'cost not yet measurable'}` + + const text = + ` ${item.title}\n ${item.costPerSession != null ? `${cost.toLocaleString()} tokens/session` : 'cost not yet measurable'}` + `${priced ? ` (~${money(priced.amount)}/month)` : ''}; ${action}`; const printCost = estimate(text); @@ -209,14 +239,19 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s lines.push(text); } - const head = shown.length ? ['What to do next, most expensive first:', ...lines] : ['Nothing addressable found.']; + const head = shown.length + ? ['What to do next, most expensive first:', ...lines] + : ['Nothing addressable found.']; const body = [...head]; if (withheld.length) { const worth = withheld.reduce((sum, w) => sum + (w.costPerSession || 0), 0); - body.push('', ` ... ${withheld.length} more finding(s) worth ${worth.toLocaleString()} tokens/session in total, ` + - 'not shown -- printing them costs more than they are worth. Pass full=true to see all.'); + body.push( + '', + ` ... ${withheld.length} more finding(s) worth ${worth.toLocaleString()} tokens/session in total, ` + + 'not shown -- printing them costs more than they are worth. Pass full=true to see all.' + ); } // What we already told you, and what it bought. @@ -224,23 +259,35 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s body.push('', 'Already applied, and what it actually saved:'); for (const rule of done.slice(0, 6)) { const saved = rule.savedPerSession; - const priced = saved == null ? null : monthly(saved, { tier, sessionsPerMonth }); - body.push(` + ${rule.id} -- ${saved == null - ? 'not yet measurable (fewer than two sessions since)' - : `measured ${saved.toLocaleString()} tokens/session${priced ? `, ~${money(priced.amount)}/month` : ''}`}`); + const priced = + saved == null ? null : monthly(saved, { tier, sessionsPerMonth }); + body.push( + ` + ${rule.id} -- ${ + saved == null + ? 'not yet measurable (fewer than two sessions since)' + : `measured ${saved.toLocaleString()} tokens/session${priced ? `, ~${money(priced.amount)}/month` : ''}` + }` + ); } // Never a silent cap: the withheld block above states its remainder and so must this one. // Truncating to six with no disclosure showed a user six savings and let them conclude that // was everything the tool had bought them, which understates its own measured value. if (done.length > 6) { - body.push(` ... and ${done.length - 6} more applied fix(es), not shown.`); + body.push( + ` ... and ${done.length - 6} more applied fix(es), not shown.` + ); } } if (suppressed.length) { - body.push('', `Not repeating ${suppressed.length} recommendation(s) you have declined ` + - `${DECLINE_LIMIT}+ times. Ask for full=true to see them.`); - if (full) for (const item of suppressed) body.push(` - ${item.title} (declined ${item.times}x)`); + body.push( + '', + `Not repeating ${suppressed.length} recommendation(s) you have declined ` + + `${DECLINE_LIMIT}+ times. Ask for full=true to see them.` + ); + if (full) + for (const item of suppressed) + body.push(` - ${item.title} (declined ${item.times}x)`); } // Whether the advice changed anything. @@ -254,20 +301,30 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s for (const a of anchors) anchorsBy.get(f.id).add(a); } const trends = [...new Set(findings.map((f) => f.id))] - .map((detector) => habitTrend(dir, detector, { - anchors: anchorsBy.has(detector) ? [...anchorsBy.get(detector)] : null, - })) + .map((detector) => + habitTrend(dir, detector, { + anchors: anchorsBy.has(detector) ? [...anchorsBy.get(detector)] : null, + }) + ) .filter(Boolean); if (trends.length) { body.push('', 'Since the advice was given:'); for (const trend of trends) { - body.push(` ${trend.detector}: ${trend.before.toLocaleString()} -> ${trend.after.toLocaleString()} tokens/session ` + - `(${trend.improved ? 'improved' : 'worse'})`); + body.push( + ` ${trend.detector}: ${trend.before.toLocaleString()} -> ${trend.after.toLocaleString()} tokens/session ` + + `(${trend.improved ? 'improved' : 'worse'})` + ); } } - const addressable = queue.reduce((sum, item) => sum + (item.costPerSession || 0), 0); + const addressable = queue.reduce( + (sum, item) => sum + (item.costPerSession || 0), + 0 + ); + const addressableMonthly = addressable + ? monthly(addressable, { tier, sessionsPerMonth }) + : null; const note = priceNote(tier); if (note) body.push('', note); @@ -276,9 +333,10 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s // pushing it left the report's longest line out of its own reported cost, understating it by // 10-20% on a short audit. This figure exists to prove the audit is not a net loss, so // understating the cost side biases exactly the comparison it was written to be honest about. - const closing = (n) => `This report cost about ${n.toLocaleString()} tokens and names ` + + const closing = (n) => + `This report cost about ${n.toLocaleString()} tokens and names ` + `${addressable.toLocaleString()} tokens/session of addressable waste` + - `${addressable ? ` (~${money(monthly(addressable, { tier, sessionsPerMonth })?.amount)}/month across ${sessionsPerMonth} sessions)` : ''}.`; + `${addressableMonthly ? ` (~${money(addressableMonthly.amount)}/month across ${sessionsPerMonth} sessions)` : ''}.`; const selfCost = estimate([...body, '', closing(0)].join('\n')); body.push('', closing(selfCost)); diff --git a/hooks-core/cache.mjs b/hooks-core/cache.mjs index 25ef1b38e..c8436dee6 100644 --- a/hooks-core/cache.mjs +++ b/hooks-core/cache.mjs @@ -1,12 +1,12 @@ /** * Prompt-cache economics. * - * Cache economics is real money in a way most compression is not: a cache read - * costs a tenth of a fresh token and a cache WRITE costs a quarter more than - * one, so a prefix that keeps invalidating can cost more than every saving this - * product makes elsewhere. It is also the one area where we do not have to - * model anything -- the client's own transcript records cache_read_input_tokens - * and cache_creation_input_tokens per turn, which is ground truth. + * Anthropic prompt-cache usage, read from Claude Code transcripts. A cache read + * and a cache write are both billable, at different published multipliers from + * uncached input. Raw cache_read_input_tokens and + * cache_creation_input_tokens are observations. Any multiplier-based value is + * an Anthropic input-cost equivalent, not a universally comparable token count + * and not a provider invoice. * * MEASUREMENT ALONE IS NOT ACTIONABLE. A hit rate tells you the cache missed; * it cannot tell you why, and an unactionable number is the same dead thing as @@ -28,7 +28,15 @@ * cache-safety-by-construction half of this file. */ -import { readFileSync, readdirSync, statSync, existsSync, openSync, readSync, closeSync } from 'node:fs'; +import { + readFileSync, + readdirSync, + statSync, + existsSync, + openSync, + readSync, + closeSync, +} from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; @@ -49,7 +57,8 @@ export const READ_MULTIPLIER = 0.1; const SYNTHETIC_MODEL = ''; /** True for a turn naming a model an inference actually ran under. */ -const realModel = (turn) => Boolean(turn.model) && turn.model !== SYNTHETIC_MODEL; +const realModel = (turn) => + Boolean(turn.model) && turn.model !== SYNTHETIC_MODEL; /* -------------------------------------------------------------- MEASUREMENT */ @@ -70,7 +79,10 @@ export function transcriptFor(cwd = process.cwd()) { try { const files = readdirSync(dir) .filter((name) => name.endsWith('.jsonl')) - .map((name) => ({ path: join(dir, name), at: statSync(join(dir, name)).mtimeMs })) + .map((name) => ({ + path: join(dir, name), + at: statSync(join(dir, name)).mtimeMs, + })) .sort((a, b) => b.at - a.at); return files.length ? files[0].path : null; } catch { @@ -155,7 +167,8 @@ export function cacheHealth(turns) { read += turn.read; written += turn.written; if (!realModel(turn)) continue; - if (!models.has(turn.model)) models.set(turn.model, { read: 0, written: 0, turns: 0 }); + if (!models.has(turn.model)) + models.set(turn.model, { read: 0, written: 0, turns: 0 }); const entry = models.get(turn.model); entry.read += turn.read; entry.written += turn.written; @@ -169,20 +182,28 @@ export function cacheHealth(turns) { // attribution price, made keepWarm answer "unknown", and returned null from // modelSwitchCost under a guard its caller does not share, which crashed // cache_audit outright with "Cannot read properties of null". - const last = turns.reduce((best, turn) => (turn.read + turn.written > 0 ? turn : best), turns[turns.length - 1]); + const last = turns.reduce( + (best, turn) => (turn.read + turn.written > 0 ? turn : best), + turns[turns.length - 1] + ); const total = read + written; return { + provider: 'anthropic', + source: 'claude-code-transcript', turns: turns.length, read, written, hitRate: total ? read / total : null, prefixTokens: last.read + last.written, - // Priced in plain-token equivalents, which is what makes it comparable with - // every other saving this product reports. - writeCost: Math.round(written * WRITE_MULTIPLIER), - readCost: Math.round(read * READ_MULTIPLIER), - savedVersusNoCache: Math.round(read * (1 - READ_MULTIPLIER)), + // These are Anthropic uncached-input cost equivalents. They stay outside + // the verified MCP transport ledger and must not be relabeled as tokens + // saved across clients. + writeInputCostEquivalent: Math.round(written * WRITE_MULTIPLIER), + readInputCostEquivalent: Math.round(read * READ_MULTIPLIER), + inputCostEquivalentAvoidedVersusUncached: Math.round( + read * (1 - READ_MULTIPLIER) + ), models: Object.fromEntries(models), }; } @@ -198,13 +219,17 @@ export function modelSwitchCost(turns) { const health = cacheHealth(turns); if (!health?.prefixTokens) return null; - const switched = new Set(turns.filter(realModel).map((t) => t.model)).size > 1; + const switched = + new Set(turns.filter(realModel).map((t) => t.model)).size > 1; return { prefixTokens: health.prefixTokens, - rewriteCost: Math.round(health.prefixTokens * WRITE_MULTIPLIER), + rewriteInputCostEquivalent: Math.round( + health.prefixTokens * WRITE_MULTIPLIER + ), alreadySwitched: switched, - text: `Switching model now discards a ${health.prefixTokens.toLocaleString()}-token warm prefix; ` + - `re-writing it costs about ${Math.round(health.prefixTokens * WRITE_MULTIPLIER).toLocaleString()} tokens.`, + text: + `Switching model now discards a ${health.prefixTokens.toLocaleString()}-token warm prefix; ` + + `under Anthropic's current 5-minute write multiplier, re-writing it is about ${Math.round(health.prefixTokens * WRITE_MULTIPLIER).toLocaleString()} uncached-input cost-equivalent tokens.`, }; } @@ -219,17 +244,38 @@ export function modelSwitchCost(turns) { * time. That is why this class of bug survives -- nothing about it looks wrong. */ const VOLATILE = [ - { id: 'timestamp', re: /\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/, why: 'an embedded timestamp' }, - { id: 'date', re: /\b(?:today|current date)\b[^\n]{0,40}\d{4}-\d{2}-\d{2}/i, why: 'an embedded current date' }, + { + id: 'timestamp', + re: /\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/, + why: 'an embedded timestamp', + }, + { + id: 'date', + re: /\b(?:today|current date)\b[^\n]{0,40}\d{4}-\d{2}-\d{2}/i, + why: 'an embedded current date', + }, // A bare date is a SUSPICION, not a measurement: "- 2025-10-31: reworked the // loader" is byte-identical every session. Kept, because stableText fails // closed over our own output and dropping a line there costs little -- but // NOT priced, because in the blame report a false positive invents a // per-session cost and tells the user to delete their changelog. - { id: 'iso-date', re: /\b\d{4}-\d{2}-\d{2}\b/, why: 'an embedded date', priced: false }, - { id: 'session-id', re: /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i, why: 'a session id' }, + { + id: 'iso-date', + re: /\b\d{4}-\d{2}-\d{2}\b/, + why: 'an embedded date', + priced: false, + }, + { + id: 'session-id', + re: /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i, + why: 'a session id', + }, { id: 'git-sha', re: /\b[0-9a-f]{40}\b/, why: 'a git sha' }, - { id: 'counter', re: /\b(?:run|build|attempt|iteration)\s*#?\d+\b/i, why: 'a run counter' }, + { + id: 'counter', + re: /\b(?:run|build|attempt|iteration)\s*#?\d+\b/i, + why: 'a run counter', + }, { id: 'epoch', re: /\b1[6-9]\d{11}\b/, why: 'an epoch timestamp' }, ]; @@ -240,7 +286,13 @@ export function volatileLines(text) { for (let i = 0; i < lines.length; i++) { for (const rule of VOLATILE) { if (!rule.re.test(lines[i])) continue; - out.push({ line: i + 1, id: rule.id, why: rule.why, priced: rule.priced !== false, text: lines[i].trim().slice(0, 120) }); + out.push({ + line: i + 1, + id: rule.id, + why: rule.why, + priced: rule.priced !== false, + text: lines[i].trim().slice(0, 120), + }); break; // one cause per line is enough to act on } } @@ -262,7 +314,11 @@ const estimate = (text) => Math.ceil(String(text || '').length / 4); * unknown, and this reports the construct WITHOUT a price rather than inventing * one. */ -export function attributeInvalidation(cwd, prefixTokens = null, { files = null } = {}) { +export function attributeInvalidation( + cwd, + prefixTokens = null, + { files = null } = {} +) { // Ordered as the client assembles them: the client preamble and tool // definitions come first, then project instructions, then the conversation. const candidates = files || ['CLAUDE.md', 'AGENTS.md', '.claude/CLAUDE.md']; @@ -290,7 +346,8 @@ export function attributeInvalidation(cwd, prefixTokens = null, { files = null } // prefix -- 3.75x the entire prefix. Conservative still: the preamble // ahead of the file is not counted, so this remains a floor. const ahead = offset + estimate(lines.slice(0, hit.line - 1).join('\n')); - const downstream = prefixTokens != null ? Math.max(0, prefixTokens - ahead) : null; + const downstream = + prefixTokens != null ? Math.max(0, prefixTokens - ahead) : null; // A prefix cache invalidates ONCE, at the earliest difference. A later // volatile line in the same file costs nothing extra until the first one // is fixed, so it is reported for context but not billed again. @@ -302,7 +359,9 @@ export function attributeInvalidation(cwd, prefixTokens = null, { files = null } // into a reported zero. const firstPriced = found.find((h) => h.priced); const subsumedBy = - hit.priced && firstPriced && firstPriced !== hit ? `${relative}:${firstPriced.line}` : null; + hit.priced && firstPriced && firstPriced !== hit + ? `${relative}:${firstPriced.line}` + : null; out.push({ file: relative, line: hit.line, @@ -314,9 +373,12 @@ export function attributeInvalidation(cwd, prefixTokens = null, { files = null } // decline to price. 0 means measured and genuinely free, which is what a // subsumed hit is: real, but already billed by the earlier line. Folding // those two into one value is the same mistake this file warns about. - costPerSession: downstream == null || !hit.priced - ? null - : (subsumedBy ? 0 : Math.round(downstream * WRITE_MULTIPLIER)), + costPerSession: + downstream == null || !hit.priced + ? null + : subsumedBy + ? 0 + : Math.round(downstream * WRITE_MULTIPLIER), remedy: { kind: 'yours', type: 'edit', @@ -367,7 +429,7 @@ export function stableText(text) { */ export function cacheOrdered(items) { return [...items].sort((a, b) => { - const stability = (item) => (item.volatility ?? (item.fresh ? 1 : 0)); + const stability = (item) => item.volatility ?? (item.fresh ? 1 : 0); return stability(a) - stability(b); }); } diff --git a/hooks-core/decide.mjs b/hooks-core/decide.mjs index 364a77976..37a7dd8f6 100644 --- a/hooks-core/decide.mjs +++ b/hooks-core/decide.mjs @@ -390,6 +390,21 @@ export function touchedFiles(payload) { add(input.path); add(input.notebook_path); + // Codex code mode carries apply_patch as the nested program text rather than + // as a first-class file_path. Without reading these headers, successful + // code-mode edits incremented the semantic-harvest counter but produced no + // structural file/symbol nodes, which is why the live dashboard showed very + // few nodes after substantial work. PostToolUse can resolve Add/Update paths + // because the file now exists; deleted paths deliberately remain absent. + for (const match of command.matchAll( + /^\*\*\* (?:Add|Update|Delete) File:\s*(.+)$/gm + )) { + add(match[1].trim().replace(/^['"]|['"]$/g, '')); + } + for (const match of command.matchAll(/^\*\*\* Move to:\s*(.+)$/gm)) { + add(match[1].trim().replace(/^['"]|['"]$/g, '')); + } + // EVERY pipeline segment, not just the first. `fileOperands` looks only at // the head of the pipeline because that is where the COST is -- `git log | // head` must not be mistaken for a file dump. But observation is a different diff --git a/hooks-core/experiment.mjs b/hooks-core/experiment.mjs index 112456d2d..18d6fb255 100644 --- a/hooks-core/experiment.mjs +++ b/hooks-core/experiment.mjs @@ -65,16 +65,71 @@ export function episodeMeta({ client, raw = {}, payload = {}, env = process.env } export function usageFrom(raw = {}) { - const usage = raw.usage || raw.token_usage || raw.tokenUsage || raw.metrics?.usage || {}; + const usage = raw.usage || raw.token_usage || raw.tokenUsage || raw.usageMetadata || + raw.usage_metadata || raw.metrics?.usage || raw.response?.usage || + raw.response?.usageMetadata || {}; const number = (...values) => { const value = first(...values); const parsed = Number(value); return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; }; + const cachedInputTokens = number( + usage.cached_input_tokens, usage.cachedInputTokens, + usage.cache_read_input_tokens, usage.cachedContentTokenCount + ); + const cacheCreation = usage.cache_creation || usage.cacheCreation || {}; + const cacheWrite5mInputTokens = number( + usage.cache_write_5m_input_tokens, usage.cacheWrite5mInputTokens, + cacheCreation.ephemeral_5m_input_tokens, cacheCreation.ephemeral5mInputTokens + ); + const cacheWrite1hInputTokens = number( + usage.cache_write_1h_input_tokens, usage.cacheWrite1hInputTokens, + cacheCreation.ephemeral_1h_input_tokens, cacheCreation.ephemeral1hInputTokens + ); + const detailedWrites = (cacheWrite5mInputTokens || 0) + (cacheWrite1hInputTokens || 0); + const reportedWrites = number( + usage.cache_write_input_tokens, usage.cacheWriteInputTokens, + usage.cache_creation_input_tokens, usage.cacheCreationInputTokens + ); + const cacheWriteInputTokens = reportedWrites == null + ? null + : Math.max(0, reportedWrites - detailedWrites); + const explicitUncached = number(usage.uncached_input_tokens, usage.uncachedInputTokens); + const promptInput = number( + usage.promptTokenCount, usage.prompt_token_count, + usage.input_tokens, usage.inputTokens + ); + const isAnthropic = usage.cache_creation_input_tokens !== undefined || + usage.cache_read_input_tokens !== undefined; + const isGemini = usage.promptTokenCount !== undefined || usage.cachedContentTokenCount !== undefined; + const uncachedInputTokens = explicitUncached != null + ? explicitUncached + : promptInput == null + ? null + : isAnthropic + ? promptInput + : isGemini || cachedInputTokens != null || reportedWrites != null + ? Math.max(0, promptInput - (cachedInputTokens || 0) - (reportedWrites || 0)) + : promptInput; + const candidates = number(usage.candidatesTokenCount, usage.candidates_token_count); + const thoughts = number(usage.thoughtsTokenCount, usage.thoughts_token_count); + const outputTokens = candidates != null || thoughts != null + ? (candidates || 0) + (thoughts || 0) + : number(usage.output_tokens, usage.outputTokens); return { - uncachedInputTokens: number(usage.uncached_input_tokens, usage.uncachedInputTokens), - cachedInputTokens: number(usage.cached_input_tokens, usage.cachedInputTokens, usage.cache_read_input_tokens), - outputTokens: number(usage.output_tokens, usage.outputTokens), - totalTokens: number(usage.total_tokens, usage.totalTokens, raw.tokens_used, raw.tokensUsed), + uncachedInputTokens, + cachedInputTokens, + cacheWrite5mInputTokens, + cacheWrite1hInputTokens, + cacheWriteInputTokens, + outputTokens, + totalTokens: number( + usage.total_tokens, usage.totalTokens, usage.totalTokenCount, + usage.total_token_count, raw.tokens_used, raw.tokensUsed + ), + usageMeasurementId: first( + raw.requestId, raw.request_id, raw.responseId, raw.response_id, + usage.responseId, usage.response_id + ) ?? null, }; } diff --git a/hooks-core/harvest-write.mjs b/hooks-core/harvest-write.mjs index 79017ec13..ed91bb973 100644 --- a/hooks-core/harvest-write.mjs +++ b/hooks-core/harvest-write.mjs @@ -22,7 +22,7 @@ */ import { - putNodeWithEdges, load, nodeId, sharedDir, isSharedDir, putNode, + putNodeWithEdges, load, nodeId, sharedDir, isSharedDir, putNode, putEdge, } from './wiki.mjs'; import { indexFile } from './staleness.mjs'; import { symbolKey } from './symbols.mjs'; @@ -45,6 +45,37 @@ import { ORIGIN_HARVESTED, ORIGIN_AGENT, ORIGIN_HUMAN } from './curate.mjs'; */ export const SHAREABLE_TYPES = new Set(['command', 'failure', 'decision', 'feedback']); +/** + * Synthetic study traffic must never become advice for an unrelated live repo. + * Evaluation roots are intentionally realistic and may carry their own `.git` + * marker, so repository discovery alone cannot distinguish them. Publication + * remains useful inside the isolated study store; this guard protects only the + * default machine-wide store when a harness was accidentally run without an + * isolated TOKEN_OPTIMIZER_SHARED_DIR. + */ +export function isEphemeralProject(projectRoot) { + const root = canonicalPath(projectRoot || '').toLowerCase(); + if (!root) return true; + return ( + /\/appdata\/local\/temp\//.test(root) || + /(^|\/)tmp\//.test(root) || + /\/(artifacts|\.token-optimizer)\/(?:[^/]*\/)*(?:eval|scenario|study|fixture|debug)[^/]*\//.test(`${root}/`) + ); +} + +export function quarantineSharedSource(projectRoot) { + if (process.env.TOKEN_OPTIMIZER_ALLOW_EPHEMERAL_SHARED === '1') return false; + if (!isEphemeralProject(projectRoot)) return false; + // Isolated studies deliberately point the shared tier at scratch storage. + // Their findings may cross their own fixture projects, but cannot escape into + // the user's default machine-wide graph. Strict mode exists for a regression + // probe that verifies the guard without writing to a real user directory. + return ( + !process.env.TOKEN_OPTIMIZER_SHARED_DIR || + process.env.TOKEN_OPTIMIZER_STRICT_SHARED_PROVENANCE === '1' + ); +} + /** Claim text, normalised enough that the same lesson learned twice is one row. */ const claimFingerprint = (claim) => String(claim || '').toLowerCase().replace(/\s+/g, ' ').trim().slice(0, 300); @@ -70,6 +101,7 @@ function promoteToShared(finding, { projectRoot, sessionId, provenance, key }) { if (!projectRoot) return false; const root = canonicalPath(projectRoot); + if (quarantineSharedSource(root)) return false; const rootId = nodeId('file', root); // Same claim, already carried up from anywhere: keep the first. Two repos @@ -365,11 +397,21 @@ export function writeHarvested( && !node.retired && node.type === finding.type && claimFingerprint(node.claim) === fingerprint - && resolved.every((target) => currentGraph.edges.some((edge) => - edge.from === node.id && edge.edge === 'derived_from' && edge.to === target - )) ); if (duplicate) { + // Enrich, do not duplicate. A symbol extractor may learn how to resolve a + // previously file-only anchor (or a later session may add another real + // anchor) after the claim was first stored. Repeating the same semantic + // conclusion should connect that existing node to the newly available + // evidence, not create a second dashboard card with the same text. + for (const target of resolved) { + const alreadyLinked = currentGraph.edges.some((edge) => + edge.from === duplicate.id + && edge.edge === 'derived_from' + && edge.to === target + ); + if (!alreadyLinked) putEdge(dir, duplicate.id, 'derived_from', target); + } written.push(duplicate.key); continue; } @@ -435,7 +477,12 @@ export function writeHarvested( if ( id && !isSharedDir(dir) - && (finding.scope === undefined || finding.scope === 'organization' || finding.scope === 'global') + // Cross-project publication is explicit. `writeHarvested` stores an + // omitted scope as project-local above, so treating `undefined` as global + // here contradicted the stored record and promoted fixture/evaluation + // seeds into real user sessions. This was observed live as "learned in + // scenario" advice while working in AiDotNet. + && (finding.scope === 'organization' || finding.scope === 'global') ) { promoteToShared(finding, { projectRoot, sessionId, provenance, key }); } diff --git a/hooks-core/inject.mjs b/hooks-core/inject.mjs index b2d686134..001c8b520 100644 --- a/hooks-core/inject.mjs +++ b/hooks-core/inject.mjs @@ -29,6 +29,7 @@ import { canonicalPath, resolvableCandidates } from './paths.mjs'; import { annotatedSkeleton } from './skeleton.mjs'; import { substitutionBudget } from './metrics.mjs'; import { assessFindings } from './utility.mjs'; +import { quarantineSharedSource } from './harvest-write.mjs'; // Read per call for the same reason as the holdout fraction in metrics.mjs. const touchBudget = () => Number(process.env.TOKEN_OPTIMIZER_TOUCH_BUDGET) || 500; @@ -560,6 +561,7 @@ export function forRepeatedAct( const home = projectRoot ? canonicalPath(projectRoot) : null; for (const node of graph.nodes.values()) { if (node.kind !== 'finding' || node.retired || !node.claim) continue; + if (quarantineSharedSource(node.sourceProject)) continue; if (home && node.sourceProject && canonicalPath(node.sourceProject) === home) continue; const claimClasses = classesOf(node.claim, 'claim'); if (![...crossedClasses].some((c) => claimClasses.has(c))) continue; @@ -665,6 +667,7 @@ export function forSharedCommand( const candidates = []; for (const node of graph.nodes.values()) { if (node.kind !== 'finding' || node.retired) continue; + if (quarantineSharedSource(node.sourceProject)) continue; if (alreadyInjected.has(node.key)) continue; // ITS OWN LESSON IS NOT NEWS. A finding this project contributed is already // served by the local path; repeating it under a "from elsewhere" label @@ -1052,7 +1055,13 @@ export function standingRules(dir, graph, { budget = standingBudget(), episode = * informative response available -- more useful than the file, not a lossier * version of it. */ -export function substitutionFor(dir, graph, rawPath, source, { sessionId } = {}) { +export function substitutionFor( + dir, + graph, + rawPath, + source, + { sessionId, client = null, clientVersion = null, model = null, modelVersion = null } = {} +) { const filePath = canonicalPath(rawPath); const budget = substitutionBudget(dir, filePath); const built = annotatedSkeleton(graph, rawPath, source, { budget }); @@ -1087,6 +1096,10 @@ export function substitutionFor(dir, graph, rawPath, source, { sessionId } = {}) // on this machine turned out to be the enforcement suite's own fixture and // nothing distinguished them from real work. sessionId, + client, + clientVersion, + model, + modelVersion, holdout, tokens: holdout ? 0 : built.tokens, findings: built.findings, diff --git a/hooks-core/metrics.mjs b/hooks-core/metrics.mjs index b56d7b55b..c0fcad127 100644 --- a/hooks-core/metrics.mjs +++ b/hooks-core/metrics.mjs @@ -17,8 +17,15 @@ */ import { - appendFileSync, readFileSync, existsSync, mkdirSync, chmodSync, - statSync, openSync, readSync, closeSync, + appendFileSync, + readFileSync, + existsSync, + mkdirSync, + chmodSync, + statSync, + openSync, + readSync, + closeSync, } from 'node:fs'; import { join } from 'node:path'; import { createHash, randomBytes } from 'node:crypto'; @@ -84,6 +91,10 @@ export const BALANCE_KINDS = new Set([ 'keepwarm', ]); +/** Latest timestamp without passing an unbounded event log as function arguments. */ +export const latestEventTimestamp = (rows) => + rows.reduce((latest, event) => Math.max(latest, Number(event.at) || 0), 0); + /** * Causal records have their own bounded log. Tool outcomes are much rarer * than read/capture telemetry but more frequent than balance records; mixing @@ -128,7 +139,7 @@ export function inHoldout(anchorKey, now = Date.now()) { // other side than before. With nine holdout records in existence that costs // nothing, and stratification is a fresh draw either way. const digest = createHash('sha256').update(`${anchorKey}:${epoch}`).digest(); - return (digest[0] / 256) < fraction; + return digest[0] / 256 < fraction; } /** @@ -256,7 +267,8 @@ export function record(dir, event) { * It is also the right statistics. A saving measured months ago says little * about the code as it stands now. */ -const MAX_BYTES = Number(process.env.TOKEN_OPTIMIZER_METRICS_BYTES) || 2_000_000; +const MAX_BYTES = + Number(process.env.TOKEN_OPTIMIZER_METRICS_BYTES) || 2_000_000; const MAX_EVENTS = Number(process.env.TOKEN_OPTIMIZER_METRICS_WINDOW) || 5000; /** @@ -430,7 +442,10 @@ export function readBalance(dir) { // path for records written before ids existed; it is lossy by nature, which // is precisely why new records carry an id instead. const id = - e.id ?? [e.kind, e.at, e.anchor ?? '', e.sessionId ?? '', e.tokens ?? ''].join('|'); + e.id ?? + [e.kind, e.at, e.anchor ?? '', e.sessionId ?? '', e.tokens ?? ''].join( + '|' + ); if (seen.has(id)) continue; seen.add(id); out.push(e); @@ -491,16 +506,24 @@ export function recordToolOutcome(dir, outcome) { const evidence = readEvidence(dir); const anchor = String(outcome.anchor || '').slice(0, 120); const candidates = evidence - .filter((event) => event.kind === 'inject' && event.episodeId === outcome.episodeId) + .filter( + (event) => + event.kind === 'inject' && event.episodeId === outcome.episodeId + ) .filter((event) => { if (outcome.toolCallId && event.toolCallId) return String(event.toolCallId) === String(outcome.toolCallId); - return event.surface === outcome.surface && String(event.anchor || '').slice(0, 120) === anchor; + return ( + event.surface === outcome.surface && + String(event.anchor || '').slice(0, 120) === anchor + ); }) .sort((a, b) => (b.at || 0) - (a.at || 0)); const injection = candidates[0] || null; const joinMethod = injection - ? (outcome.toolCallId && injection.toolCallId ? 'tool-call-id' : 'episode-anchor') + ? outcome.toolCallId && injection.toolCallId + ? 'tool-call-id' + : 'episode-anchor' : 'none'; return record(dir, { @@ -540,12 +563,11 @@ export function isFixtureAnchor(anchor) { // code: it excluded ALL temp paths -- including a real project checked out // under /tmp -- and on macOS excluded nothing at all, because tmpdir() there // is /var/folders///T/ and did not match. - const underTemp = - new RegExp( - '[\\\\/](AppData[\\\\/]Local[\\\\/])?(Temp|tmp)[\\\\/]' + - '|[\\\\/]var[\\\\/]folders[\\\\/][^\\\\/]+[\\\\/][^\\\\/]+[\\\\/]T[\\\\/]', - 'i' - ).test(p); + const underTemp = new RegExp( + '[\\\\/](AppData[\\\\/]Local[\\\\/])?(Temp|tmp)[\\\\/]' + + '|[\\\\/]var[\\\\/]folders[\\\\/][^\\\\/]+[\\\\/][^\\\\/]+[\\\\/]T[\\\\/]', + 'i' + ).test(p); if (!underTemp) return false; // Only names this suite actually creates. A user's own scratch checkout under @@ -684,8 +706,12 @@ export function balanceSheet(dir) { // THE CONTROL ARM'S ACTUAL COST. `tokensFullFile` was recorded and read // by nothing, so the comparison the holdout exists for was not // computable from the report. - controlArmTokens: withheldSubs.reduce((sum, e) => sum + (e.tokensFullFile || 0), 0), - assumption: 'that the model would have read the file it explicitly requested', + controlArmTokens: withheldSubs.reduce( + (sum, e) => sum + (e.tokensFullFile || 0), + 0 + ), + assumption: + 'that the model would have read the file it explicitly requested', }, estimatedCausal: { what: 'findings injection, from the stratified holdout', @@ -700,7 +726,12 @@ export function balanceSheet(dir) { }; })(), }, - costs: { injection: injectCost, harvest: harvestCost, standing: standingCost, substitution: substitutionCost }, + costs: { + injection: injectCost, + harvest: harvestCost, + standing: standingCost, + substitution: substitutionCost, + }, waste, // Deliberately NOT a single number. The two benefit lines are known // differently; adding them would launder an assumption into a measurement. @@ -725,7 +756,9 @@ export function balanceSheet(dir) { /** Cheap path normalisation for grouping reads; not the identity canonicaliser. */ function canonicalKeyish(p) { - return String(p || '').replace(/\\/g, '/').toLowerCase(); + return String(p || '') + .replace(/\\/g, '/') + .toLowerCase(); } /** @@ -736,7 +769,43 @@ function canonicalKeyish(p) { * arms, multiplied by treated touches. Reporting it as a measured fact would be * the same overclaiming this project criticises competitors for. */ -function buildReport(events, balance) { +function buildReport(events, balance, sourceCoverage = {}) { + const substitutions = balance.filter( + (event) => event.kind === 'substitute' && !isFixtureAnchor(event.anchor) + ); + const servedSubstitutions = substitutions.filter((event) => !event.holdout); + const substitutionTokensSaved = servedSubstitutions.reduce( + (sum, event) => + sum + + (event.tokensNetAvoided ?? + Math.max( + 0, + Math.ceil((event.bytesAvoided || 0) / 4) - (event.tokens || 0) + )), + 0 + ); + const substitutionTokensReturned = servedSubstitutions.reduce( + (sum, event) => sum + (event.tokens || 0), + 0 + ); + const substitutionByClient = {}; + for (const event of servedSubstitutions) { + const name = event.client || 'Historical — client not recorded'; + const current = substitutionByClient[name] || { + substitutions: 0, + tokensReturned: 0, + tokensSaved: 0, + }; + current.substitutions += 1; + current.tokensReturned += event.tokens || 0; + current.tokensSaved += + event.tokensNetAvoided ?? + Math.max( + 0, + Math.ceil((event.bytesAvoided || 0) / 4) - (event.tokens || 0) + ); + substitutionByClient[name] = current; + } // COMMAND INJECTIONS ARE COUNTED SEPARATELY, not mixed into the balance. // @@ -760,13 +829,19 @@ function buildReport(events, balance) { // Report its cost independently so it cannot dilute the file-touch estimate. const isSessionStart = (e) => e.surface === 'session-start'; const sessionStartInjections = allInjections.filter(isSessionStart); - const commandInjections = allInjections.filter((e) => !isSessionStart(e) && isCommand(e)); - const injections = allInjections.filter((e) => !isSessionStart(e) && !isCommand(e)); + const commandInjections = allInjections.filter( + (e) => !isSessionStart(e) && isCommand(e) + ); + const injections = allInjections.filter( + (e) => !isSessionStart(e) && !isCommand(e) + ); const treated = injections.filter((e) => !e.holdout); const withheld = injections.filter((e) => e.holdout); const mean = (rows, field) => - rows.length ? rows.reduce((sum, r) => sum + (r[field] || 0), 0) / rows.length : 0; + rows.length + ? rows.reduce((sum, r) => sum + (r[field] || 0), 0) / rows.length + : 0; const injectedTokens = treated.reduce((sum, e) => sum + (e.tokens || 0), 0); const harvestTokens = balance @@ -817,7 +892,9 @@ function buildReport(events, balance) { const after = event.at ?? 0; const total = bucket.reduce( - (sum, r) => sum + ((r.at ?? 0) >= after ? (r.tokens || 0) : 0), 0); + (sum, r) => sum + ((r.at ?? 0) >= after ? r.tokens || 0 : 0), + 0 + ); // Split across the injections that share this key. Each touch is one // observation, so charging every one the full total would count the same @@ -826,52 +903,198 @@ function buildReport(events, balance) { }; const meanDownstream = (rows) => - rows.length ? rows.reduce((sum, r) => sum + downstreamOf(r), 0) / rows.length : 0; + rows.length + ? rows.reduce((sum, r) => sum + downstreamOf(r), 0) / rows.length + : 0; const treatedCost = meanDownstream(treated); const withheldCost = meanDownstream(withheld); const perTouchSaving = withheldCost - treatedCost; - const estimatedAvoided = Math.max(0, Math.round(perTouchSaving * treated.length)); + const estimatedAvoided = Math.max( + 0, + Math.round(perTouchSaving * treated.length) + ); + + const downstreamSamples = injections.filter((event) => { + if (event.downstream != null) return true; + const key = `${event.sessionId || ''}|${event.anchor}`; + return (reads.get(key) || []).some( + (read) => (read.at ?? 0) >= (event.at ?? 0) + ); + }).length; + const downstreamMeasured = downstreamSamples > 0; // Below this, arm means are noise and a ratio would be theatre. - const sufficient = treated.length >= 20 && withheld.length >= 5; + const sufficient = + downstreamMeasured && treated.length >= 20 && withheld.length >= 5; + + const coverageNumber = (value, fallback) => { + if (value === null || value === undefined || value === '') return fallback; + const number = Number(value); + return Number.isFinite(number) ? Math.max(0, number) : fallback; + }; + const projectCount = coverageNumber(sourceCoverage.projects, 1); + const telemetryProjects = coverageNumber( + sourceCoverage.projectsWithTelemetry, + 0 + ); + const balanceProjects = coverageNumber( + sourceCoverage.projectsWithBalanceEvents, + balance.length ? 1 : 0 + ); + const lastEventAt = Math.max( + latestEventTimestamp(events), + latestEventTimestamp(balance) + ); + const measured = + telemetryProjects > 0 || events.length > 0 || balance.length > 0; + const balanceMeasured = balanceProjects > 0 || balance.length > 0; + const metric = (status, source, samples, extra = {}) => ({ + status, + source, + samples, + ...extra, + }); return { + nativeOptimizer: { + substitutions: servedSubstitutions.length, + holdouts: substitutions.filter((event) => event.holdout).length, + tokensReturned: substitutionTokensReturned, + tokensSaved: substitutionTokensSaved, + byClient: substitutionByClient, + recent: servedSubstitutions + .slice() + .sort((a, b) => (b.at || 0) - (a.at || 0)) + .slice(0, 40) + .map((event) => { + const tokensSaved = + event.tokensNetAvoided ?? + Math.max( + 0, + Math.ceil((event.bytesAvoided || 0) / 4) - (event.tokens || 0) + ); + return { + name: 'live_graph_substitution', + client: event.client || 'Historical — client not recorded', + originalTokens: (event.tokens || 0) + tokensSaved, + optimizedTokens: event.tokens || 0, + tokensSaved, + savingsMeasured: false, + classification: 'modeled-counterfactual', + reportedTokensSaved: tokensSaved, + timestamp: event.at ? new Date(event.at).toISOString() : null, + }; + }), + source: + 'balance.jsonl: modeled full-file counterfactual minus annotated-skeleton tokens; excluded from verified MCP savings', + }, memoryDeliveries: allInjections.filter((event) => !event.holdout).length, memoryHoldouts: allInjections.filter((event) => event.holdout).length, deliveryTokens: allInjections .filter((event) => !event.holdout) .reduce( - (sum, event) => - sum + (event.deliveredTokens ?? event.tokens ?? 0), + (sum, event) => sum + (event.deliveredTokens ?? event.tokens ?? 0), 0 ), injections: treated.length, sessionStartInjections: sessionStartInjections.length, sessionStartInjectedTokens: sessionStartInjections.reduce( - (sum, event) => sum + (event.deliveredTokens ?? event.tokens ?? 0), 0 + (sum, event) => sum + (event.deliveredTokens ?? event.tokens ?? 0), + 0 ), commandInjections: commandInjections.length, commandHoldouts: commandInjections.filter((e) => e.holdout).length, holdouts: withheld.length, staleServed: injections.filter((e) => e.stale).length, - staleRate: injections.length ? injections.filter((e) => e.stale).length / injections.length : 0, + staleRate: injections.length + ? injections.filter((e) => e.stale).length / injections.length + : 0, injectedTokens, harvestTokens, estimatedTokensAvoided: sufficient ? estimatedAvoided : null, - netTokens: sufficient ? estimatedAvoided - injectedTokens - harvestTokens : null, + netTokens: sufficient + ? estimatedAvoided - injectedTokens - harvestTokens + : null, sufficientData: sufficient, verdict: !sufficient ? `insufficient data (${treated.length} treated, ${withheld.length} holdout; need 20 and 5)` : estimatedAvoided > injectedTokens + harvestTokens ? 'the graph is saving more than it costs' : 'the graph is NOT yet paying for itself', + measurement: { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + sourceCoverage: { + projects: projectCount, + projectsWithTelemetry: telemetryProjects || (measured ? 1 : 0), + projectsWithBalanceEvents: balanceProjects, + projectsWithoutTelemetry: Math.max( + 0, + projectCount - (telemetryProjects || (measured ? 1 : 0)) + ), + }, + freshness: { + lastEventAt: lastEventAt || null, + ageMs: lastEventAt ? Math.max(0, Date.now() - lastEventAt) : null, + status: !lastEventAt + ? 'not-measured' + : Date.now() - lastEventAt <= 7 * 86_400_000 + ? 'fresh' + : 'stale', + }, + metrics: { + nativeSubstitutions: metric( + balanceMeasured ? 'measured' : 'not-measured', + 'balance.jsonl: modeled full-file counterfactual minus annotated-skeleton tokens; not a materialized MCP before/after pair', + servedSubstitutions.length, + { classification: 'modeled-counterfactual' } + ), + memoryDeliveries: metric( + balanceMeasured ? 'measured' : 'not-measured', + 'balance.jsonl: inject events in the treated arm', + allInjections.filter((event) => !event.holdout).length + ), + memoryHoldouts: metric( + balanceMeasured ? 'measured' : 'not-measured', + 'balance.jsonl: inject events in the withheld arm', + allInjections.filter((event) => event.holdout).length + ), + rememberingCost: metric( + balanceMeasured ? 'measured' : 'not-measured', + 'balance.jsonl: delivered injection tokens plus semantic harvest tokens', + allInjections.length + + balance.filter((event) => event.kind === 'harvest').length + ), + readingAvoided: metric( + !downstreamMeasured + ? 'not-measured' + : sufficient + ? 'measured' + : 'collecting', + 'stratified file-touch holdout joined to downstream read events', + downstreamSamples, + { + treated: treated.length, + holdouts: withheld.length, + requiredTreated: 20, + requiredHoldouts: 5, + } + ), + }, + }, }; } export function report(dir) { - return buildReport(readAll(dir), readBalance(dir)); + const events = readAll(dir); + const balance = readBalance(dir); + return buildReport(events, balance, { + projects: 1, + projectsWithTelemetry: events.length || balance.length ? 1 : 0, + projectsWithBalanceEvents: balance.length ? 1 : 0, + }); } /** @@ -884,10 +1107,16 @@ export function report(dir) { */ export function reportMany(dirs) { const unique = [...new Set((dirs || []).map((dir) => String(dir)))]; - const report = buildReport( - unique.flatMap((dir) => readAll(dir)), - unique.flatMap((dir) => readBalance(dir)) - ); + const eventSources = unique.map((dir) => readAll(dir)); + const balanceSources = unique.map((dir) => readBalance(dir)); + const report = buildReport(eventSources.flat(), balanceSources.flat(), { + projects: unique.length, + projectsWithTelemetry: eventSources.filter( + (events, index) => events.length || balanceSources[index].length + ).length, + projectsWithBalanceEvents: balanceSources.filter((events) => events.length) + .length, + }); return { ...report, projects: unique.length }; } @@ -905,7 +1134,11 @@ export function reportMany(dirs) { * costs almost nothing. Without a control arm there is no way to tell those two * apart, which is why nobody else can do this. */ -export function substitutionBudget(dir, anchor, { floor = 300, base = 1200, ceiling = 3000 } = {}) { +export function substitutionBudget( + dir, + anchor, + { floor = 300, base = 1200, ceiling = 3000 } = {} +) { const events = readAll(dir); const mine = events.filter((e) => e.kind === 'inject' && e.anchor === anchor); @@ -930,13 +1163,17 @@ export function substitutionBudget(dir, anchor, { floor = 300, base = 1200, ceil for (const row of rows) { const bucket = reads.get(row.sessionId || '') || []; const after = row.at ?? 0; - total += bucket.reduce((sum, r) => sum + ((r.at ?? 0) >= after ? (r.tokens || 0) : 0), 0); + total += bucket.reduce( + (sum, r) => sum + ((r.at ?? 0) >= after ? r.tokens || 0 : 0), + 0 + ); } return total / rows.length; }; const saved = downstream(withheld) - downstream(treated); - const spent = treated.reduce((sum, e) => sum + (e.tokens || 0), 0) / treated.length; + const spent = + treated.reduce((sum, e) => sum + (e.tokens || 0), 0) / treated.length; // Ratio of what annotating this file avoided to what annotating it cost. // Above 1 it is paying for itself and earns room; below, it shrinks. @@ -957,7 +1194,10 @@ export function substitutionBudget(dir, anchor, { floor = 300, base = 1200, ceil * graph whose index leads to queries grows its allowance; a noisy one shrinks * back toward the floor. Nobody configures it, and it cannot run away. */ -export function indexBudget(dir, { floor = 150, base = 300, ceiling = 1200 } = {}) { +export function indexBudget( + dir, + { floor = 150, base = 300, ceiling = 1200 } = {} +) { const events = readAll(dir); const listed = events.filter((e) => e.kind === 'index').length; const queries = events.filter((e) => e.kind === 'query').length; @@ -966,7 +1206,9 @@ export function indexBudget(dir, { floor = 150, base = 300, ceiling = 1200 } = { const hitRate = queries / listed; // 0% hit rate falls to the floor; ~50% and above reaches the ceiling. - const scaled = Math.round(floor + (ceiling - floor) * Math.min(1, hitRate * 2)); + const scaled = Math.round( + floor + (ceiling - floor) * Math.min(1, hitRate * 2) + ); return Math.max(floor, Math.min(ceiling, scaled)); } @@ -1003,7 +1245,10 @@ function seededRandom(seedText) { } /** Percentile bootstrap interval for a mean, paired delta, or other scalar. */ -export function bootstrapMeanInterval(values, { samples = 1000, seed = 'token-optimizer' } = {}) { +export function bootstrapMeanInterval( + values, + { samples = 1000, seed = 'token-optimizer' } = {} +) { const finite = values.map(numeric).filter((value) => value !== null); if (!finite.length) return { mean: null, low: null, high: null, n: 0 }; if (finite.length === 1) { @@ -1035,24 +1280,32 @@ export function proportionInterval(successes, total, z = 1.96) { const denominator = 1 + (z * z) / total; const centre = (rate + (z * z) / (2 * total)) / denominator; const radius = - (z / denominator) * Math.sqrt((rate * (1 - rate)) / total + (z * z) / (4 * total * total)); - return { rate, low: Math.max(0, centre - radius), high: Math.min(1, centre + radius), n: total }; + (z / denominator) * + Math.sqrt((rate * (1 - rate)) / total + (z * z) / (4 * total * total)); + return { + rate, + low: Math.max(0, centre - radius), + high: Math.min(1, centre + radius), + n: total, + }; } -const cohortKey = (run) => [ - run.client || 'unknown', - run.clientVersion || 'unknown', - run.model || 'unknown', - run.modelVersion || 'unknown', - run.taskId || 'unknown', -].join('|'); +const cohortKey = (run) => + [ + run.client || 'unknown', + run.clientVersion || 'unknown', + run.model || 'unknown', + run.modelVersion || 'unknown', + run.taskId || 'unknown', + ].join('|'); function armMetrics(runs) { const correct = runs.filter((run) => run.correct === true).length; - const interval = (field) => bootstrapMeanInterval( - runs.map((run) => run[field]).filter((value) => numeric(value) !== null), - { seed: field } - ); + const interval = (field) => + bootstrapMeanInterval( + runs.map((run) => run[field]).filter((value) => numeric(value) !== null), + { seed: field } + ); return { runs: runs.length, correctness: proportionInterval(correct, runs.length), @@ -1066,29 +1319,40 @@ function armMetrics(runs) { latencyMs: interval('latencyMs'), costUsd: interval('costUsd'), injectedTokens: interval('injectedTokens'), - harmfulFindings: runs.reduce((sum, run) => sum + (numeric(run.harmfulFindings) || 0), 0), + harmfulFindings: runs.reduce( + (sum, run) => sum + (numeric(run.harmfulFindings) || 0), + 0 + ), }; } -function pairedEffects(runs, controlArm, treatmentArm, contrastType = 'incremental') { +function pairedEffects( + runs, + controlArm, + treatmentArm, + contrastType = 'incremental' +) { const pairs = new Map(); for (const run of runs) { if (!run.pairId || ![controlArm, treatmentArm].includes(run.arm)) continue; if (!pairs.has(run.pairId)) pairs.set(run.pairId, {}); pairs.get(run.pairId)[run.arm] = run; } - const complete = [...pairs.values()].filter((pair) => pair[controlArm] && pair[treatmentArm]); - const delta = (field, lowerIsBetter = true) => bootstrapMeanInterval( - complete - .map((pair) => { - const baseline = numeric(pair[controlArm][field]); - const treatment = numeric(pair[treatmentArm][field]); - if (baseline === null || treatment === null) return null; - return lowerIsBetter ? baseline - treatment : treatment - baseline; - }) - .filter((value) => value !== null), - { seed: `${controlArm}:${treatmentArm}:${field}` } + const complete = [...pairs.values()].filter( + (pair) => pair[controlArm] && pair[treatmentArm] ); + const delta = (field, lowerIsBetter = true) => + bootstrapMeanInterval( + complete + .map((pair) => { + const baseline = numeric(pair[controlArm][field]); + const treatment = numeric(pair[treatmentArm][field]); + if (baseline === null || treatment === null) return null; + return lowerIsBetter ? baseline - treatment : treatment - baseline; + }) + .filter((value) => value !== null), + { seed: `${controlArm}:${treatmentArm}:${field}` } + ); return { arm: treatmentArm, controlArm, @@ -1109,21 +1373,32 @@ function pairedEffects(runs, controlArm, treatmentArm, contrastType = 'increment } function matchesFilters(event, filters) { - for (const field of ['client', 'clientVersion', 'model', 'modelVersion', 'taskId', 'arm']) { + for (const field of [ + 'client', + 'clientVersion', + 'model', + 'modelVersion', + 'taskId', + 'arm', + ]) { if (!filters[field]) continue; let actual = event[field]; if (event.kind === 'handoff-run') { - actual = field === 'taskId' - ? event.scenarioId - : field === 'arm' - ? event.arm - : (event.consumer?.[field] ?? event.producer?.[field] ?? event[field]); + actual = + field === 'taskId' + ? event.scenarioId + : field === 'arm' + ? event.arm + : (event.consumer?.[field] ?? + event.producer?.[field] ?? + event[field]); } else if (event.kind === 'concurrency-run') { - actual = field === 'taskId' - ? 'concurrent-combined' - : field === 'arm' - ? event.arm - : (event.consumer?.[field] ?? event[field]); + actual = + field === 'taskId' + ? 'concurrent-combined' + : field === 'arm' + ? event.arm + : (event.consumer?.[field] ?? event[field]); } if (String(actual || '') !== String(filters[field])) return false; } @@ -1132,11 +1407,15 @@ function matchesFilters(event, filters) { function handoffArmMetrics(runs) { const consumers = runs.map((run) => run.consumer || {}); - const count = (field) => consumers.filter((consumer) => consumer[field] === true).length; - const interval = (field) => bootstrapMeanInterval( - consumers.map((consumer) => consumer[field]).filter((value) => numeric(value) !== null), - { seed: `handoff:${field}` } - ); + const count = (field) => + consumers.filter((consumer) => consumer[field] === true).length; + const interval = (field) => + bootstrapMeanInterval( + consumers + .map((consumer) => consumer[field]) + .filter((value) => numeric(value) !== null), + { seed: `handoff:${field}` } + ); return { runs: runs.length, delivery: proportionInterval( @@ -1145,7 +1424,10 @@ function handoffArmMetrics(runs) { ), correctness: proportionInterval(count('correct'), runs.length), firstPass: proportionInterval(count('firstPass'), runs.length), - mistakeAttempted: proportionInterval(count('mistakeAttempted'), runs.length), + mistakeAttempted: proportionInterval( + count('mistakeAttempted'), + runs.length + ), mistakeExecuted: proportionInterval(count('mistakeExecuted'), runs.length), totalTokens: interval('totalTokens'), toolCalls: interval('toolCalls'), @@ -1161,16 +1443,21 @@ function handoffPairedEffect(runs, controlArm, treatmentArm) { if (!pairs.has(run.pairId)) pairs.set(run.pairId, {}); pairs.get(run.pairId)[run.arm] = run; } - const complete = [...pairs.values()].filter((pair) => pair[controlArm] && pair[treatmentArm]); - const effect = (field, positiveWhenLower = true) => bootstrapMeanInterval( - complete.map((pair) => { - const control = numeric(pair[controlArm].consumer?.[field]); - const treatment = numeric(pair[treatmentArm].consumer?.[field]); - if (control === null || treatment === null) return null; - return positiveWhenLower ? control - treatment : treatment - control; - }).filter((value) => value !== null), - { seed: `handoff:${controlArm}:${treatmentArm}:${field}` } + const complete = [...pairs.values()].filter( + (pair) => pair[controlArm] && pair[treatmentArm] ); + const effect = (field, positiveWhenLower = true) => + bootstrapMeanInterval( + complete + .map((pair) => { + const control = numeric(pair[controlArm].consumer?.[field]); + const treatment = numeric(pair[treatmentArm].consumer?.[field]); + if (control === null || treatment === null) return null; + return positiveWhenLower ? control - treatment : treatment - control; + }) + .filter((value) => value !== null), + { seed: `handoff:${controlArm}:${treatmentArm}:${field}` } + ); return { comparison: `${treatmentArm} vs ${controlArm}`, pairs: complete.length, @@ -1185,13 +1472,22 @@ function handoffPairedEffect(runs, controlArm, treatmentArm) { }; } -const handoffKey = (run) => [ - run.producer?.client || 'unknown', run.producer?.model || 'unknown', - run.consumer?.client || 'unknown', run.consumer?.model || 'unknown', - run.scenarioId || 'unknown', -].join('|'); - -const HANDOFF_REPORT_ARMS = ['empty', 'natural', 'oracle', 'irrelevant', 'stale']; +const handoffKey = (run) => + [ + run.producer?.client || 'unknown', + run.producer?.model || 'unknown', + run.consumer?.client || 'unknown', + run.consumer?.model || 'unknown', + run.scenarioId || 'unknown', + ].join('|'); + +const HANDOFF_REPORT_ARMS = [ + 'empty', + 'natural', + 'oracle', + 'irrelevant', + 'stale', +]; const handoffMinimumPairs = () => Math.max(2, Number(process.env.TOKEN_OPTIMIZER_HANDOFF_MIN_PAIRS) || 10); @@ -1204,43 +1500,54 @@ function handoffCohorts(runs) { } const minimumPairs = handoffMinimumPairs(); return [...grouped.entries()].map(([key, rows]) => { - const arms = Object.fromEntries(HANDOFF_REPORT_ARMS.map((arm) => [ - arm, handoffArmMetrics(rows.filter((row) => row.arm === arm)), - ])); + const arms = Object.fromEntries( + HANDOFF_REPORT_ARMS.map((arm) => [ + arm, + handoffArmMetrics(rows.filter((row) => row.arm === arm)), + ]) + ); const naturalRows = rows.filter((row) => row.arm === 'natural'); const naturalVsEmpty = handoffPairedEffect(rows, 'empty', 'natural'); const naturalVsOracle = handoffPairedEffect(rows, 'oracle', 'natural'); const captureRate = naturalRows.length - ? naturalRows.filter((row) => row.producer?.captureSuccess).length / naturalRows.length + ? naturalRows.filter((row) => row.producer?.captureSuccess).length / + naturalRows.length : null; const preActionDeliveryRate = naturalRows.length - ? naturalRows.filter((row) => row.delivery?.beforeFirstExecutedMistake).length / naturalRows.length + ? naturalRows.filter((row) => row.delivery?.beforeFirstExecutedMistake) + .length / naturalRows.length : null; const emptyRecurrence = arms.empty.mistakeExecuted.rate; const naturalRecurrence = arms.natural.mistakeExecuted.rate; - const relativeRecurrenceReduction = emptyRecurrence !== null - && naturalRecurrence !== null - && emptyRecurrence !== 0 - ? (emptyRecurrence - naturalRecurrence) / emptyRecurrence - : null; - const controlsSafe = ['irrelevant', 'stale'].every((arm) => - arms[arm].correctness.rate !== null - && arms.empty.correctness.rate !== null - && arms[arm].correctness.rate >= arms.empty.correctness.rate - 0.1 + const relativeRecurrenceReduction = + emptyRecurrence !== null && + naturalRecurrence !== null && + emptyRecurrence !== 0 + ? (emptyRecurrence - naturalRecurrence) / emptyRecurrence + : null; + const controlsSafe = ['irrelevant', 'stale'].every( + (arm) => + arms[arm].correctness.rate !== null && + arms.empty.correctness.rate !== null && + arms[arm].correctness.rate >= arms.empty.correctness.rate - 0.1 ); - const irrelevantSuppressed = arms.irrelevant.delivery.rate !== null - && arms.irrelevant.delivery.rate === 0; + const irrelevantSuppressed = + arms.irrelevant.delivery.rate !== null && + arms.irrelevant.delivery.rate === 0; const gates = { minimumPairs: naturalVsEmpty.pairs >= minimumPairs, capture: captureRate !== null && captureRate >= 0.8, recurrenceMagnitude: - relativeRecurrenceReduction !== null && relativeRecurrenceReduction >= 0.5, - recurrenceInterval: (naturalVsEmpty.executedMistakesPrevented.low ?? -Infinity) > 0, + relativeRecurrenceReduction !== null && + relativeRecurrenceReduction >= 0.5, + recurrenceInterval: + (naturalVsEmpty.executedMistakesPrevented.low ?? -Infinity) > 0, correctness: - arms.natural.correctness.rate !== null - && arms.empty.correctness.rate !== null - && arms.natural.correctness.rate >= arms.empty.correctness.rate - 0.1, - preActionDelivery: preActionDeliveryRate !== null && preActionDeliveryRate >= 0.8, + arms.natural.correctness.rate !== null && + arms.empty.correctness.rate !== null && + arms.natural.correctness.rate >= arms.empty.correctness.rate - 0.1, + preActionDelivery: + preActionDeliveryRate !== null && preActionDeliveryRate >= 0.8, negativeControls: controlsSafe && irrelevantSuppressed, }; const claimReady = Object.values(gates).every(Boolean); @@ -1267,14 +1574,24 @@ function handoffCohorts(runs) { function concurrencySummary(runs) { const natural = runs.filter((run) => run.arm === 'natural'); const writers = natural.reduce((sum, run) => sum + (run.writerCount || 0), 0); - const captures = natural.reduce((sum, run) => sum + (run.captureSuccesses || 0), 0); - const integrityPasses = natural.filter((run) => - run.integrity?.zeroLoss - && run.integrity?.parseable - && run.integrity?.orphanedFindings === 0 + const captures = natural.reduce( + (sum, run) => sum + (run.captureSuccesses || 0), + 0 + ); + const integrityPasses = natural.filter( + (run) => + run.integrity?.zeroLoss && + run.integrity?.parseable && + run.integrity?.orphanedFindings === 0 ).length; - const delivered = natural.reduce((sum, run) => sum + (run.delivery?.delivered || 0), 0); - const expected = natural.reduce((sum, run) => sum + (run.delivery?.expected || 0), 0); + const delivered = natural.reduce( + (sum, run) => sum + (run.delivery?.delivered || 0), + 0 + ); + const expected = natural.reduce( + (sum, run) => sum + (run.delivery?.expected || 0), + 0 + ); const effect = handoffPairedEffect(runs, 'empty', 'natural'); return { runs: runs.length, @@ -1284,7 +1601,8 @@ function concurrencySummary(runs) { integrityPassRate: natural.length ? integrityPasses / natural.length : null, deliveryCoverage: expected ? delivered / expected : null, naturalCorrectness: proportionInterval( - natural.filter((run) => run.consumer?.correct).length, natural.length + natural.filter((run) => run.consumer?.correct).length, + natural.length ), effect, }; @@ -1295,17 +1613,26 @@ function concurrencySummary(runs) { * live hook traces, and randomized model evals remain visibly distinct: only * `eval-run` records with paired arms can produce a causal effect interval. */ -function buildEvidenceReport(evidence, { filters = {}, episodeLimit = 100 } = {}) { +function buildEvidenceReport( + evidence, + { filters = {}, episodeLimit = 100 } = {} +) { evidence = evidence.filter((event) => matchesFilters(event, filters)); const runs = evidence.filter((event) => event.kind === 'eval-run'); const handoffRuns = evidence.filter((event) => event.kind === 'handoff-run'); - const concurrencyRuns = evidence.filter((event) => event.kind === 'concurrency-run'); + const concurrencyRuns = evidence.filter( + (event) => event.kind === 'concurrency-run' + ); const injections = evidence.filter((event) => event.kind === 'inject'); const outcomes = evidence.filter((event) => event.kind === 'tool-outcome'); - const feedback = evidence.filter((event) => event.kind === 'finding-feedback'); + const feedback = evidence.filter( + (event) => event.kind === 'finding-feedback' + ); const byInjection = new Map( - outcomes.filter((event) => event.injectionId).map((event) => [event.injectionId, event]) + outcomes + .filter((event) => event.injectionId) + .map((event) => [event.injectionId, event]) ); const traced = injections .slice() @@ -1335,7 +1662,10 @@ function buildEvidenceReport(evidence, { filters = {}, episodeLimit = 100 } = {} grouped.get(key).push(run); } - const minimumPairs = Math.max(2, Number(process.env.TOKEN_OPTIMIZER_EVAL_MIN_PAIRS) || 5); + const minimumPairs = Math.max( + 2, + Number(process.env.TOKEN_OPTIMIZER_EVAL_MIN_PAIRS) || 5 + ); const cohorts = [...grouped.entries()].map(([key, cohortRuns]) => { const arms = Object.fromEntries( ['baseline', 'optimizer', 'retrieval', 'full'].map((arm) => [ @@ -1385,7 +1715,9 @@ function buildEvidenceReport(evidence, { filters = {}, episodeLimit = 100 } = {} cohorts: cohorts.length, harmfulFeedback: harmful, harmRate: feedback.length ? harmful / feedback.length : null, - evidenceStatus: cohorts.some((cohort) => cohort.evidenceStatus === 'causal estimate available') + evidenceStatus: cohorts.some( + (cohort) => cohort.evidenceStatus === 'causal estimate available' + ) ? 'causal estimates available' : 'insufficient randomized evidence', }, @@ -1394,7 +1726,8 @@ function buildEvidenceReport(evidence, { filters = {}, episodeLimit = 100 } = {} concurrency, episodes: traced, methodology: { - intervals: 'deterministic percentile bootstrap (95%); Wilson interval for correctness', + intervals: + 'deterministic percentile bootstrap (95%); Wilson interval for correctness', causalRule: 'matched pairs estimate optimizer vs baseline, retrieval vs optimizer, full vs retrieval, and full vs baseline', minimumPairs, diff --git a/hooks-core/observability.mjs b/hooks-core/observability.mjs index 172ed261f..d02799ba9 100644 --- a/hooks-core/observability.mjs +++ b/hooks-core/observability.mjs @@ -23,7 +23,7 @@ import { createHash, randomUUID } from 'node:crypto'; import { homedir } from 'node:os'; import { join } from 'node:path'; -export const HOOK_LOG_SCHEMA_VERSION = 1; +export const HOOK_LOG_SCHEMA_VERSION = 2; const DEFAULT_MAX_BYTES = 5 * 1024 * 1024; const DEFAULT_RETENTION_DAYS = 14; const DEFAULT_MAX_FILES = 40; @@ -194,6 +194,8 @@ export function beginHookInvocation(client, event, options = {}) { turnIdHash: null, toolUseIdHash: null, toolName: null, + rawToolName: null, + codeModeEnvelope: false, clientVersion: null, model: null, cwdHash: null, @@ -205,12 +207,29 @@ export function beginHookInvocation(client, event, options = {}) { const spanId = hash(invocationId, 16); let traceId = hash(invocationId, 32); + // Write the start before reading stdin or importing project state. If the + // host kills the process, Node aborts, or a native call terminates without a + // JavaScript exception, diagnostics can now reconcile this record with the + // missing completion instead of reporting a false success rate. + writeHookEvent({ + level: 'info', + event: 'hook.started', + invocationId, + traceId, + spanId, + client, + hookEvent: event, + processId: process.pid, + }); + let deadline; const finish = (nextOutcome = outcome, nextReason = reason, nextError = error) => { if (ended) return; ended = true; if (deadline) clearTimeout(deadline); process.removeListener('exit', onExit); + process.removeListener('uncaughtException', onUncaughtException); + process.removeListener('unhandledRejection', onUnhandledRejection); writeHookEvent({ level: nextOutcome === 'failure' || nextOutcome === 'timeout' ? 'error' : 'info', event: 'hook.completed', @@ -228,7 +247,17 @@ export function beginHookInvocation(client, event, options = {}) { }); }; const onExit = () => finish(); + const onUncaughtException = (nextError) => { + finish('failure', 'uncaught_exception', nextError); + process.exit(0); + }; + const onUnhandledRejection = (nextError) => { + finish('failure', 'unhandled_rejection', nextError); + process.exit(0); + }; process.once('exit', onExit); + process.once('uncaughtException', onUncaughtException); + process.once('unhandledRejection', onUnhandledRejection); deadline = setTimeout(() => { outcome = 'timeout'; @@ -247,6 +276,11 @@ export function beginHookInvocation(client, event, options = {}) { dimensions.turnIdHash = hash(raw.turn_id ?? raw.turnId ?? null); dimensions.toolUseIdHash = hash(raw.tool_use_id ?? raw.toolUseId ?? null); dimensions.toolName = payload?.tool_name ?? raw.tool_name ?? raw.toolName ?? null; + dimensions.rawToolName = dimension( + raw.tool_name ?? raw.toolName ?? raw.tool ?? null + ); + dimensions.codeModeEnvelope = + payload?.tool_input?.code_mode_envelope === true; dimensions.clientVersion = dimension(raw.client_version ?? raw.clientVersion ?? null); dimensions.model = dimension(raw.model ?? raw.model_name ?? raw.modelName ?? null); dimensions.cwdHash = hash(raw.cwd ?? raw.working_directory ?? process.cwd()); @@ -288,6 +322,11 @@ export function beginHookInvocation(client, event, options = {}) { error = nextError; finish(outcome, reason, error); }, + block(nextReason = 'policy_denied') { + outcome = 'blocked'; + reason = nextReason; + finish(outcome, reason); + }, succeed(nextReason = 'completed') { outcome = 'success'; reason = nextReason; @@ -318,7 +357,11 @@ export function recordHookBootstrapFailure(client, event, error) { }); } -export function readHookEvents({ limit = 200, sinceMs = 24 * 60 * 60 * 1000 } = {}) { +export function readHookEvents({ + limit = 200, + sinceMs = 24 * 60 * 60 * 1000, + includeLifecycle = false, +} = {}) { try { const dir = hookLogDirectory(); if (!existsSync(dir)) return []; @@ -335,6 +378,7 @@ export function readHookEvents({ limit = 200, sinceMs = 24 * 60 * 60 * 1000 } = try { const event = JSON.parse(line); if (Date.parse(event.timestamp) < cutoff) continue; + if (!includeLifecycle && event.event === 'hook.started') continue; events.push(event); if (events.length >= limit) return events; } catch { @@ -350,38 +394,102 @@ export function readHookEvents({ limit = 200, sinceMs = 24 * 60 * 60 * 1000 } = export function hookHealthSummary(options = {}) { const sinceMs = options.sinceMs ?? 24 * 60 * 60 * 1000; - const events = readHookEvents({ limit: options.limit || 5000, sinceMs }); - const durations = events + const events = readHookEvents({ + limit: options.limit || 10000, + sinceMs, + includeLifecycle: true, + }); + const starts = events.filter((event) => event.event === 'hook.started'); + const completed = events.filter( + (event) => event.event === 'hook.completed' || !event.event + ); + const completedIds = new Set( + completed.map((event) => event.invocationId).filter(Boolean) + ); + const abandoned = starts + .filter( + (event) => + event.invocationId && + !completedIds.has(event.invocationId) && + Date.now() - Date.parse(event.timestamp) > 10_000 + ) + .map((event) => ({ + ...event, + event: 'hook.completed', + outcome: 'failure', + reason: 'process_terminated_before_completion', + durationMs: null, + })); + const runs = [...completed, ...abandoned]; + const durations = runs .map((event) => Number(event.durationMs)) .filter(Number.isFinite) .sort((a, b) => a - b); - const failures = events.filter((event) => event.outcome === 'failure'); - const timeouts = events.filter((event) => event.outcome === 'timeout'); - const skipped = events.filter((event) => event.outcome === 'skipped'); - const successes = events.filter((event) => event.outcome === 'success'); + const failures = runs.filter((event) => event.outcome === 'failure'); + const timeouts = runs.filter((event) => event.outcome === 'timeout'); + const skipped = runs.filter((event) => event.outcome === 'skipped'); + const blocked = runs.filter((event) => event.outcome === 'blocked'); + const successes = runs.filter((event) => event.outcome === 'success'); + const actions = runs.filter( + (event) => event.hookEvent === 'pre-tool' && event.toolName + ); + const byTool = {}; + for (const event of actions) { + const name = String(event.toolName || 'Unknown action'); + byTool[name] = (byTool[name] || 0) + 1; + } const byClient = {}; - for (const event of events) { + for (const event of runs) { const key = event.client || 'unknown'; - const current = byClient[key] || { total: 0, failures: 0, timeouts: 0, skipped: 0 }; + const current = byClient[key] || { + total: 0, + failures: 0, + timeouts: 0, + skipped: 0, + blocked: 0, + hookEvents: [], + }; current.total++; if (event.outcome === 'failure') current.failures++; if (event.outcome === 'timeout') current.timeouts++; if (event.outcome === 'skipped') current.skipped++; + if (event.outcome === 'blocked') current.blocked++; + if (event.hookEvent && !current.hookEvents.includes(event.hookEvent)) + current.hookEvents.push(event.hookEvent); byClient[key] = current; } + for (const current of Object.values(byClient)) current.hookEvents.sort(); const percentile = (p) => durations.length ? durations[Math.min(durations.length - 1, Math.floor(durations.length * p))] : null; const summary = { schemaVersion: HOOK_LOG_SCHEMA_VERSION, - available: events.length > 0, + available: runs.length > 0, windowHours: sinceMs / 3_600_000, - total: events.length, + total: runs.length, failures: failures.length, timeouts: timeouts.length, skipped: skipped.length, - successRate: events.length ? successes.length / events.length : null, + blocked: blocked.length, + abandoned: abandoned.length, + successes: successes.length, + actions: actions.length, + byTool, + // A skipped or policy-blocked hook completed its protocol successfully. + // Runtime health answers whether the hook process worked, not whether the + // optimizer chose to allow the host operation. + successRate: runs.length + ? (successes.length + skipped.length + blocked.length) / runs.length + : null, + healthStatus: + failures.length || timeouts.length + ? 'failing' + : skipped.length + ? 'degraded' + : runs.length + ? 'healthy' + : 'unavailable', p50DurationMs: percentile(0.5), p95DurationMs: percentile(0.95), byClient, diff --git a/hooks-core/pricing.mjs b/hooks-core/pricing.mjs index 4f2de0c24..a2eb4b3f3 100644 --- a/hooks-core/pricing.mjs +++ b/hooks-core/pricing.mjs @@ -1,99 +1,70 @@ /** - * Tokens in the unit people actually feel. + * Optional cost equivalents for token measurements. * - * The same data as everywhere else in this project, converted once, in one - * place, under three rules that keep a dollar figure honest: - * - * THE TABLE IS VISIBLE AND OVERRIDABLE. Prices change. A figure computed from - * rates buried in code goes stale silently and keeps looking authoritative, - * which is worse than no figure. The rates are printed next to the number and - * can be replaced without a release. - * - * DOLLARS BESIDE TOKENS, NOT INSTEAD OF THEM. The token count is exact and - * never goes out of date; the dollar figure is the token count times a rate - * that will. Showing both means the durable number survives the perishable - * one. - * - * NEVER A PRICE ON AN UNMEASURED SAVING. An invented number is worse in - * dollars than in tokens, because dollars get quoted to other people. A - * saving we cannot measure returns null and renders as "not yet measurable", - * never as "$0.00" -- which reads as "saved nothing" and is a different - * claim entirely. + * The runtime cannot infer a user's bill from a client name. A CLI may route + * through a subscription, direct API account, cloud marketplace, enterprise + * agreement, included credits, or a provider whose cache-read/write prices + * differ. Provider usage records are the source of truth when available. + * Everywhere else this module fails closed until the user supplies an + * effective blended input-token rate. */ -/** Cache multipliers, relative to an input token. */ -const CACHE_WRITE = 1.25; -const CACHE_READ = 0.1; +const RATE_ENV = 'TOKEN_OPTIMIZER_EFFECTIVE_INPUT_USD_PER_MILLION'; -/** - * Dollars per million tokens, by tier. - * - * Approximate published list rates as of 2026-07, carried with their date so a - * stale table is visible rather than silent. Override wholesale with - * TOKEN_OPTIMIZER_PRICES as JSON, e.g. - * {"asOf":"2026-09","opus":{"input":15,"output":75}}. - */ -const DEFAULT_PRICES = { - asOf: '2026-07', - haiku: { input: 1, output: 5 }, - sonnet: { input: 3, output: 15 }, - opus: { input: 15, output: 75 }, -}; - -export function prices() { - const raw = process.env.TOKEN_OPTIMIZER_PRICES; - if (raw) { - try { - const parsed = JSON.parse(raw); - if (parsed && typeof parsed === 'object') return { ...DEFAULT_PRICES, ...parsed, overridden: true }; - } catch { /* an unparseable override is ignored rather than fatal */ } - } - return DEFAULT_PRICES; +/** The configured effective input rate, or null when billing is unobservable. */ +export function effectiveRate() { + const rate = Number(process.env[RATE_ENV]); + return Number.isFinite(rate) && rate > 0 ? rate : null; } /** - * What a number of tokens costs. + * Dollars for a measured token quantity. * - * Returns null for a null input and says why: this is the single place the - * "unmeasured must not become $0.00" rule is enforced, so every caller inherits - * it rather than each remembering. + * `kind` is accepted for backwards compatibility but is not multiplied by a + * universal cache constant. The configured rate must already reflect the + * user's actual provider/model/cache/tier/credit mix. */ -export function dollars(tokens, { tier = 'opus', kind = 'input' } = {}) { - if (!Number.isFinite(tokens)) return null; - - const table = prices(); - const rate = table[tier]?.[kind === 'output' ? 'output' : 'input']; - if (!Number.isFinite(rate)) return null; - - const multiplier = kind === 'cacheWrite' ? CACHE_WRITE : kind === 'cacheRead' ? CACHE_READ : 1; - return (tokens / 1_000_000) * rate * multiplier; +export function dollars(tokens, { rate = effectiveRate() } = {}) { + if (!Number.isFinite(tokens) || !Number.isFinite(rate) || rate <= 0) { + return null; + } + return (tokens / 1_000_000) * rate; } /** A dollar figure, or an honest absence of one. */ -export function money(amount, { unmeasured = 'not yet measurable' } = {}) { +export function money(amount, { unmeasured = 'not priced' } = {}) { if (amount == null || !Number.isFinite(amount)) return unmeasured; if (amount === 0) return '$0.00'; if (Math.abs(amount) < 0.01) return '<$0.01'; return `$${amount.toFixed(2)}`; } -/** - * Per-session tokens expressed as a monthly cost. - * - * `sessionsPerMonth` is an assumption, so it is stated in the output rather - * than folded invisibly into the number. - */ -export function monthly(tokensPerSession, { tier = 'opus', sessionsPerMonth = 60 } = {}) { +/** Per-session tokens expressed with a stated monthly-frequency assumption. */ +export function monthly(tokensPerSession, { sessionsPerMonth = 60 } = {}) { if (!Number.isFinite(tokensPerSession)) return null; - const amount = dollars(tokensPerSession * sessionsPerMonth, { tier }); - return amount == null ? null : { amount, sessionsPerMonth, tier }; + const amount = dollars(tokensPerSession * sessionsPerMonth); + return amount == null + ? null + : { + amount, + sessionsPerMonth, + effectiveInputUsdPerMillion: effectiveRate(), + }; +} + +/** The pricing basis printed beside any cost equivalent. */ +export function priceNote() { + const rate = effectiveRate(); + return rate == null + ? `cost not priced: set ${RATE_ENV} to your effective blended input rate after provider, model, cache, tier, plan, and credits` + : `cost equivalent: configured effective input rate $${rate}/M via ${RATE_ENV}`; } -/** The rates behind the figures, for printing beside them. */ -export function priceNote(tier = 'opus') { - const table = prices(); - const rate = table[tier]; - if (!rate) return null; - return `prices: ${tier} $${rate.input}/$${rate.output} per Mtok (${table.asOf}` + - `${table.overridden ? ', overridden' : ''}); override with TOKEN_OPTIMIZER_PRICES`; +/** Compatibility surface: no built-in provider table is claimed. */ +export function prices() { + return { + source: + effectiveRate() == null ? 'unavailable' : 'configured-effective-rate', + effectiveInputUsdPerMillion: effectiveRate(), + }; } diff --git a/hooks-core/routing.mjs b/hooks-core/routing.mjs index baeaa7838..1dcdcdf5d 100644 --- a/hooks-core/routing.mjs +++ b/hooks-core/routing.mjs @@ -63,7 +63,9 @@ export function modelCosts() { if (raw) { try { return JSON.parse(raw); - } catch { /* fall through to the defaults */ } + } catch { + /* fall through to the defaults */ + } } return { haiku: 1, sonnet: 3, opus: 15 }; } @@ -104,7 +106,12 @@ export function readEpisodes(path, { maxBytes = 6_000_000 } = {}) { let current = null; const close = () => { - if (current && current.turns > 0) episodes.push({ ...current, files: [...current.files], tools: [...current.tools] }); + if (current && current.turns > 0) + episodes.push({ + ...current, + files: [...current.files], + tools: [...current.tools], + }); current = null; }; @@ -121,7 +128,15 @@ export function readEpisodes(path, { maxBytes = 6_000_000 } = {}) { // a new episode. if (row.type === 'user' && typeof row.message?.content === 'string') { close(); - current = { at: Date.parse(row.timestamp) || null, models: new Set(), tools: new Set(), files: new Set(), turns: 0, errors: 0, results: 0 }; + current = { + at: Date.parse(row.timestamp) || null, + models: new Set(), + tools: new Set(), + files: new Set(), + turns: 0, + errors: 0, + results: 0, + }; continue; } if (!current) continue; @@ -155,8 +170,16 @@ export function readEpisodes(path, { maxBytes = 6_000_000 } = {}) { /* ---------------------------------------------------------------- SHAPES */ -const EDIT_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit', 'smart_edit', 'smart_write']); -const TEST_PATTERN = /\b(test|jest|pytest|vitest|dotnet test|npm test|go test)\b/i; +const EDIT_TOOLS = new Set([ + 'Edit', + 'MultiEdit', + 'Write', + 'NotebookEdit', + 'smart_edit', + 'smart_write', +]); +const TEST_PATTERN = + /\b(test|jest|pytest|vitest|dotnet test|npm test|go test)\b/i; /** * What kind of work this was. @@ -169,11 +192,19 @@ export function classifyShape(episode) { const tools = new Set(episode.tools || []); const edited = [...tools].some((t) => EDIT_TOOLS.has(t)); const files = (episode.files || []).length; - const ranTests = [...tools].some((t) => TEST_PATTERN.test(t)) || (episode.tools || []).includes('Bash'); + const ranTests = + [...tools].some((t) => TEST_PATTERN.test(t)) || + (episode.tools || []).includes('Bash'); if (edited && files >= 3) return 'multi-file-change'; if (edited) return 'single-file-change'; - if (files > 0 || tools.has('Grep') || tools.has('Glob') || tools.has('smart_grep')) return 'investigation'; + if ( + files > 0 || + tools.has('Grep') || + tools.has('Glob') || + tools.has('smart_grep') + ) + return 'investigation'; if (ranTests) return 'build-or-test'; return 'conversation'; } @@ -206,7 +237,13 @@ export function outcomeTable(episodes) { const tier = [...tiers][0]; table[shape] = table[shape] || {}; - const cell = table[shape][tier] = table[shape][tier] || { episodes: 0, turns: 0, errors: 0, results: 0, errored: 0 }; + const cell = (table[shape][tier] = table[shape][tier] || { + episodes: 0, + turns: 0, + errors: 0, + results: 0, + errored: 0, + }); cell.episodes += 1; cell.turns += episode.turns; cell.errors += episode.errors; @@ -285,7 +322,8 @@ export function route(shape, table = {}) { shape, recommend: heuristic, basis: 'heuristic', - reason: `every measured tier for ${shape} needed a retry in more than ` + + reason: + `every measured tier for ${shape} needed a retry in more than ` + `${Math.round(MAX_ERROR_RATE * 100)}% of episodes; falling back to the shipped default`, excluded: excluded.map((o) => ({ tier: o.tier, errorRate: o.errorRate })), options, @@ -304,17 +342,24 @@ export function route(shape, table = {}) { recommend: best.tier, basis: 'measured', // What each mistake would cost, from this project's own rates. - overpowered: strongest.tier === best.tier ? null : { - tier: strongest.tier, - wasted: Number((strongest.cost - best.cost).toFixed(2)), - }, - underpowered: cheapest.tier === best.tier ? null : { - tier: cheapest.tier, - expectedCost: Number(cheapest.cost.toFixed(2)), - errorRate: cheapest.errorRate, - excluded: cheapest.errorRate > MAX_ERROR_RATE, - }, - reason: `${shape}: ${best.tier} costs ${best.cost.toFixed(2)} in expectation over ${best.episodes} episodes ` + + overpowered: + strongest.tier === best.tier + ? null + : { + tier: strongest.tier, + wasted: Number((strongest.cost - best.cost).toFixed(2)), + }, + underpowered: + cheapest.tier === best.tier + ? null + : { + tier: cheapest.tier, + expectedCost: Number(cheapest.cost.toFixed(2)), + errorRate: cheapest.errorRate, + excluded: cheapest.errorRate > MAX_ERROR_RATE, + }, + reason: + `${shape}: ${best.tier} costs ${best.cost.toFixed(2)} in expectation over ${best.episodes} episodes ` + `(${Math.round(best.errorRate * 100)}% needed a retry)`, options, }; @@ -338,20 +383,31 @@ export function routingNote(shape, table, { currentModel, switchCost } = {}) { const lines = [decision.reason]; if (decision.underpowered) { - lines.push(` ${decision.underpowered.tier} looks cheaper per token, but needed a retry in ` + - `${Math.round(decision.underpowered.errorRate * 100)}% of episodes -- expected cost ${decision.underpowered.expectedCost}` + - `${decision.underpowered.excluded ? ', which is why it is not a candidate here at any price' : ''}.`); + lines.push( + ` ${decision.underpowered.tier} looks cheaper per token, but needed a retry in ` + + `${Math.round(decision.underpowered.errorRate * 100)}% of episodes -- expected cost ${decision.underpowered.expectedCost}` + + `${decision.underpowered.excluded ? ', which is why it is not a candidate here at any price' : ''}.` + ); } if (decision.overpowered) { - lines.push(` ${decision.overpowered.tier} would spend ${decision.overpowered.wasted} more than needed here.`); + lines.push( + ` ${decision.overpowered.tier} would spend ${decision.overpowered.wasted} more than needed here.` + ); } if (switchCost?.prefixTokens) { - lines.push(` Switching now also discards a ${switchCost.prefixTokens.toLocaleString()}-token warm prefix ` + - `(~${switchCost.rewriteCost.toLocaleString()} tokens to re-write); consider switching at the next break.`); + lines.push( + ` Switching now also discards a ${switchCost.prefixTokens.toLocaleString()}-token warm prefix ` + + `(~${switchCost.rewriteInputCostEquivalent.toLocaleString()} Anthropic uncached-input cost-equivalent tokens to re-write); consider switching at the next break.` + ); } - return { shape, recommend: decision.recommend, basis: decision.basis, text: lines.join('\n') }; + return { + shape, + recommend: decision.recommend, + basis: decision.basis, + text: lines.join('\n'), + }; } /** @@ -369,7 +425,9 @@ export function routingBriefing(table) { const decision = route(shape, table); if (decision.basis !== 'measured') continue; if (decision.recommend === HEURISTIC[shape]) continue; // nothing learned worth saying - lines.push(`${shape.replace(/-/g, ' ')} work goes better on ${decision.recommend} in this project.`); + lines.push( + `${shape.replace(/-/g, ' ')} work goes better on ${decision.recommend} in this project.` + ); } return lines.length ? lines.join('\n') : null; } @@ -387,7 +445,11 @@ export function routingBriefing(table) { * few more episodes accumulated would invalidate the cache behind it. Stability * is the feature; the memo is how it is bought. */ -export function cachedRoutingBriefing(dir, transcriptPath, { growthBytes = 4_000_000, maxAgeMs = 24 * 60 * 60 * 1000 } = {}) { +export function cachedRoutingBriefing( + dir, + transcriptPath, + { growthBytes = 4_000_000, maxAgeMs = 24 * 60 * 60 * 1000 } = {} +) { const memoPath = join(dir, 'routing-brief.json'); let stat; try { @@ -398,17 +460,31 @@ export function cachedRoutingBriefing(dir, transcriptPath, { growthBytes = 4_000 try { const memo = JSON.parse(readFileSync(memoPath, 'utf8')); - const fresh = memo.path === transcriptPath - && Math.abs(stat.size - memo.size) < growthBytes - && Date.now() - memo.at < maxAgeMs; + const fresh = + memo.path === transcriptPath && + Math.abs(stat.size - memo.size) < growthBytes && + Date.now() - memo.at < maxAgeMs; if (fresh) return memo.text || null; - } catch { /* no memo, or an unreadable one; recompute */ } + } catch { + /* no memo, or an unreadable one; recompute */ + } const text = routingBriefing(outcomeTable(readEpisodes(transcriptPath))); try { mkdirSync(dir, { recursive: true, mode: 0o700 }); - writeFileSync(memoPath, JSON.stringify({ path: transcriptPath, size: stat.size, at: Date.now(), text }), { mode: 0o600 }); - } catch { /* the memo is an optimisation; failing to write it costs latency, not correctness */ } + writeFileSync( + memoPath, + JSON.stringify({ + path: transcriptPath, + size: stat.size, + at: Date.now(), + text, + }), + { mode: 0o600 } + ); + } catch { + /* the memo is an optimisation; failing to write it costs latency, not correctness */ + } return text; } @@ -425,9 +501,11 @@ export function routingReport(table) { for (const tier of Object.keys(table[shape])) { const cell = table[shape][tier]; const cost = expectedCost(tier, cell); - lines.push(` ${tier.padEnd(7)} ${cell.episodes} episode(s), ` + - `${cell.meanTurns.toFixed(1)} turns avg, ${Math.round(cell.errorRate * 100)}% needed a retry, ` + - `expected cost ${cost.cost.toFixed(2)}${cell.measured ? '' : ' [too few episodes to trust]'}`); + lines.push( + ` ${tier.padEnd(7)} ${cell.episodes} episode(s), ` + + `${cell.meanTurns.toFixed(1)} turns avg, ${Math.round(cell.errorRate * 100)}% needed a retry, ` + + `expected cost ${cost.cost.toFixed(2)}${cell.measured ? '' : ' [too few episodes to trust]'}` + ); } } return lines.join('\n'); diff --git a/hooks-core/symbols.mjs b/hooks-core/symbols.mjs index 76d775d54..650fb8701 100644 --- a/hooks-core/symbols.mjs +++ b/hooks-core/symbols.mjs @@ -48,7 +48,11 @@ const PATTERNS = { /^\s*(?:pub(?:\([^)]*\))?\s+)?(?:struct|enum|trait|impl)\s+([A-Za-z_]\w*)/, ], clike: [ - /^\s*(?:public|private|protected|internal|static|final|abstract|virtual|override|async|\s)*(?:[\w<>\[\],.?]+\s+)([A-Za-z_]\w*)\s*\([^;]*\)\s*\{/, + // Accept both K&R (`Run() {`) and Allman (`Run()` then `{`) declarations. + // Requiring the opening brace on the declaration line silently discarded + // nearly every method in idiomatic C# projects, leaving file nodes but no + // symbol nodes and making valid `file.cs#Method` anchors unresolved. + /^\s*(?:public|private|protected|internal|static|final|abstract|virtual|override|async|sealed|new|extern|unsafe|partial|\s)*(?:[\w<>\[\],.?]+\s+)([A-Za-z_]\w*)(?:<[^;()]+>)?\s*\([^;]*\)\s*(?:\{|=>|$)/, /^\s*(?:public|private|protected|internal|static|abstract|sealed|partial|\s)*(?:class|struct|interface|enum|record)\s+([A-Za-z_]\w*)/, ], ruby: [ diff --git a/integrations/cline/hooks/token-optimizer/lib/adapter.mjs b/integrations/cline/hooks/token-optimizer/lib/adapter.mjs index fee0102b9..9f76bbdea 100644 --- a/integrations/cline/hooks/token-optimizer/lib/adapter.mjs +++ b/integrations/cline/hooks/token-optimizer/lib/adapter.mjs @@ -188,8 +188,143 @@ function contextOutput(client, eventName, additionalContext) { }; } +/** Convert one static JavaScript string literal without evaluating code. */ +function codexStringLiteral(literal) { + if (typeof literal !== 'string' || literal.length < 2) return null; + const quote = literal[0]; + if (!['"', "'", '`'].includes(quote) || literal.at(-1) !== quote) return null; + + let decoded = ''; + for (let index = 1; index < literal.length - 1; index += 1) { + const character = literal[index]; + if (quote === '`' && character === '$' && literal[index + 1] === '{') + return null; + if (character !== '\\') { + decoded += character; + continue; + } + + index += 1; + if (index >= literal.length - 1) return null; + const escaped = literal[index]; + const simple = { + b: '\b', f: '\f', n: '\n', r: '\r', t: '\t', v: '\v', 0: '\0', + '\\': '\\', "'": "'", '"': '"', '`': '`', '$': '$', + }; + if (Object.hasOwn(simple, escaped)) { + decoded += simple[escaped]; + continue; + } + if (escaped === '\n') continue; + if (escaped === '\r') { + if (literal[index + 1] === '\n') index += 1; + continue; + } + if (escaped === 'x') { + const digits = literal.slice(index + 1, index + 3); + if (!/^[0-9a-f]{2}$/i.test(digits)) return null; + decoded += String.fromCodePoint(Number.parseInt(digits, 16)); + index += 2; + continue; + } + if (escaped === 'u') { + const braced = literal[index + 1] === '{'; + const close = braced ? literal.indexOf('}', index + 2) : index + 5; + const digits = braced + ? literal.slice(index + 2, close) + : literal.slice(index + 1, close); + if ( + close < 0 || + !(braced ? /^[0-9a-f]{1,6}$/i : /^[0-9a-f]{4}$/i).test(digits) + ) return null; + const codePoint = Number.parseInt(digits, 16); + if (codePoint > 0x10ffff) return null; + decoded += String.fromCodePoint(codePoint); + index = close; + continue; + } + // JavaScript identity escapes drop the slash (for example, '\q' is 'q'). + decoded += escaped; + } + return decoded; +} + /** Convert client-specific lifecycle envelopes into the common tool shape. */ +function codexStringBindings(source) { + const values = new Map(); + const declaration = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*((?:"(?:\\.|[^"\\])*")|(?:'(?:\\.|[^'\\])*')|(?:`(?:\\.|[^`\\])*`))/gs; + for (const match of String(source || '').matchAll(declaration)) { + try { + const value = codexStringLiteral(match[2]); + if (value !== null) values.set(match[1], value); + } catch { + // A computed or malformed JavaScript expression is not safe to execute + // merely to understand a hook envelope. Leave it opaque. + } + } + return values; +} + +function codexCallArguments(source, method) { + const calls = []; + const pattern = new RegExp(`(?:tools\\.)?${method}\\s*\\(\\s*([^),]+)`, 'gi'); + for (const match of String(source || '').matchAll(pattern)) + calls.push(match[1].trim()); + return calls; +} + +function codexLiteral(value, bindings) { + if (bindings.has(value)) return String(bindings.get(value)); + return codexStringLiteral(value); +} + +function codexExecCommands(source, bindings) { + const commands = []; + for (const call of String(source || '').matchAll( + /(?:tools\.)?exec_command\s*\(\s*\{([\s\S]*?)\}\s*\)/gi + )) { + const property = /\bcmd\s*:\s*((?:"(?:\\.|[^"\\])*")|(?:'(?:\\.|[^'\\])*')|(?:`(?:\\.|[^`\\])*`)|(?:[A-Za-z_$][\w$]*))/s.exec(call[1]); + if (!property) continue; + const command = codexLiteral(property[1], bindings); + if (command) commands.push(command); + } + return commands; +} + export function normalizeClientPayload(clientName, event, raw) { + if (clientName === 'codex') { + const outerName = String(raw.tool_name ?? raw.toolName ?? raw.tool ?? ''); + if (/^(?:functions\.)?exec$/i.test(outerName)) { + const envelope = raw.tool_input ?? raw.toolInput ?? raw.arguments ?? raw.args; + const source = + typeof envelope === 'string' + ? envelope + : envelope?.code ?? envelope?.input ?? envelope?.source ?? ''; + const bindings = codexStringBindings(source); + const patchCalls = codexCallArguments(source, 'apply_patch'); + const patch = patchCalls + .map((argument) => codexLiteral(argument, bindings)) + .find(Boolean); + const mutation = patchCalls.length > 0; + const commands = codexExecCommands(source, bindings); + + // `functions.exec` is also the orchestration envelope for web, image and + // other non-filesystem tools. Do not reinterpret those as shell commands: + // doing so produced irrelevant cross-project advice and fabricated graph + // activity for operations the filesystem hooks never observed. + if (!mutation && !commands.length) return raw; + return { + ...raw, + tool_name: mutation ? 'apply_patch' : 'run_command', + tool_input: { + ...(envelope && typeof envelope === 'object' ? envelope : {}), + command: String(patch || commands.join('\n') || source || ''), + code_mode_envelope: true, + }, + }; + } + } + if (clientName === 'cline') { const body = event === 'post-tool' ? raw.postToolUse : raw.preToolUse; if (!body) return raw; @@ -385,6 +520,22 @@ function projectBriefing() { function observeAndInject(payload, state, episode, features) { const touched = touchedFiles(payload); const dirFor = (path) => wikiDir(projectRootFor(path, payload.cwd)); + const registerRoot = (root) => { + const dir = wikiDir(root); + registerProject({ + root, + graphDir: dir, + client: episode.client || 'unknown', + }); + return dir; + }; + + // SessionStart is not delivered consistently by every host/version. The + // graph was still written in that case, but the dashboard registry never + // learned it existed. Register every repository observed by a real tool call + // as a second, idempotent production path. + for (const { path } of touched) + registerRoot(projectRootFor(path, payload.cwd)); const bytes = readCostBytes(payload); if (bytes && payload.tool_input.file_path) { @@ -425,7 +576,7 @@ function observeAndInject(payload, state, episode, features) { const command = payload.tool_input?.command; if (command) { const root = commandProjectRoot(payload, payload.cwd); - const dir = wikiDir(root); + const dir = registerRoot(root); const local = forCommand(dir, load(dir), command, { sessionId: payload.session_id, alreadyInjected, @@ -750,7 +901,14 @@ async function runHook(clientName, event, invocation) { client.canDeny && event === 'pre-tool' && !repeat && - mode() !== MODE_ADVISE + mode() !== MODE_ADVISE && + // Codex code mode presents an orchestration program as one outer + // `functions.exec` call. Refusing that envelope is rendered by the host + // as a failed script and can discard several otherwise-safe nested + // operations. Observe it, capture it and advise, but reserve a hard veto + // for legacy one-operation Codex tool calls where the denied operation is + // unambiguous. + !(clientName === 'codex' && payload.tool_input?.code_mode_envelope) ); if (canRefuse) { @@ -759,19 +917,23 @@ async function runHook(clientName, event, invocation) { // Every client's refusal carries the off switch, for the same reason Claude // Code's does: enforcement that hides its own disable is coercive. if (client.denyStyle === 'top-level') { + invocation.block('policy_denied'); emit({ decision: 'deny', reason: withEscape(verdict.reason) }); } else if (client.denyStyle === 'top-level-permission') { + invocation.block('policy_denied'); emit({ permissionDecision: 'deny', permissionDecisionReason: withEscape(verdict.reason), }); } else if (client.denyStyle === 'cline') { + invocation.block('policy_denied'); emit({ cancel: true, contextModification: '', errorMessage: withEscape(verdict.reason), }); } else if (client.denyStyle === 'cursor') { + invocation.block('policy_denied'); emit({ continue: true, permission: 'deny', @@ -780,9 +942,11 @@ async function runHook(clientName, event, invocation) { 'Token Optimizer redirected an expensive built-in operation.', }); } else if (client.denyStyle === 'exit-2') { + invocation.block('policy_denied'); process.stderr.write(withEscape(verdict.reason)); process.exit(2); } else { + invocation.block('policy_denied'); emit({ hookSpecificOutput: { hookEventName: eventName, diff --git a/integrations/cline/hooks/token-optimizer/lib/audit.mjs b/integrations/cline/hooks/token-optimizer/lib/audit.mjs index b82186586..df8a08b82 100644 --- a/integrations/cline/hooks/token-optimizer/lib/audit.mjs +++ b/integrations/cline/hooks/token-optimizer/lib/audit.mjs @@ -50,15 +50,17 @@ export function decline(dir, id) { export function declines(dir, { events = readMetrics(dir) } = {}) { const counts = new Map(); for (const event of events) { - if (event.kind !== 'advice' || event.action !== 'declined' || !event.id) continue; + if (event.kind !== 'advice' || event.action !== 'declined' || !event.id) + continue; counts.set(event.id, (counts.get(event.id) || 0) + 1); } return counts; } -const idOf = (finding) => finding.remedy - ? `${finding.remedy.type}:${finding.remedy.anchor || finding.remedy.file || (finding.remedy.anchors || []).join(',')}` - : `${finding.id}:${finding.anchor || finding.file || ''}`; +const idOf = (finding) => + finding.remedy + ? `${finding.remedy.type}:${finding.remedy.anchor || finding.remedy.file || (finding.remedy.anchors || []).join(',')}` + : `${finding.id}:${finding.anchor || finding.file || ''}`; /** * Whether a habit improved after we said something about it. @@ -68,12 +70,18 @@ const idOf = (finding) => finding.remedy * -- a trend computed from one session on each side is a coin flip with a * percentage sign. */ -export function habitTrend(dir, detector, { events = readMetrics(dir), minSessions = 2, anchors = null } = {}) { +export function habitTrend( + dir, + detector, + { events = readMetrics(dir), minSessions = 2, anchors = null } = {} +) { // SPLIT BY POSITION, NOT BY TIMESTAMP. The log is append-only, so its order is // authoritative -- while `at` has millisecond granularity, and a burst of // events inside one millisecond would land on the wrong side of the split or // collapse one side to nothing. Position is exact where the clock is not. - const raisedAt = events.findIndex((e) => e.kind === 'advice' && e.detector === detector); + const raisedAt = events.findIndex( + (e) => e.kind === 'advice' && e.detector === detector + ); if (raisedAt === -1) return null; // SCOPED TO THE FILES THIS DETECTOR NAMED. Counting every read in the window @@ -114,7 +122,12 @@ export function habitTrend(dir, detector, { events = readMetrics(dir), minSessio /** Marks a recommendation as having been raised, so its effect can be scored. */ export function raise(dir, finding) { - record(dir, { kind: 'advice', action: 'raised', id: idOf(finding), detector: finding.id }); + record(dir, { + kind: 'advice', + action: 'raised', + id: idOf(finding), + detector: finding.id, + }); } /** @@ -123,7 +136,11 @@ export function raise(dir, finding) { * `findings` comes from the detectors; `extra` lets the cache and routing * modules contribute without this file importing the world. */ -export function buildQueue(dir, findings = [], { events = readMetrics(dir) } = {}) { +export function buildQueue( + dir, + findings = [], + { events = readMetrics(dir) } = {} +) { const declined = declines(dir, { events }); const ledger = remedyLedger(dir); const applied = new Set(ledger.filter((r) => !r.revertedAt).map((r) => r.id)); @@ -146,7 +163,9 @@ export function buildQueue(dir, findings = [], { events = readMetrics(dir) } = { } return { - queue: queue.sort((a, b) => (b.costPerSession ?? 0) - (a.costPerSession ?? 0)), + queue: queue.sort( + (a, b) => (b.costPerSession ?? 0) - (a.costPerSession ?? 0) + ), suppressed, done: ledger.filter((r) => !r.revertedAt), }; @@ -161,7 +180,11 @@ export function buildQueue(dir, findings = [], { events = readMetrics(dir) } = { * many were withheld -- and what they are collectively worth -- keeps that from * being a silent cap. */ -export function renderAudit(dir, findings = [], { tier = 'opus', full = false, sessionsPerMonth = 60 } = {}) { +export function renderAudit( + dir, + findings = [], + { tier = 'opus', full = false, sessionsPerMonth = 60 } = {} +) { const { queue, suppressed, done } = buildQueue(dir, findings); const lines = []; @@ -175,22 +198,29 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s // read as "this costs nothing" beside a finding whose cost we simply do not // know -- the same unknown-becomes-zero error the rest of this project // corrects, in the one unit that gets quoted to other people. - const priced = item.costPerSession == null ? null : monthly(cost, { tier, sessionsPerMonth }); + const priced = + item.costPerSession == null + ? null + : monthly(cost, { tier, sessionsPerMonth }); // The id appears on EVERY actionable line, not just the appliable ones. It is the only // handle `decline` accepts and it cannot be guessed from the title, so omitting it made the // advertised decline path unreachable for precisely the findings most likely to be // unwanted -- model-routing findings carry no remedy, so they could never be suppressed no // matter how many times a user declined them. - const action = item.remedy?.kind === 'ours' ? `apply: waste_audit action="apply" id="${item.id}"` - : item.remedy?.kind === 'yours' ? `proposed edit -- needs your yes, nothing changed; decline: ${item.id}` - : `advice only -- no automatic fix; decline: ${item.id}`; + const action = + item.remedy?.kind === 'ours' + ? `apply: waste_audit action="apply" id="${item.id}"` + : item.remedy?.kind === 'yours' + ? `proposed edit -- needs your yes, nothing changed; decline: ${item.id}` + : `advice only -- no automatic fix; decline: ${item.id}`; // MEASURED ZERO IS NOT UNMEASURED. Testing `cost` for truthiness sent a real 0 down the // "not yet measurable" branch while `priced` above, which tests for null, still rendered // $0.00 beside it -- producing `cost not yet measurable (~$0.00/month)`, the exact string // the test suite declares must never appear. waste.mjs defaults every finding to 0 and // hard-sets it on the co-occurrence detector, so this is reachable, not theoretical. - const text = ` ${item.title}\n ${item.costPerSession != null ? `${cost.toLocaleString()} tokens/session` : 'cost not yet measurable'}` + + const text = + ` ${item.title}\n ${item.costPerSession != null ? `${cost.toLocaleString()} tokens/session` : 'cost not yet measurable'}` + `${priced ? ` (~${money(priced.amount)}/month)` : ''}; ${action}`; const printCost = estimate(text); @@ -211,14 +241,19 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s lines.push(text); } - const head = shown.length ? ['What to do next, most expensive first:', ...lines] : ['Nothing addressable found.']; + const head = shown.length + ? ['What to do next, most expensive first:', ...lines] + : ['Nothing addressable found.']; const body = [...head]; if (withheld.length) { const worth = withheld.reduce((sum, w) => sum + (w.costPerSession || 0), 0); - body.push('', ` ... ${withheld.length} more finding(s) worth ${worth.toLocaleString()} tokens/session in total, ` + - 'not shown -- printing them costs more than they are worth. Pass full=true to see all.'); + body.push( + '', + ` ... ${withheld.length} more finding(s) worth ${worth.toLocaleString()} tokens/session in total, ` + + 'not shown -- printing them costs more than they are worth. Pass full=true to see all.' + ); } // What we already told you, and what it bought. @@ -226,23 +261,35 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s body.push('', 'Already applied, and what it actually saved:'); for (const rule of done.slice(0, 6)) { const saved = rule.savedPerSession; - const priced = saved == null ? null : monthly(saved, { tier, sessionsPerMonth }); - body.push(` + ${rule.id} -- ${saved == null - ? 'not yet measurable (fewer than two sessions since)' - : `measured ${saved.toLocaleString()} tokens/session${priced ? `, ~${money(priced.amount)}/month` : ''}`}`); + const priced = + saved == null ? null : monthly(saved, { tier, sessionsPerMonth }); + body.push( + ` + ${rule.id} -- ${ + saved == null + ? 'not yet measurable (fewer than two sessions since)' + : `measured ${saved.toLocaleString()} tokens/session${priced ? `, ~${money(priced.amount)}/month` : ''}` + }` + ); } // Never a silent cap: the withheld block above states its remainder and so must this one. // Truncating to six with no disclosure showed a user six savings and let them conclude that // was everything the tool had bought them, which understates its own measured value. if (done.length > 6) { - body.push(` ... and ${done.length - 6} more applied fix(es), not shown.`); + body.push( + ` ... and ${done.length - 6} more applied fix(es), not shown.` + ); } } if (suppressed.length) { - body.push('', `Not repeating ${suppressed.length} recommendation(s) you have declined ` + - `${DECLINE_LIMIT}+ times. Ask for full=true to see them.`); - if (full) for (const item of suppressed) body.push(` - ${item.title} (declined ${item.times}x)`); + body.push( + '', + `Not repeating ${suppressed.length} recommendation(s) you have declined ` + + `${DECLINE_LIMIT}+ times. Ask for full=true to see them.` + ); + if (full) + for (const item of suppressed) + body.push(` - ${item.title} (declined ${item.times}x)`); } // Whether the advice changed anything. @@ -256,20 +303,30 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s for (const a of anchors) anchorsBy.get(f.id).add(a); } const trends = [...new Set(findings.map((f) => f.id))] - .map((detector) => habitTrend(dir, detector, { - anchors: anchorsBy.has(detector) ? [...anchorsBy.get(detector)] : null, - })) + .map((detector) => + habitTrend(dir, detector, { + anchors: anchorsBy.has(detector) ? [...anchorsBy.get(detector)] : null, + }) + ) .filter(Boolean); if (trends.length) { body.push('', 'Since the advice was given:'); for (const trend of trends) { - body.push(` ${trend.detector}: ${trend.before.toLocaleString()} -> ${trend.after.toLocaleString()} tokens/session ` + - `(${trend.improved ? 'improved' : 'worse'})`); + body.push( + ` ${trend.detector}: ${trend.before.toLocaleString()} -> ${trend.after.toLocaleString()} tokens/session ` + + `(${trend.improved ? 'improved' : 'worse'})` + ); } } - const addressable = queue.reduce((sum, item) => sum + (item.costPerSession || 0), 0); + const addressable = queue.reduce( + (sum, item) => sum + (item.costPerSession || 0), + 0 + ); + const addressableMonthly = addressable + ? monthly(addressable, { tier, sessionsPerMonth }) + : null; const note = priceNote(tier); if (note) body.push('', note); @@ -278,9 +335,10 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s // pushing it left the report's longest line out of its own reported cost, understating it by // 10-20% on a short audit. This figure exists to prove the audit is not a net loss, so // understating the cost side biases exactly the comparison it was written to be honest about. - const closing = (n) => `This report cost about ${n.toLocaleString()} tokens and names ` + + const closing = (n) => + `This report cost about ${n.toLocaleString()} tokens and names ` + `${addressable.toLocaleString()} tokens/session of addressable waste` + - `${addressable ? ` (~${money(monthly(addressable, { tier, sessionsPerMonth })?.amount)}/month across ${sessionsPerMonth} sessions)` : ''}.`; + `${addressableMonthly ? ` (~${money(addressableMonthly.amount)}/month across ${sessionsPerMonth} sessions)` : ''}.`; const selfCost = estimate([...body, '', closing(0)].join('\n')); body.push('', closing(selfCost)); diff --git a/integrations/cline/hooks/token-optimizer/lib/cache.mjs b/integrations/cline/hooks/token-optimizer/lib/cache.mjs index 9b11173ff..9782c3262 100644 --- a/integrations/cline/hooks/token-optimizer/lib/cache.mjs +++ b/integrations/cline/hooks/token-optimizer/lib/cache.mjs @@ -3,12 +3,12 @@ /** * Prompt-cache economics. * - * Cache economics is real money in a way most compression is not: a cache read - * costs a tenth of a fresh token and a cache WRITE costs a quarter more than - * one, so a prefix that keeps invalidating can cost more than every saving this - * product makes elsewhere. It is also the one area where we do not have to - * model anything -- the client's own transcript records cache_read_input_tokens - * and cache_creation_input_tokens per turn, which is ground truth. + * Anthropic prompt-cache usage, read from Claude Code transcripts. A cache read + * and a cache write are both billable, at different published multipliers from + * uncached input. Raw cache_read_input_tokens and + * cache_creation_input_tokens are observations. Any multiplier-based value is + * an Anthropic input-cost equivalent, not a universally comparable token count + * and not a provider invoice. * * MEASUREMENT ALONE IS NOT ACTIONABLE. A hit rate tells you the cache missed; * it cannot tell you why, and an unactionable number is the same dead thing as @@ -30,7 +30,15 @@ * cache-safety-by-construction half of this file. */ -import { readFileSync, readdirSync, statSync, existsSync, openSync, readSync, closeSync } from 'node:fs'; +import { + readFileSync, + readdirSync, + statSync, + existsSync, + openSync, + readSync, + closeSync, +} from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; @@ -51,7 +59,8 @@ export const READ_MULTIPLIER = 0.1; const SYNTHETIC_MODEL = ''; /** True for a turn naming a model an inference actually ran under. */ -const realModel = (turn) => Boolean(turn.model) && turn.model !== SYNTHETIC_MODEL; +const realModel = (turn) => + Boolean(turn.model) && turn.model !== SYNTHETIC_MODEL; /* -------------------------------------------------------------- MEASUREMENT */ @@ -72,7 +81,10 @@ export function transcriptFor(cwd = process.cwd()) { try { const files = readdirSync(dir) .filter((name) => name.endsWith('.jsonl')) - .map((name) => ({ path: join(dir, name), at: statSync(join(dir, name)).mtimeMs })) + .map((name) => ({ + path: join(dir, name), + at: statSync(join(dir, name)).mtimeMs, + })) .sort((a, b) => b.at - a.at); return files.length ? files[0].path : null; } catch { @@ -157,7 +169,8 @@ export function cacheHealth(turns) { read += turn.read; written += turn.written; if (!realModel(turn)) continue; - if (!models.has(turn.model)) models.set(turn.model, { read: 0, written: 0, turns: 0 }); + if (!models.has(turn.model)) + models.set(turn.model, { read: 0, written: 0, turns: 0 }); const entry = models.get(turn.model); entry.read += turn.read; entry.written += turn.written; @@ -171,20 +184,28 @@ export function cacheHealth(turns) { // attribution price, made keepWarm answer "unknown", and returned null from // modelSwitchCost under a guard its caller does not share, which crashed // cache_audit outright with "Cannot read properties of null". - const last = turns.reduce((best, turn) => (turn.read + turn.written > 0 ? turn : best), turns[turns.length - 1]); + const last = turns.reduce( + (best, turn) => (turn.read + turn.written > 0 ? turn : best), + turns[turns.length - 1] + ); const total = read + written; return { + provider: 'anthropic', + source: 'claude-code-transcript', turns: turns.length, read, written, hitRate: total ? read / total : null, prefixTokens: last.read + last.written, - // Priced in plain-token equivalents, which is what makes it comparable with - // every other saving this product reports. - writeCost: Math.round(written * WRITE_MULTIPLIER), - readCost: Math.round(read * READ_MULTIPLIER), - savedVersusNoCache: Math.round(read * (1 - READ_MULTIPLIER)), + // These are Anthropic uncached-input cost equivalents. They stay outside + // the verified MCP transport ledger and must not be relabeled as tokens + // saved across clients. + writeInputCostEquivalent: Math.round(written * WRITE_MULTIPLIER), + readInputCostEquivalent: Math.round(read * READ_MULTIPLIER), + inputCostEquivalentAvoidedVersusUncached: Math.round( + read * (1 - READ_MULTIPLIER) + ), models: Object.fromEntries(models), }; } @@ -200,13 +221,17 @@ export function modelSwitchCost(turns) { const health = cacheHealth(turns); if (!health?.prefixTokens) return null; - const switched = new Set(turns.filter(realModel).map((t) => t.model)).size > 1; + const switched = + new Set(turns.filter(realModel).map((t) => t.model)).size > 1; return { prefixTokens: health.prefixTokens, - rewriteCost: Math.round(health.prefixTokens * WRITE_MULTIPLIER), + rewriteInputCostEquivalent: Math.round( + health.prefixTokens * WRITE_MULTIPLIER + ), alreadySwitched: switched, - text: `Switching model now discards a ${health.prefixTokens.toLocaleString()}-token warm prefix; ` + - `re-writing it costs about ${Math.round(health.prefixTokens * WRITE_MULTIPLIER).toLocaleString()} tokens.`, + text: + `Switching model now discards a ${health.prefixTokens.toLocaleString()}-token warm prefix; ` + + `under Anthropic's current 5-minute write multiplier, re-writing it is about ${Math.round(health.prefixTokens * WRITE_MULTIPLIER).toLocaleString()} uncached-input cost-equivalent tokens.`, }; } @@ -221,17 +246,38 @@ export function modelSwitchCost(turns) { * time. That is why this class of bug survives -- nothing about it looks wrong. */ const VOLATILE = [ - { id: 'timestamp', re: /\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/, why: 'an embedded timestamp' }, - { id: 'date', re: /\b(?:today|current date)\b[^\n]{0,40}\d{4}-\d{2}-\d{2}/i, why: 'an embedded current date' }, + { + id: 'timestamp', + re: /\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/, + why: 'an embedded timestamp', + }, + { + id: 'date', + re: /\b(?:today|current date)\b[^\n]{0,40}\d{4}-\d{2}-\d{2}/i, + why: 'an embedded current date', + }, // A bare date is a SUSPICION, not a measurement: "- 2025-10-31: reworked the // loader" is byte-identical every session. Kept, because stableText fails // closed over our own output and dropping a line there costs little -- but // NOT priced, because in the blame report a false positive invents a // per-session cost and tells the user to delete their changelog. - { id: 'iso-date', re: /\b\d{4}-\d{2}-\d{2}\b/, why: 'an embedded date', priced: false }, - { id: 'session-id', re: /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i, why: 'a session id' }, + { + id: 'iso-date', + re: /\b\d{4}-\d{2}-\d{2}\b/, + why: 'an embedded date', + priced: false, + }, + { + id: 'session-id', + re: /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i, + why: 'a session id', + }, { id: 'git-sha', re: /\b[0-9a-f]{40}\b/, why: 'a git sha' }, - { id: 'counter', re: /\b(?:run|build|attempt|iteration)\s*#?\d+\b/i, why: 'a run counter' }, + { + id: 'counter', + re: /\b(?:run|build|attempt|iteration)\s*#?\d+\b/i, + why: 'a run counter', + }, { id: 'epoch', re: /\b1[6-9]\d{11}\b/, why: 'an epoch timestamp' }, ]; @@ -242,7 +288,13 @@ export function volatileLines(text) { for (let i = 0; i < lines.length; i++) { for (const rule of VOLATILE) { if (!rule.re.test(lines[i])) continue; - out.push({ line: i + 1, id: rule.id, why: rule.why, priced: rule.priced !== false, text: lines[i].trim().slice(0, 120) }); + out.push({ + line: i + 1, + id: rule.id, + why: rule.why, + priced: rule.priced !== false, + text: lines[i].trim().slice(0, 120), + }); break; // one cause per line is enough to act on } } @@ -264,7 +316,11 @@ const estimate = (text) => Math.ceil(String(text || '').length / 4); * unknown, and this reports the construct WITHOUT a price rather than inventing * one. */ -export function attributeInvalidation(cwd, prefixTokens = null, { files = null } = {}) { +export function attributeInvalidation( + cwd, + prefixTokens = null, + { files = null } = {} +) { // Ordered as the client assembles them: the client preamble and tool // definitions come first, then project instructions, then the conversation. const candidates = files || ['CLAUDE.md', 'AGENTS.md', '.claude/CLAUDE.md']; @@ -292,7 +348,8 @@ export function attributeInvalidation(cwd, prefixTokens = null, { files = null } // prefix -- 3.75x the entire prefix. Conservative still: the preamble // ahead of the file is not counted, so this remains a floor. const ahead = offset + estimate(lines.slice(0, hit.line - 1).join('\n')); - const downstream = prefixTokens != null ? Math.max(0, prefixTokens - ahead) : null; + const downstream = + prefixTokens != null ? Math.max(0, prefixTokens - ahead) : null; // A prefix cache invalidates ONCE, at the earliest difference. A later // volatile line in the same file costs nothing extra until the first one // is fixed, so it is reported for context but not billed again. @@ -304,7 +361,9 @@ export function attributeInvalidation(cwd, prefixTokens = null, { files = null } // into a reported zero. const firstPriced = found.find((h) => h.priced); const subsumedBy = - hit.priced && firstPriced && firstPriced !== hit ? `${relative}:${firstPriced.line}` : null; + hit.priced && firstPriced && firstPriced !== hit + ? `${relative}:${firstPriced.line}` + : null; out.push({ file: relative, line: hit.line, @@ -316,9 +375,12 @@ export function attributeInvalidation(cwd, prefixTokens = null, { files = null } // decline to price. 0 means measured and genuinely free, which is what a // subsumed hit is: real, but already billed by the earlier line. Folding // those two into one value is the same mistake this file warns about. - costPerSession: downstream == null || !hit.priced - ? null - : (subsumedBy ? 0 : Math.round(downstream * WRITE_MULTIPLIER)), + costPerSession: + downstream == null || !hit.priced + ? null + : subsumedBy + ? 0 + : Math.round(downstream * WRITE_MULTIPLIER), remedy: { kind: 'yours', type: 'edit', @@ -369,7 +431,7 @@ export function stableText(text) { */ export function cacheOrdered(items) { return [...items].sort((a, b) => { - const stability = (item) => (item.volatility ?? (item.fresh ? 1 : 0)); + const stability = (item) => item.volatility ?? (item.fresh ? 1 : 0); return stability(a) - stability(b); }); } diff --git a/integrations/cline/hooks/token-optimizer/lib/decide.mjs b/integrations/cline/hooks/token-optimizer/lib/decide.mjs index caae7de2f..a61dafdd5 100644 --- a/integrations/cline/hooks/token-optimizer/lib/decide.mjs +++ b/integrations/cline/hooks/token-optimizer/lib/decide.mjs @@ -392,6 +392,21 @@ export function touchedFiles(payload) { add(input.path); add(input.notebook_path); + // Codex code mode carries apply_patch as the nested program text rather than + // as a first-class file_path. Without reading these headers, successful + // code-mode edits incremented the semantic-harvest counter but produced no + // structural file/symbol nodes, which is why the live dashboard showed very + // few nodes after substantial work. PostToolUse can resolve Add/Update paths + // because the file now exists; deleted paths deliberately remain absent. + for (const match of command.matchAll( + /^\*\*\* (?:Add|Update|Delete) File:\s*(.+)$/gm + )) { + add(match[1].trim().replace(/^['"]|['"]$/g, '')); + } + for (const match of command.matchAll(/^\*\*\* Move to:\s*(.+)$/gm)) { + add(match[1].trim().replace(/^['"]|['"]$/g, '')); + } + // EVERY pipeline segment, not just the first. `fileOperands` looks only at // the head of the pipeline because that is where the COST is -- `git log | // head` must not be mistaken for a file dump. But observation is a different diff --git a/integrations/cline/hooks/token-optimizer/lib/experiment.mjs b/integrations/cline/hooks/token-optimizer/lib/experiment.mjs index 1f64713f1..c35c6cf5c 100644 --- a/integrations/cline/hooks/token-optimizer/lib/experiment.mjs +++ b/integrations/cline/hooks/token-optimizer/lib/experiment.mjs @@ -67,16 +67,71 @@ export function episodeMeta({ client, raw = {}, payload = {}, env = process.env } export function usageFrom(raw = {}) { - const usage = raw.usage || raw.token_usage || raw.tokenUsage || raw.metrics?.usage || {}; + const usage = raw.usage || raw.token_usage || raw.tokenUsage || raw.usageMetadata || + raw.usage_metadata || raw.metrics?.usage || raw.response?.usage || + raw.response?.usageMetadata || {}; const number = (...values) => { const value = first(...values); const parsed = Number(value); return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; }; + const cachedInputTokens = number( + usage.cached_input_tokens, usage.cachedInputTokens, + usage.cache_read_input_tokens, usage.cachedContentTokenCount + ); + const cacheCreation = usage.cache_creation || usage.cacheCreation || {}; + const cacheWrite5mInputTokens = number( + usage.cache_write_5m_input_tokens, usage.cacheWrite5mInputTokens, + cacheCreation.ephemeral_5m_input_tokens, cacheCreation.ephemeral5mInputTokens + ); + const cacheWrite1hInputTokens = number( + usage.cache_write_1h_input_tokens, usage.cacheWrite1hInputTokens, + cacheCreation.ephemeral_1h_input_tokens, cacheCreation.ephemeral1hInputTokens + ); + const detailedWrites = (cacheWrite5mInputTokens || 0) + (cacheWrite1hInputTokens || 0); + const reportedWrites = number( + usage.cache_write_input_tokens, usage.cacheWriteInputTokens, + usage.cache_creation_input_tokens, usage.cacheCreationInputTokens + ); + const cacheWriteInputTokens = reportedWrites == null + ? null + : Math.max(0, reportedWrites - detailedWrites); + const explicitUncached = number(usage.uncached_input_tokens, usage.uncachedInputTokens); + const promptInput = number( + usage.promptTokenCount, usage.prompt_token_count, + usage.input_tokens, usage.inputTokens + ); + const isAnthropic = usage.cache_creation_input_tokens !== undefined || + usage.cache_read_input_tokens !== undefined; + const isGemini = usage.promptTokenCount !== undefined || usage.cachedContentTokenCount !== undefined; + const uncachedInputTokens = explicitUncached != null + ? explicitUncached + : promptInput == null + ? null + : isAnthropic + ? promptInput + : isGemini || cachedInputTokens != null || reportedWrites != null + ? Math.max(0, promptInput - (cachedInputTokens || 0) - (reportedWrites || 0)) + : promptInput; + const candidates = number(usage.candidatesTokenCount, usage.candidates_token_count); + const thoughts = number(usage.thoughtsTokenCount, usage.thoughts_token_count); + const outputTokens = candidates != null || thoughts != null + ? (candidates || 0) + (thoughts || 0) + : number(usage.output_tokens, usage.outputTokens); return { - uncachedInputTokens: number(usage.uncached_input_tokens, usage.uncachedInputTokens), - cachedInputTokens: number(usage.cached_input_tokens, usage.cachedInputTokens, usage.cache_read_input_tokens), - outputTokens: number(usage.output_tokens, usage.outputTokens), - totalTokens: number(usage.total_tokens, usage.totalTokens, raw.tokens_used, raw.tokensUsed), + uncachedInputTokens, + cachedInputTokens, + cacheWrite5mInputTokens, + cacheWrite1hInputTokens, + cacheWriteInputTokens, + outputTokens, + totalTokens: number( + usage.total_tokens, usage.totalTokens, usage.totalTokenCount, + usage.total_token_count, raw.tokens_used, raw.tokensUsed + ), + usageMeasurementId: first( + raw.requestId, raw.request_id, raw.responseId, raw.response_id, + usage.responseId, usage.response_id + ) ?? null, }; } diff --git a/integrations/cline/hooks/token-optimizer/lib/harvest-write.mjs b/integrations/cline/hooks/token-optimizer/lib/harvest-write.mjs index 30433d370..db436c1dc 100644 --- a/integrations/cline/hooks/token-optimizer/lib/harvest-write.mjs +++ b/integrations/cline/hooks/token-optimizer/lib/harvest-write.mjs @@ -24,7 +24,7 @@ */ import { - putNodeWithEdges, load, nodeId, sharedDir, isSharedDir, putNode, + putNodeWithEdges, load, nodeId, sharedDir, isSharedDir, putNode, putEdge, } from './wiki.mjs'; import { indexFile } from './staleness.mjs'; import { symbolKey } from './symbols.mjs'; @@ -47,6 +47,37 @@ import { ORIGIN_HARVESTED, ORIGIN_AGENT, ORIGIN_HUMAN } from './curate.mjs'; */ export const SHAREABLE_TYPES = new Set(['command', 'failure', 'decision', 'feedback']); +/** + * Synthetic study traffic must never become advice for an unrelated live repo. + * Evaluation roots are intentionally realistic and may carry their own `.git` + * marker, so repository discovery alone cannot distinguish them. Publication + * remains useful inside the isolated study store; this guard protects only the + * default machine-wide store when a harness was accidentally run without an + * isolated TOKEN_OPTIMIZER_SHARED_DIR. + */ +export function isEphemeralProject(projectRoot) { + const root = canonicalPath(projectRoot || '').toLowerCase(); + if (!root) return true; + return ( + /\/appdata\/local\/temp\//.test(root) || + /(^|\/)tmp\//.test(root) || + /\/(artifacts|\.token-optimizer)\/(?:[^/]*\/)*(?:eval|scenario|study|fixture|debug)[^/]*\//.test(`${root}/`) + ); +} + +export function quarantineSharedSource(projectRoot) { + if (process.env.TOKEN_OPTIMIZER_ALLOW_EPHEMERAL_SHARED === '1') return false; + if (!isEphemeralProject(projectRoot)) return false; + // Isolated studies deliberately point the shared tier at scratch storage. + // Their findings may cross their own fixture projects, but cannot escape into + // the user's default machine-wide graph. Strict mode exists for a regression + // probe that verifies the guard without writing to a real user directory. + return ( + !process.env.TOKEN_OPTIMIZER_SHARED_DIR || + process.env.TOKEN_OPTIMIZER_STRICT_SHARED_PROVENANCE === '1' + ); +} + /** Claim text, normalised enough that the same lesson learned twice is one row. */ const claimFingerprint = (claim) => String(claim || '').toLowerCase().replace(/\s+/g, ' ').trim().slice(0, 300); @@ -72,6 +103,7 @@ function promoteToShared(finding, { projectRoot, sessionId, provenance, key }) { if (!projectRoot) return false; const root = canonicalPath(projectRoot); + if (quarantineSharedSource(root)) return false; const rootId = nodeId('file', root); // Same claim, already carried up from anywhere: keep the first. Two repos @@ -367,11 +399,21 @@ export function writeHarvested( && !node.retired && node.type === finding.type && claimFingerprint(node.claim) === fingerprint - && resolved.every((target) => currentGraph.edges.some((edge) => - edge.from === node.id && edge.edge === 'derived_from' && edge.to === target - )) ); if (duplicate) { + // Enrich, do not duplicate. A symbol extractor may learn how to resolve a + // previously file-only anchor (or a later session may add another real + // anchor) after the claim was first stored. Repeating the same semantic + // conclusion should connect that existing node to the newly available + // evidence, not create a second dashboard card with the same text. + for (const target of resolved) { + const alreadyLinked = currentGraph.edges.some((edge) => + edge.from === duplicate.id + && edge.edge === 'derived_from' + && edge.to === target + ); + if (!alreadyLinked) putEdge(dir, duplicate.id, 'derived_from', target); + } written.push(duplicate.key); continue; } @@ -437,7 +479,12 @@ export function writeHarvested( if ( id && !isSharedDir(dir) - && (finding.scope === undefined || finding.scope === 'organization' || finding.scope === 'global') + // Cross-project publication is explicit. `writeHarvested` stores an + // omitted scope as project-local above, so treating `undefined` as global + // here contradicted the stored record and promoted fixture/evaluation + // seeds into real user sessions. This was observed live as "learned in + // scenario" advice while working in AiDotNet. + && (finding.scope === 'organization' || finding.scope === 'global') ) { promoteToShared(finding, { projectRoot, sessionId, provenance, key }); } diff --git a/integrations/cline/hooks/token-optimizer/lib/inject.mjs b/integrations/cline/hooks/token-optimizer/lib/inject.mjs index b00494a01..c0aa2f1bf 100644 --- a/integrations/cline/hooks/token-optimizer/lib/inject.mjs +++ b/integrations/cline/hooks/token-optimizer/lib/inject.mjs @@ -31,6 +31,7 @@ import { canonicalPath, resolvableCandidates } from './paths.mjs'; import { annotatedSkeleton } from './skeleton.mjs'; import { substitutionBudget } from './metrics.mjs'; import { assessFindings } from './utility.mjs'; +import { quarantineSharedSource } from './harvest-write.mjs'; // Read per call for the same reason as the holdout fraction in metrics.mjs. const touchBudget = () => Number(process.env.TOKEN_OPTIMIZER_TOUCH_BUDGET) || 500; @@ -562,6 +563,7 @@ export function forRepeatedAct( const home = projectRoot ? canonicalPath(projectRoot) : null; for (const node of graph.nodes.values()) { if (node.kind !== 'finding' || node.retired || !node.claim) continue; + if (quarantineSharedSource(node.sourceProject)) continue; if (home && node.sourceProject && canonicalPath(node.sourceProject) === home) continue; const claimClasses = classesOf(node.claim, 'claim'); if (![...crossedClasses].some((c) => claimClasses.has(c))) continue; @@ -667,6 +669,7 @@ export function forSharedCommand( const candidates = []; for (const node of graph.nodes.values()) { if (node.kind !== 'finding' || node.retired) continue; + if (quarantineSharedSource(node.sourceProject)) continue; if (alreadyInjected.has(node.key)) continue; // ITS OWN LESSON IS NOT NEWS. A finding this project contributed is already // served by the local path; repeating it under a "from elsewhere" label @@ -1054,7 +1057,13 @@ export function standingRules(dir, graph, { budget = standingBudget(), episode = * informative response available -- more useful than the file, not a lossier * version of it. */ -export function substitutionFor(dir, graph, rawPath, source, { sessionId } = {}) { +export function substitutionFor( + dir, + graph, + rawPath, + source, + { sessionId, client = null, clientVersion = null, model = null, modelVersion = null } = {} +) { const filePath = canonicalPath(rawPath); const budget = substitutionBudget(dir, filePath); const built = annotatedSkeleton(graph, rawPath, source, { budget }); @@ -1089,6 +1098,10 @@ export function substitutionFor(dir, graph, rawPath, source, { sessionId } = {}) // on this machine turned out to be the enforcement suite's own fixture and // nothing distinguished them from real work. sessionId, + client, + clientVersion, + model, + modelVersion, holdout, tokens: holdout ? 0 : built.tokens, findings: built.findings, diff --git a/integrations/cline/hooks/token-optimizer/lib/metrics.mjs b/integrations/cline/hooks/token-optimizer/lib/metrics.mjs index 0bb08f1bc..09f822630 100644 --- a/integrations/cline/hooks/token-optimizer/lib/metrics.mjs +++ b/integrations/cline/hooks/token-optimizer/lib/metrics.mjs @@ -19,8 +19,15 @@ */ import { - appendFileSync, readFileSync, existsSync, mkdirSync, chmodSync, - statSync, openSync, readSync, closeSync, + appendFileSync, + readFileSync, + existsSync, + mkdirSync, + chmodSync, + statSync, + openSync, + readSync, + closeSync, } from 'node:fs'; import { join } from 'node:path'; import { createHash, randomBytes } from 'node:crypto'; @@ -86,6 +93,10 @@ export const BALANCE_KINDS = new Set([ 'keepwarm', ]); +/** Latest timestamp without passing an unbounded event log as function arguments. */ +export const latestEventTimestamp = (rows) => + rows.reduce((latest, event) => Math.max(latest, Number(event.at) || 0), 0); + /** * Causal records have their own bounded log. Tool outcomes are much rarer * than read/capture telemetry but more frequent than balance records; mixing @@ -130,7 +141,7 @@ export function inHoldout(anchorKey, now = Date.now()) { // other side than before. With nine holdout records in existence that costs // nothing, and stratification is a fresh draw either way. const digest = createHash('sha256').update(`${anchorKey}:${epoch}`).digest(); - return (digest[0] / 256) < fraction; + return digest[0] / 256 < fraction; } /** @@ -258,7 +269,8 @@ export function record(dir, event) { * It is also the right statistics. A saving measured months ago says little * about the code as it stands now. */ -const MAX_BYTES = Number(process.env.TOKEN_OPTIMIZER_METRICS_BYTES) || 2_000_000; +const MAX_BYTES = + Number(process.env.TOKEN_OPTIMIZER_METRICS_BYTES) || 2_000_000; const MAX_EVENTS = Number(process.env.TOKEN_OPTIMIZER_METRICS_WINDOW) || 5000; /** @@ -432,7 +444,10 @@ export function readBalance(dir) { // path for records written before ids existed; it is lossy by nature, which // is precisely why new records carry an id instead. const id = - e.id ?? [e.kind, e.at, e.anchor ?? '', e.sessionId ?? '', e.tokens ?? ''].join('|'); + e.id ?? + [e.kind, e.at, e.anchor ?? '', e.sessionId ?? '', e.tokens ?? ''].join( + '|' + ); if (seen.has(id)) continue; seen.add(id); out.push(e); @@ -493,16 +508,24 @@ export function recordToolOutcome(dir, outcome) { const evidence = readEvidence(dir); const anchor = String(outcome.anchor || '').slice(0, 120); const candidates = evidence - .filter((event) => event.kind === 'inject' && event.episodeId === outcome.episodeId) + .filter( + (event) => + event.kind === 'inject' && event.episodeId === outcome.episodeId + ) .filter((event) => { if (outcome.toolCallId && event.toolCallId) return String(event.toolCallId) === String(outcome.toolCallId); - return event.surface === outcome.surface && String(event.anchor || '').slice(0, 120) === anchor; + return ( + event.surface === outcome.surface && + String(event.anchor || '').slice(0, 120) === anchor + ); }) .sort((a, b) => (b.at || 0) - (a.at || 0)); const injection = candidates[0] || null; const joinMethod = injection - ? (outcome.toolCallId && injection.toolCallId ? 'tool-call-id' : 'episode-anchor') + ? outcome.toolCallId && injection.toolCallId + ? 'tool-call-id' + : 'episode-anchor' : 'none'; return record(dir, { @@ -542,12 +565,11 @@ export function isFixtureAnchor(anchor) { // code: it excluded ALL temp paths -- including a real project checked out // under /tmp -- and on macOS excluded nothing at all, because tmpdir() there // is /var/folders///T/ and did not match. - const underTemp = - new RegExp( - '[\\\\/](AppData[\\\\/]Local[\\\\/])?(Temp|tmp)[\\\\/]' + - '|[\\\\/]var[\\\\/]folders[\\\\/][^\\\\/]+[\\\\/][^\\\\/]+[\\\\/]T[\\\\/]', - 'i' - ).test(p); + const underTemp = new RegExp( + '[\\\\/](AppData[\\\\/]Local[\\\\/])?(Temp|tmp)[\\\\/]' + + '|[\\\\/]var[\\\\/]folders[\\\\/][^\\\\/]+[\\\\/][^\\\\/]+[\\\\/]T[\\\\/]', + 'i' + ).test(p); if (!underTemp) return false; // Only names this suite actually creates. A user's own scratch checkout under @@ -686,8 +708,12 @@ export function balanceSheet(dir) { // THE CONTROL ARM'S ACTUAL COST. `tokensFullFile` was recorded and read // by nothing, so the comparison the holdout exists for was not // computable from the report. - controlArmTokens: withheldSubs.reduce((sum, e) => sum + (e.tokensFullFile || 0), 0), - assumption: 'that the model would have read the file it explicitly requested', + controlArmTokens: withheldSubs.reduce( + (sum, e) => sum + (e.tokensFullFile || 0), + 0 + ), + assumption: + 'that the model would have read the file it explicitly requested', }, estimatedCausal: { what: 'findings injection, from the stratified holdout', @@ -702,7 +728,12 @@ export function balanceSheet(dir) { }; })(), }, - costs: { injection: injectCost, harvest: harvestCost, standing: standingCost, substitution: substitutionCost }, + costs: { + injection: injectCost, + harvest: harvestCost, + standing: standingCost, + substitution: substitutionCost, + }, waste, // Deliberately NOT a single number. The two benefit lines are known // differently; adding them would launder an assumption into a measurement. @@ -727,7 +758,9 @@ export function balanceSheet(dir) { /** Cheap path normalisation for grouping reads; not the identity canonicaliser. */ function canonicalKeyish(p) { - return String(p || '').replace(/\\/g, '/').toLowerCase(); + return String(p || '') + .replace(/\\/g, '/') + .toLowerCase(); } /** @@ -738,7 +771,43 @@ function canonicalKeyish(p) { * arms, multiplied by treated touches. Reporting it as a measured fact would be * the same overclaiming this project criticises competitors for. */ -function buildReport(events, balance) { +function buildReport(events, balance, sourceCoverage = {}) { + const substitutions = balance.filter( + (event) => event.kind === 'substitute' && !isFixtureAnchor(event.anchor) + ); + const servedSubstitutions = substitutions.filter((event) => !event.holdout); + const substitutionTokensSaved = servedSubstitutions.reduce( + (sum, event) => + sum + + (event.tokensNetAvoided ?? + Math.max( + 0, + Math.ceil((event.bytesAvoided || 0) / 4) - (event.tokens || 0) + )), + 0 + ); + const substitutionTokensReturned = servedSubstitutions.reduce( + (sum, event) => sum + (event.tokens || 0), + 0 + ); + const substitutionByClient = {}; + for (const event of servedSubstitutions) { + const name = event.client || 'Historical — client not recorded'; + const current = substitutionByClient[name] || { + substitutions: 0, + tokensReturned: 0, + tokensSaved: 0, + }; + current.substitutions += 1; + current.tokensReturned += event.tokens || 0; + current.tokensSaved += + event.tokensNetAvoided ?? + Math.max( + 0, + Math.ceil((event.bytesAvoided || 0) / 4) - (event.tokens || 0) + ); + substitutionByClient[name] = current; + } // COMMAND INJECTIONS ARE COUNTED SEPARATELY, not mixed into the balance. // @@ -762,13 +831,19 @@ function buildReport(events, balance) { // Report its cost independently so it cannot dilute the file-touch estimate. const isSessionStart = (e) => e.surface === 'session-start'; const sessionStartInjections = allInjections.filter(isSessionStart); - const commandInjections = allInjections.filter((e) => !isSessionStart(e) && isCommand(e)); - const injections = allInjections.filter((e) => !isSessionStart(e) && !isCommand(e)); + const commandInjections = allInjections.filter( + (e) => !isSessionStart(e) && isCommand(e) + ); + const injections = allInjections.filter( + (e) => !isSessionStart(e) && !isCommand(e) + ); const treated = injections.filter((e) => !e.holdout); const withheld = injections.filter((e) => e.holdout); const mean = (rows, field) => - rows.length ? rows.reduce((sum, r) => sum + (r[field] || 0), 0) / rows.length : 0; + rows.length + ? rows.reduce((sum, r) => sum + (r[field] || 0), 0) / rows.length + : 0; const injectedTokens = treated.reduce((sum, e) => sum + (e.tokens || 0), 0); const harvestTokens = balance @@ -819,7 +894,9 @@ function buildReport(events, balance) { const after = event.at ?? 0; const total = bucket.reduce( - (sum, r) => sum + ((r.at ?? 0) >= after ? (r.tokens || 0) : 0), 0); + (sum, r) => sum + ((r.at ?? 0) >= after ? r.tokens || 0 : 0), + 0 + ); // Split across the injections that share this key. Each touch is one // observation, so charging every one the full total would count the same @@ -828,52 +905,198 @@ function buildReport(events, balance) { }; const meanDownstream = (rows) => - rows.length ? rows.reduce((sum, r) => sum + downstreamOf(r), 0) / rows.length : 0; + rows.length + ? rows.reduce((sum, r) => sum + downstreamOf(r), 0) / rows.length + : 0; const treatedCost = meanDownstream(treated); const withheldCost = meanDownstream(withheld); const perTouchSaving = withheldCost - treatedCost; - const estimatedAvoided = Math.max(0, Math.round(perTouchSaving * treated.length)); + const estimatedAvoided = Math.max( + 0, + Math.round(perTouchSaving * treated.length) + ); + + const downstreamSamples = injections.filter((event) => { + if (event.downstream != null) return true; + const key = `${event.sessionId || ''}|${event.anchor}`; + return (reads.get(key) || []).some( + (read) => (read.at ?? 0) >= (event.at ?? 0) + ); + }).length; + const downstreamMeasured = downstreamSamples > 0; // Below this, arm means are noise and a ratio would be theatre. - const sufficient = treated.length >= 20 && withheld.length >= 5; + const sufficient = + downstreamMeasured && treated.length >= 20 && withheld.length >= 5; + + const coverageNumber = (value, fallback) => { + if (value === null || value === undefined || value === '') return fallback; + const number = Number(value); + return Number.isFinite(number) ? Math.max(0, number) : fallback; + }; + const projectCount = coverageNumber(sourceCoverage.projects, 1); + const telemetryProjects = coverageNumber( + sourceCoverage.projectsWithTelemetry, + 0 + ); + const balanceProjects = coverageNumber( + sourceCoverage.projectsWithBalanceEvents, + balance.length ? 1 : 0 + ); + const lastEventAt = Math.max( + latestEventTimestamp(events), + latestEventTimestamp(balance) + ); + const measured = + telemetryProjects > 0 || events.length > 0 || balance.length > 0; + const balanceMeasured = balanceProjects > 0 || balance.length > 0; + const metric = (status, source, samples, extra = {}) => ({ + status, + source, + samples, + ...extra, + }); return { + nativeOptimizer: { + substitutions: servedSubstitutions.length, + holdouts: substitutions.filter((event) => event.holdout).length, + tokensReturned: substitutionTokensReturned, + tokensSaved: substitutionTokensSaved, + byClient: substitutionByClient, + recent: servedSubstitutions + .slice() + .sort((a, b) => (b.at || 0) - (a.at || 0)) + .slice(0, 40) + .map((event) => { + const tokensSaved = + event.tokensNetAvoided ?? + Math.max( + 0, + Math.ceil((event.bytesAvoided || 0) / 4) - (event.tokens || 0) + ); + return { + name: 'live_graph_substitution', + client: event.client || 'Historical — client not recorded', + originalTokens: (event.tokens || 0) + tokensSaved, + optimizedTokens: event.tokens || 0, + tokensSaved, + savingsMeasured: false, + classification: 'modeled-counterfactual', + reportedTokensSaved: tokensSaved, + timestamp: event.at ? new Date(event.at).toISOString() : null, + }; + }), + source: + 'balance.jsonl: modeled full-file counterfactual minus annotated-skeleton tokens; excluded from verified MCP savings', + }, memoryDeliveries: allInjections.filter((event) => !event.holdout).length, memoryHoldouts: allInjections.filter((event) => event.holdout).length, deliveryTokens: allInjections .filter((event) => !event.holdout) .reduce( - (sum, event) => - sum + (event.deliveredTokens ?? event.tokens ?? 0), + (sum, event) => sum + (event.deliveredTokens ?? event.tokens ?? 0), 0 ), injections: treated.length, sessionStartInjections: sessionStartInjections.length, sessionStartInjectedTokens: sessionStartInjections.reduce( - (sum, event) => sum + (event.deliveredTokens ?? event.tokens ?? 0), 0 + (sum, event) => sum + (event.deliveredTokens ?? event.tokens ?? 0), + 0 ), commandInjections: commandInjections.length, commandHoldouts: commandInjections.filter((e) => e.holdout).length, holdouts: withheld.length, staleServed: injections.filter((e) => e.stale).length, - staleRate: injections.length ? injections.filter((e) => e.stale).length / injections.length : 0, + staleRate: injections.length + ? injections.filter((e) => e.stale).length / injections.length + : 0, injectedTokens, harvestTokens, estimatedTokensAvoided: sufficient ? estimatedAvoided : null, - netTokens: sufficient ? estimatedAvoided - injectedTokens - harvestTokens : null, + netTokens: sufficient + ? estimatedAvoided - injectedTokens - harvestTokens + : null, sufficientData: sufficient, verdict: !sufficient ? `insufficient data (${treated.length} treated, ${withheld.length} holdout; need 20 and 5)` : estimatedAvoided > injectedTokens + harvestTokens ? 'the graph is saving more than it costs' : 'the graph is NOT yet paying for itself', + measurement: { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + sourceCoverage: { + projects: projectCount, + projectsWithTelemetry: telemetryProjects || (measured ? 1 : 0), + projectsWithBalanceEvents: balanceProjects, + projectsWithoutTelemetry: Math.max( + 0, + projectCount - (telemetryProjects || (measured ? 1 : 0)) + ), + }, + freshness: { + lastEventAt: lastEventAt || null, + ageMs: lastEventAt ? Math.max(0, Date.now() - lastEventAt) : null, + status: !lastEventAt + ? 'not-measured' + : Date.now() - lastEventAt <= 7 * 86_400_000 + ? 'fresh' + : 'stale', + }, + metrics: { + nativeSubstitutions: metric( + balanceMeasured ? 'measured' : 'not-measured', + 'balance.jsonl: modeled full-file counterfactual minus annotated-skeleton tokens; not a materialized MCP before/after pair', + servedSubstitutions.length, + { classification: 'modeled-counterfactual' } + ), + memoryDeliveries: metric( + balanceMeasured ? 'measured' : 'not-measured', + 'balance.jsonl: inject events in the treated arm', + allInjections.filter((event) => !event.holdout).length + ), + memoryHoldouts: metric( + balanceMeasured ? 'measured' : 'not-measured', + 'balance.jsonl: inject events in the withheld arm', + allInjections.filter((event) => event.holdout).length + ), + rememberingCost: metric( + balanceMeasured ? 'measured' : 'not-measured', + 'balance.jsonl: delivered injection tokens plus semantic harvest tokens', + allInjections.length + + balance.filter((event) => event.kind === 'harvest').length + ), + readingAvoided: metric( + !downstreamMeasured + ? 'not-measured' + : sufficient + ? 'measured' + : 'collecting', + 'stratified file-touch holdout joined to downstream read events', + downstreamSamples, + { + treated: treated.length, + holdouts: withheld.length, + requiredTreated: 20, + requiredHoldouts: 5, + } + ), + }, + }, }; } export function report(dir) { - return buildReport(readAll(dir), readBalance(dir)); + const events = readAll(dir); + const balance = readBalance(dir); + return buildReport(events, balance, { + projects: 1, + projectsWithTelemetry: events.length || balance.length ? 1 : 0, + projectsWithBalanceEvents: balance.length ? 1 : 0, + }); } /** @@ -886,10 +1109,16 @@ export function report(dir) { */ export function reportMany(dirs) { const unique = [...new Set((dirs || []).map((dir) => String(dir)))]; - const report = buildReport( - unique.flatMap((dir) => readAll(dir)), - unique.flatMap((dir) => readBalance(dir)) - ); + const eventSources = unique.map((dir) => readAll(dir)); + const balanceSources = unique.map((dir) => readBalance(dir)); + const report = buildReport(eventSources.flat(), balanceSources.flat(), { + projects: unique.length, + projectsWithTelemetry: eventSources.filter( + (events, index) => events.length || balanceSources[index].length + ).length, + projectsWithBalanceEvents: balanceSources.filter((events) => events.length) + .length, + }); return { ...report, projects: unique.length }; } @@ -907,7 +1136,11 @@ export function reportMany(dirs) { * costs almost nothing. Without a control arm there is no way to tell those two * apart, which is why nobody else can do this. */ -export function substitutionBudget(dir, anchor, { floor = 300, base = 1200, ceiling = 3000 } = {}) { +export function substitutionBudget( + dir, + anchor, + { floor = 300, base = 1200, ceiling = 3000 } = {} +) { const events = readAll(dir); const mine = events.filter((e) => e.kind === 'inject' && e.anchor === anchor); @@ -932,13 +1165,17 @@ export function substitutionBudget(dir, anchor, { floor = 300, base = 1200, ceil for (const row of rows) { const bucket = reads.get(row.sessionId || '') || []; const after = row.at ?? 0; - total += bucket.reduce((sum, r) => sum + ((r.at ?? 0) >= after ? (r.tokens || 0) : 0), 0); + total += bucket.reduce( + (sum, r) => sum + ((r.at ?? 0) >= after ? r.tokens || 0 : 0), + 0 + ); } return total / rows.length; }; const saved = downstream(withheld) - downstream(treated); - const spent = treated.reduce((sum, e) => sum + (e.tokens || 0), 0) / treated.length; + const spent = + treated.reduce((sum, e) => sum + (e.tokens || 0), 0) / treated.length; // Ratio of what annotating this file avoided to what annotating it cost. // Above 1 it is paying for itself and earns room; below, it shrinks. @@ -959,7 +1196,10 @@ export function substitutionBudget(dir, anchor, { floor = 300, base = 1200, ceil * graph whose index leads to queries grows its allowance; a noisy one shrinks * back toward the floor. Nobody configures it, and it cannot run away. */ -export function indexBudget(dir, { floor = 150, base = 300, ceiling = 1200 } = {}) { +export function indexBudget( + dir, + { floor = 150, base = 300, ceiling = 1200 } = {} +) { const events = readAll(dir); const listed = events.filter((e) => e.kind === 'index').length; const queries = events.filter((e) => e.kind === 'query').length; @@ -968,7 +1208,9 @@ export function indexBudget(dir, { floor = 150, base = 300, ceiling = 1200 } = { const hitRate = queries / listed; // 0% hit rate falls to the floor; ~50% and above reaches the ceiling. - const scaled = Math.round(floor + (ceiling - floor) * Math.min(1, hitRate * 2)); + const scaled = Math.round( + floor + (ceiling - floor) * Math.min(1, hitRate * 2) + ); return Math.max(floor, Math.min(ceiling, scaled)); } @@ -1005,7 +1247,10 @@ function seededRandom(seedText) { } /** Percentile bootstrap interval for a mean, paired delta, or other scalar. */ -export function bootstrapMeanInterval(values, { samples = 1000, seed = 'token-optimizer' } = {}) { +export function bootstrapMeanInterval( + values, + { samples = 1000, seed = 'token-optimizer' } = {} +) { const finite = values.map(numeric).filter((value) => value !== null); if (!finite.length) return { mean: null, low: null, high: null, n: 0 }; if (finite.length === 1) { @@ -1037,24 +1282,32 @@ export function proportionInterval(successes, total, z = 1.96) { const denominator = 1 + (z * z) / total; const centre = (rate + (z * z) / (2 * total)) / denominator; const radius = - (z / denominator) * Math.sqrt((rate * (1 - rate)) / total + (z * z) / (4 * total * total)); - return { rate, low: Math.max(0, centre - radius), high: Math.min(1, centre + radius), n: total }; + (z / denominator) * + Math.sqrt((rate * (1 - rate)) / total + (z * z) / (4 * total * total)); + return { + rate, + low: Math.max(0, centre - radius), + high: Math.min(1, centre + radius), + n: total, + }; } -const cohortKey = (run) => [ - run.client || 'unknown', - run.clientVersion || 'unknown', - run.model || 'unknown', - run.modelVersion || 'unknown', - run.taskId || 'unknown', -].join('|'); +const cohortKey = (run) => + [ + run.client || 'unknown', + run.clientVersion || 'unknown', + run.model || 'unknown', + run.modelVersion || 'unknown', + run.taskId || 'unknown', + ].join('|'); function armMetrics(runs) { const correct = runs.filter((run) => run.correct === true).length; - const interval = (field) => bootstrapMeanInterval( - runs.map((run) => run[field]).filter((value) => numeric(value) !== null), - { seed: field } - ); + const interval = (field) => + bootstrapMeanInterval( + runs.map((run) => run[field]).filter((value) => numeric(value) !== null), + { seed: field } + ); return { runs: runs.length, correctness: proportionInterval(correct, runs.length), @@ -1068,29 +1321,40 @@ function armMetrics(runs) { latencyMs: interval('latencyMs'), costUsd: interval('costUsd'), injectedTokens: interval('injectedTokens'), - harmfulFindings: runs.reduce((sum, run) => sum + (numeric(run.harmfulFindings) || 0), 0), + harmfulFindings: runs.reduce( + (sum, run) => sum + (numeric(run.harmfulFindings) || 0), + 0 + ), }; } -function pairedEffects(runs, controlArm, treatmentArm, contrastType = 'incremental') { +function pairedEffects( + runs, + controlArm, + treatmentArm, + contrastType = 'incremental' +) { const pairs = new Map(); for (const run of runs) { if (!run.pairId || ![controlArm, treatmentArm].includes(run.arm)) continue; if (!pairs.has(run.pairId)) pairs.set(run.pairId, {}); pairs.get(run.pairId)[run.arm] = run; } - const complete = [...pairs.values()].filter((pair) => pair[controlArm] && pair[treatmentArm]); - const delta = (field, lowerIsBetter = true) => bootstrapMeanInterval( - complete - .map((pair) => { - const baseline = numeric(pair[controlArm][field]); - const treatment = numeric(pair[treatmentArm][field]); - if (baseline === null || treatment === null) return null; - return lowerIsBetter ? baseline - treatment : treatment - baseline; - }) - .filter((value) => value !== null), - { seed: `${controlArm}:${treatmentArm}:${field}` } + const complete = [...pairs.values()].filter( + (pair) => pair[controlArm] && pair[treatmentArm] ); + const delta = (field, lowerIsBetter = true) => + bootstrapMeanInterval( + complete + .map((pair) => { + const baseline = numeric(pair[controlArm][field]); + const treatment = numeric(pair[treatmentArm][field]); + if (baseline === null || treatment === null) return null; + return lowerIsBetter ? baseline - treatment : treatment - baseline; + }) + .filter((value) => value !== null), + { seed: `${controlArm}:${treatmentArm}:${field}` } + ); return { arm: treatmentArm, controlArm, @@ -1111,21 +1375,32 @@ function pairedEffects(runs, controlArm, treatmentArm, contrastType = 'increment } function matchesFilters(event, filters) { - for (const field of ['client', 'clientVersion', 'model', 'modelVersion', 'taskId', 'arm']) { + for (const field of [ + 'client', + 'clientVersion', + 'model', + 'modelVersion', + 'taskId', + 'arm', + ]) { if (!filters[field]) continue; let actual = event[field]; if (event.kind === 'handoff-run') { - actual = field === 'taskId' - ? event.scenarioId - : field === 'arm' - ? event.arm - : (event.consumer?.[field] ?? event.producer?.[field] ?? event[field]); + actual = + field === 'taskId' + ? event.scenarioId + : field === 'arm' + ? event.arm + : (event.consumer?.[field] ?? + event.producer?.[field] ?? + event[field]); } else if (event.kind === 'concurrency-run') { - actual = field === 'taskId' - ? 'concurrent-combined' - : field === 'arm' - ? event.arm - : (event.consumer?.[field] ?? event[field]); + actual = + field === 'taskId' + ? 'concurrent-combined' + : field === 'arm' + ? event.arm + : (event.consumer?.[field] ?? event[field]); } if (String(actual || '') !== String(filters[field])) return false; } @@ -1134,11 +1409,15 @@ function matchesFilters(event, filters) { function handoffArmMetrics(runs) { const consumers = runs.map((run) => run.consumer || {}); - const count = (field) => consumers.filter((consumer) => consumer[field] === true).length; - const interval = (field) => bootstrapMeanInterval( - consumers.map((consumer) => consumer[field]).filter((value) => numeric(value) !== null), - { seed: `handoff:${field}` } - ); + const count = (field) => + consumers.filter((consumer) => consumer[field] === true).length; + const interval = (field) => + bootstrapMeanInterval( + consumers + .map((consumer) => consumer[field]) + .filter((value) => numeric(value) !== null), + { seed: `handoff:${field}` } + ); return { runs: runs.length, delivery: proportionInterval( @@ -1147,7 +1426,10 @@ function handoffArmMetrics(runs) { ), correctness: proportionInterval(count('correct'), runs.length), firstPass: proportionInterval(count('firstPass'), runs.length), - mistakeAttempted: proportionInterval(count('mistakeAttempted'), runs.length), + mistakeAttempted: proportionInterval( + count('mistakeAttempted'), + runs.length + ), mistakeExecuted: proportionInterval(count('mistakeExecuted'), runs.length), totalTokens: interval('totalTokens'), toolCalls: interval('toolCalls'), @@ -1163,16 +1445,21 @@ function handoffPairedEffect(runs, controlArm, treatmentArm) { if (!pairs.has(run.pairId)) pairs.set(run.pairId, {}); pairs.get(run.pairId)[run.arm] = run; } - const complete = [...pairs.values()].filter((pair) => pair[controlArm] && pair[treatmentArm]); - const effect = (field, positiveWhenLower = true) => bootstrapMeanInterval( - complete.map((pair) => { - const control = numeric(pair[controlArm].consumer?.[field]); - const treatment = numeric(pair[treatmentArm].consumer?.[field]); - if (control === null || treatment === null) return null; - return positiveWhenLower ? control - treatment : treatment - control; - }).filter((value) => value !== null), - { seed: `handoff:${controlArm}:${treatmentArm}:${field}` } + const complete = [...pairs.values()].filter( + (pair) => pair[controlArm] && pair[treatmentArm] ); + const effect = (field, positiveWhenLower = true) => + bootstrapMeanInterval( + complete + .map((pair) => { + const control = numeric(pair[controlArm].consumer?.[field]); + const treatment = numeric(pair[treatmentArm].consumer?.[field]); + if (control === null || treatment === null) return null; + return positiveWhenLower ? control - treatment : treatment - control; + }) + .filter((value) => value !== null), + { seed: `handoff:${controlArm}:${treatmentArm}:${field}` } + ); return { comparison: `${treatmentArm} vs ${controlArm}`, pairs: complete.length, @@ -1187,13 +1474,22 @@ function handoffPairedEffect(runs, controlArm, treatmentArm) { }; } -const handoffKey = (run) => [ - run.producer?.client || 'unknown', run.producer?.model || 'unknown', - run.consumer?.client || 'unknown', run.consumer?.model || 'unknown', - run.scenarioId || 'unknown', -].join('|'); - -const HANDOFF_REPORT_ARMS = ['empty', 'natural', 'oracle', 'irrelevant', 'stale']; +const handoffKey = (run) => + [ + run.producer?.client || 'unknown', + run.producer?.model || 'unknown', + run.consumer?.client || 'unknown', + run.consumer?.model || 'unknown', + run.scenarioId || 'unknown', + ].join('|'); + +const HANDOFF_REPORT_ARMS = [ + 'empty', + 'natural', + 'oracle', + 'irrelevant', + 'stale', +]; const handoffMinimumPairs = () => Math.max(2, Number(process.env.TOKEN_OPTIMIZER_HANDOFF_MIN_PAIRS) || 10); @@ -1206,43 +1502,54 @@ function handoffCohorts(runs) { } const minimumPairs = handoffMinimumPairs(); return [...grouped.entries()].map(([key, rows]) => { - const arms = Object.fromEntries(HANDOFF_REPORT_ARMS.map((arm) => [ - arm, handoffArmMetrics(rows.filter((row) => row.arm === arm)), - ])); + const arms = Object.fromEntries( + HANDOFF_REPORT_ARMS.map((arm) => [ + arm, + handoffArmMetrics(rows.filter((row) => row.arm === arm)), + ]) + ); const naturalRows = rows.filter((row) => row.arm === 'natural'); const naturalVsEmpty = handoffPairedEffect(rows, 'empty', 'natural'); const naturalVsOracle = handoffPairedEffect(rows, 'oracle', 'natural'); const captureRate = naturalRows.length - ? naturalRows.filter((row) => row.producer?.captureSuccess).length / naturalRows.length + ? naturalRows.filter((row) => row.producer?.captureSuccess).length / + naturalRows.length : null; const preActionDeliveryRate = naturalRows.length - ? naturalRows.filter((row) => row.delivery?.beforeFirstExecutedMistake).length / naturalRows.length + ? naturalRows.filter((row) => row.delivery?.beforeFirstExecutedMistake) + .length / naturalRows.length : null; const emptyRecurrence = arms.empty.mistakeExecuted.rate; const naturalRecurrence = arms.natural.mistakeExecuted.rate; - const relativeRecurrenceReduction = emptyRecurrence !== null - && naturalRecurrence !== null - && emptyRecurrence !== 0 - ? (emptyRecurrence - naturalRecurrence) / emptyRecurrence - : null; - const controlsSafe = ['irrelevant', 'stale'].every((arm) => - arms[arm].correctness.rate !== null - && arms.empty.correctness.rate !== null - && arms[arm].correctness.rate >= arms.empty.correctness.rate - 0.1 + const relativeRecurrenceReduction = + emptyRecurrence !== null && + naturalRecurrence !== null && + emptyRecurrence !== 0 + ? (emptyRecurrence - naturalRecurrence) / emptyRecurrence + : null; + const controlsSafe = ['irrelevant', 'stale'].every( + (arm) => + arms[arm].correctness.rate !== null && + arms.empty.correctness.rate !== null && + arms[arm].correctness.rate >= arms.empty.correctness.rate - 0.1 ); - const irrelevantSuppressed = arms.irrelevant.delivery.rate !== null - && arms.irrelevant.delivery.rate === 0; + const irrelevantSuppressed = + arms.irrelevant.delivery.rate !== null && + arms.irrelevant.delivery.rate === 0; const gates = { minimumPairs: naturalVsEmpty.pairs >= minimumPairs, capture: captureRate !== null && captureRate >= 0.8, recurrenceMagnitude: - relativeRecurrenceReduction !== null && relativeRecurrenceReduction >= 0.5, - recurrenceInterval: (naturalVsEmpty.executedMistakesPrevented.low ?? -Infinity) > 0, + relativeRecurrenceReduction !== null && + relativeRecurrenceReduction >= 0.5, + recurrenceInterval: + (naturalVsEmpty.executedMistakesPrevented.low ?? -Infinity) > 0, correctness: - arms.natural.correctness.rate !== null - && arms.empty.correctness.rate !== null - && arms.natural.correctness.rate >= arms.empty.correctness.rate - 0.1, - preActionDelivery: preActionDeliveryRate !== null && preActionDeliveryRate >= 0.8, + arms.natural.correctness.rate !== null && + arms.empty.correctness.rate !== null && + arms.natural.correctness.rate >= arms.empty.correctness.rate - 0.1, + preActionDelivery: + preActionDeliveryRate !== null && preActionDeliveryRate >= 0.8, negativeControls: controlsSafe && irrelevantSuppressed, }; const claimReady = Object.values(gates).every(Boolean); @@ -1269,14 +1576,24 @@ function handoffCohorts(runs) { function concurrencySummary(runs) { const natural = runs.filter((run) => run.arm === 'natural'); const writers = natural.reduce((sum, run) => sum + (run.writerCount || 0), 0); - const captures = natural.reduce((sum, run) => sum + (run.captureSuccesses || 0), 0); - const integrityPasses = natural.filter((run) => - run.integrity?.zeroLoss - && run.integrity?.parseable - && run.integrity?.orphanedFindings === 0 + const captures = natural.reduce( + (sum, run) => sum + (run.captureSuccesses || 0), + 0 + ); + const integrityPasses = natural.filter( + (run) => + run.integrity?.zeroLoss && + run.integrity?.parseable && + run.integrity?.orphanedFindings === 0 ).length; - const delivered = natural.reduce((sum, run) => sum + (run.delivery?.delivered || 0), 0); - const expected = natural.reduce((sum, run) => sum + (run.delivery?.expected || 0), 0); + const delivered = natural.reduce( + (sum, run) => sum + (run.delivery?.delivered || 0), + 0 + ); + const expected = natural.reduce( + (sum, run) => sum + (run.delivery?.expected || 0), + 0 + ); const effect = handoffPairedEffect(runs, 'empty', 'natural'); return { runs: runs.length, @@ -1286,7 +1603,8 @@ function concurrencySummary(runs) { integrityPassRate: natural.length ? integrityPasses / natural.length : null, deliveryCoverage: expected ? delivered / expected : null, naturalCorrectness: proportionInterval( - natural.filter((run) => run.consumer?.correct).length, natural.length + natural.filter((run) => run.consumer?.correct).length, + natural.length ), effect, }; @@ -1297,17 +1615,26 @@ function concurrencySummary(runs) { * live hook traces, and randomized model evals remain visibly distinct: only * `eval-run` records with paired arms can produce a causal effect interval. */ -function buildEvidenceReport(evidence, { filters = {}, episodeLimit = 100 } = {}) { +function buildEvidenceReport( + evidence, + { filters = {}, episodeLimit = 100 } = {} +) { evidence = evidence.filter((event) => matchesFilters(event, filters)); const runs = evidence.filter((event) => event.kind === 'eval-run'); const handoffRuns = evidence.filter((event) => event.kind === 'handoff-run'); - const concurrencyRuns = evidence.filter((event) => event.kind === 'concurrency-run'); + const concurrencyRuns = evidence.filter( + (event) => event.kind === 'concurrency-run' + ); const injections = evidence.filter((event) => event.kind === 'inject'); const outcomes = evidence.filter((event) => event.kind === 'tool-outcome'); - const feedback = evidence.filter((event) => event.kind === 'finding-feedback'); + const feedback = evidence.filter( + (event) => event.kind === 'finding-feedback' + ); const byInjection = new Map( - outcomes.filter((event) => event.injectionId).map((event) => [event.injectionId, event]) + outcomes + .filter((event) => event.injectionId) + .map((event) => [event.injectionId, event]) ); const traced = injections .slice() @@ -1337,7 +1664,10 @@ function buildEvidenceReport(evidence, { filters = {}, episodeLimit = 100 } = {} grouped.get(key).push(run); } - const minimumPairs = Math.max(2, Number(process.env.TOKEN_OPTIMIZER_EVAL_MIN_PAIRS) || 5); + const minimumPairs = Math.max( + 2, + Number(process.env.TOKEN_OPTIMIZER_EVAL_MIN_PAIRS) || 5 + ); const cohorts = [...grouped.entries()].map(([key, cohortRuns]) => { const arms = Object.fromEntries( ['baseline', 'optimizer', 'retrieval', 'full'].map((arm) => [ @@ -1387,7 +1717,9 @@ function buildEvidenceReport(evidence, { filters = {}, episodeLimit = 100 } = {} cohorts: cohorts.length, harmfulFeedback: harmful, harmRate: feedback.length ? harmful / feedback.length : null, - evidenceStatus: cohorts.some((cohort) => cohort.evidenceStatus === 'causal estimate available') + evidenceStatus: cohorts.some( + (cohort) => cohort.evidenceStatus === 'causal estimate available' + ) ? 'causal estimates available' : 'insufficient randomized evidence', }, @@ -1396,7 +1728,8 @@ function buildEvidenceReport(evidence, { filters = {}, episodeLimit = 100 } = {} concurrency, episodes: traced, methodology: { - intervals: 'deterministic percentile bootstrap (95%); Wilson interval for correctness', + intervals: + 'deterministic percentile bootstrap (95%); Wilson interval for correctness', causalRule: 'matched pairs estimate optimizer vs baseline, retrieval vs optimizer, full vs retrieval, and full vs baseline', minimumPairs, diff --git a/integrations/cline/hooks/token-optimizer/lib/observability.mjs b/integrations/cline/hooks/token-optimizer/lib/observability.mjs index b9980076e..ef1c037af 100644 --- a/integrations/cline/hooks/token-optimizer/lib/observability.mjs +++ b/integrations/cline/hooks/token-optimizer/lib/observability.mjs @@ -26,7 +26,7 @@ import { createHash, randomUUID } from 'node:crypto'; import { homedir } from 'node:os'; import { join } from 'node:path'; -export const HOOK_LOG_SCHEMA_VERSION = 1; +export const HOOK_LOG_SCHEMA_VERSION = 2; const DEFAULT_MAX_BYTES = 5 * 1024 * 1024; const DEFAULT_RETENTION_DAYS = 14; const DEFAULT_MAX_FILES = 40; @@ -197,6 +197,8 @@ export function beginHookInvocation(client, event, options = {}) { turnIdHash: null, toolUseIdHash: null, toolName: null, + rawToolName: null, + codeModeEnvelope: false, clientVersion: null, model: null, cwdHash: null, @@ -208,12 +210,29 @@ export function beginHookInvocation(client, event, options = {}) { const spanId = hash(invocationId, 16); let traceId = hash(invocationId, 32); + // Write the start before reading stdin or importing project state. If the + // host kills the process, Node aborts, or a native call terminates without a + // JavaScript exception, diagnostics can now reconcile this record with the + // missing completion instead of reporting a false success rate. + writeHookEvent({ + level: 'info', + event: 'hook.started', + invocationId, + traceId, + spanId, + client, + hookEvent: event, + processId: process.pid, + }); + let deadline; const finish = (nextOutcome = outcome, nextReason = reason, nextError = error) => { if (ended) return; ended = true; if (deadline) clearTimeout(deadline); process.removeListener('exit', onExit); + process.removeListener('uncaughtException', onUncaughtException); + process.removeListener('unhandledRejection', onUnhandledRejection); writeHookEvent({ level: nextOutcome === 'failure' || nextOutcome === 'timeout' ? 'error' : 'info', event: 'hook.completed', @@ -231,7 +250,17 @@ export function beginHookInvocation(client, event, options = {}) { }); }; const onExit = () => finish(); + const onUncaughtException = (nextError) => { + finish('failure', 'uncaught_exception', nextError); + process.exit(0); + }; + const onUnhandledRejection = (nextError) => { + finish('failure', 'unhandled_rejection', nextError); + process.exit(0); + }; process.once('exit', onExit); + process.once('uncaughtException', onUncaughtException); + process.once('unhandledRejection', onUnhandledRejection); deadline = setTimeout(() => { outcome = 'timeout'; @@ -250,6 +279,11 @@ export function beginHookInvocation(client, event, options = {}) { dimensions.turnIdHash = hash(raw.turn_id ?? raw.turnId ?? null); dimensions.toolUseIdHash = hash(raw.tool_use_id ?? raw.toolUseId ?? null); dimensions.toolName = payload?.tool_name ?? raw.tool_name ?? raw.toolName ?? null; + dimensions.rawToolName = dimension( + raw.tool_name ?? raw.toolName ?? raw.tool ?? null + ); + dimensions.codeModeEnvelope = + payload?.tool_input?.code_mode_envelope === true; dimensions.clientVersion = dimension(raw.client_version ?? raw.clientVersion ?? null); dimensions.model = dimension(raw.model ?? raw.model_name ?? raw.modelName ?? null); dimensions.cwdHash = hash(raw.cwd ?? raw.working_directory ?? process.cwd()); @@ -291,6 +325,11 @@ export function beginHookInvocation(client, event, options = {}) { error = nextError; finish(outcome, reason, error); }, + block(nextReason = 'policy_denied') { + outcome = 'blocked'; + reason = nextReason; + finish(outcome, reason); + }, succeed(nextReason = 'completed') { outcome = 'success'; reason = nextReason; @@ -321,7 +360,11 @@ export function recordHookBootstrapFailure(client, event, error) { }); } -export function readHookEvents({ limit = 200, sinceMs = 24 * 60 * 60 * 1000 } = {}) { +export function readHookEvents({ + limit = 200, + sinceMs = 24 * 60 * 60 * 1000, + includeLifecycle = false, +} = {}) { try { const dir = hookLogDirectory(); if (!existsSync(dir)) return []; @@ -338,6 +381,7 @@ export function readHookEvents({ limit = 200, sinceMs = 24 * 60 * 60 * 1000 } = try { const event = JSON.parse(line); if (Date.parse(event.timestamp) < cutoff) continue; + if (!includeLifecycle && event.event === 'hook.started') continue; events.push(event); if (events.length >= limit) return events; } catch { @@ -353,38 +397,102 @@ export function readHookEvents({ limit = 200, sinceMs = 24 * 60 * 60 * 1000 } = export function hookHealthSummary(options = {}) { const sinceMs = options.sinceMs ?? 24 * 60 * 60 * 1000; - const events = readHookEvents({ limit: options.limit || 5000, sinceMs }); - const durations = events + const events = readHookEvents({ + limit: options.limit || 10000, + sinceMs, + includeLifecycle: true, + }); + const starts = events.filter((event) => event.event === 'hook.started'); + const completed = events.filter( + (event) => event.event === 'hook.completed' || !event.event + ); + const completedIds = new Set( + completed.map((event) => event.invocationId).filter(Boolean) + ); + const abandoned = starts + .filter( + (event) => + event.invocationId && + !completedIds.has(event.invocationId) && + Date.now() - Date.parse(event.timestamp) > 10_000 + ) + .map((event) => ({ + ...event, + event: 'hook.completed', + outcome: 'failure', + reason: 'process_terminated_before_completion', + durationMs: null, + })); + const runs = [...completed, ...abandoned]; + const durations = runs .map((event) => Number(event.durationMs)) .filter(Number.isFinite) .sort((a, b) => a - b); - const failures = events.filter((event) => event.outcome === 'failure'); - const timeouts = events.filter((event) => event.outcome === 'timeout'); - const skipped = events.filter((event) => event.outcome === 'skipped'); - const successes = events.filter((event) => event.outcome === 'success'); + const failures = runs.filter((event) => event.outcome === 'failure'); + const timeouts = runs.filter((event) => event.outcome === 'timeout'); + const skipped = runs.filter((event) => event.outcome === 'skipped'); + const blocked = runs.filter((event) => event.outcome === 'blocked'); + const successes = runs.filter((event) => event.outcome === 'success'); + const actions = runs.filter( + (event) => event.hookEvent === 'pre-tool' && event.toolName + ); + const byTool = {}; + for (const event of actions) { + const name = String(event.toolName || 'Unknown action'); + byTool[name] = (byTool[name] || 0) + 1; + } const byClient = {}; - for (const event of events) { + for (const event of runs) { const key = event.client || 'unknown'; - const current = byClient[key] || { total: 0, failures: 0, timeouts: 0, skipped: 0 }; + const current = byClient[key] || { + total: 0, + failures: 0, + timeouts: 0, + skipped: 0, + blocked: 0, + hookEvents: [], + }; current.total++; if (event.outcome === 'failure') current.failures++; if (event.outcome === 'timeout') current.timeouts++; if (event.outcome === 'skipped') current.skipped++; + if (event.outcome === 'blocked') current.blocked++; + if (event.hookEvent && !current.hookEvents.includes(event.hookEvent)) + current.hookEvents.push(event.hookEvent); byClient[key] = current; } + for (const current of Object.values(byClient)) current.hookEvents.sort(); const percentile = (p) => durations.length ? durations[Math.min(durations.length - 1, Math.floor(durations.length * p))] : null; const summary = { schemaVersion: HOOK_LOG_SCHEMA_VERSION, - available: events.length > 0, + available: runs.length > 0, windowHours: sinceMs / 3_600_000, - total: events.length, + total: runs.length, failures: failures.length, timeouts: timeouts.length, skipped: skipped.length, - successRate: events.length ? successes.length / events.length : null, + blocked: blocked.length, + abandoned: abandoned.length, + successes: successes.length, + actions: actions.length, + byTool, + // A skipped or policy-blocked hook completed its protocol successfully. + // Runtime health answers whether the hook process worked, not whether the + // optimizer chose to allow the host operation. + successRate: runs.length + ? (successes.length + skipped.length + blocked.length) / runs.length + : null, + healthStatus: + failures.length || timeouts.length + ? 'failing' + : skipped.length + ? 'degraded' + : runs.length + ? 'healthy' + : 'unavailable', p50DurationMs: percentile(0.5), p95DurationMs: percentile(0.95), byClient, diff --git a/integrations/cline/hooks/token-optimizer/lib/pricing.mjs b/integrations/cline/hooks/token-optimizer/lib/pricing.mjs index a4b09ebed..50b23b9cc 100644 --- a/integrations/cline/hooks/token-optimizer/lib/pricing.mjs +++ b/integrations/cline/hooks/token-optimizer/lib/pricing.mjs @@ -1,101 +1,72 @@ // GENERATED FILE -- do not edit. // Source of truth: hooks-core/pricing.mjs. Regenerate with `npm run sync:hooks`. /** - * Tokens in the unit people actually feel. + * Optional cost equivalents for token measurements. * - * The same data as everywhere else in this project, converted once, in one - * place, under three rules that keep a dollar figure honest: - * - * THE TABLE IS VISIBLE AND OVERRIDABLE. Prices change. A figure computed from - * rates buried in code goes stale silently and keeps looking authoritative, - * which is worse than no figure. The rates are printed next to the number and - * can be replaced without a release. - * - * DOLLARS BESIDE TOKENS, NOT INSTEAD OF THEM. The token count is exact and - * never goes out of date; the dollar figure is the token count times a rate - * that will. Showing both means the durable number survives the perishable - * one. - * - * NEVER A PRICE ON AN UNMEASURED SAVING. An invented number is worse in - * dollars than in tokens, because dollars get quoted to other people. A - * saving we cannot measure returns null and renders as "not yet measurable", - * never as "$0.00" -- which reads as "saved nothing" and is a different - * claim entirely. + * The runtime cannot infer a user's bill from a client name. A CLI may route + * through a subscription, direct API account, cloud marketplace, enterprise + * agreement, included credits, or a provider whose cache-read/write prices + * differ. Provider usage records are the source of truth when available. + * Everywhere else this module fails closed until the user supplies an + * effective blended input-token rate. */ -/** Cache multipliers, relative to an input token. */ -const CACHE_WRITE = 1.25; -const CACHE_READ = 0.1; +const RATE_ENV = 'TOKEN_OPTIMIZER_EFFECTIVE_INPUT_USD_PER_MILLION'; -/** - * Dollars per million tokens, by tier. - * - * Approximate published list rates as of 2026-07, carried with their date so a - * stale table is visible rather than silent. Override wholesale with - * TOKEN_OPTIMIZER_PRICES as JSON, e.g. - * {"asOf":"2026-09","opus":{"input":15,"output":75}}. - */ -const DEFAULT_PRICES = { - asOf: '2026-07', - haiku: { input: 1, output: 5 }, - sonnet: { input: 3, output: 15 }, - opus: { input: 15, output: 75 }, -}; - -export function prices() { - const raw = process.env.TOKEN_OPTIMIZER_PRICES; - if (raw) { - try { - const parsed = JSON.parse(raw); - if (parsed && typeof parsed === 'object') return { ...DEFAULT_PRICES, ...parsed, overridden: true }; - } catch { /* an unparseable override is ignored rather than fatal */ } - } - return DEFAULT_PRICES; +/** The configured effective input rate, or null when billing is unobservable. */ +export function effectiveRate() { + const rate = Number(process.env[RATE_ENV]); + return Number.isFinite(rate) && rate > 0 ? rate : null; } /** - * What a number of tokens costs. + * Dollars for a measured token quantity. * - * Returns null for a null input and says why: this is the single place the - * "unmeasured must not become $0.00" rule is enforced, so every caller inherits - * it rather than each remembering. + * `kind` is accepted for backwards compatibility but is not multiplied by a + * universal cache constant. The configured rate must already reflect the + * user's actual provider/model/cache/tier/credit mix. */ -export function dollars(tokens, { tier = 'opus', kind = 'input' } = {}) { - if (!Number.isFinite(tokens)) return null; - - const table = prices(); - const rate = table[tier]?.[kind === 'output' ? 'output' : 'input']; - if (!Number.isFinite(rate)) return null; - - const multiplier = kind === 'cacheWrite' ? CACHE_WRITE : kind === 'cacheRead' ? CACHE_READ : 1; - return (tokens / 1_000_000) * rate * multiplier; +export function dollars(tokens, { rate = effectiveRate() } = {}) { + if (!Number.isFinite(tokens) || !Number.isFinite(rate) || rate <= 0) { + return null; + } + return (tokens / 1_000_000) * rate; } /** A dollar figure, or an honest absence of one. */ -export function money(amount, { unmeasured = 'not yet measurable' } = {}) { +export function money(amount, { unmeasured = 'not priced' } = {}) { if (amount == null || !Number.isFinite(amount)) return unmeasured; if (amount === 0) return '$0.00'; if (Math.abs(amount) < 0.01) return '<$0.01'; return `$${amount.toFixed(2)}`; } -/** - * Per-session tokens expressed as a monthly cost. - * - * `sessionsPerMonth` is an assumption, so it is stated in the output rather - * than folded invisibly into the number. - */ -export function monthly(tokensPerSession, { tier = 'opus', sessionsPerMonth = 60 } = {}) { +/** Per-session tokens expressed with a stated monthly-frequency assumption. */ +export function monthly(tokensPerSession, { sessionsPerMonth = 60 } = {}) { if (!Number.isFinite(tokensPerSession)) return null; - const amount = dollars(tokensPerSession * sessionsPerMonth, { tier }); - return amount == null ? null : { amount, sessionsPerMonth, tier }; + const amount = dollars(tokensPerSession * sessionsPerMonth); + return amount == null + ? null + : { + amount, + sessionsPerMonth, + effectiveInputUsdPerMillion: effectiveRate(), + }; +} + +/** The pricing basis printed beside any cost equivalent. */ +export function priceNote() { + const rate = effectiveRate(); + return rate == null + ? `cost not priced: set ${RATE_ENV} to your effective blended input rate after provider, model, cache, tier, plan, and credits` + : `cost equivalent: configured effective input rate $${rate}/M via ${RATE_ENV}`; } -/** The rates behind the figures, for printing beside them. */ -export function priceNote(tier = 'opus') { - const table = prices(); - const rate = table[tier]; - if (!rate) return null; - return `prices: ${tier} $${rate.input}/$${rate.output} per Mtok (${table.asOf}` + - `${table.overridden ? ', overridden' : ''}); override with TOKEN_OPTIMIZER_PRICES`; +/** Compatibility surface: no built-in provider table is claimed. */ +export function prices() { + return { + source: + effectiveRate() == null ? 'unavailable' : 'configured-effective-rate', + effectiveInputUsdPerMillion: effectiveRate(), + }; } diff --git a/integrations/cline/hooks/token-optimizer/lib/routing.mjs b/integrations/cline/hooks/token-optimizer/lib/routing.mjs index 9b4642e0e..84ac866e4 100644 --- a/integrations/cline/hooks/token-optimizer/lib/routing.mjs +++ b/integrations/cline/hooks/token-optimizer/lib/routing.mjs @@ -65,7 +65,9 @@ export function modelCosts() { if (raw) { try { return JSON.parse(raw); - } catch { /* fall through to the defaults */ } + } catch { + /* fall through to the defaults */ + } } return { haiku: 1, sonnet: 3, opus: 15 }; } @@ -106,7 +108,12 @@ export function readEpisodes(path, { maxBytes = 6_000_000 } = {}) { let current = null; const close = () => { - if (current && current.turns > 0) episodes.push({ ...current, files: [...current.files], tools: [...current.tools] }); + if (current && current.turns > 0) + episodes.push({ + ...current, + files: [...current.files], + tools: [...current.tools], + }); current = null; }; @@ -123,7 +130,15 @@ export function readEpisodes(path, { maxBytes = 6_000_000 } = {}) { // a new episode. if (row.type === 'user' && typeof row.message?.content === 'string') { close(); - current = { at: Date.parse(row.timestamp) || null, models: new Set(), tools: new Set(), files: new Set(), turns: 0, errors: 0, results: 0 }; + current = { + at: Date.parse(row.timestamp) || null, + models: new Set(), + tools: new Set(), + files: new Set(), + turns: 0, + errors: 0, + results: 0, + }; continue; } if (!current) continue; @@ -157,8 +172,16 @@ export function readEpisodes(path, { maxBytes = 6_000_000 } = {}) { /* ---------------------------------------------------------------- SHAPES */ -const EDIT_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit', 'smart_edit', 'smart_write']); -const TEST_PATTERN = /\b(test|jest|pytest|vitest|dotnet test|npm test|go test)\b/i; +const EDIT_TOOLS = new Set([ + 'Edit', + 'MultiEdit', + 'Write', + 'NotebookEdit', + 'smart_edit', + 'smart_write', +]); +const TEST_PATTERN = + /\b(test|jest|pytest|vitest|dotnet test|npm test|go test)\b/i; /** * What kind of work this was. @@ -171,11 +194,19 @@ export function classifyShape(episode) { const tools = new Set(episode.tools || []); const edited = [...tools].some((t) => EDIT_TOOLS.has(t)); const files = (episode.files || []).length; - const ranTests = [...tools].some((t) => TEST_PATTERN.test(t)) || (episode.tools || []).includes('Bash'); + const ranTests = + [...tools].some((t) => TEST_PATTERN.test(t)) || + (episode.tools || []).includes('Bash'); if (edited && files >= 3) return 'multi-file-change'; if (edited) return 'single-file-change'; - if (files > 0 || tools.has('Grep') || tools.has('Glob') || tools.has('smart_grep')) return 'investigation'; + if ( + files > 0 || + tools.has('Grep') || + tools.has('Glob') || + tools.has('smart_grep') + ) + return 'investigation'; if (ranTests) return 'build-or-test'; return 'conversation'; } @@ -208,7 +239,13 @@ export function outcomeTable(episodes) { const tier = [...tiers][0]; table[shape] = table[shape] || {}; - const cell = table[shape][tier] = table[shape][tier] || { episodes: 0, turns: 0, errors: 0, results: 0, errored: 0 }; + const cell = (table[shape][tier] = table[shape][tier] || { + episodes: 0, + turns: 0, + errors: 0, + results: 0, + errored: 0, + }); cell.episodes += 1; cell.turns += episode.turns; cell.errors += episode.errors; @@ -287,7 +324,8 @@ export function route(shape, table = {}) { shape, recommend: heuristic, basis: 'heuristic', - reason: `every measured tier for ${shape} needed a retry in more than ` + + reason: + `every measured tier for ${shape} needed a retry in more than ` + `${Math.round(MAX_ERROR_RATE * 100)}% of episodes; falling back to the shipped default`, excluded: excluded.map((o) => ({ tier: o.tier, errorRate: o.errorRate })), options, @@ -306,17 +344,24 @@ export function route(shape, table = {}) { recommend: best.tier, basis: 'measured', // What each mistake would cost, from this project's own rates. - overpowered: strongest.tier === best.tier ? null : { - tier: strongest.tier, - wasted: Number((strongest.cost - best.cost).toFixed(2)), - }, - underpowered: cheapest.tier === best.tier ? null : { - tier: cheapest.tier, - expectedCost: Number(cheapest.cost.toFixed(2)), - errorRate: cheapest.errorRate, - excluded: cheapest.errorRate > MAX_ERROR_RATE, - }, - reason: `${shape}: ${best.tier} costs ${best.cost.toFixed(2)} in expectation over ${best.episodes} episodes ` + + overpowered: + strongest.tier === best.tier + ? null + : { + tier: strongest.tier, + wasted: Number((strongest.cost - best.cost).toFixed(2)), + }, + underpowered: + cheapest.tier === best.tier + ? null + : { + tier: cheapest.tier, + expectedCost: Number(cheapest.cost.toFixed(2)), + errorRate: cheapest.errorRate, + excluded: cheapest.errorRate > MAX_ERROR_RATE, + }, + reason: + `${shape}: ${best.tier} costs ${best.cost.toFixed(2)} in expectation over ${best.episodes} episodes ` + `(${Math.round(best.errorRate * 100)}% needed a retry)`, options, }; @@ -340,20 +385,31 @@ export function routingNote(shape, table, { currentModel, switchCost } = {}) { const lines = [decision.reason]; if (decision.underpowered) { - lines.push(` ${decision.underpowered.tier} looks cheaper per token, but needed a retry in ` + - `${Math.round(decision.underpowered.errorRate * 100)}% of episodes -- expected cost ${decision.underpowered.expectedCost}` + - `${decision.underpowered.excluded ? ', which is why it is not a candidate here at any price' : ''}.`); + lines.push( + ` ${decision.underpowered.tier} looks cheaper per token, but needed a retry in ` + + `${Math.round(decision.underpowered.errorRate * 100)}% of episodes -- expected cost ${decision.underpowered.expectedCost}` + + `${decision.underpowered.excluded ? ', which is why it is not a candidate here at any price' : ''}.` + ); } if (decision.overpowered) { - lines.push(` ${decision.overpowered.tier} would spend ${decision.overpowered.wasted} more than needed here.`); + lines.push( + ` ${decision.overpowered.tier} would spend ${decision.overpowered.wasted} more than needed here.` + ); } if (switchCost?.prefixTokens) { - lines.push(` Switching now also discards a ${switchCost.prefixTokens.toLocaleString()}-token warm prefix ` + - `(~${switchCost.rewriteCost.toLocaleString()} tokens to re-write); consider switching at the next break.`); + lines.push( + ` Switching now also discards a ${switchCost.prefixTokens.toLocaleString()}-token warm prefix ` + + `(~${switchCost.rewriteInputCostEquivalent.toLocaleString()} Anthropic uncached-input cost-equivalent tokens to re-write); consider switching at the next break.` + ); } - return { shape, recommend: decision.recommend, basis: decision.basis, text: lines.join('\n') }; + return { + shape, + recommend: decision.recommend, + basis: decision.basis, + text: lines.join('\n'), + }; } /** @@ -371,7 +427,9 @@ export function routingBriefing(table) { const decision = route(shape, table); if (decision.basis !== 'measured') continue; if (decision.recommend === HEURISTIC[shape]) continue; // nothing learned worth saying - lines.push(`${shape.replace(/-/g, ' ')} work goes better on ${decision.recommend} in this project.`); + lines.push( + `${shape.replace(/-/g, ' ')} work goes better on ${decision.recommend} in this project.` + ); } return lines.length ? lines.join('\n') : null; } @@ -389,7 +447,11 @@ export function routingBriefing(table) { * few more episodes accumulated would invalidate the cache behind it. Stability * is the feature; the memo is how it is bought. */ -export function cachedRoutingBriefing(dir, transcriptPath, { growthBytes = 4_000_000, maxAgeMs = 24 * 60 * 60 * 1000 } = {}) { +export function cachedRoutingBriefing( + dir, + transcriptPath, + { growthBytes = 4_000_000, maxAgeMs = 24 * 60 * 60 * 1000 } = {} +) { const memoPath = join(dir, 'routing-brief.json'); let stat; try { @@ -400,17 +462,31 @@ export function cachedRoutingBriefing(dir, transcriptPath, { growthBytes = 4_000 try { const memo = JSON.parse(readFileSync(memoPath, 'utf8')); - const fresh = memo.path === transcriptPath - && Math.abs(stat.size - memo.size) < growthBytes - && Date.now() - memo.at < maxAgeMs; + const fresh = + memo.path === transcriptPath && + Math.abs(stat.size - memo.size) < growthBytes && + Date.now() - memo.at < maxAgeMs; if (fresh) return memo.text || null; - } catch { /* no memo, or an unreadable one; recompute */ } + } catch { + /* no memo, or an unreadable one; recompute */ + } const text = routingBriefing(outcomeTable(readEpisodes(transcriptPath))); try { mkdirSync(dir, { recursive: true, mode: 0o700 }); - writeFileSync(memoPath, JSON.stringify({ path: transcriptPath, size: stat.size, at: Date.now(), text }), { mode: 0o600 }); - } catch { /* the memo is an optimisation; failing to write it costs latency, not correctness */ } + writeFileSync( + memoPath, + JSON.stringify({ + path: transcriptPath, + size: stat.size, + at: Date.now(), + text, + }), + { mode: 0o600 } + ); + } catch { + /* the memo is an optimisation; failing to write it costs latency, not correctness */ + } return text; } @@ -427,9 +503,11 @@ export function routingReport(table) { for (const tier of Object.keys(table[shape])) { const cell = table[shape][tier]; const cost = expectedCost(tier, cell); - lines.push(` ${tier.padEnd(7)} ${cell.episodes} episode(s), ` + - `${cell.meanTurns.toFixed(1)} turns avg, ${Math.round(cell.errorRate * 100)}% needed a retry, ` + - `expected cost ${cost.cost.toFixed(2)}${cell.measured ? '' : ' [too few episodes to trust]'}`); + lines.push( + ` ${tier.padEnd(7)} ${cell.episodes} episode(s), ` + + `${cell.meanTurns.toFixed(1)} turns avg, ${Math.round(cell.errorRate * 100)}% needed a retry, ` + + `expected cost ${cost.cost.toFixed(2)}${cell.measured ? '' : ' [too few episodes to trust]'}` + ); } } return lines.join('\n'); diff --git a/integrations/cline/hooks/token-optimizer/lib/symbols.mjs b/integrations/cline/hooks/token-optimizer/lib/symbols.mjs index 2a80e1e42..4d80666ac 100644 --- a/integrations/cline/hooks/token-optimizer/lib/symbols.mjs +++ b/integrations/cline/hooks/token-optimizer/lib/symbols.mjs @@ -50,7 +50,11 @@ const PATTERNS = { /^\s*(?:pub(?:\([^)]*\))?\s+)?(?:struct|enum|trait|impl)\s+([A-Za-z_]\w*)/, ], clike: [ - /^\s*(?:public|private|protected|internal|static|final|abstract|virtual|override|async|\s)*(?:[\w<>\[\],.?]+\s+)([A-Za-z_]\w*)\s*\([^;]*\)\s*\{/, + // Accept both K&R (`Run() {`) and Allman (`Run()` then `{`) declarations. + // Requiring the opening brace on the declaration line silently discarded + // nearly every method in idiomatic C# projects, leaving file nodes but no + // symbol nodes and making valid `file.cs#Method` anchors unresolved. + /^\s*(?:public|private|protected|internal|static|final|abstract|virtual|override|async|sealed|new|extern|unsafe|partial|\s)*(?:[\w<>\[\],.?]+\s+)([A-Za-z_]\w*)(?:<[^;()]+>)?\s*\([^;]*\)\s*(?:\{|=>|$)/, /^\s*(?:public|private|protected|internal|static|abstract|sealed|partial|\s)*(?:class|struct|interface|enum|record)\s+([A-Za-z_]\w*)/, ], ruby: [ diff --git a/integrations/codex/hooks/hooks.json b/integrations/codex/hooks/hooks.json index 0787343f7..0f187b7ad 100644 --- a/integrations/codex/hooks/hooks.json +++ b/integrations/codex/hooks/hooks.json @@ -17,7 +17,7 @@ ], "PreToolUse": [ { - "matcher": "Read|read_file|view_file|Grep|grep|search_file_content|Glob|glob|find_files|Edit|edit_file|apply_patch|Write|write_file|Bash|shell|run_command", + "matcher": "Read|read_file|view_file|Grep|grep|search_file_content|Glob|glob|find_files|Edit|edit_file|apply_patch|Write|write_file|Bash|shell|run_command|functions\\.exec|exec", "hooks": [ { "type": "command", @@ -31,7 +31,7 @@ ], "PostToolUse": [ { - "matcher": "apply_patch|Edit|Write|edit_file|write_file|smart_edit|smart_write", + "matcher": "apply_patch|Edit|Write|edit_file|write_file|smart_edit|smart_write|functions\\.exec|exec", "hooks": [ { "type": "command", diff --git a/integrations/codex/hooks/lib/adapter.mjs b/integrations/codex/hooks/lib/adapter.mjs index fee0102b9..9f76bbdea 100644 --- a/integrations/codex/hooks/lib/adapter.mjs +++ b/integrations/codex/hooks/lib/adapter.mjs @@ -188,8 +188,143 @@ function contextOutput(client, eventName, additionalContext) { }; } +/** Convert one static JavaScript string literal without evaluating code. */ +function codexStringLiteral(literal) { + if (typeof literal !== 'string' || literal.length < 2) return null; + const quote = literal[0]; + if (!['"', "'", '`'].includes(quote) || literal.at(-1) !== quote) return null; + + let decoded = ''; + for (let index = 1; index < literal.length - 1; index += 1) { + const character = literal[index]; + if (quote === '`' && character === '$' && literal[index + 1] === '{') + return null; + if (character !== '\\') { + decoded += character; + continue; + } + + index += 1; + if (index >= literal.length - 1) return null; + const escaped = literal[index]; + const simple = { + b: '\b', f: '\f', n: '\n', r: '\r', t: '\t', v: '\v', 0: '\0', + '\\': '\\', "'": "'", '"': '"', '`': '`', '$': '$', + }; + if (Object.hasOwn(simple, escaped)) { + decoded += simple[escaped]; + continue; + } + if (escaped === '\n') continue; + if (escaped === '\r') { + if (literal[index + 1] === '\n') index += 1; + continue; + } + if (escaped === 'x') { + const digits = literal.slice(index + 1, index + 3); + if (!/^[0-9a-f]{2}$/i.test(digits)) return null; + decoded += String.fromCodePoint(Number.parseInt(digits, 16)); + index += 2; + continue; + } + if (escaped === 'u') { + const braced = literal[index + 1] === '{'; + const close = braced ? literal.indexOf('}', index + 2) : index + 5; + const digits = braced + ? literal.slice(index + 2, close) + : literal.slice(index + 1, close); + if ( + close < 0 || + !(braced ? /^[0-9a-f]{1,6}$/i : /^[0-9a-f]{4}$/i).test(digits) + ) return null; + const codePoint = Number.parseInt(digits, 16); + if (codePoint > 0x10ffff) return null; + decoded += String.fromCodePoint(codePoint); + index = close; + continue; + } + // JavaScript identity escapes drop the slash (for example, '\q' is 'q'). + decoded += escaped; + } + return decoded; +} + /** Convert client-specific lifecycle envelopes into the common tool shape. */ +function codexStringBindings(source) { + const values = new Map(); + const declaration = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*((?:"(?:\\.|[^"\\])*")|(?:'(?:\\.|[^'\\])*')|(?:`(?:\\.|[^`\\])*`))/gs; + for (const match of String(source || '').matchAll(declaration)) { + try { + const value = codexStringLiteral(match[2]); + if (value !== null) values.set(match[1], value); + } catch { + // A computed or malformed JavaScript expression is not safe to execute + // merely to understand a hook envelope. Leave it opaque. + } + } + return values; +} + +function codexCallArguments(source, method) { + const calls = []; + const pattern = new RegExp(`(?:tools\\.)?${method}\\s*\\(\\s*([^),]+)`, 'gi'); + for (const match of String(source || '').matchAll(pattern)) + calls.push(match[1].trim()); + return calls; +} + +function codexLiteral(value, bindings) { + if (bindings.has(value)) return String(bindings.get(value)); + return codexStringLiteral(value); +} + +function codexExecCommands(source, bindings) { + const commands = []; + for (const call of String(source || '').matchAll( + /(?:tools\.)?exec_command\s*\(\s*\{([\s\S]*?)\}\s*\)/gi + )) { + const property = /\bcmd\s*:\s*((?:"(?:\\.|[^"\\])*")|(?:'(?:\\.|[^'\\])*')|(?:`(?:\\.|[^`\\])*`)|(?:[A-Za-z_$][\w$]*))/s.exec(call[1]); + if (!property) continue; + const command = codexLiteral(property[1], bindings); + if (command) commands.push(command); + } + return commands; +} + export function normalizeClientPayload(clientName, event, raw) { + if (clientName === 'codex') { + const outerName = String(raw.tool_name ?? raw.toolName ?? raw.tool ?? ''); + if (/^(?:functions\.)?exec$/i.test(outerName)) { + const envelope = raw.tool_input ?? raw.toolInput ?? raw.arguments ?? raw.args; + const source = + typeof envelope === 'string' + ? envelope + : envelope?.code ?? envelope?.input ?? envelope?.source ?? ''; + const bindings = codexStringBindings(source); + const patchCalls = codexCallArguments(source, 'apply_patch'); + const patch = patchCalls + .map((argument) => codexLiteral(argument, bindings)) + .find(Boolean); + const mutation = patchCalls.length > 0; + const commands = codexExecCommands(source, bindings); + + // `functions.exec` is also the orchestration envelope for web, image and + // other non-filesystem tools. Do not reinterpret those as shell commands: + // doing so produced irrelevant cross-project advice and fabricated graph + // activity for operations the filesystem hooks never observed. + if (!mutation && !commands.length) return raw; + return { + ...raw, + tool_name: mutation ? 'apply_patch' : 'run_command', + tool_input: { + ...(envelope && typeof envelope === 'object' ? envelope : {}), + command: String(patch || commands.join('\n') || source || ''), + code_mode_envelope: true, + }, + }; + } + } + if (clientName === 'cline') { const body = event === 'post-tool' ? raw.postToolUse : raw.preToolUse; if (!body) return raw; @@ -385,6 +520,22 @@ function projectBriefing() { function observeAndInject(payload, state, episode, features) { const touched = touchedFiles(payload); const dirFor = (path) => wikiDir(projectRootFor(path, payload.cwd)); + const registerRoot = (root) => { + const dir = wikiDir(root); + registerProject({ + root, + graphDir: dir, + client: episode.client || 'unknown', + }); + return dir; + }; + + // SessionStart is not delivered consistently by every host/version. The + // graph was still written in that case, but the dashboard registry never + // learned it existed. Register every repository observed by a real tool call + // as a second, idempotent production path. + for (const { path } of touched) + registerRoot(projectRootFor(path, payload.cwd)); const bytes = readCostBytes(payload); if (bytes && payload.tool_input.file_path) { @@ -425,7 +576,7 @@ function observeAndInject(payload, state, episode, features) { const command = payload.tool_input?.command; if (command) { const root = commandProjectRoot(payload, payload.cwd); - const dir = wikiDir(root); + const dir = registerRoot(root); const local = forCommand(dir, load(dir), command, { sessionId: payload.session_id, alreadyInjected, @@ -750,7 +901,14 @@ async function runHook(clientName, event, invocation) { client.canDeny && event === 'pre-tool' && !repeat && - mode() !== MODE_ADVISE + mode() !== MODE_ADVISE && + // Codex code mode presents an orchestration program as one outer + // `functions.exec` call. Refusing that envelope is rendered by the host + // as a failed script and can discard several otherwise-safe nested + // operations. Observe it, capture it and advise, but reserve a hard veto + // for legacy one-operation Codex tool calls where the denied operation is + // unambiguous. + !(clientName === 'codex' && payload.tool_input?.code_mode_envelope) ); if (canRefuse) { @@ -759,19 +917,23 @@ async function runHook(clientName, event, invocation) { // Every client's refusal carries the off switch, for the same reason Claude // Code's does: enforcement that hides its own disable is coercive. if (client.denyStyle === 'top-level') { + invocation.block('policy_denied'); emit({ decision: 'deny', reason: withEscape(verdict.reason) }); } else if (client.denyStyle === 'top-level-permission') { + invocation.block('policy_denied'); emit({ permissionDecision: 'deny', permissionDecisionReason: withEscape(verdict.reason), }); } else if (client.denyStyle === 'cline') { + invocation.block('policy_denied'); emit({ cancel: true, contextModification: '', errorMessage: withEscape(verdict.reason), }); } else if (client.denyStyle === 'cursor') { + invocation.block('policy_denied'); emit({ continue: true, permission: 'deny', @@ -780,9 +942,11 @@ async function runHook(clientName, event, invocation) { 'Token Optimizer redirected an expensive built-in operation.', }); } else if (client.denyStyle === 'exit-2') { + invocation.block('policy_denied'); process.stderr.write(withEscape(verdict.reason)); process.exit(2); } else { + invocation.block('policy_denied'); emit({ hookSpecificOutput: { hookEventName: eventName, diff --git a/integrations/codex/hooks/lib/audit.mjs b/integrations/codex/hooks/lib/audit.mjs index b82186586..df8a08b82 100644 --- a/integrations/codex/hooks/lib/audit.mjs +++ b/integrations/codex/hooks/lib/audit.mjs @@ -50,15 +50,17 @@ export function decline(dir, id) { export function declines(dir, { events = readMetrics(dir) } = {}) { const counts = new Map(); for (const event of events) { - if (event.kind !== 'advice' || event.action !== 'declined' || !event.id) continue; + if (event.kind !== 'advice' || event.action !== 'declined' || !event.id) + continue; counts.set(event.id, (counts.get(event.id) || 0) + 1); } return counts; } -const idOf = (finding) => finding.remedy - ? `${finding.remedy.type}:${finding.remedy.anchor || finding.remedy.file || (finding.remedy.anchors || []).join(',')}` - : `${finding.id}:${finding.anchor || finding.file || ''}`; +const idOf = (finding) => + finding.remedy + ? `${finding.remedy.type}:${finding.remedy.anchor || finding.remedy.file || (finding.remedy.anchors || []).join(',')}` + : `${finding.id}:${finding.anchor || finding.file || ''}`; /** * Whether a habit improved after we said something about it. @@ -68,12 +70,18 @@ const idOf = (finding) => finding.remedy * -- a trend computed from one session on each side is a coin flip with a * percentage sign. */ -export function habitTrend(dir, detector, { events = readMetrics(dir), minSessions = 2, anchors = null } = {}) { +export function habitTrend( + dir, + detector, + { events = readMetrics(dir), minSessions = 2, anchors = null } = {} +) { // SPLIT BY POSITION, NOT BY TIMESTAMP. The log is append-only, so its order is // authoritative -- while `at` has millisecond granularity, and a burst of // events inside one millisecond would land on the wrong side of the split or // collapse one side to nothing. Position is exact where the clock is not. - const raisedAt = events.findIndex((e) => e.kind === 'advice' && e.detector === detector); + const raisedAt = events.findIndex( + (e) => e.kind === 'advice' && e.detector === detector + ); if (raisedAt === -1) return null; // SCOPED TO THE FILES THIS DETECTOR NAMED. Counting every read in the window @@ -114,7 +122,12 @@ export function habitTrend(dir, detector, { events = readMetrics(dir), minSessio /** Marks a recommendation as having been raised, so its effect can be scored. */ export function raise(dir, finding) { - record(dir, { kind: 'advice', action: 'raised', id: idOf(finding), detector: finding.id }); + record(dir, { + kind: 'advice', + action: 'raised', + id: idOf(finding), + detector: finding.id, + }); } /** @@ -123,7 +136,11 @@ export function raise(dir, finding) { * `findings` comes from the detectors; `extra` lets the cache and routing * modules contribute without this file importing the world. */ -export function buildQueue(dir, findings = [], { events = readMetrics(dir) } = {}) { +export function buildQueue( + dir, + findings = [], + { events = readMetrics(dir) } = {} +) { const declined = declines(dir, { events }); const ledger = remedyLedger(dir); const applied = new Set(ledger.filter((r) => !r.revertedAt).map((r) => r.id)); @@ -146,7 +163,9 @@ export function buildQueue(dir, findings = [], { events = readMetrics(dir) } = { } return { - queue: queue.sort((a, b) => (b.costPerSession ?? 0) - (a.costPerSession ?? 0)), + queue: queue.sort( + (a, b) => (b.costPerSession ?? 0) - (a.costPerSession ?? 0) + ), suppressed, done: ledger.filter((r) => !r.revertedAt), }; @@ -161,7 +180,11 @@ export function buildQueue(dir, findings = [], { events = readMetrics(dir) } = { * many were withheld -- and what they are collectively worth -- keeps that from * being a silent cap. */ -export function renderAudit(dir, findings = [], { tier = 'opus', full = false, sessionsPerMonth = 60 } = {}) { +export function renderAudit( + dir, + findings = [], + { tier = 'opus', full = false, sessionsPerMonth = 60 } = {} +) { const { queue, suppressed, done } = buildQueue(dir, findings); const lines = []; @@ -175,22 +198,29 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s // read as "this costs nothing" beside a finding whose cost we simply do not // know -- the same unknown-becomes-zero error the rest of this project // corrects, in the one unit that gets quoted to other people. - const priced = item.costPerSession == null ? null : monthly(cost, { tier, sessionsPerMonth }); + const priced = + item.costPerSession == null + ? null + : monthly(cost, { tier, sessionsPerMonth }); // The id appears on EVERY actionable line, not just the appliable ones. It is the only // handle `decline` accepts and it cannot be guessed from the title, so omitting it made the // advertised decline path unreachable for precisely the findings most likely to be // unwanted -- model-routing findings carry no remedy, so they could never be suppressed no // matter how many times a user declined them. - const action = item.remedy?.kind === 'ours' ? `apply: waste_audit action="apply" id="${item.id}"` - : item.remedy?.kind === 'yours' ? `proposed edit -- needs your yes, nothing changed; decline: ${item.id}` - : `advice only -- no automatic fix; decline: ${item.id}`; + const action = + item.remedy?.kind === 'ours' + ? `apply: waste_audit action="apply" id="${item.id}"` + : item.remedy?.kind === 'yours' + ? `proposed edit -- needs your yes, nothing changed; decline: ${item.id}` + : `advice only -- no automatic fix; decline: ${item.id}`; // MEASURED ZERO IS NOT UNMEASURED. Testing `cost` for truthiness sent a real 0 down the // "not yet measurable" branch while `priced` above, which tests for null, still rendered // $0.00 beside it -- producing `cost not yet measurable (~$0.00/month)`, the exact string // the test suite declares must never appear. waste.mjs defaults every finding to 0 and // hard-sets it on the co-occurrence detector, so this is reachable, not theoretical. - const text = ` ${item.title}\n ${item.costPerSession != null ? `${cost.toLocaleString()} tokens/session` : 'cost not yet measurable'}` + + const text = + ` ${item.title}\n ${item.costPerSession != null ? `${cost.toLocaleString()} tokens/session` : 'cost not yet measurable'}` + `${priced ? ` (~${money(priced.amount)}/month)` : ''}; ${action}`; const printCost = estimate(text); @@ -211,14 +241,19 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s lines.push(text); } - const head = shown.length ? ['What to do next, most expensive first:', ...lines] : ['Nothing addressable found.']; + const head = shown.length + ? ['What to do next, most expensive first:', ...lines] + : ['Nothing addressable found.']; const body = [...head]; if (withheld.length) { const worth = withheld.reduce((sum, w) => sum + (w.costPerSession || 0), 0); - body.push('', ` ... ${withheld.length} more finding(s) worth ${worth.toLocaleString()} tokens/session in total, ` + - 'not shown -- printing them costs more than they are worth. Pass full=true to see all.'); + body.push( + '', + ` ... ${withheld.length} more finding(s) worth ${worth.toLocaleString()} tokens/session in total, ` + + 'not shown -- printing them costs more than they are worth. Pass full=true to see all.' + ); } // What we already told you, and what it bought. @@ -226,23 +261,35 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s body.push('', 'Already applied, and what it actually saved:'); for (const rule of done.slice(0, 6)) { const saved = rule.savedPerSession; - const priced = saved == null ? null : monthly(saved, { tier, sessionsPerMonth }); - body.push(` + ${rule.id} -- ${saved == null - ? 'not yet measurable (fewer than two sessions since)' - : `measured ${saved.toLocaleString()} tokens/session${priced ? `, ~${money(priced.amount)}/month` : ''}`}`); + const priced = + saved == null ? null : monthly(saved, { tier, sessionsPerMonth }); + body.push( + ` + ${rule.id} -- ${ + saved == null + ? 'not yet measurable (fewer than two sessions since)' + : `measured ${saved.toLocaleString()} tokens/session${priced ? `, ~${money(priced.amount)}/month` : ''}` + }` + ); } // Never a silent cap: the withheld block above states its remainder and so must this one. // Truncating to six with no disclosure showed a user six savings and let them conclude that // was everything the tool had bought them, which understates its own measured value. if (done.length > 6) { - body.push(` ... and ${done.length - 6} more applied fix(es), not shown.`); + body.push( + ` ... and ${done.length - 6} more applied fix(es), not shown.` + ); } } if (suppressed.length) { - body.push('', `Not repeating ${suppressed.length} recommendation(s) you have declined ` + - `${DECLINE_LIMIT}+ times. Ask for full=true to see them.`); - if (full) for (const item of suppressed) body.push(` - ${item.title} (declined ${item.times}x)`); + body.push( + '', + `Not repeating ${suppressed.length} recommendation(s) you have declined ` + + `${DECLINE_LIMIT}+ times. Ask for full=true to see them.` + ); + if (full) + for (const item of suppressed) + body.push(` - ${item.title} (declined ${item.times}x)`); } // Whether the advice changed anything. @@ -256,20 +303,30 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s for (const a of anchors) anchorsBy.get(f.id).add(a); } const trends = [...new Set(findings.map((f) => f.id))] - .map((detector) => habitTrend(dir, detector, { - anchors: anchorsBy.has(detector) ? [...anchorsBy.get(detector)] : null, - })) + .map((detector) => + habitTrend(dir, detector, { + anchors: anchorsBy.has(detector) ? [...anchorsBy.get(detector)] : null, + }) + ) .filter(Boolean); if (trends.length) { body.push('', 'Since the advice was given:'); for (const trend of trends) { - body.push(` ${trend.detector}: ${trend.before.toLocaleString()} -> ${trend.after.toLocaleString()} tokens/session ` + - `(${trend.improved ? 'improved' : 'worse'})`); + body.push( + ` ${trend.detector}: ${trend.before.toLocaleString()} -> ${trend.after.toLocaleString()} tokens/session ` + + `(${trend.improved ? 'improved' : 'worse'})` + ); } } - const addressable = queue.reduce((sum, item) => sum + (item.costPerSession || 0), 0); + const addressable = queue.reduce( + (sum, item) => sum + (item.costPerSession || 0), + 0 + ); + const addressableMonthly = addressable + ? monthly(addressable, { tier, sessionsPerMonth }) + : null; const note = priceNote(tier); if (note) body.push('', note); @@ -278,9 +335,10 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s // pushing it left the report's longest line out of its own reported cost, understating it by // 10-20% on a short audit. This figure exists to prove the audit is not a net loss, so // understating the cost side biases exactly the comparison it was written to be honest about. - const closing = (n) => `This report cost about ${n.toLocaleString()} tokens and names ` + + const closing = (n) => + `This report cost about ${n.toLocaleString()} tokens and names ` + `${addressable.toLocaleString()} tokens/session of addressable waste` + - `${addressable ? ` (~${money(monthly(addressable, { tier, sessionsPerMonth })?.amount)}/month across ${sessionsPerMonth} sessions)` : ''}.`; + `${addressableMonthly ? ` (~${money(addressableMonthly.amount)}/month across ${sessionsPerMonth} sessions)` : ''}.`; const selfCost = estimate([...body, '', closing(0)].join('\n')); body.push('', closing(selfCost)); diff --git a/integrations/codex/hooks/lib/cache.mjs b/integrations/codex/hooks/lib/cache.mjs index 9b11173ff..9782c3262 100644 --- a/integrations/codex/hooks/lib/cache.mjs +++ b/integrations/codex/hooks/lib/cache.mjs @@ -3,12 +3,12 @@ /** * Prompt-cache economics. * - * Cache economics is real money in a way most compression is not: a cache read - * costs a tenth of a fresh token and a cache WRITE costs a quarter more than - * one, so a prefix that keeps invalidating can cost more than every saving this - * product makes elsewhere. It is also the one area where we do not have to - * model anything -- the client's own transcript records cache_read_input_tokens - * and cache_creation_input_tokens per turn, which is ground truth. + * Anthropic prompt-cache usage, read from Claude Code transcripts. A cache read + * and a cache write are both billable, at different published multipliers from + * uncached input. Raw cache_read_input_tokens and + * cache_creation_input_tokens are observations. Any multiplier-based value is + * an Anthropic input-cost equivalent, not a universally comparable token count + * and not a provider invoice. * * MEASUREMENT ALONE IS NOT ACTIONABLE. A hit rate tells you the cache missed; * it cannot tell you why, and an unactionable number is the same dead thing as @@ -30,7 +30,15 @@ * cache-safety-by-construction half of this file. */ -import { readFileSync, readdirSync, statSync, existsSync, openSync, readSync, closeSync } from 'node:fs'; +import { + readFileSync, + readdirSync, + statSync, + existsSync, + openSync, + readSync, + closeSync, +} from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; @@ -51,7 +59,8 @@ export const READ_MULTIPLIER = 0.1; const SYNTHETIC_MODEL = ''; /** True for a turn naming a model an inference actually ran under. */ -const realModel = (turn) => Boolean(turn.model) && turn.model !== SYNTHETIC_MODEL; +const realModel = (turn) => + Boolean(turn.model) && turn.model !== SYNTHETIC_MODEL; /* -------------------------------------------------------------- MEASUREMENT */ @@ -72,7 +81,10 @@ export function transcriptFor(cwd = process.cwd()) { try { const files = readdirSync(dir) .filter((name) => name.endsWith('.jsonl')) - .map((name) => ({ path: join(dir, name), at: statSync(join(dir, name)).mtimeMs })) + .map((name) => ({ + path: join(dir, name), + at: statSync(join(dir, name)).mtimeMs, + })) .sort((a, b) => b.at - a.at); return files.length ? files[0].path : null; } catch { @@ -157,7 +169,8 @@ export function cacheHealth(turns) { read += turn.read; written += turn.written; if (!realModel(turn)) continue; - if (!models.has(turn.model)) models.set(turn.model, { read: 0, written: 0, turns: 0 }); + if (!models.has(turn.model)) + models.set(turn.model, { read: 0, written: 0, turns: 0 }); const entry = models.get(turn.model); entry.read += turn.read; entry.written += turn.written; @@ -171,20 +184,28 @@ export function cacheHealth(turns) { // attribution price, made keepWarm answer "unknown", and returned null from // modelSwitchCost under a guard its caller does not share, which crashed // cache_audit outright with "Cannot read properties of null". - const last = turns.reduce((best, turn) => (turn.read + turn.written > 0 ? turn : best), turns[turns.length - 1]); + const last = turns.reduce( + (best, turn) => (turn.read + turn.written > 0 ? turn : best), + turns[turns.length - 1] + ); const total = read + written; return { + provider: 'anthropic', + source: 'claude-code-transcript', turns: turns.length, read, written, hitRate: total ? read / total : null, prefixTokens: last.read + last.written, - // Priced in plain-token equivalents, which is what makes it comparable with - // every other saving this product reports. - writeCost: Math.round(written * WRITE_MULTIPLIER), - readCost: Math.round(read * READ_MULTIPLIER), - savedVersusNoCache: Math.round(read * (1 - READ_MULTIPLIER)), + // These are Anthropic uncached-input cost equivalents. They stay outside + // the verified MCP transport ledger and must not be relabeled as tokens + // saved across clients. + writeInputCostEquivalent: Math.round(written * WRITE_MULTIPLIER), + readInputCostEquivalent: Math.round(read * READ_MULTIPLIER), + inputCostEquivalentAvoidedVersusUncached: Math.round( + read * (1 - READ_MULTIPLIER) + ), models: Object.fromEntries(models), }; } @@ -200,13 +221,17 @@ export function modelSwitchCost(turns) { const health = cacheHealth(turns); if (!health?.prefixTokens) return null; - const switched = new Set(turns.filter(realModel).map((t) => t.model)).size > 1; + const switched = + new Set(turns.filter(realModel).map((t) => t.model)).size > 1; return { prefixTokens: health.prefixTokens, - rewriteCost: Math.round(health.prefixTokens * WRITE_MULTIPLIER), + rewriteInputCostEquivalent: Math.round( + health.prefixTokens * WRITE_MULTIPLIER + ), alreadySwitched: switched, - text: `Switching model now discards a ${health.prefixTokens.toLocaleString()}-token warm prefix; ` + - `re-writing it costs about ${Math.round(health.prefixTokens * WRITE_MULTIPLIER).toLocaleString()} tokens.`, + text: + `Switching model now discards a ${health.prefixTokens.toLocaleString()}-token warm prefix; ` + + `under Anthropic's current 5-minute write multiplier, re-writing it is about ${Math.round(health.prefixTokens * WRITE_MULTIPLIER).toLocaleString()} uncached-input cost-equivalent tokens.`, }; } @@ -221,17 +246,38 @@ export function modelSwitchCost(turns) { * time. That is why this class of bug survives -- nothing about it looks wrong. */ const VOLATILE = [ - { id: 'timestamp', re: /\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/, why: 'an embedded timestamp' }, - { id: 'date', re: /\b(?:today|current date)\b[^\n]{0,40}\d{4}-\d{2}-\d{2}/i, why: 'an embedded current date' }, + { + id: 'timestamp', + re: /\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/, + why: 'an embedded timestamp', + }, + { + id: 'date', + re: /\b(?:today|current date)\b[^\n]{0,40}\d{4}-\d{2}-\d{2}/i, + why: 'an embedded current date', + }, // A bare date is a SUSPICION, not a measurement: "- 2025-10-31: reworked the // loader" is byte-identical every session. Kept, because stableText fails // closed over our own output and dropping a line there costs little -- but // NOT priced, because in the blame report a false positive invents a // per-session cost and tells the user to delete their changelog. - { id: 'iso-date', re: /\b\d{4}-\d{2}-\d{2}\b/, why: 'an embedded date', priced: false }, - { id: 'session-id', re: /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i, why: 'a session id' }, + { + id: 'iso-date', + re: /\b\d{4}-\d{2}-\d{2}\b/, + why: 'an embedded date', + priced: false, + }, + { + id: 'session-id', + re: /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i, + why: 'a session id', + }, { id: 'git-sha', re: /\b[0-9a-f]{40}\b/, why: 'a git sha' }, - { id: 'counter', re: /\b(?:run|build|attempt|iteration)\s*#?\d+\b/i, why: 'a run counter' }, + { + id: 'counter', + re: /\b(?:run|build|attempt|iteration)\s*#?\d+\b/i, + why: 'a run counter', + }, { id: 'epoch', re: /\b1[6-9]\d{11}\b/, why: 'an epoch timestamp' }, ]; @@ -242,7 +288,13 @@ export function volatileLines(text) { for (let i = 0; i < lines.length; i++) { for (const rule of VOLATILE) { if (!rule.re.test(lines[i])) continue; - out.push({ line: i + 1, id: rule.id, why: rule.why, priced: rule.priced !== false, text: lines[i].trim().slice(0, 120) }); + out.push({ + line: i + 1, + id: rule.id, + why: rule.why, + priced: rule.priced !== false, + text: lines[i].trim().slice(0, 120), + }); break; // one cause per line is enough to act on } } @@ -264,7 +316,11 @@ const estimate = (text) => Math.ceil(String(text || '').length / 4); * unknown, and this reports the construct WITHOUT a price rather than inventing * one. */ -export function attributeInvalidation(cwd, prefixTokens = null, { files = null } = {}) { +export function attributeInvalidation( + cwd, + prefixTokens = null, + { files = null } = {} +) { // Ordered as the client assembles them: the client preamble and tool // definitions come first, then project instructions, then the conversation. const candidates = files || ['CLAUDE.md', 'AGENTS.md', '.claude/CLAUDE.md']; @@ -292,7 +348,8 @@ export function attributeInvalidation(cwd, prefixTokens = null, { files = null } // prefix -- 3.75x the entire prefix. Conservative still: the preamble // ahead of the file is not counted, so this remains a floor. const ahead = offset + estimate(lines.slice(0, hit.line - 1).join('\n')); - const downstream = prefixTokens != null ? Math.max(0, prefixTokens - ahead) : null; + const downstream = + prefixTokens != null ? Math.max(0, prefixTokens - ahead) : null; // A prefix cache invalidates ONCE, at the earliest difference. A later // volatile line in the same file costs nothing extra until the first one // is fixed, so it is reported for context but not billed again. @@ -304,7 +361,9 @@ export function attributeInvalidation(cwd, prefixTokens = null, { files = null } // into a reported zero. const firstPriced = found.find((h) => h.priced); const subsumedBy = - hit.priced && firstPriced && firstPriced !== hit ? `${relative}:${firstPriced.line}` : null; + hit.priced && firstPriced && firstPriced !== hit + ? `${relative}:${firstPriced.line}` + : null; out.push({ file: relative, line: hit.line, @@ -316,9 +375,12 @@ export function attributeInvalidation(cwd, prefixTokens = null, { files = null } // decline to price. 0 means measured and genuinely free, which is what a // subsumed hit is: real, but already billed by the earlier line. Folding // those two into one value is the same mistake this file warns about. - costPerSession: downstream == null || !hit.priced - ? null - : (subsumedBy ? 0 : Math.round(downstream * WRITE_MULTIPLIER)), + costPerSession: + downstream == null || !hit.priced + ? null + : subsumedBy + ? 0 + : Math.round(downstream * WRITE_MULTIPLIER), remedy: { kind: 'yours', type: 'edit', @@ -369,7 +431,7 @@ export function stableText(text) { */ export function cacheOrdered(items) { return [...items].sort((a, b) => { - const stability = (item) => (item.volatility ?? (item.fresh ? 1 : 0)); + const stability = (item) => item.volatility ?? (item.fresh ? 1 : 0); return stability(a) - stability(b); }); } diff --git a/integrations/codex/hooks/lib/decide.mjs b/integrations/codex/hooks/lib/decide.mjs index caae7de2f..a61dafdd5 100644 --- a/integrations/codex/hooks/lib/decide.mjs +++ b/integrations/codex/hooks/lib/decide.mjs @@ -392,6 +392,21 @@ export function touchedFiles(payload) { add(input.path); add(input.notebook_path); + // Codex code mode carries apply_patch as the nested program text rather than + // as a first-class file_path. Without reading these headers, successful + // code-mode edits incremented the semantic-harvest counter but produced no + // structural file/symbol nodes, which is why the live dashboard showed very + // few nodes after substantial work. PostToolUse can resolve Add/Update paths + // because the file now exists; deleted paths deliberately remain absent. + for (const match of command.matchAll( + /^\*\*\* (?:Add|Update|Delete) File:\s*(.+)$/gm + )) { + add(match[1].trim().replace(/^['"]|['"]$/g, '')); + } + for (const match of command.matchAll(/^\*\*\* Move to:\s*(.+)$/gm)) { + add(match[1].trim().replace(/^['"]|['"]$/g, '')); + } + // EVERY pipeline segment, not just the first. `fileOperands` looks only at // the head of the pipeline because that is where the COST is -- `git log | // head` must not be mistaken for a file dump. But observation is a different diff --git a/integrations/codex/hooks/lib/experiment.mjs b/integrations/codex/hooks/lib/experiment.mjs index 1f64713f1..c35c6cf5c 100644 --- a/integrations/codex/hooks/lib/experiment.mjs +++ b/integrations/codex/hooks/lib/experiment.mjs @@ -67,16 +67,71 @@ export function episodeMeta({ client, raw = {}, payload = {}, env = process.env } export function usageFrom(raw = {}) { - const usage = raw.usage || raw.token_usage || raw.tokenUsage || raw.metrics?.usage || {}; + const usage = raw.usage || raw.token_usage || raw.tokenUsage || raw.usageMetadata || + raw.usage_metadata || raw.metrics?.usage || raw.response?.usage || + raw.response?.usageMetadata || {}; const number = (...values) => { const value = first(...values); const parsed = Number(value); return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; }; + const cachedInputTokens = number( + usage.cached_input_tokens, usage.cachedInputTokens, + usage.cache_read_input_tokens, usage.cachedContentTokenCount + ); + const cacheCreation = usage.cache_creation || usage.cacheCreation || {}; + const cacheWrite5mInputTokens = number( + usage.cache_write_5m_input_tokens, usage.cacheWrite5mInputTokens, + cacheCreation.ephemeral_5m_input_tokens, cacheCreation.ephemeral5mInputTokens + ); + const cacheWrite1hInputTokens = number( + usage.cache_write_1h_input_tokens, usage.cacheWrite1hInputTokens, + cacheCreation.ephemeral_1h_input_tokens, cacheCreation.ephemeral1hInputTokens + ); + const detailedWrites = (cacheWrite5mInputTokens || 0) + (cacheWrite1hInputTokens || 0); + const reportedWrites = number( + usage.cache_write_input_tokens, usage.cacheWriteInputTokens, + usage.cache_creation_input_tokens, usage.cacheCreationInputTokens + ); + const cacheWriteInputTokens = reportedWrites == null + ? null + : Math.max(0, reportedWrites - detailedWrites); + const explicitUncached = number(usage.uncached_input_tokens, usage.uncachedInputTokens); + const promptInput = number( + usage.promptTokenCount, usage.prompt_token_count, + usage.input_tokens, usage.inputTokens + ); + const isAnthropic = usage.cache_creation_input_tokens !== undefined || + usage.cache_read_input_tokens !== undefined; + const isGemini = usage.promptTokenCount !== undefined || usage.cachedContentTokenCount !== undefined; + const uncachedInputTokens = explicitUncached != null + ? explicitUncached + : promptInput == null + ? null + : isAnthropic + ? promptInput + : isGemini || cachedInputTokens != null || reportedWrites != null + ? Math.max(0, promptInput - (cachedInputTokens || 0) - (reportedWrites || 0)) + : promptInput; + const candidates = number(usage.candidatesTokenCount, usage.candidates_token_count); + const thoughts = number(usage.thoughtsTokenCount, usage.thoughts_token_count); + const outputTokens = candidates != null || thoughts != null + ? (candidates || 0) + (thoughts || 0) + : number(usage.output_tokens, usage.outputTokens); return { - uncachedInputTokens: number(usage.uncached_input_tokens, usage.uncachedInputTokens), - cachedInputTokens: number(usage.cached_input_tokens, usage.cachedInputTokens, usage.cache_read_input_tokens), - outputTokens: number(usage.output_tokens, usage.outputTokens), - totalTokens: number(usage.total_tokens, usage.totalTokens, raw.tokens_used, raw.tokensUsed), + uncachedInputTokens, + cachedInputTokens, + cacheWrite5mInputTokens, + cacheWrite1hInputTokens, + cacheWriteInputTokens, + outputTokens, + totalTokens: number( + usage.total_tokens, usage.totalTokens, usage.totalTokenCount, + usage.total_token_count, raw.tokens_used, raw.tokensUsed + ), + usageMeasurementId: first( + raw.requestId, raw.request_id, raw.responseId, raw.response_id, + usage.responseId, usage.response_id + ) ?? null, }; } diff --git a/integrations/codex/hooks/lib/harvest-write.mjs b/integrations/codex/hooks/lib/harvest-write.mjs index 30433d370..db436c1dc 100644 --- a/integrations/codex/hooks/lib/harvest-write.mjs +++ b/integrations/codex/hooks/lib/harvest-write.mjs @@ -24,7 +24,7 @@ */ import { - putNodeWithEdges, load, nodeId, sharedDir, isSharedDir, putNode, + putNodeWithEdges, load, nodeId, sharedDir, isSharedDir, putNode, putEdge, } from './wiki.mjs'; import { indexFile } from './staleness.mjs'; import { symbolKey } from './symbols.mjs'; @@ -47,6 +47,37 @@ import { ORIGIN_HARVESTED, ORIGIN_AGENT, ORIGIN_HUMAN } from './curate.mjs'; */ export const SHAREABLE_TYPES = new Set(['command', 'failure', 'decision', 'feedback']); +/** + * Synthetic study traffic must never become advice for an unrelated live repo. + * Evaluation roots are intentionally realistic and may carry their own `.git` + * marker, so repository discovery alone cannot distinguish them. Publication + * remains useful inside the isolated study store; this guard protects only the + * default machine-wide store when a harness was accidentally run without an + * isolated TOKEN_OPTIMIZER_SHARED_DIR. + */ +export function isEphemeralProject(projectRoot) { + const root = canonicalPath(projectRoot || '').toLowerCase(); + if (!root) return true; + return ( + /\/appdata\/local\/temp\//.test(root) || + /(^|\/)tmp\//.test(root) || + /\/(artifacts|\.token-optimizer)\/(?:[^/]*\/)*(?:eval|scenario|study|fixture|debug)[^/]*\//.test(`${root}/`) + ); +} + +export function quarantineSharedSource(projectRoot) { + if (process.env.TOKEN_OPTIMIZER_ALLOW_EPHEMERAL_SHARED === '1') return false; + if (!isEphemeralProject(projectRoot)) return false; + // Isolated studies deliberately point the shared tier at scratch storage. + // Their findings may cross their own fixture projects, but cannot escape into + // the user's default machine-wide graph. Strict mode exists for a regression + // probe that verifies the guard without writing to a real user directory. + return ( + !process.env.TOKEN_OPTIMIZER_SHARED_DIR || + process.env.TOKEN_OPTIMIZER_STRICT_SHARED_PROVENANCE === '1' + ); +} + /** Claim text, normalised enough that the same lesson learned twice is one row. */ const claimFingerprint = (claim) => String(claim || '').toLowerCase().replace(/\s+/g, ' ').trim().slice(0, 300); @@ -72,6 +103,7 @@ function promoteToShared(finding, { projectRoot, sessionId, provenance, key }) { if (!projectRoot) return false; const root = canonicalPath(projectRoot); + if (quarantineSharedSource(root)) return false; const rootId = nodeId('file', root); // Same claim, already carried up from anywhere: keep the first. Two repos @@ -367,11 +399,21 @@ export function writeHarvested( && !node.retired && node.type === finding.type && claimFingerprint(node.claim) === fingerprint - && resolved.every((target) => currentGraph.edges.some((edge) => - edge.from === node.id && edge.edge === 'derived_from' && edge.to === target - )) ); if (duplicate) { + // Enrich, do not duplicate. A symbol extractor may learn how to resolve a + // previously file-only anchor (or a later session may add another real + // anchor) after the claim was first stored. Repeating the same semantic + // conclusion should connect that existing node to the newly available + // evidence, not create a second dashboard card with the same text. + for (const target of resolved) { + const alreadyLinked = currentGraph.edges.some((edge) => + edge.from === duplicate.id + && edge.edge === 'derived_from' + && edge.to === target + ); + if (!alreadyLinked) putEdge(dir, duplicate.id, 'derived_from', target); + } written.push(duplicate.key); continue; } @@ -437,7 +479,12 @@ export function writeHarvested( if ( id && !isSharedDir(dir) - && (finding.scope === undefined || finding.scope === 'organization' || finding.scope === 'global') + // Cross-project publication is explicit. `writeHarvested` stores an + // omitted scope as project-local above, so treating `undefined` as global + // here contradicted the stored record and promoted fixture/evaluation + // seeds into real user sessions. This was observed live as "learned in + // scenario" advice while working in AiDotNet. + && (finding.scope === 'organization' || finding.scope === 'global') ) { promoteToShared(finding, { projectRoot, sessionId, provenance, key }); } diff --git a/integrations/codex/hooks/lib/inject.mjs b/integrations/codex/hooks/lib/inject.mjs index b00494a01..c0aa2f1bf 100644 --- a/integrations/codex/hooks/lib/inject.mjs +++ b/integrations/codex/hooks/lib/inject.mjs @@ -31,6 +31,7 @@ import { canonicalPath, resolvableCandidates } from './paths.mjs'; import { annotatedSkeleton } from './skeleton.mjs'; import { substitutionBudget } from './metrics.mjs'; import { assessFindings } from './utility.mjs'; +import { quarantineSharedSource } from './harvest-write.mjs'; // Read per call for the same reason as the holdout fraction in metrics.mjs. const touchBudget = () => Number(process.env.TOKEN_OPTIMIZER_TOUCH_BUDGET) || 500; @@ -562,6 +563,7 @@ export function forRepeatedAct( const home = projectRoot ? canonicalPath(projectRoot) : null; for (const node of graph.nodes.values()) { if (node.kind !== 'finding' || node.retired || !node.claim) continue; + if (quarantineSharedSource(node.sourceProject)) continue; if (home && node.sourceProject && canonicalPath(node.sourceProject) === home) continue; const claimClasses = classesOf(node.claim, 'claim'); if (![...crossedClasses].some((c) => claimClasses.has(c))) continue; @@ -667,6 +669,7 @@ export function forSharedCommand( const candidates = []; for (const node of graph.nodes.values()) { if (node.kind !== 'finding' || node.retired) continue; + if (quarantineSharedSource(node.sourceProject)) continue; if (alreadyInjected.has(node.key)) continue; // ITS OWN LESSON IS NOT NEWS. A finding this project contributed is already // served by the local path; repeating it under a "from elsewhere" label @@ -1054,7 +1057,13 @@ export function standingRules(dir, graph, { budget = standingBudget(), episode = * informative response available -- more useful than the file, not a lossier * version of it. */ -export function substitutionFor(dir, graph, rawPath, source, { sessionId } = {}) { +export function substitutionFor( + dir, + graph, + rawPath, + source, + { sessionId, client = null, clientVersion = null, model = null, modelVersion = null } = {} +) { const filePath = canonicalPath(rawPath); const budget = substitutionBudget(dir, filePath); const built = annotatedSkeleton(graph, rawPath, source, { budget }); @@ -1089,6 +1098,10 @@ export function substitutionFor(dir, graph, rawPath, source, { sessionId } = {}) // on this machine turned out to be the enforcement suite's own fixture and // nothing distinguished them from real work. sessionId, + client, + clientVersion, + model, + modelVersion, holdout, tokens: holdout ? 0 : built.tokens, findings: built.findings, diff --git a/integrations/codex/hooks/lib/metrics.mjs b/integrations/codex/hooks/lib/metrics.mjs index 0bb08f1bc..09f822630 100644 --- a/integrations/codex/hooks/lib/metrics.mjs +++ b/integrations/codex/hooks/lib/metrics.mjs @@ -19,8 +19,15 @@ */ import { - appendFileSync, readFileSync, existsSync, mkdirSync, chmodSync, - statSync, openSync, readSync, closeSync, + appendFileSync, + readFileSync, + existsSync, + mkdirSync, + chmodSync, + statSync, + openSync, + readSync, + closeSync, } from 'node:fs'; import { join } from 'node:path'; import { createHash, randomBytes } from 'node:crypto'; @@ -86,6 +93,10 @@ export const BALANCE_KINDS = new Set([ 'keepwarm', ]); +/** Latest timestamp without passing an unbounded event log as function arguments. */ +export const latestEventTimestamp = (rows) => + rows.reduce((latest, event) => Math.max(latest, Number(event.at) || 0), 0); + /** * Causal records have their own bounded log. Tool outcomes are much rarer * than read/capture telemetry but more frequent than balance records; mixing @@ -130,7 +141,7 @@ export function inHoldout(anchorKey, now = Date.now()) { // other side than before. With nine holdout records in existence that costs // nothing, and stratification is a fresh draw either way. const digest = createHash('sha256').update(`${anchorKey}:${epoch}`).digest(); - return (digest[0] / 256) < fraction; + return digest[0] / 256 < fraction; } /** @@ -258,7 +269,8 @@ export function record(dir, event) { * It is also the right statistics. A saving measured months ago says little * about the code as it stands now. */ -const MAX_BYTES = Number(process.env.TOKEN_OPTIMIZER_METRICS_BYTES) || 2_000_000; +const MAX_BYTES = + Number(process.env.TOKEN_OPTIMIZER_METRICS_BYTES) || 2_000_000; const MAX_EVENTS = Number(process.env.TOKEN_OPTIMIZER_METRICS_WINDOW) || 5000; /** @@ -432,7 +444,10 @@ export function readBalance(dir) { // path for records written before ids existed; it is lossy by nature, which // is precisely why new records carry an id instead. const id = - e.id ?? [e.kind, e.at, e.anchor ?? '', e.sessionId ?? '', e.tokens ?? ''].join('|'); + e.id ?? + [e.kind, e.at, e.anchor ?? '', e.sessionId ?? '', e.tokens ?? ''].join( + '|' + ); if (seen.has(id)) continue; seen.add(id); out.push(e); @@ -493,16 +508,24 @@ export function recordToolOutcome(dir, outcome) { const evidence = readEvidence(dir); const anchor = String(outcome.anchor || '').slice(0, 120); const candidates = evidence - .filter((event) => event.kind === 'inject' && event.episodeId === outcome.episodeId) + .filter( + (event) => + event.kind === 'inject' && event.episodeId === outcome.episodeId + ) .filter((event) => { if (outcome.toolCallId && event.toolCallId) return String(event.toolCallId) === String(outcome.toolCallId); - return event.surface === outcome.surface && String(event.anchor || '').slice(0, 120) === anchor; + return ( + event.surface === outcome.surface && + String(event.anchor || '').slice(0, 120) === anchor + ); }) .sort((a, b) => (b.at || 0) - (a.at || 0)); const injection = candidates[0] || null; const joinMethod = injection - ? (outcome.toolCallId && injection.toolCallId ? 'tool-call-id' : 'episode-anchor') + ? outcome.toolCallId && injection.toolCallId + ? 'tool-call-id' + : 'episode-anchor' : 'none'; return record(dir, { @@ -542,12 +565,11 @@ export function isFixtureAnchor(anchor) { // code: it excluded ALL temp paths -- including a real project checked out // under /tmp -- and on macOS excluded nothing at all, because tmpdir() there // is /var/folders///T/ and did not match. - const underTemp = - new RegExp( - '[\\\\/](AppData[\\\\/]Local[\\\\/])?(Temp|tmp)[\\\\/]' + - '|[\\\\/]var[\\\\/]folders[\\\\/][^\\\\/]+[\\\\/][^\\\\/]+[\\\\/]T[\\\\/]', - 'i' - ).test(p); + const underTemp = new RegExp( + '[\\\\/](AppData[\\\\/]Local[\\\\/])?(Temp|tmp)[\\\\/]' + + '|[\\\\/]var[\\\\/]folders[\\\\/][^\\\\/]+[\\\\/][^\\\\/]+[\\\\/]T[\\\\/]', + 'i' + ).test(p); if (!underTemp) return false; // Only names this suite actually creates. A user's own scratch checkout under @@ -686,8 +708,12 @@ export function balanceSheet(dir) { // THE CONTROL ARM'S ACTUAL COST. `tokensFullFile` was recorded and read // by nothing, so the comparison the holdout exists for was not // computable from the report. - controlArmTokens: withheldSubs.reduce((sum, e) => sum + (e.tokensFullFile || 0), 0), - assumption: 'that the model would have read the file it explicitly requested', + controlArmTokens: withheldSubs.reduce( + (sum, e) => sum + (e.tokensFullFile || 0), + 0 + ), + assumption: + 'that the model would have read the file it explicitly requested', }, estimatedCausal: { what: 'findings injection, from the stratified holdout', @@ -702,7 +728,12 @@ export function balanceSheet(dir) { }; })(), }, - costs: { injection: injectCost, harvest: harvestCost, standing: standingCost, substitution: substitutionCost }, + costs: { + injection: injectCost, + harvest: harvestCost, + standing: standingCost, + substitution: substitutionCost, + }, waste, // Deliberately NOT a single number. The two benefit lines are known // differently; adding them would launder an assumption into a measurement. @@ -727,7 +758,9 @@ export function balanceSheet(dir) { /** Cheap path normalisation for grouping reads; not the identity canonicaliser. */ function canonicalKeyish(p) { - return String(p || '').replace(/\\/g, '/').toLowerCase(); + return String(p || '') + .replace(/\\/g, '/') + .toLowerCase(); } /** @@ -738,7 +771,43 @@ function canonicalKeyish(p) { * arms, multiplied by treated touches. Reporting it as a measured fact would be * the same overclaiming this project criticises competitors for. */ -function buildReport(events, balance) { +function buildReport(events, balance, sourceCoverage = {}) { + const substitutions = balance.filter( + (event) => event.kind === 'substitute' && !isFixtureAnchor(event.anchor) + ); + const servedSubstitutions = substitutions.filter((event) => !event.holdout); + const substitutionTokensSaved = servedSubstitutions.reduce( + (sum, event) => + sum + + (event.tokensNetAvoided ?? + Math.max( + 0, + Math.ceil((event.bytesAvoided || 0) / 4) - (event.tokens || 0) + )), + 0 + ); + const substitutionTokensReturned = servedSubstitutions.reduce( + (sum, event) => sum + (event.tokens || 0), + 0 + ); + const substitutionByClient = {}; + for (const event of servedSubstitutions) { + const name = event.client || 'Historical — client not recorded'; + const current = substitutionByClient[name] || { + substitutions: 0, + tokensReturned: 0, + tokensSaved: 0, + }; + current.substitutions += 1; + current.tokensReturned += event.tokens || 0; + current.tokensSaved += + event.tokensNetAvoided ?? + Math.max( + 0, + Math.ceil((event.bytesAvoided || 0) / 4) - (event.tokens || 0) + ); + substitutionByClient[name] = current; + } // COMMAND INJECTIONS ARE COUNTED SEPARATELY, not mixed into the balance. // @@ -762,13 +831,19 @@ function buildReport(events, balance) { // Report its cost independently so it cannot dilute the file-touch estimate. const isSessionStart = (e) => e.surface === 'session-start'; const sessionStartInjections = allInjections.filter(isSessionStart); - const commandInjections = allInjections.filter((e) => !isSessionStart(e) && isCommand(e)); - const injections = allInjections.filter((e) => !isSessionStart(e) && !isCommand(e)); + const commandInjections = allInjections.filter( + (e) => !isSessionStart(e) && isCommand(e) + ); + const injections = allInjections.filter( + (e) => !isSessionStart(e) && !isCommand(e) + ); const treated = injections.filter((e) => !e.holdout); const withheld = injections.filter((e) => e.holdout); const mean = (rows, field) => - rows.length ? rows.reduce((sum, r) => sum + (r[field] || 0), 0) / rows.length : 0; + rows.length + ? rows.reduce((sum, r) => sum + (r[field] || 0), 0) / rows.length + : 0; const injectedTokens = treated.reduce((sum, e) => sum + (e.tokens || 0), 0); const harvestTokens = balance @@ -819,7 +894,9 @@ function buildReport(events, balance) { const after = event.at ?? 0; const total = bucket.reduce( - (sum, r) => sum + ((r.at ?? 0) >= after ? (r.tokens || 0) : 0), 0); + (sum, r) => sum + ((r.at ?? 0) >= after ? r.tokens || 0 : 0), + 0 + ); // Split across the injections that share this key. Each touch is one // observation, so charging every one the full total would count the same @@ -828,52 +905,198 @@ function buildReport(events, balance) { }; const meanDownstream = (rows) => - rows.length ? rows.reduce((sum, r) => sum + downstreamOf(r), 0) / rows.length : 0; + rows.length + ? rows.reduce((sum, r) => sum + downstreamOf(r), 0) / rows.length + : 0; const treatedCost = meanDownstream(treated); const withheldCost = meanDownstream(withheld); const perTouchSaving = withheldCost - treatedCost; - const estimatedAvoided = Math.max(0, Math.round(perTouchSaving * treated.length)); + const estimatedAvoided = Math.max( + 0, + Math.round(perTouchSaving * treated.length) + ); + + const downstreamSamples = injections.filter((event) => { + if (event.downstream != null) return true; + const key = `${event.sessionId || ''}|${event.anchor}`; + return (reads.get(key) || []).some( + (read) => (read.at ?? 0) >= (event.at ?? 0) + ); + }).length; + const downstreamMeasured = downstreamSamples > 0; // Below this, arm means are noise and a ratio would be theatre. - const sufficient = treated.length >= 20 && withheld.length >= 5; + const sufficient = + downstreamMeasured && treated.length >= 20 && withheld.length >= 5; + + const coverageNumber = (value, fallback) => { + if (value === null || value === undefined || value === '') return fallback; + const number = Number(value); + return Number.isFinite(number) ? Math.max(0, number) : fallback; + }; + const projectCount = coverageNumber(sourceCoverage.projects, 1); + const telemetryProjects = coverageNumber( + sourceCoverage.projectsWithTelemetry, + 0 + ); + const balanceProjects = coverageNumber( + sourceCoverage.projectsWithBalanceEvents, + balance.length ? 1 : 0 + ); + const lastEventAt = Math.max( + latestEventTimestamp(events), + latestEventTimestamp(balance) + ); + const measured = + telemetryProjects > 0 || events.length > 0 || balance.length > 0; + const balanceMeasured = balanceProjects > 0 || balance.length > 0; + const metric = (status, source, samples, extra = {}) => ({ + status, + source, + samples, + ...extra, + }); return { + nativeOptimizer: { + substitutions: servedSubstitutions.length, + holdouts: substitutions.filter((event) => event.holdout).length, + tokensReturned: substitutionTokensReturned, + tokensSaved: substitutionTokensSaved, + byClient: substitutionByClient, + recent: servedSubstitutions + .slice() + .sort((a, b) => (b.at || 0) - (a.at || 0)) + .slice(0, 40) + .map((event) => { + const tokensSaved = + event.tokensNetAvoided ?? + Math.max( + 0, + Math.ceil((event.bytesAvoided || 0) / 4) - (event.tokens || 0) + ); + return { + name: 'live_graph_substitution', + client: event.client || 'Historical — client not recorded', + originalTokens: (event.tokens || 0) + tokensSaved, + optimizedTokens: event.tokens || 0, + tokensSaved, + savingsMeasured: false, + classification: 'modeled-counterfactual', + reportedTokensSaved: tokensSaved, + timestamp: event.at ? new Date(event.at).toISOString() : null, + }; + }), + source: + 'balance.jsonl: modeled full-file counterfactual minus annotated-skeleton tokens; excluded from verified MCP savings', + }, memoryDeliveries: allInjections.filter((event) => !event.holdout).length, memoryHoldouts: allInjections.filter((event) => event.holdout).length, deliveryTokens: allInjections .filter((event) => !event.holdout) .reduce( - (sum, event) => - sum + (event.deliveredTokens ?? event.tokens ?? 0), + (sum, event) => sum + (event.deliveredTokens ?? event.tokens ?? 0), 0 ), injections: treated.length, sessionStartInjections: sessionStartInjections.length, sessionStartInjectedTokens: sessionStartInjections.reduce( - (sum, event) => sum + (event.deliveredTokens ?? event.tokens ?? 0), 0 + (sum, event) => sum + (event.deliveredTokens ?? event.tokens ?? 0), + 0 ), commandInjections: commandInjections.length, commandHoldouts: commandInjections.filter((e) => e.holdout).length, holdouts: withheld.length, staleServed: injections.filter((e) => e.stale).length, - staleRate: injections.length ? injections.filter((e) => e.stale).length / injections.length : 0, + staleRate: injections.length + ? injections.filter((e) => e.stale).length / injections.length + : 0, injectedTokens, harvestTokens, estimatedTokensAvoided: sufficient ? estimatedAvoided : null, - netTokens: sufficient ? estimatedAvoided - injectedTokens - harvestTokens : null, + netTokens: sufficient + ? estimatedAvoided - injectedTokens - harvestTokens + : null, sufficientData: sufficient, verdict: !sufficient ? `insufficient data (${treated.length} treated, ${withheld.length} holdout; need 20 and 5)` : estimatedAvoided > injectedTokens + harvestTokens ? 'the graph is saving more than it costs' : 'the graph is NOT yet paying for itself', + measurement: { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + sourceCoverage: { + projects: projectCount, + projectsWithTelemetry: telemetryProjects || (measured ? 1 : 0), + projectsWithBalanceEvents: balanceProjects, + projectsWithoutTelemetry: Math.max( + 0, + projectCount - (telemetryProjects || (measured ? 1 : 0)) + ), + }, + freshness: { + lastEventAt: lastEventAt || null, + ageMs: lastEventAt ? Math.max(0, Date.now() - lastEventAt) : null, + status: !lastEventAt + ? 'not-measured' + : Date.now() - lastEventAt <= 7 * 86_400_000 + ? 'fresh' + : 'stale', + }, + metrics: { + nativeSubstitutions: metric( + balanceMeasured ? 'measured' : 'not-measured', + 'balance.jsonl: modeled full-file counterfactual minus annotated-skeleton tokens; not a materialized MCP before/after pair', + servedSubstitutions.length, + { classification: 'modeled-counterfactual' } + ), + memoryDeliveries: metric( + balanceMeasured ? 'measured' : 'not-measured', + 'balance.jsonl: inject events in the treated arm', + allInjections.filter((event) => !event.holdout).length + ), + memoryHoldouts: metric( + balanceMeasured ? 'measured' : 'not-measured', + 'balance.jsonl: inject events in the withheld arm', + allInjections.filter((event) => event.holdout).length + ), + rememberingCost: metric( + balanceMeasured ? 'measured' : 'not-measured', + 'balance.jsonl: delivered injection tokens plus semantic harvest tokens', + allInjections.length + + balance.filter((event) => event.kind === 'harvest').length + ), + readingAvoided: metric( + !downstreamMeasured + ? 'not-measured' + : sufficient + ? 'measured' + : 'collecting', + 'stratified file-touch holdout joined to downstream read events', + downstreamSamples, + { + treated: treated.length, + holdouts: withheld.length, + requiredTreated: 20, + requiredHoldouts: 5, + } + ), + }, + }, }; } export function report(dir) { - return buildReport(readAll(dir), readBalance(dir)); + const events = readAll(dir); + const balance = readBalance(dir); + return buildReport(events, balance, { + projects: 1, + projectsWithTelemetry: events.length || balance.length ? 1 : 0, + projectsWithBalanceEvents: balance.length ? 1 : 0, + }); } /** @@ -886,10 +1109,16 @@ export function report(dir) { */ export function reportMany(dirs) { const unique = [...new Set((dirs || []).map((dir) => String(dir)))]; - const report = buildReport( - unique.flatMap((dir) => readAll(dir)), - unique.flatMap((dir) => readBalance(dir)) - ); + const eventSources = unique.map((dir) => readAll(dir)); + const balanceSources = unique.map((dir) => readBalance(dir)); + const report = buildReport(eventSources.flat(), balanceSources.flat(), { + projects: unique.length, + projectsWithTelemetry: eventSources.filter( + (events, index) => events.length || balanceSources[index].length + ).length, + projectsWithBalanceEvents: balanceSources.filter((events) => events.length) + .length, + }); return { ...report, projects: unique.length }; } @@ -907,7 +1136,11 @@ export function reportMany(dirs) { * costs almost nothing. Without a control arm there is no way to tell those two * apart, which is why nobody else can do this. */ -export function substitutionBudget(dir, anchor, { floor = 300, base = 1200, ceiling = 3000 } = {}) { +export function substitutionBudget( + dir, + anchor, + { floor = 300, base = 1200, ceiling = 3000 } = {} +) { const events = readAll(dir); const mine = events.filter((e) => e.kind === 'inject' && e.anchor === anchor); @@ -932,13 +1165,17 @@ export function substitutionBudget(dir, anchor, { floor = 300, base = 1200, ceil for (const row of rows) { const bucket = reads.get(row.sessionId || '') || []; const after = row.at ?? 0; - total += bucket.reduce((sum, r) => sum + ((r.at ?? 0) >= after ? (r.tokens || 0) : 0), 0); + total += bucket.reduce( + (sum, r) => sum + ((r.at ?? 0) >= after ? r.tokens || 0 : 0), + 0 + ); } return total / rows.length; }; const saved = downstream(withheld) - downstream(treated); - const spent = treated.reduce((sum, e) => sum + (e.tokens || 0), 0) / treated.length; + const spent = + treated.reduce((sum, e) => sum + (e.tokens || 0), 0) / treated.length; // Ratio of what annotating this file avoided to what annotating it cost. // Above 1 it is paying for itself and earns room; below, it shrinks. @@ -959,7 +1196,10 @@ export function substitutionBudget(dir, anchor, { floor = 300, base = 1200, ceil * graph whose index leads to queries grows its allowance; a noisy one shrinks * back toward the floor. Nobody configures it, and it cannot run away. */ -export function indexBudget(dir, { floor = 150, base = 300, ceiling = 1200 } = {}) { +export function indexBudget( + dir, + { floor = 150, base = 300, ceiling = 1200 } = {} +) { const events = readAll(dir); const listed = events.filter((e) => e.kind === 'index').length; const queries = events.filter((e) => e.kind === 'query').length; @@ -968,7 +1208,9 @@ export function indexBudget(dir, { floor = 150, base = 300, ceiling = 1200 } = { const hitRate = queries / listed; // 0% hit rate falls to the floor; ~50% and above reaches the ceiling. - const scaled = Math.round(floor + (ceiling - floor) * Math.min(1, hitRate * 2)); + const scaled = Math.round( + floor + (ceiling - floor) * Math.min(1, hitRate * 2) + ); return Math.max(floor, Math.min(ceiling, scaled)); } @@ -1005,7 +1247,10 @@ function seededRandom(seedText) { } /** Percentile bootstrap interval for a mean, paired delta, or other scalar. */ -export function bootstrapMeanInterval(values, { samples = 1000, seed = 'token-optimizer' } = {}) { +export function bootstrapMeanInterval( + values, + { samples = 1000, seed = 'token-optimizer' } = {} +) { const finite = values.map(numeric).filter((value) => value !== null); if (!finite.length) return { mean: null, low: null, high: null, n: 0 }; if (finite.length === 1) { @@ -1037,24 +1282,32 @@ export function proportionInterval(successes, total, z = 1.96) { const denominator = 1 + (z * z) / total; const centre = (rate + (z * z) / (2 * total)) / denominator; const radius = - (z / denominator) * Math.sqrt((rate * (1 - rate)) / total + (z * z) / (4 * total * total)); - return { rate, low: Math.max(0, centre - radius), high: Math.min(1, centre + radius), n: total }; + (z / denominator) * + Math.sqrt((rate * (1 - rate)) / total + (z * z) / (4 * total * total)); + return { + rate, + low: Math.max(0, centre - radius), + high: Math.min(1, centre + radius), + n: total, + }; } -const cohortKey = (run) => [ - run.client || 'unknown', - run.clientVersion || 'unknown', - run.model || 'unknown', - run.modelVersion || 'unknown', - run.taskId || 'unknown', -].join('|'); +const cohortKey = (run) => + [ + run.client || 'unknown', + run.clientVersion || 'unknown', + run.model || 'unknown', + run.modelVersion || 'unknown', + run.taskId || 'unknown', + ].join('|'); function armMetrics(runs) { const correct = runs.filter((run) => run.correct === true).length; - const interval = (field) => bootstrapMeanInterval( - runs.map((run) => run[field]).filter((value) => numeric(value) !== null), - { seed: field } - ); + const interval = (field) => + bootstrapMeanInterval( + runs.map((run) => run[field]).filter((value) => numeric(value) !== null), + { seed: field } + ); return { runs: runs.length, correctness: proportionInterval(correct, runs.length), @@ -1068,29 +1321,40 @@ function armMetrics(runs) { latencyMs: interval('latencyMs'), costUsd: interval('costUsd'), injectedTokens: interval('injectedTokens'), - harmfulFindings: runs.reduce((sum, run) => sum + (numeric(run.harmfulFindings) || 0), 0), + harmfulFindings: runs.reduce( + (sum, run) => sum + (numeric(run.harmfulFindings) || 0), + 0 + ), }; } -function pairedEffects(runs, controlArm, treatmentArm, contrastType = 'incremental') { +function pairedEffects( + runs, + controlArm, + treatmentArm, + contrastType = 'incremental' +) { const pairs = new Map(); for (const run of runs) { if (!run.pairId || ![controlArm, treatmentArm].includes(run.arm)) continue; if (!pairs.has(run.pairId)) pairs.set(run.pairId, {}); pairs.get(run.pairId)[run.arm] = run; } - const complete = [...pairs.values()].filter((pair) => pair[controlArm] && pair[treatmentArm]); - const delta = (field, lowerIsBetter = true) => bootstrapMeanInterval( - complete - .map((pair) => { - const baseline = numeric(pair[controlArm][field]); - const treatment = numeric(pair[treatmentArm][field]); - if (baseline === null || treatment === null) return null; - return lowerIsBetter ? baseline - treatment : treatment - baseline; - }) - .filter((value) => value !== null), - { seed: `${controlArm}:${treatmentArm}:${field}` } + const complete = [...pairs.values()].filter( + (pair) => pair[controlArm] && pair[treatmentArm] ); + const delta = (field, lowerIsBetter = true) => + bootstrapMeanInterval( + complete + .map((pair) => { + const baseline = numeric(pair[controlArm][field]); + const treatment = numeric(pair[treatmentArm][field]); + if (baseline === null || treatment === null) return null; + return lowerIsBetter ? baseline - treatment : treatment - baseline; + }) + .filter((value) => value !== null), + { seed: `${controlArm}:${treatmentArm}:${field}` } + ); return { arm: treatmentArm, controlArm, @@ -1111,21 +1375,32 @@ function pairedEffects(runs, controlArm, treatmentArm, contrastType = 'increment } function matchesFilters(event, filters) { - for (const field of ['client', 'clientVersion', 'model', 'modelVersion', 'taskId', 'arm']) { + for (const field of [ + 'client', + 'clientVersion', + 'model', + 'modelVersion', + 'taskId', + 'arm', + ]) { if (!filters[field]) continue; let actual = event[field]; if (event.kind === 'handoff-run') { - actual = field === 'taskId' - ? event.scenarioId - : field === 'arm' - ? event.arm - : (event.consumer?.[field] ?? event.producer?.[field] ?? event[field]); + actual = + field === 'taskId' + ? event.scenarioId + : field === 'arm' + ? event.arm + : (event.consumer?.[field] ?? + event.producer?.[field] ?? + event[field]); } else if (event.kind === 'concurrency-run') { - actual = field === 'taskId' - ? 'concurrent-combined' - : field === 'arm' - ? event.arm - : (event.consumer?.[field] ?? event[field]); + actual = + field === 'taskId' + ? 'concurrent-combined' + : field === 'arm' + ? event.arm + : (event.consumer?.[field] ?? event[field]); } if (String(actual || '') !== String(filters[field])) return false; } @@ -1134,11 +1409,15 @@ function matchesFilters(event, filters) { function handoffArmMetrics(runs) { const consumers = runs.map((run) => run.consumer || {}); - const count = (field) => consumers.filter((consumer) => consumer[field] === true).length; - const interval = (field) => bootstrapMeanInterval( - consumers.map((consumer) => consumer[field]).filter((value) => numeric(value) !== null), - { seed: `handoff:${field}` } - ); + const count = (field) => + consumers.filter((consumer) => consumer[field] === true).length; + const interval = (field) => + bootstrapMeanInterval( + consumers + .map((consumer) => consumer[field]) + .filter((value) => numeric(value) !== null), + { seed: `handoff:${field}` } + ); return { runs: runs.length, delivery: proportionInterval( @@ -1147,7 +1426,10 @@ function handoffArmMetrics(runs) { ), correctness: proportionInterval(count('correct'), runs.length), firstPass: proportionInterval(count('firstPass'), runs.length), - mistakeAttempted: proportionInterval(count('mistakeAttempted'), runs.length), + mistakeAttempted: proportionInterval( + count('mistakeAttempted'), + runs.length + ), mistakeExecuted: proportionInterval(count('mistakeExecuted'), runs.length), totalTokens: interval('totalTokens'), toolCalls: interval('toolCalls'), @@ -1163,16 +1445,21 @@ function handoffPairedEffect(runs, controlArm, treatmentArm) { if (!pairs.has(run.pairId)) pairs.set(run.pairId, {}); pairs.get(run.pairId)[run.arm] = run; } - const complete = [...pairs.values()].filter((pair) => pair[controlArm] && pair[treatmentArm]); - const effect = (field, positiveWhenLower = true) => bootstrapMeanInterval( - complete.map((pair) => { - const control = numeric(pair[controlArm].consumer?.[field]); - const treatment = numeric(pair[treatmentArm].consumer?.[field]); - if (control === null || treatment === null) return null; - return positiveWhenLower ? control - treatment : treatment - control; - }).filter((value) => value !== null), - { seed: `handoff:${controlArm}:${treatmentArm}:${field}` } + const complete = [...pairs.values()].filter( + (pair) => pair[controlArm] && pair[treatmentArm] ); + const effect = (field, positiveWhenLower = true) => + bootstrapMeanInterval( + complete + .map((pair) => { + const control = numeric(pair[controlArm].consumer?.[field]); + const treatment = numeric(pair[treatmentArm].consumer?.[field]); + if (control === null || treatment === null) return null; + return positiveWhenLower ? control - treatment : treatment - control; + }) + .filter((value) => value !== null), + { seed: `handoff:${controlArm}:${treatmentArm}:${field}` } + ); return { comparison: `${treatmentArm} vs ${controlArm}`, pairs: complete.length, @@ -1187,13 +1474,22 @@ function handoffPairedEffect(runs, controlArm, treatmentArm) { }; } -const handoffKey = (run) => [ - run.producer?.client || 'unknown', run.producer?.model || 'unknown', - run.consumer?.client || 'unknown', run.consumer?.model || 'unknown', - run.scenarioId || 'unknown', -].join('|'); - -const HANDOFF_REPORT_ARMS = ['empty', 'natural', 'oracle', 'irrelevant', 'stale']; +const handoffKey = (run) => + [ + run.producer?.client || 'unknown', + run.producer?.model || 'unknown', + run.consumer?.client || 'unknown', + run.consumer?.model || 'unknown', + run.scenarioId || 'unknown', + ].join('|'); + +const HANDOFF_REPORT_ARMS = [ + 'empty', + 'natural', + 'oracle', + 'irrelevant', + 'stale', +]; const handoffMinimumPairs = () => Math.max(2, Number(process.env.TOKEN_OPTIMIZER_HANDOFF_MIN_PAIRS) || 10); @@ -1206,43 +1502,54 @@ function handoffCohorts(runs) { } const minimumPairs = handoffMinimumPairs(); return [...grouped.entries()].map(([key, rows]) => { - const arms = Object.fromEntries(HANDOFF_REPORT_ARMS.map((arm) => [ - arm, handoffArmMetrics(rows.filter((row) => row.arm === arm)), - ])); + const arms = Object.fromEntries( + HANDOFF_REPORT_ARMS.map((arm) => [ + arm, + handoffArmMetrics(rows.filter((row) => row.arm === arm)), + ]) + ); const naturalRows = rows.filter((row) => row.arm === 'natural'); const naturalVsEmpty = handoffPairedEffect(rows, 'empty', 'natural'); const naturalVsOracle = handoffPairedEffect(rows, 'oracle', 'natural'); const captureRate = naturalRows.length - ? naturalRows.filter((row) => row.producer?.captureSuccess).length / naturalRows.length + ? naturalRows.filter((row) => row.producer?.captureSuccess).length / + naturalRows.length : null; const preActionDeliveryRate = naturalRows.length - ? naturalRows.filter((row) => row.delivery?.beforeFirstExecutedMistake).length / naturalRows.length + ? naturalRows.filter((row) => row.delivery?.beforeFirstExecutedMistake) + .length / naturalRows.length : null; const emptyRecurrence = arms.empty.mistakeExecuted.rate; const naturalRecurrence = arms.natural.mistakeExecuted.rate; - const relativeRecurrenceReduction = emptyRecurrence !== null - && naturalRecurrence !== null - && emptyRecurrence !== 0 - ? (emptyRecurrence - naturalRecurrence) / emptyRecurrence - : null; - const controlsSafe = ['irrelevant', 'stale'].every((arm) => - arms[arm].correctness.rate !== null - && arms.empty.correctness.rate !== null - && arms[arm].correctness.rate >= arms.empty.correctness.rate - 0.1 + const relativeRecurrenceReduction = + emptyRecurrence !== null && + naturalRecurrence !== null && + emptyRecurrence !== 0 + ? (emptyRecurrence - naturalRecurrence) / emptyRecurrence + : null; + const controlsSafe = ['irrelevant', 'stale'].every( + (arm) => + arms[arm].correctness.rate !== null && + arms.empty.correctness.rate !== null && + arms[arm].correctness.rate >= arms.empty.correctness.rate - 0.1 ); - const irrelevantSuppressed = arms.irrelevant.delivery.rate !== null - && arms.irrelevant.delivery.rate === 0; + const irrelevantSuppressed = + arms.irrelevant.delivery.rate !== null && + arms.irrelevant.delivery.rate === 0; const gates = { minimumPairs: naturalVsEmpty.pairs >= minimumPairs, capture: captureRate !== null && captureRate >= 0.8, recurrenceMagnitude: - relativeRecurrenceReduction !== null && relativeRecurrenceReduction >= 0.5, - recurrenceInterval: (naturalVsEmpty.executedMistakesPrevented.low ?? -Infinity) > 0, + relativeRecurrenceReduction !== null && + relativeRecurrenceReduction >= 0.5, + recurrenceInterval: + (naturalVsEmpty.executedMistakesPrevented.low ?? -Infinity) > 0, correctness: - arms.natural.correctness.rate !== null - && arms.empty.correctness.rate !== null - && arms.natural.correctness.rate >= arms.empty.correctness.rate - 0.1, - preActionDelivery: preActionDeliveryRate !== null && preActionDeliveryRate >= 0.8, + arms.natural.correctness.rate !== null && + arms.empty.correctness.rate !== null && + arms.natural.correctness.rate >= arms.empty.correctness.rate - 0.1, + preActionDelivery: + preActionDeliveryRate !== null && preActionDeliveryRate >= 0.8, negativeControls: controlsSafe && irrelevantSuppressed, }; const claimReady = Object.values(gates).every(Boolean); @@ -1269,14 +1576,24 @@ function handoffCohorts(runs) { function concurrencySummary(runs) { const natural = runs.filter((run) => run.arm === 'natural'); const writers = natural.reduce((sum, run) => sum + (run.writerCount || 0), 0); - const captures = natural.reduce((sum, run) => sum + (run.captureSuccesses || 0), 0); - const integrityPasses = natural.filter((run) => - run.integrity?.zeroLoss - && run.integrity?.parseable - && run.integrity?.orphanedFindings === 0 + const captures = natural.reduce( + (sum, run) => sum + (run.captureSuccesses || 0), + 0 + ); + const integrityPasses = natural.filter( + (run) => + run.integrity?.zeroLoss && + run.integrity?.parseable && + run.integrity?.orphanedFindings === 0 ).length; - const delivered = natural.reduce((sum, run) => sum + (run.delivery?.delivered || 0), 0); - const expected = natural.reduce((sum, run) => sum + (run.delivery?.expected || 0), 0); + const delivered = natural.reduce( + (sum, run) => sum + (run.delivery?.delivered || 0), + 0 + ); + const expected = natural.reduce( + (sum, run) => sum + (run.delivery?.expected || 0), + 0 + ); const effect = handoffPairedEffect(runs, 'empty', 'natural'); return { runs: runs.length, @@ -1286,7 +1603,8 @@ function concurrencySummary(runs) { integrityPassRate: natural.length ? integrityPasses / natural.length : null, deliveryCoverage: expected ? delivered / expected : null, naturalCorrectness: proportionInterval( - natural.filter((run) => run.consumer?.correct).length, natural.length + natural.filter((run) => run.consumer?.correct).length, + natural.length ), effect, }; @@ -1297,17 +1615,26 @@ function concurrencySummary(runs) { * live hook traces, and randomized model evals remain visibly distinct: only * `eval-run` records with paired arms can produce a causal effect interval. */ -function buildEvidenceReport(evidence, { filters = {}, episodeLimit = 100 } = {}) { +function buildEvidenceReport( + evidence, + { filters = {}, episodeLimit = 100 } = {} +) { evidence = evidence.filter((event) => matchesFilters(event, filters)); const runs = evidence.filter((event) => event.kind === 'eval-run'); const handoffRuns = evidence.filter((event) => event.kind === 'handoff-run'); - const concurrencyRuns = evidence.filter((event) => event.kind === 'concurrency-run'); + const concurrencyRuns = evidence.filter( + (event) => event.kind === 'concurrency-run' + ); const injections = evidence.filter((event) => event.kind === 'inject'); const outcomes = evidence.filter((event) => event.kind === 'tool-outcome'); - const feedback = evidence.filter((event) => event.kind === 'finding-feedback'); + const feedback = evidence.filter( + (event) => event.kind === 'finding-feedback' + ); const byInjection = new Map( - outcomes.filter((event) => event.injectionId).map((event) => [event.injectionId, event]) + outcomes + .filter((event) => event.injectionId) + .map((event) => [event.injectionId, event]) ); const traced = injections .slice() @@ -1337,7 +1664,10 @@ function buildEvidenceReport(evidence, { filters = {}, episodeLimit = 100 } = {} grouped.get(key).push(run); } - const minimumPairs = Math.max(2, Number(process.env.TOKEN_OPTIMIZER_EVAL_MIN_PAIRS) || 5); + const minimumPairs = Math.max( + 2, + Number(process.env.TOKEN_OPTIMIZER_EVAL_MIN_PAIRS) || 5 + ); const cohorts = [...grouped.entries()].map(([key, cohortRuns]) => { const arms = Object.fromEntries( ['baseline', 'optimizer', 'retrieval', 'full'].map((arm) => [ @@ -1387,7 +1717,9 @@ function buildEvidenceReport(evidence, { filters = {}, episodeLimit = 100 } = {} cohorts: cohorts.length, harmfulFeedback: harmful, harmRate: feedback.length ? harmful / feedback.length : null, - evidenceStatus: cohorts.some((cohort) => cohort.evidenceStatus === 'causal estimate available') + evidenceStatus: cohorts.some( + (cohort) => cohort.evidenceStatus === 'causal estimate available' + ) ? 'causal estimates available' : 'insufficient randomized evidence', }, @@ -1396,7 +1728,8 @@ function buildEvidenceReport(evidence, { filters = {}, episodeLimit = 100 } = {} concurrency, episodes: traced, methodology: { - intervals: 'deterministic percentile bootstrap (95%); Wilson interval for correctness', + intervals: + 'deterministic percentile bootstrap (95%); Wilson interval for correctness', causalRule: 'matched pairs estimate optimizer vs baseline, retrieval vs optimizer, full vs retrieval, and full vs baseline', minimumPairs, diff --git a/integrations/codex/hooks/lib/observability.mjs b/integrations/codex/hooks/lib/observability.mjs index b9980076e..ef1c037af 100644 --- a/integrations/codex/hooks/lib/observability.mjs +++ b/integrations/codex/hooks/lib/observability.mjs @@ -26,7 +26,7 @@ import { createHash, randomUUID } from 'node:crypto'; import { homedir } from 'node:os'; import { join } from 'node:path'; -export const HOOK_LOG_SCHEMA_VERSION = 1; +export const HOOK_LOG_SCHEMA_VERSION = 2; const DEFAULT_MAX_BYTES = 5 * 1024 * 1024; const DEFAULT_RETENTION_DAYS = 14; const DEFAULT_MAX_FILES = 40; @@ -197,6 +197,8 @@ export function beginHookInvocation(client, event, options = {}) { turnIdHash: null, toolUseIdHash: null, toolName: null, + rawToolName: null, + codeModeEnvelope: false, clientVersion: null, model: null, cwdHash: null, @@ -208,12 +210,29 @@ export function beginHookInvocation(client, event, options = {}) { const spanId = hash(invocationId, 16); let traceId = hash(invocationId, 32); + // Write the start before reading stdin or importing project state. If the + // host kills the process, Node aborts, or a native call terminates without a + // JavaScript exception, diagnostics can now reconcile this record with the + // missing completion instead of reporting a false success rate. + writeHookEvent({ + level: 'info', + event: 'hook.started', + invocationId, + traceId, + spanId, + client, + hookEvent: event, + processId: process.pid, + }); + let deadline; const finish = (nextOutcome = outcome, nextReason = reason, nextError = error) => { if (ended) return; ended = true; if (deadline) clearTimeout(deadline); process.removeListener('exit', onExit); + process.removeListener('uncaughtException', onUncaughtException); + process.removeListener('unhandledRejection', onUnhandledRejection); writeHookEvent({ level: nextOutcome === 'failure' || nextOutcome === 'timeout' ? 'error' : 'info', event: 'hook.completed', @@ -231,7 +250,17 @@ export function beginHookInvocation(client, event, options = {}) { }); }; const onExit = () => finish(); + const onUncaughtException = (nextError) => { + finish('failure', 'uncaught_exception', nextError); + process.exit(0); + }; + const onUnhandledRejection = (nextError) => { + finish('failure', 'unhandled_rejection', nextError); + process.exit(0); + }; process.once('exit', onExit); + process.once('uncaughtException', onUncaughtException); + process.once('unhandledRejection', onUnhandledRejection); deadline = setTimeout(() => { outcome = 'timeout'; @@ -250,6 +279,11 @@ export function beginHookInvocation(client, event, options = {}) { dimensions.turnIdHash = hash(raw.turn_id ?? raw.turnId ?? null); dimensions.toolUseIdHash = hash(raw.tool_use_id ?? raw.toolUseId ?? null); dimensions.toolName = payload?.tool_name ?? raw.tool_name ?? raw.toolName ?? null; + dimensions.rawToolName = dimension( + raw.tool_name ?? raw.toolName ?? raw.tool ?? null + ); + dimensions.codeModeEnvelope = + payload?.tool_input?.code_mode_envelope === true; dimensions.clientVersion = dimension(raw.client_version ?? raw.clientVersion ?? null); dimensions.model = dimension(raw.model ?? raw.model_name ?? raw.modelName ?? null); dimensions.cwdHash = hash(raw.cwd ?? raw.working_directory ?? process.cwd()); @@ -291,6 +325,11 @@ export function beginHookInvocation(client, event, options = {}) { error = nextError; finish(outcome, reason, error); }, + block(nextReason = 'policy_denied') { + outcome = 'blocked'; + reason = nextReason; + finish(outcome, reason); + }, succeed(nextReason = 'completed') { outcome = 'success'; reason = nextReason; @@ -321,7 +360,11 @@ export function recordHookBootstrapFailure(client, event, error) { }); } -export function readHookEvents({ limit = 200, sinceMs = 24 * 60 * 60 * 1000 } = {}) { +export function readHookEvents({ + limit = 200, + sinceMs = 24 * 60 * 60 * 1000, + includeLifecycle = false, +} = {}) { try { const dir = hookLogDirectory(); if (!existsSync(dir)) return []; @@ -338,6 +381,7 @@ export function readHookEvents({ limit = 200, sinceMs = 24 * 60 * 60 * 1000 } = try { const event = JSON.parse(line); if (Date.parse(event.timestamp) < cutoff) continue; + if (!includeLifecycle && event.event === 'hook.started') continue; events.push(event); if (events.length >= limit) return events; } catch { @@ -353,38 +397,102 @@ export function readHookEvents({ limit = 200, sinceMs = 24 * 60 * 60 * 1000 } = export function hookHealthSummary(options = {}) { const sinceMs = options.sinceMs ?? 24 * 60 * 60 * 1000; - const events = readHookEvents({ limit: options.limit || 5000, sinceMs }); - const durations = events + const events = readHookEvents({ + limit: options.limit || 10000, + sinceMs, + includeLifecycle: true, + }); + const starts = events.filter((event) => event.event === 'hook.started'); + const completed = events.filter( + (event) => event.event === 'hook.completed' || !event.event + ); + const completedIds = new Set( + completed.map((event) => event.invocationId).filter(Boolean) + ); + const abandoned = starts + .filter( + (event) => + event.invocationId && + !completedIds.has(event.invocationId) && + Date.now() - Date.parse(event.timestamp) > 10_000 + ) + .map((event) => ({ + ...event, + event: 'hook.completed', + outcome: 'failure', + reason: 'process_terminated_before_completion', + durationMs: null, + })); + const runs = [...completed, ...abandoned]; + const durations = runs .map((event) => Number(event.durationMs)) .filter(Number.isFinite) .sort((a, b) => a - b); - const failures = events.filter((event) => event.outcome === 'failure'); - const timeouts = events.filter((event) => event.outcome === 'timeout'); - const skipped = events.filter((event) => event.outcome === 'skipped'); - const successes = events.filter((event) => event.outcome === 'success'); + const failures = runs.filter((event) => event.outcome === 'failure'); + const timeouts = runs.filter((event) => event.outcome === 'timeout'); + const skipped = runs.filter((event) => event.outcome === 'skipped'); + const blocked = runs.filter((event) => event.outcome === 'blocked'); + const successes = runs.filter((event) => event.outcome === 'success'); + const actions = runs.filter( + (event) => event.hookEvent === 'pre-tool' && event.toolName + ); + const byTool = {}; + for (const event of actions) { + const name = String(event.toolName || 'Unknown action'); + byTool[name] = (byTool[name] || 0) + 1; + } const byClient = {}; - for (const event of events) { + for (const event of runs) { const key = event.client || 'unknown'; - const current = byClient[key] || { total: 0, failures: 0, timeouts: 0, skipped: 0 }; + const current = byClient[key] || { + total: 0, + failures: 0, + timeouts: 0, + skipped: 0, + blocked: 0, + hookEvents: [], + }; current.total++; if (event.outcome === 'failure') current.failures++; if (event.outcome === 'timeout') current.timeouts++; if (event.outcome === 'skipped') current.skipped++; + if (event.outcome === 'blocked') current.blocked++; + if (event.hookEvent && !current.hookEvents.includes(event.hookEvent)) + current.hookEvents.push(event.hookEvent); byClient[key] = current; } + for (const current of Object.values(byClient)) current.hookEvents.sort(); const percentile = (p) => durations.length ? durations[Math.min(durations.length - 1, Math.floor(durations.length * p))] : null; const summary = { schemaVersion: HOOK_LOG_SCHEMA_VERSION, - available: events.length > 0, + available: runs.length > 0, windowHours: sinceMs / 3_600_000, - total: events.length, + total: runs.length, failures: failures.length, timeouts: timeouts.length, skipped: skipped.length, - successRate: events.length ? successes.length / events.length : null, + blocked: blocked.length, + abandoned: abandoned.length, + successes: successes.length, + actions: actions.length, + byTool, + // A skipped or policy-blocked hook completed its protocol successfully. + // Runtime health answers whether the hook process worked, not whether the + // optimizer chose to allow the host operation. + successRate: runs.length + ? (successes.length + skipped.length + blocked.length) / runs.length + : null, + healthStatus: + failures.length || timeouts.length + ? 'failing' + : skipped.length + ? 'degraded' + : runs.length + ? 'healthy' + : 'unavailable', p50DurationMs: percentile(0.5), p95DurationMs: percentile(0.95), byClient, diff --git a/integrations/codex/hooks/lib/pricing.mjs b/integrations/codex/hooks/lib/pricing.mjs index a4b09ebed..50b23b9cc 100644 --- a/integrations/codex/hooks/lib/pricing.mjs +++ b/integrations/codex/hooks/lib/pricing.mjs @@ -1,101 +1,72 @@ // GENERATED FILE -- do not edit. // Source of truth: hooks-core/pricing.mjs. Regenerate with `npm run sync:hooks`. /** - * Tokens in the unit people actually feel. + * Optional cost equivalents for token measurements. * - * The same data as everywhere else in this project, converted once, in one - * place, under three rules that keep a dollar figure honest: - * - * THE TABLE IS VISIBLE AND OVERRIDABLE. Prices change. A figure computed from - * rates buried in code goes stale silently and keeps looking authoritative, - * which is worse than no figure. The rates are printed next to the number and - * can be replaced without a release. - * - * DOLLARS BESIDE TOKENS, NOT INSTEAD OF THEM. The token count is exact and - * never goes out of date; the dollar figure is the token count times a rate - * that will. Showing both means the durable number survives the perishable - * one. - * - * NEVER A PRICE ON AN UNMEASURED SAVING. An invented number is worse in - * dollars than in tokens, because dollars get quoted to other people. A - * saving we cannot measure returns null and renders as "not yet measurable", - * never as "$0.00" -- which reads as "saved nothing" and is a different - * claim entirely. + * The runtime cannot infer a user's bill from a client name. A CLI may route + * through a subscription, direct API account, cloud marketplace, enterprise + * agreement, included credits, or a provider whose cache-read/write prices + * differ. Provider usage records are the source of truth when available. + * Everywhere else this module fails closed until the user supplies an + * effective blended input-token rate. */ -/** Cache multipliers, relative to an input token. */ -const CACHE_WRITE = 1.25; -const CACHE_READ = 0.1; +const RATE_ENV = 'TOKEN_OPTIMIZER_EFFECTIVE_INPUT_USD_PER_MILLION'; -/** - * Dollars per million tokens, by tier. - * - * Approximate published list rates as of 2026-07, carried with their date so a - * stale table is visible rather than silent. Override wholesale with - * TOKEN_OPTIMIZER_PRICES as JSON, e.g. - * {"asOf":"2026-09","opus":{"input":15,"output":75}}. - */ -const DEFAULT_PRICES = { - asOf: '2026-07', - haiku: { input: 1, output: 5 }, - sonnet: { input: 3, output: 15 }, - opus: { input: 15, output: 75 }, -}; - -export function prices() { - const raw = process.env.TOKEN_OPTIMIZER_PRICES; - if (raw) { - try { - const parsed = JSON.parse(raw); - if (parsed && typeof parsed === 'object') return { ...DEFAULT_PRICES, ...parsed, overridden: true }; - } catch { /* an unparseable override is ignored rather than fatal */ } - } - return DEFAULT_PRICES; +/** The configured effective input rate, or null when billing is unobservable. */ +export function effectiveRate() { + const rate = Number(process.env[RATE_ENV]); + return Number.isFinite(rate) && rate > 0 ? rate : null; } /** - * What a number of tokens costs. + * Dollars for a measured token quantity. * - * Returns null for a null input and says why: this is the single place the - * "unmeasured must not become $0.00" rule is enforced, so every caller inherits - * it rather than each remembering. + * `kind` is accepted for backwards compatibility but is not multiplied by a + * universal cache constant. The configured rate must already reflect the + * user's actual provider/model/cache/tier/credit mix. */ -export function dollars(tokens, { tier = 'opus', kind = 'input' } = {}) { - if (!Number.isFinite(tokens)) return null; - - const table = prices(); - const rate = table[tier]?.[kind === 'output' ? 'output' : 'input']; - if (!Number.isFinite(rate)) return null; - - const multiplier = kind === 'cacheWrite' ? CACHE_WRITE : kind === 'cacheRead' ? CACHE_READ : 1; - return (tokens / 1_000_000) * rate * multiplier; +export function dollars(tokens, { rate = effectiveRate() } = {}) { + if (!Number.isFinite(tokens) || !Number.isFinite(rate) || rate <= 0) { + return null; + } + return (tokens / 1_000_000) * rate; } /** A dollar figure, or an honest absence of one. */ -export function money(amount, { unmeasured = 'not yet measurable' } = {}) { +export function money(amount, { unmeasured = 'not priced' } = {}) { if (amount == null || !Number.isFinite(amount)) return unmeasured; if (amount === 0) return '$0.00'; if (Math.abs(amount) < 0.01) return '<$0.01'; return `$${amount.toFixed(2)}`; } -/** - * Per-session tokens expressed as a monthly cost. - * - * `sessionsPerMonth` is an assumption, so it is stated in the output rather - * than folded invisibly into the number. - */ -export function monthly(tokensPerSession, { tier = 'opus', sessionsPerMonth = 60 } = {}) { +/** Per-session tokens expressed with a stated monthly-frequency assumption. */ +export function monthly(tokensPerSession, { sessionsPerMonth = 60 } = {}) { if (!Number.isFinite(tokensPerSession)) return null; - const amount = dollars(tokensPerSession * sessionsPerMonth, { tier }); - return amount == null ? null : { amount, sessionsPerMonth, tier }; + const amount = dollars(tokensPerSession * sessionsPerMonth); + return amount == null + ? null + : { + amount, + sessionsPerMonth, + effectiveInputUsdPerMillion: effectiveRate(), + }; +} + +/** The pricing basis printed beside any cost equivalent. */ +export function priceNote() { + const rate = effectiveRate(); + return rate == null + ? `cost not priced: set ${RATE_ENV} to your effective blended input rate after provider, model, cache, tier, plan, and credits` + : `cost equivalent: configured effective input rate $${rate}/M via ${RATE_ENV}`; } -/** The rates behind the figures, for printing beside them. */ -export function priceNote(tier = 'opus') { - const table = prices(); - const rate = table[tier]; - if (!rate) return null; - return `prices: ${tier} $${rate.input}/$${rate.output} per Mtok (${table.asOf}` + - `${table.overridden ? ', overridden' : ''}); override with TOKEN_OPTIMIZER_PRICES`; +/** Compatibility surface: no built-in provider table is claimed. */ +export function prices() { + return { + source: + effectiveRate() == null ? 'unavailable' : 'configured-effective-rate', + effectiveInputUsdPerMillion: effectiveRate(), + }; } diff --git a/integrations/codex/hooks/lib/routing.mjs b/integrations/codex/hooks/lib/routing.mjs index 9b4642e0e..84ac866e4 100644 --- a/integrations/codex/hooks/lib/routing.mjs +++ b/integrations/codex/hooks/lib/routing.mjs @@ -65,7 +65,9 @@ export function modelCosts() { if (raw) { try { return JSON.parse(raw); - } catch { /* fall through to the defaults */ } + } catch { + /* fall through to the defaults */ + } } return { haiku: 1, sonnet: 3, opus: 15 }; } @@ -106,7 +108,12 @@ export function readEpisodes(path, { maxBytes = 6_000_000 } = {}) { let current = null; const close = () => { - if (current && current.turns > 0) episodes.push({ ...current, files: [...current.files], tools: [...current.tools] }); + if (current && current.turns > 0) + episodes.push({ + ...current, + files: [...current.files], + tools: [...current.tools], + }); current = null; }; @@ -123,7 +130,15 @@ export function readEpisodes(path, { maxBytes = 6_000_000 } = {}) { // a new episode. if (row.type === 'user' && typeof row.message?.content === 'string') { close(); - current = { at: Date.parse(row.timestamp) || null, models: new Set(), tools: new Set(), files: new Set(), turns: 0, errors: 0, results: 0 }; + current = { + at: Date.parse(row.timestamp) || null, + models: new Set(), + tools: new Set(), + files: new Set(), + turns: 0, + errors: 0, + results: 0, + }; continue; } if (!current) continue; @@ -157,8 +172,16 @@ export function readEpisodes(path, { maxBytes = 6_000_000 } = {}) { /* ---------------------------------------------------------------- SHAPES */ -const EDIT_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit', 'smart_edit', 'smart_write']); -const TEST_PATTERN = /\b(test|jest|pytest|vitest|dotnet test|npm test|go test)\b/i; +const EDIT_TOOLS = new Set([ + 'Edit', + 'MultiEdit', + 'Write', + 'NotebookEdit', + 'smart_edit', + 'smart_write', +]); +const TEST_PATTERN = + /\b(test|jest|pytest|vitest|dotnet test|npm test|go test)\b/i; /** * What kind of work this was. @@ -171,11 +194,19 @@ export function classifyShape(episode) { const tools = new Set(episode.tools || []); const edited = [...tools].some((t) => EDIT_TOOLS.has(t)); const files = (episode.files || []).length; - const ranTests = [...tools].some((t) => TEST_PATTERN.test(t)) || (episode.tools || []).includes('Bash'); + const ranTests = + [...tools].some((t) => TEST_PATTERN.test(t)) || + (episode.tools || []).includes('Bash'); if (edited && files >= 3) return 'multi-file-change'; if (edited) return 'single-file-change'; - if (files > 0 || tools.has('Grep') || tools.has('Glob') || tools.has('smart_grep')) return 'investigation'; + if ( + files > 0 || + tools.has('Grep') || + tools.has('Glob') || + tools.has('smart_grep') + ) + return 'investigation'; if (ranTests) return 'build-or-test'; return 'conversation'; } @@ -208,7 +239,13 @@ export function outcomeTable(episodes) { const tier = [...tiers][0]; table[shape] = table[shape] || {}; - const cell = table[shape][tier] = table[shape][tier] || { episodes: 0, turns: 0, errors: 0, results: 0, errored: 0 }; + const cell = (table[shape][tier] = table[shape][tier] || { + episodes: 0, + turns: 0, + errors: 0, + results: 0, + errored: 0, + }); cell.episodes += 1; cell.turns += episode.turns; cell.errors += episode.errors; @@ -287,7 +324,8 @@ export function route(shape, table = {}) { shape, recommend: heuristic, basis: 'heuristic', - reason: `every measured tier for ${shape} needed a retry in more than ` + + reason: + `every measured tier for ${shape} needed a retry in more than ` + `${Math.round(MAX_ERROR_RATE * 100)}% of episodes; falling back to the shipped default`, excluded: excluded.map((o) => ({ tier: o.tier, errorRate: o.errorRate })), options, @@ -306,17 +344,24 @@ export function route(shape, table = {}) { recommend: best.tier, basis: 'measured', // What each mistake would cost, from this project's own rates. - overpowered: strongest.tier === best.tier ? null : { - tier: strongest.tier, - wasted: Number((strongest.cost - best.cost).toFixed(2)), - }, - underpowered: cheapest.tier === best.tier ? null : { - tier: cheapest.tier, - expectedCost: Number(cheapest.cost.toFixed(2)), - errorRate: cheapest.errorRate, - excluded: cheapest.errorRate > MAX_ERROR_RATE, - }, - reason: `${shape}: ${best.tier} costs ${best.cost.toFixed(2)} in expectation over ${best.episodes} episodes ` + + overpowered: + strongest.tier === best.tier + ? null + : { + tier: strongest.tier, + wasted: Number((strongest.cost - best.cost).toFixed(2)), + }, + underpowered: + cheapest.tier === best.tier + ? null + : { + tier: cheapest.tier, + expectedCost: Number(cheapest.cost.toFixed(2)), + errorRate: cheapest.errorRate, + excluded: cheapest.errorRate > MAX_ERROR_RATE, + }, + reason: + `${shape}: ${best.tier} costs ${best.cost.toFixed(2)} in expectation over ${best.episodes} episodes ` + `(${Math.round(best.errorRate * 100)}% needed a retry)`, options, }; @@ -340,20 +385,31 @@ export function routingNote(shape, table, { currentModel, switchCost } = {}) { const lines = [decision.reason]; if (decision.underpowered) { - lines.push(` ${decision.underpowered.tier} looks cheaper per token, but needed a retry in ` + - `${Math.round(decision.underpowered.errorRate * 100)}% of episodes -- expected cost ${decision.underpowered.expectedCost}` + - `${decision.underpowered.excluded ? ', which is why it is not a candidate here at any price' : ''}.`); + lines.push( + ` ${decision.underpowered.tier} looks cheaper per token, but needed a retry in ` + + `${Math.round(decision.underpowered.errorRate * 100)}% of episodes -- expected cost ${decision.underpowered.expectedCost}` + + `${decision.underpowered.excluded ? ', which is why it is not a candidate here at any price' : ''}.` + ); } if (decision.overpowered) { - lines.push(` ${decision.overpowered.tier} would spend ${decision.overpowered.wasted} more than needed here.`); + lines.push( + ` ${decision.overpowered.tier} would spend ${decision.overpowered.wasted} more than needed here.` + ); } if (switchCost?.prefixTokens) { - lines.push(` Switching now also discards a ${switchCost.prefixTokens.toLocaleString()}-token warm prefix ` + - `(~${switchCost.rewriteCost.toLocaleString()} tokens to re-write); consider switching at the next break.`); + lines.push( + ` Switching now also discards a ${switchCost.prefixTokens.toLocaleString()}-token warm prefix ` + + `(~${switchCost.rewriteInputCostEquivalent.toLocaleString()} Anthropic uncached-input cost-equivalent tokens to re-write); consider switching at the next break.` + ); } - return { shape, recommend: decision.recommend, basis: decision.basis, text: lines.join('\n') }; + return { + shape, + recommend: decision.recommend, + basis: decision.basis, + text: lines.join('\n'), + }; } /** @@ -371,7 +427,9 @@ export function routingBriefing(table) { const decision = route(shape, table); if (decision.basis !== 'measured') continue; if (decision.recommend === HEURISTIC[shape]) continue; // nothing learned worth saying - lines.push(`${shape.replace(/-/g, ' ')} work goes better on ${decision.recommend} in this project.`); + lines.push( + `${shape.replace(/-/g, ' ')} work goes better on ${decision.recommend} in this project.` + ); } return lines.length ? lines.join('\n') : null; } @@ -389,7 +447,11 @@ export function routingBriefing(table) { * few more episodes accumulated would invalidate the cache behind it. Stability * is the feature; the memo is how it is bought. */ -export function cachedRoutingBriefing(dir, transcriptPath, { growthBytes = 4_000_000, maxAgeMs = 24 * 60 * 60 * 1000 } = {}) { +export function cachedRoutingBriefing( + dir, + transcriptPath, + { growthBytes = 4_000_000, maxAgeMs = 24 * 60 * 60 * 1000 } = {} +) { const memoPath = join(dir, 'routing-brief.json'); let stat; try { @@ -400,17 +462,31 @@ export function cachedRoutingBriefing(dir, transcriptPath, { growthBytes = 4_000 try { const memo = JSON.parse(readFileSync(memoPath, 'utf8')); - const fresh = memo.path === transcriptPath - && Math.abs(stat.size - memo.size) < growthBytes - && Date.now() - memo.at < maxAgeMs; + const fresh = + memo.path === transcriptPath && + Math.abs(stat.size - memo.size) < growthBytes && + Date.now() - memo.at < maxAgeMs; if (fresh) return memo.text || null; - } catch { /* no memo, or an unreadable one; recompute */ } + } catch { + /* no memo, or an unreadable one; recompute */ + } const text = routingBriefing(outcomeTable(readEpisodes(transcriptPath))); try { mkdirSync(dir, { recursive: true, mode: 0o700 }); - writeFileSync(memoPath, JSON.stringify({ path: transcriptPath, size: stat.size, at: Date.now(), text }), { mode: 0o600 }); - } catch { /* the memo is an optimisation; failing to write it costs latency, not correctness */ } + writeFileSync( + memoPath, + JSON.stringify({ + path: transcriptPath, + size: stat.size, + at: Date.now(), + text, + }), + { mode: 0o600 } + ); + } catch { + /* the memo is an optimisation; failing to write it costs latency, not correctness */ + } return text; } @@ -427,9 +503,11 @@ export function routingReport(table) { for (const tier of Object.keys(table[shape])) { const cell = table[shape][tier]; const cost = expectedCost(tier, cell); - lines.push(` ${tier.padEnd(7)} ${cell.episodes} episode(s), ` + - `${cell.meanTurns.toFixed(1)} turns avg, ${Math.round(cell.errorRate * 100)}% needed a retry, ` + - `expected cost ${cost.cost.toFixed(2)}${cell.measured ? '' : ' [too few episodes to trust]'}`); + lines.push( + ` ${tier.padEnd(7)} ${cell.episodes} episode(s), ` + + `${cell.meanTurns.toFixed(1)} turns avg, ${Math.round(cell.errorRate * 100)}% needed a retry, ` + + `expected cost ${cost.cost.toFixed(2)}${cell.measured ? '' : ' [too few episodes to trust]'}` + ); } } return lines.join('\n'); diff --git a/integrations/codex/hooks/lib/symbols.mjs b/integrations/codex/hooks/lib/symbols.mjs index 2a80e1e42..4d80666ac 100644 --- a/integrations/codex/hooks/lib/symbols.mjs +++ b/integrations/codex/hooks/lib/symbols.mjs @@ -50,7 +50,11 @@ const PATTERNS = { /^\s*(?:pub(?:\([^)]*\))?\s+)?(?:struct|enum|trait|impl)\s+([A-Za-z_]\w*)/, ], clike: [ - /^\s*(?:public|private|protected|internal|static|final|abstract|virtual|override|async|\s)*(?:[\w<>\[\],.?]+\s+)([A-Za-z_]\w*)\s*\([^;]*\)\s*\{/, + // Accept both K&R (`Run() {`) and Allman (`Run()` then `{`) declarations. + // Requiring the opening brace on the declaration line silently discarded + // nearly every method in idiomatic C# projects, leaving file nodes but no + // symbol nodes and making valid `file.cs#Method` anchors unresolved. + /^\s*(?:public|private|protected|internal|static|final|abstract|virtual|override|async|sealed|new|extern|unsafe|partial|\s)*(?:[\w<>\[\],.?]+\s+)([A-Za-z_]\w*)(?:<[^;()]+>)?\s*\([^;]*\)\s*(?:\{|=>|$)/, /^\s*(?:public|private|protected|internal|static|abstract|sealed|partial|\s)*(?:class|struct|interface|enum|record)\s+([A-Za-z_]\w*)/, ], ruby: [ diff --git a/integrations/codex/plugin/.mcp.json b/integrations/codex/plugin/.mcp.json index 39de674ce..e8384cc16 100644 --- a/integrations/codex/plugin/.mcp.json +++ b/integrations/codex/plugin/.mcp.json @@ -2,7 +2,10 @@ "mcpServers": { "token-optimizer": { "command": "npx", - "args": ["-y", "@ooples/token-optimizer-mcp@latest"] + "args": ["-y", "@ooples/token-optimizer-mcp@latest"], + "required": true, + "startup_timeout_sec": 30, + "tool_timeout_sec": 120 } } } diff --git a/integrations/codex/plugin/hooks/hooks.json b/integrations/codex/plugin/hooks/hooks.json index ed08582b1..e29539010 100644 --- a/integrations/codex/plugin/hooks/hooks.json +++ b/integrations/codex/plugin/hooks/hooks.json @@ -17,7 +17,7 @@ ], "PreToolUse": [ { - "matcher": "Read|read_file|view_file|Grep|grep|search_file_content|Glob|glob|find_files|Edit|edit_file|apply_patch|Write|write_file|Bash|shell|run_command", + "matcher": "Read|read_file|view_file|Grep|grep|search_file_content|Glob|glob|find_files|Edit|edit_file|apply_patch|Write|write_file|Bash|shell|run_command|functions\\.exec|exec", "hooks": [ { "type": "command", @@ -31,7 +31,7 @@ ], "PostToolUse": [ { - "matcher": "apply_patch|Edit|Write|edit_file|write_file|smart_edit|smart_write", + "matcher": "apply_patch|Edit|Write|edit_file|write_file|smart_edit|smart_write|functions\\.exec|exec", "hooks": [ { "type": "command", diff --git a/integrations/codex/plugin/hooks/lib/adapter.mjs b/integrations/codex/plugin/hooks/lib/adapter.mjs index fee0102b9..9f76bbdea 100644 --- a/integrations/codex/plugin/hooks/lib/adapter.mjs +++ b/integrations/codex/plugin/hooks/lib/adapter.mjs @@ -188,8 +188,143 @@ function contextOutput(client, eventName, additionalContext) { }; } +/** Convert one static JavaScript string literal without evaluating code. */ +function codexStringLiteral(literal) { + if (typeof literal !== 'string' || literal.length < 2) return null; + const quote = literal[0]; + if (!['"', "'", '`'].includes(quote) || literal.at(-1) !== quote) return null; + + let decoded = ''; + for (let index = 1; index < literal.length - 1; index += 1) { + const character = literal[index]; + if (quote === '`' && character === '$' && literal[index + 1] === '{') + return null; + if (character !== '\\') { + decoded += character; + continue; + } + + index += 1; + if (index >= literal.length - 1) return null; + const escaped = literal[index]; + const simple = { + b: '\b', f: '\f', n: '\n', r: '\r', t: '\t', v: '\v', 0: '\0', + '\\': '\\', "'": "'", '"': '"', '`': '`', '$': '$', + }; + if (Object.hasOwn(simple, escaped)) { + decoded += simple[escaped]; + continue; + } + if (escaped === '\n') continue; + if (escaped === '\r') { + if (literal[index + 1] === '\n') index += 1; + continue; + } + if (escaped === 'x') { + const digits = literal.slice(index + 1, index + 3); + if (!/^[0-9a-f]{2}$/i.test(digits)) return null; + decoded += String.fromCodePoint(Number.parseInt(digits, 16)); + index += 2; + continue; + } + if (escaped === 'u') { + const braced = literal[index + 1] === '{'; + const close = braced ? literal.indexOf('}', index + 2) : index + 5; + const digits = braced + ? literal.slice(index + 2, close) + : literal.slice(index + 1, close); + if ( + close < 0 || + !(braced ? /^[0-9a-f]{1,6}$/i : /^[0-9a-f]{4}$/i).test(digits) + ) return null; + const codePoint = Number.parseInt(digits, 16); + if (codePoint > 0x10ffff) return null; + decoded += String.fromCodePoint(codePoint); + index = close; + continue; + } + // JavaScript identity escapes drop the slash (for example, '\q' is 'q'). + decoded += escaped; + } + return decoded; +} + /** Convert client-specific lifecycle envelopes into the common tool shape. */ +function codexStringBindings(source) { + const values = new Map(); + const declaration = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*((?:"(?:\\.|[^"\\])*")|(?:'(?:\\.|[^'\\])*')|(?:`(?:\\.|[^`\\])*`))/gs; + for (const match of String(source || '').matchAll(declaration)) { + try { + const value = codexStringLiteral(match[2]); + if (value !== null) values.set(match[1], value); + } catch { + // A computed or malformed JavaScript expression is not safe to execute + // merely to understand a hook envelope. Leave it opaque. + } + } + return values; +} + +function codexCallArguments(source, method) { + const calls = []; + const pattern = new RegExp(`(?:tools\\.)?${method}\\s*\\(\\s*([^),]+)`, 'gi'); + for (const match of String(source || '').matchAll(pattern)) + calls.push(match[1].trim()); + return calls; +} + +function codexLiteral(value, bindings) { + if (bindings.has(value)) return String(bindings.get(value)); + return codexStringLiteral(value); +} + +function codexExecCommands(source, bindings) { + const commands = []; + for (const call of String(source || '').matchAll( + /(?:tools\.)?exec_command\s*\(\s*\{([\s\S]*?)\}\s*\)/gi + )) { + const property = /\bcmd\s*:\s*((?:"(?:\\.|[^"\\])*")|(?:'(?:\\.|[^'\\])*')|(?:`(?:\\.|[^`\\])*`)|(?:[A-Za-z_$][\w$]*))/s.exec(call[1]); + if (!property) continue; + const command = codexLiteral(property[1], bindings); + if (command) commands.push(command); + } + return commands; +} + export function normalizeClientPayload(clientName, event, raw) { + if (clientName === 'codex') { + const outerName = String(raw.tool_name ?? raw.toolName ?? raw.tool ?? ''); + if (/^(?:functions\.)?exec$/i.test(outerName)) { + const envelope = raw.tool_input ?? raw.toolInput ?? raw.arguments ?? raw.args; + const source = + typeof envelope === 'string' + ? envelope + : envelope?.code ?? envelope?.input ?? envelope?.source ?? ''; + const bindings = codexStringBindings(source); + const patchCalls = codexCallArguments(source, 'apply_patch'); + const patch = patchCalls + .map((argument) => codexLiteral(argument, bindings)) + .find(Boolean); + const mutation = patchCalls.length > 0; + const commands = codexExecCommands(source, bindings); + + // `functions.exec` is also the orchestration envelope for web, image and + // other non-filesystem tools. Do not reinterpret those as shell commands: + // doing so produced irrelevant cross-project advice and fabricated graph + // activity for operations the filesystem hooks never observed. + if (!mutation && !commands.length) return raw; + return { + ...raw, + tool_name: mutation ? 'apply_patch' : 'run_command', + tool_input: { + ...(envelope && typeof envelope === 'object' ? envelope : {}), + command: String(patch || commands.join('\n') || source || ''), + code_mode_envelope: true, + }, + }; + } + } + if (clientName === 'cline') { const body = event === 'post-tool' ? raw.postToolUse : raw.preToolUse; if (!body) return raw; @@ -385,6 +520,22 @@ function projectBriefing() { function observeAndInject(payload, state, episode, features) { const touched = touchedFiles(payload); const dirFor = (path) => wikiDir(projectRootFor(path, payload.cwd)); + const registerRoot = (root) => { + const dir = wikiDir(root); + registerProject({ + root, + graphDir: dir, + client: episode.client || 'unknown', + }); + return dir; + }; + + // SessionStart is not delivered consistently by every host/version. The + // graph was still written in that case, but the dashboard registry never + // learned it existed. Register every repository observed by a real tool call + // as a second, idempotent production path. + for (const { path } of touched) + registerRoot(projectRootFor(path, payload.cwd)); const bytes = readCostBytes(payload); if (bytes && payload.tool_input.file_path) { @@ -425,7 +576,7 @@ function observeAndInject(payload, state, episode, features) { const command = payload.tool_input?.command; if (command) { const root = commandProjectRoot(payload, payload.cwd); - const dir = wikiDir(root); + const dir = registerRoot(root); const local = forCommand(dir, load(dir), command, { sessionId: payload.session_id, alreadyInjected, @@ -750,7 +901,14 @@ async function runHook(clientName, event, invocation) { client.canDeny && event === 'pre-tool' && !repeat && - mode() !== MODE_ADVISE + mode() !== MODE_ADVISE && + // Codex code mode presents an orchestration program as one outer + // `functions.exec` call. Refusing that envelope is rendered by the host + // as a failed script and can discard several otherwise-safe nested + // operations. Observe it, capture it and advise, but reserve a hard veto + // for legacy one-operation Codex tool calls where the denied operation is + // unambiguous. + !(clientName === 'codex' && payload.tool_input?.code_mode_envelope) ); if (canRefuse) { @@ -759,19 +917,23 @@ async function runHook(clientName, event, invocation) { // Every client's refusal carries the off switch, for the same reason Claude // Code's does: enforcement that hides its own disable is coercive. if (client.denyStyle === 'top-level') { + invocation.block('policy_denied'); emit({ decision: 'deny', reason: withEscape(verdict.reason) }); } else if (client.denyStyle === 'top-level-permission') { + invocation.block('policy_denied'); emit({ permissionDecision: 'deny', permissionDecisionReason: withEscape(verdict.reason), }); } else if (client.denyStyle === 'cline') { + invocation.block('policy_denied'); emit({ cancel: true, contextModification: '', errorMessage: withEscape(verdict.reason), }); } else if (client.denyStyle === 'cursor') { + invocation.block('policy_denied'); emit({ continue: true, permission: 'deny', @@ -780,9 +942,11 @@ async function runHook(clientName, event, invocation) { 'Token Optimizer redirected an expensive built-in operation.', }); } else if (client.denyStyle === 'exit-2') { + invocation.block('policy_denied'); process.stderr.write(withEscape(verdict.reason)); process.exit(2); } else { + invocation.block('policy_denied'); emit({ hookSpecificOutput: { hookEventName: eventName, diff --git a/integrations/codex/plugin/hooks/lib/audit.mjs b/integrations/codex/plugin/hooks/lib/audit.mjs index b82186586..df8a08b82 100644 --- a/integrations/codex/plugin/hooks/lib/audit.mjs +++ b/integrations/codex/plugin/hooks/lib/audit.mjs @@ -50,15 +50,17 @@ export function decline(dir, id) { export function declines(dir, { events = readMetrics(dir) } = {}) { const counts = new Map(); for (const event of events) { - if (event.kind !== 'advice' || event.action !== 'declined' || !event.id) continue; + if (event.kind !== 'advice' || event.action !== 'declined' || !event.id) + continue; counts.set(event.id, (counts.get(event.id) || 0) + 1); } return counts; } -const idOf = (finding) => finding.remedy - ? `${finding.remedy.type}:${finding.remedy.anchor || finding.remedy.file || (finding.remedy.anchors || []).join(',')}` - : `${finding.id}:${finding.anchor || finding.file || ''}`; +const idOf = (finding) => + finding.remedy + ? `${finding.remedy.type}:${finding.remedy.anchor || finding.remedy.file || (finding.remedy.anchors || []).join(',')}` + : `${finding.id}:${finding.anchor || finding.file || ''}`; /** * Whether a habit improved after we said something about it. @@ -68,12 +70,18 @@ const idOf = (finding) => finding.remedy * -- a trend computed from one session on each side is a coin flip with a * percentage sign. */ -export function habitTrend(dir, detector, { events = readMetrics(dir), minSessions = 2, anchors = null } = {}) { +export function habitTrend( + dir, + detector, + { events = readMetrics(dir), minSessions = 2, anchors = null } = {} +) { // SPLIT BY POSITION, NOT BY TIMESTAMP. The log is append-only, so its order is // authoritative -- while `at` has millisecond granularity, and a burst of // events inside one millisecond would land on the wrong side of the split or // collapse one side to nothing. Position is exact where the clock is not. - const raisedAt = events.findIndex((e) => e.kind === 'advice' && e.detector === detector); + const raisedAt = events.findIndex( + (e) => e.kind === 'advice' && e.detector === detector + ); if (raisedAt === -1) return null; // SCOPED TO THE FILES THIS DETECTOR NAMED. Counting every read in the window @@ -114,7 +122,12 @@ export function habitTrend(dir, detector, { events = readMetrics(dir), minSessio /** Marks a recommendation as having been raised, so its effect can be scored. */ export function raise(dir, finding) { - record(dir, { kind: 'advice', action: 'raised', id: idOf(finding), detector: finding.id }); + record(dir, { + kind: 'advice', + action: 'raised', + id: idOf(finding), + detector: finding.id, + }); } /** @@ -123,7 +136,11 @@ export function raise(dir, finding) { * `findings` comes from the detectors; `extra` lets the cache and routing * modules contribute without this file importing the world. */ -export function buildQueue(dir, findings = [], { events = readMetrics(dir) } = {}) { +export function buildQueue( + dir, + findings = [], + { events = readMetrics(dir) } = {} +) { const declined = declines(dir, { events }); const ledger = remedyLedger(dir); const applied = new Set(ledger.filter((r) => !r.revertedAt).map((r) => r.id)); @@ -146,7 +163,9 @@ export function buildQueue(dir, findings = [], { events = readMetrics(dir) } = { } return { - queue: queue.sort((a, b) => (b.costPerSession ?? 0) - (a.costPerSession ?? 0)), + queue: queue.sort( + (a, b) => (b.costPerSession ?? 0) - (a.costPerSession ?? 0) + ), suppressed, done: ledger.filter((r) => !r.revertedAt), }; @@ -161,7 +180,11 @@ export function buildQueue(dir, findings = [], { events = readMetrics(dir) } = { * many were withheld -- and what they are collectively worth -- keeps that from * being a silent cap. */ -export function renderAudit(dir, findings = [], { tier = 'opus', full = false, sessionsPerMonth = 60 } = {}) { +export function renderAudit( + dir, + findings = [], + { tier = 'opus', full = false, sessionsPerMonth = 60 } = {} +) { const { queue, suppressed, done } = buildQueue(dir, findings); const lines = []; @@ -175,22 +198,29 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s // read as "this costs nothing" beside a finding whose cost we simply do not // know -- the same unknown-becomes-zero error the rest of this project // corrects, in the one unit that gets quoted to other people. - const priced = item.costPerSession == null ? null : monthly(cost, { tier, sessionsPerMonth }); + const priced = + item.costPerSession == null + ? null + : monthly(cost, { tier, sessionsPerMonth }); // The id appears on EVERY actionable line, not just the appliable ones. It is the only // handle `decline` accepts and it cannot be guessed from the title, so omitting it made the // advertised decline path unreachable for precisely the findings most likely to be // unwanted -- model-routing findings carry no remedy, so they could never be suppressed no // matter how many times a user declined them. - const action = item.remedy?.kind === 'ours' ? `apply: waste_audit action="apply" id="${item.id}"` - : item.remedy?.kind === 'yours' ? `proposed edit -- needs your yes, nothing changed; decline: ${item.id}` - : `advice only -- no automatic fix; decline: ${item.id}`; + const action = + item.remedy?.kind === 'ours' + ? `apply: waste_audit action="apply" id="${item.id}"` + : item.remedy?.kind === 'yours' + ? `proposed edit -- needs your yes, nothing changed; decline: ${item.id}` + : `advice only -- no automatic fix; decline: ${item.id}`; // MEASURED ZERO IS NOT UNMEASURED. Testing `cost` for truthiness sent a real 0 down the // "not yet measurable" branch while `priced` above, which tests for null, still rendered // $0.00 beside it -- producing `cost not yet measurable (~$0.00/month)`, the exact string // the test suite declares must never appear. waste.mjs defaults every finding to 0 and // hard-sets it on the co-occurrence detector, so this is reachable, not theoretical. - const text = ` ${item.title}\n ${item.costPerSession != null ? `${cost.toLocaleString()} tokens/session` : 'cost not yet measurable'}` + + const text = + ` ${item.title}\n ${item.costPerSession != null ? `${cost.toLocaleString()} tokens/session` : 'cost not yet measurable'}` + `${priced ? ` (~${money(priced.amount)}/month)` : ''}; ${action}`; const printCost = estimate(text); @@ -211,14 +241,19 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s lines.push(text); } - const head = shown.length ? ['What to do next, most expensive first:', ...lines] : ['Nothing addressable found.']; + const head = shown.length + ? ['What to do next, most expensive first:', ...lines] + : ['Nothing addressable found.']; const body = [...head]; if (withheld.length) { const worth = withheld.reduce((sum, w) => sum + (w.costPerSession || 0), 0); - body.push('', ` ... ${withheld.length} more finding(s) worth ${worth.toLocaleString()} tokens/session in total, ` + - 'not shown -- printing them costs more than they are worth. Pass full=true to see all.'); + body.push( + '', + ` ... ${withheld.length} more finding(s) worth ${worth.toLocaleString()} tokens/session in total, ` + + 'not shown -- printing them costs more than they are worth. Pass full=true to see all.' + ); } // What we already told you, and what it bought. @@ -226,23 +261,35 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s body.push('', 'Already applied, and what it actually saved:'); for (const rule of done.slice(0, 6)) { const saved = rule.savedPerSession; - const priced = saved == null ? null : monthly(saved, { tier, sessionsPerMonth }); - body.push(` + ${rule.id} -- ${saved == null - ? 'not yet measurable (fewer than two sessions since)' - : `measured ${saved.toLocaleString()} tokens/session${priced ? `, ~${money(priced.amount)}/month` : ''}`}`); + const priced = + saved == null ? null : monthly(saved, { tier, sessionsPerMonth }); + body.push( + ` + ${rule.id} -- ${ + saved == null + ? 'not yet measurable (fewer than two sessions since)' + : `measured ${saved.toLocaleString()} tokens/session${priced ? `, ~${money(priced.amount)}/month` : ''}` + }` + ); } // Never a silent cap: the withheld block above states its remainder and so must this one. // Truncating to six with no disclosure showed a user six savings and let them conclude that // was everything the tool had bought them, which understates its own measured value. if (done.length > 6) { - body.push(` ... and ${done.length - 6} more applied fix(es), not shown.`); + body.push( + ` ... and ${done.length - 6} more applied fix(es), not shown.` + ); } } if (suppressed.length) { - body.push('', `Not repeating ${suppressed.length} recommendation(s) you have declined ` + - `${DECLINE_LIMIT}+ times. Ask for full=true to see them.`); - if (full) for (const item of suppressed) body.push(` - ${item.title} (declined ${item.times}x)`); + body.push( + '', + `Not repeating ${suppressed.length} recommendation(s) you have declined ` + + `${DECLINE_LIMIT}+ times. Ask for full=true to see them.` + ); + if (full) + for (const item of suppressed) + body.push(` - ${item.title} (declined ${item.times}x)`); } // Whether the advice changed anything. @@ -256,20 +303,30 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s for (const a of anchors) anchorsBy.get(f.id).add(a); } const trends = [...new Set(findings.map((f) => f.id))] - .map((detector) => habitTrend(dir, detector, { - anchors: anchorsBy.has(detector) ? [...anchorsBy.get(detector)] : null, - })) + .map((detector) => + habitTrend(dir, detector, { + anchors: anchorsBy.has(detector) ? [...anchorsBy.get(detector)] : null, + }) + ) .filter(Boolean); if (trends.length) { body.push('', 'Since the advice was given:'); for (const trend of trends) { - body.push(` ${trend.detector}: ${trend.before.toLocaleString()} -> ${trend.after.toLocaleString()} tokens/session ` + - `(${trend.improved ? 'improved' : 'worse'})`); + body.push( + ` ${trend.detector}: ${trend.before.toLocaleString()} -> ${trend.after.toLocaleString()} tokens/session ` + + `(${trend.improved ? 'improved' : 'worse'})` + ); } } - const addressable = queue.reduce((sum, item) => sum + (item.costPerSession || 0), 0); + const addressable = queue.reduce( + (sum, item) => sum + (item.costPerSession || 0), + 0 + ); + const addressableMonthly = addressable + ? monthly(addressable, { tier, sessionsPerMonth }) + : null; const note = priceNote(tier); if (note) body.push('', note); @@ -278,9 +335,10 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s // pushing it left the report's longest line out of its own reported cost, understating it by // 10-20% on a short audit. This figure exists to prove the audit is not a net loss, so // understating the cost side biases exactly the comparison it was written to be honest about. - const closing = (n) => `This report cost about ${n.toLocaleString()} tokens and names ` + + const closing = (n) => + `This report cost about ${n.toLocaleString()} tokens and names ` + `${addressable.toLocaleString()} tokens/session of addressable waste` + - `${addressable ? ` (~${money(monthly(addressable, { tier, sessionsPerMonth })?.amount)}/month across ${sessionsPerMonth} sessions)` : ''}.`; + `${addressableMonthly ? ` (~${money(addressableMonthly.amount)}/month across ${sessionsPerMonth} sessions)` : ''}.`; const selfCost = estimate([...body, '', closing(0)].join('\n')); body.push('', closing(selfCost)); diff --git a/integrations/codex/plugin/hooks/lib/cache.mjs b/integrations/codex/plugin/hooks/lib/cache.mjs index 9b11173ff..9782c3262 100644 --- a/integrations/codex/plugin/hooks/lib/cache.mjs +++ b/integrations/codex/plugin/hooks/lib/cache.mjs @@ -3,12 +3,12 @@ /** * Prompt-cache economics. * - * Cache economics is real money in a way most compression is not: a cache read - * costs a tenth of a fresh token and a cache WRITE costs a quarter more than - * one, so a prefix that keeps invalidating can cost more than every saving this - * product makes elsewhere. It is also the one area where we do not have to - * model anything -- the client's own transcript records cache_read_input_tokens - * and cache_creation_input_tokens per turn, which is ground truth. + * Anthropic prompt-cache usage, read from Claude Code transcripts. A cache read + * and a cache write are both billable, at different published multipliers from + * uncached input. Raw cache_read_input_tokens and + * cache_creation_input_tokens are observations. Any multiplier-based value is + * an Anthropic input-cost equivalent, not a universally comparable token count + * and not a provider invoice. * * MEASUREMENT ALONE IS NOT ACTIONABLE. A hit rate tells you the cache missed; * it cannot tell you why, and an unactionable number is the same dead thing as @@ -30,7 +30,15 @@ * cache-safety-by-construction half of this file. */ -import { readFileSync, readdirSync, statSync, existsSync, openSync, readSync, closeSync } from 'node:fs'; +import { + readFileSync, + readdirSync, + statSync, + existsSync, + openSync, + readSync, + closeSync, +} from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; @@ -51,7 +59,8 @@ export const READ_MULTIPLIER = 0.1; const SYNTHETIC_MODEL = ''; /** True for a turn naming a model an inference actually ran under. */ -const realModel = (turn) => Boolean(turn.model) && turn.model !== SYNTHETIC_MODEL; +const realModel = (turn) => + Boolean(turn.model) && turn.model !== SYNTHETIC_MODEL; /* -------------------------------------------------------------- MEASUREMENT */ @@ -72,7 +81,10 @@ export function transcriptFor(cwd = process.cwd()) { try { const files = readdirSync(dir) .filter((name) => name.endsWith('.jsonl')) - .map((name) => ({ path: join(dir, name), at: statSync(join(dir, name)).mtimeMs })) + .map((name) => ({ + path: join(dir, name), + at: statSync(join(dir, name)).mtimeMs, + })) .sort((a, b) => b.at - a.at); return files.length ? files[0].path : null; } catch { @@ -157,7 +169,8 @@ export function cacheHealth(turns) { read += turn.read; written += turn.written; if (!realModel(turn)) continue; - if (!models.has(turn.model)) models.set(turn.model, { read: 0, written: 0, turns: 0 }); + if (!models.has(turn.model)) + models.set(turn.model, { read: 0, written: 0, turns: 0 }); const entry = models.get(turn.model); entry.read += turn.read; entry.written += turn.written; @@ -171,20 +184,28 @@ export function cacheHealth(turns) { // attribution price, made keepWarm answer "unknown", and returned null from // modelSwitchCost under a guard its caller does not share, which crashed // cache_audit outright with "Cannot read properties of null". - const last = turns.reduce((best, turn) => (turn.read + turn.written > 0 ? turn : best), turns[turns.length - 1]); + const last = turns.reduce( + (best, turn) => (turn.read + turn.written > 0 ? turn : best), + turns[turns.length - 1] + ); const total = read + written; return { + provider: 'anthropic', + source: 'claude-code-transcript', turns: turns.length, read, written, hitRate: total ? read / total : null, prefixTokens: last.read + last.written, - // Priced in plain-token equivalents, which is what makes it comparable with - // every other saving this product reports. - writeCost: Math.round(written * WRITE_MULTIPLIER), - readCost: Math.round(read * READ_MULTIPLIER), - savedVersusNoCache: Math.round(read * (1 - READ_MULTIPLIER)), + // These are Anthropic uncached-input cost equivalents. They stay outside + // the verified MCP transport ledger and must not be relabeled as tokens + // saved across clients. + writeInputCostEquivalent: Math.round(written * WRITE_MULTIPLIER), + readInputCostEquivalent: Math.round(read * READ_MULTIPLIER), + inputCostEquivalentAvoidedVersusUncached: Math.round( + read * (1 - READ_MULTIPLIER) + ), models: Object.fromEntries(models), }; } @@ -200,13 +221,17 @@ export function modelSwitchCost(turns) { const health = cacheHealth(turns); if (!health?.prefixTokens) return null; - const switched = new Set(turns.filter(realModel).map((t) => t.model)).size > 1; + const switched = + new Set(turns.filter(realModel).map((t) => t.model)).size > 1; return { prefixTokens: health.prefixTokens, - rewriteCost: Math.round(health.prefixTokens * WRITE_MULTIPLIER), + rewriteInputCostEquivalent: Math.round( + health.prefixTokens * WRITE_MULTIPLIER + ), alreadySwitched: switched, - text: `Switching model now discards a ${health.prefixTokens.toLocaleString()}-token warm prefix; ` + - `re-writing it costs about ${Math.round(health.prefixTokens * WRITE_MULTIPLIER).toLocaleString()} tokens.`, + text: + `Switching model now discards a ${health.prefixTokens.toLocaleString()}-token warm prefix; ` + + `under Anthropic's current 5-minute write multiplier, re-writing it is about ${Math.round(health.prefixTokens * WRITE_MULTIPLIER).toLocaleString()} uncached-input cost-equivalent tokens.`, }; } @@ -221,17 +246,38 @@ export function modelSwitchCost(turns) { * time. That is why this class of bug survives -- nothing about it looks wrong. */ const VOLATILE = [ - { id: 'timestamp', re: /\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/, why: 'an embedded timestamp' }, - { id: 'date', re: /\b(?:today|current date)\b[^\n]{0,40}\d{4}-\d{2}-\d{2}/i, why: 'an embedded current date' }, + { + id: 'timestamp', + re: /\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/, + why: 'an embedded timestamp', + }, + { + id: 'date', + re: /\b(?:today|current date)\b[^\n]{0,40}\d{4}-\d{2}-\d{2}/i, + why: 'an embedded current date', + }, // A bare date is a SUSPICION, not a measurement: "- 2025-10-31: reworked the // loader" is byte-identical every session. Kept, because stableText fails // closed over our own output and dropping a line there costs little -- but // NOT priced, because in the blame report a false positive invents a // per-session cost and tells the user to delete their changelog. - { id: 'iso-date', re: /\b\d{4}-\d{2}-\d{2}\b/, why: 'an embedded date', priced: false }, - { id: 'session-id', re: /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i, why: 'a session id' }, + { + id: 'iso-date', + re: /\b\d{4}-\d{2}-\d{2}\b/, + why: 'an embedded date', + priced: false, + }, + { + id: 'session-id', + re: /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i, + why: 'a session id', + }, { id: 'git-sha', re: /\b[0-9a-f]{40}\b/, why: 'a git sha' }, - { id: 'counter', re: /\b(?:run|build|attempt|iteration)\s*#?\d+\b/i, why: 'a run counter' }, + { + id: 'counter', + re: /\b(?:run|build|attempt|iteration)\s*#?\d+\b/i, + why: 'a run counter', + }, { id: 'epoch', re: /\b1[6-9]\d{11}\b/, why: 'an epoch timestamp' }, ]; @@ -242,7 +288,13 @@ export function volatileLines(text) { for (let i = 0; i < lines.length; i++) { for (const rule of VOLATILE) { if (!rule.re.test(lines[i])) continue; - out.push({ line: i + 1, id: rule.id, why: rule.why, priced: rule.priced !== false, text: lines[i].trim().slice(0, 120) }); + out.push({ + line: i + 1, + id: rule.id, + why: rule.why, + priced: rule.priced !== false, + text: lines[i].trim().slice(0, 120), + }); break; // one cause per line is enough to act on } } @@ -264,7 +316,11 @@ const estimate = (text) => Math.ceil(String(text || '').length / 4); * unknown, and this reports the construct WITHOUT a price rather than inventing * one. */ -export function attributeInvalidation(cwd, prefixTokens = null, { files = null } = {}) { +export function attributeInvalidation( + cwd, + prefixTokens = null, + { files = null } = {} +) { // Ordered as the client assembles them: the client preamble and tool // definitions come first, then project instructions, then the conversation. const candidates = files || ['CLAUDE.md', 'AGENTS.md', '.claude/CLAUDE.md']; @@ -292,7 +348,8 @@ export function attributeInvalidation(cwd, prefixTokens = null, { files = null } // prefix -- 3.75x the entire prefix. Conservative still: the preamble // ahead of the file is not counted, so this remains a floor. const ahead = offset + estimate(lines.slice(0, hit.line - 1).join('\n')); - const downstream = prefixTokens != null ? Math.max(0, prefixTokens - ahead) : null; + const downstream = + prefixTokens != null ? Math.max(0, prefixTokens - ahead) : null; // A prefix cache invalidates ONCE, at the earliest difference. A later // volatile line in the same file costs nothing extra until the first one // is fixed, so it is reported for context but not billed again. @@ -304,7 +361,9 @@ export function attributeInvalidation(cwd, prefixTokens = null, { files = null } // into a reported zero. const firstPriced = found.find((h) => h.priced); const subsumedBy = - hit.priced && firstPriced && firstPriced !== hit ? `${relative}:${firstPriced.line}` : null; + hit.priced && firstPriced && firstPriced !== hit + ? `${relative}:${firstPriced.line}` + : null; out.push({ file: relative, line: hit.line, @@ -316,9 +375,12 @@ export function attributeInvalidation(cwd, prefixTokens = null, { files = null } // decline to price. 0 means measured and genuinely free, which is what a // subsumed hit is: real, but already billed by the earlier line. Folding // those two into one value is the same mistake this file warns about. - costPerSession: downstream == null || !hit.priced - ? null - : (subsumedBy ? 0 : Math.round(downstream * WRITE_MULTIPLIER)), + costPerSession: + downstream == null || !hit.priced + ? null + : subsumedBy + ? 0 + : Math.round(downstream * WRITE_MULTIPLIER), remedy: { kind: 'yours', type: 'edit', @@ -369,7 +431,7 @@ export function stableText(text) { */ export function cacheOrdered(items) { return [...items].sort((a, b) => { - const stability = (item) => (item.volatility ?? (item.fresh ? 1 : 0)); + const stability = (item) => item.volatility ?? (item.fresh ? 1 : 0); return stability(a) - stability(b); }); } diff --git a/integrations/codex/plugin/hooks/lib/decide.mjs b/integrations/codex/plugin/hooks/lib/decide.mjs index caae7de2f..a61dafdd5 100644 --- a/integrations/codex/plugin/hooks/lib/decide.mjs +++ b/integrations/codex/plugin/hooks/lib/decide.mjs @@ -392,6 +392,21 @@ export function touchedFiles(payload) { add(input.path); add(input.notebook_path); + // Codex code mode carries apply_patch as the nested program text rather than + // as a first-class file_path. Without reading these headers, successful + // code-mode edits incremented the semantic-harvest counter but produced no + // structural file/symbol nodes, which is why the live dashboard showed very + // few nodes after substantial work. PostToolUse can resolve Add/Update paths + // because the file now exists; deleted paths deliberately remain absent. + for (const match of command.matchAll( + /^\*\*\* (?:Add|Update|Delete) File:\s*(.+)$/gm + )) { + add(match[1].trim().replace(/^['"]|['"]$/g, '')); + } + for (const match of command.matchAll(/^\*\*\* Move to:\s*(.+)$/gm)) { + add(match[1].trim().replace(/^['"]|['"]$/g, '')); + } + // EVERY pipeline segment, not just the first. `fileOperands` looks only at // the head of the pipeline because that is where the COST is -- `git log | // head` must not be mistaken for a file dump. But observation is a different diff --git a/integrations/codex/plugin/hooks/lib/experiment.mjs b/integrations/codex/plugin/hooks/lib/experiment.mjs index 1f64713f1..c35c6cf5c 100644 --- a/integrations/codex/plugin/hooks/lib/experiment.mjs +++ b/integrations/codex/plugin/hooks/lib/experiment.mjs @@ -67,16 +67,71 @@ export function episodeMeta({ client, raw = {}, payload = {}, env = process.env } export function usageFrom(raw = {}) { - const usage = raw.usage || raw.token_usage || raw.tokenUsage || raw.metrics?.usage || {}; + const usage = raw.usage || raw.token_usage || raw.tokenUsage || raw.usageMetadata || + raw.usage_metadata || raw.metrics?.usage || raw.response?.usage || + raw.response?.usageMetadata || {}; const number = (...values) => { const value = first(...values); const parsed = Number(value); return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; }; + const cachedInputTokens = number( + usage.cached_input_tokens, usage.cachedInputTokens, + usage.cache_read_input_tokens, usage.cachedContentTokenCount + ); + const cacheCreation = usage.cache_creation || usage.cacheCreation || {}; + const cacheWrite5mInputTokens = number( + usage.cache_write_5m_input_tokens, usage.cacheWrite5mInputTokens, + cacheCreation.ephemeral_5m_input_tokens, cacheCreation.ephemeral5mInputTokens + ); + const cacheWrite1hInputTokens = number( + usage.cache_write_1h_input_tokens, usage.cacheWrite1hInputTokens, + cacheCreation.ephemeral_1h_input_tokens, cacheCreation.ephemeral1hInputTokens + ); + const detailedWrites = (cacheWrite5mInputTokens || 0) + (cacheWrite1hInputTokens || 0); + const reportedWrites = number( + usage.cache_write_input_tokens, usage.cacheWriteInputTokens, + usage.cache_creation_input_tokens, usage.cacheCreationInputTokens + ); + const cacheWriteInputTokens = reportedWrites == null + ? null + : Math.max(0, reportedWrites - detailedWrites); + const explicitUncached = number(usage.uncached_input_tokens, usage.uncachedInputTokens); + const promptInput = number( + usage.promptTokenCount, usage.prompt_token_count, + usage.input_tokens, usage.inputTokens + ); + const isAnthropic = usage.cache_creation_input_tokens !== undefined || + usage.cache_read_input_tokens !== undefined; + const isGemini = usage.promptTokenCount !== undefined || usage.cachedContentTokenCount !== undefined; + const uncachedInputTokens = explicitUncached != null + ? explicitUncached + : promptInput == null + ? null + : isAnthropic + ? promptInput + : isGemini || cachedInputTokens != null || reportedWrites != null + ? Math.max(0, promptInput - (cachedInputTokens || 0) - (reportedWrites || 0)) + : promptInput; + const candidates = number(usage.candidatesTokenCount, usage.candidates_token_count); + const thoughts = number(usage.thoughtsTokenCount, usage.thoughts_token_count); + const outputTokens = candidates != null || thoughts != null + ? (candidates || 0) + (thoughts || 0) + : number(usage.output_tokens, usage.outputTokens); return { - uncachedInputTokens: number(usage.uncached_input_tokens, usage.uncachedInputTokens), - cachedInputTokens: number(usage.cached_input_tokens, usage.cachedInputTokens, usage.cache_read_input_tokens), - outputTokens: number(usage.output_tokens, usage.outputTokens), - totalTokens: number(usage.total_tokens, usage.totalTokens, raw.tokens_used, raw.tokensUsed), + uncachedInputTokens, + cachedInputTokens, + cacheWrite5mInputTokens, + cacheWrite1hInputTokens, + cacheWriteInputTokens, + outputTokens, + totalTokens: number( + usage.total_tokens, usage.totalTokens, usage.totalTokenCount, + usage.total_token_count, raw.tokens_used, raw.tokensUsed + ), + usageMeasurementId: first( + raw.requestId, raw.request_id, raw.responseId, raw.response_id, + usage.responseId, usage.response_id + ) ?? null, }; } diff --git a/integrations/codex/plugin/hooks/lib/harvest-write.mjs b/integrations/codex/plugin/hooks/lib/harvest-write.mjs index 30433d370..db436c1dc 100644 --- a/integrations/codex/plugin/hooks/lib/harvest-write.mjs +++ b/integrations/codex/plugin/hooks/lib/harvest-write.mjs @@ -24,7 +24,7 @@ */ import { - putNodeWithEdges, load, nodeId, sharedDir, isSharedDir, putNode, + putNodeWithEdges, load, nodeId, sharedDir, isSharedDir, putNode, putEdge, } from './wiki.mjs'; import { indexFile } from './staleness.mjs'; import { symbolKey } from './symbols.mjs'; @@ -47,6 +47,37 @@ import { ORIGIN_HARVESTED, ORIGIN_AGENT, ORIGIN_HUMAN } from './curate.mjs'; */ export const SHAREABLE_TYPES = new Set(['command', 'failure', 'decision', 'feedback']); +/** + * Synthetic study traffic must never become advice for an unrelated live repo. + * Evaluation roots are intentionally realistic and may carry their own `.git` + * marker, so repository discovery alone cannot distinguish them. Publication + * remains useful inside the isolated study store; this guard protects only the + * default machine-wide store when a harness was accidentally run without an + * isolated TOKEN_OPTIMIZER_SHARED_DIR. + */ +export function isEphemeralProject(projectRoot) { + const root = canonicalPath(projectRoot || '').toLowerCase(); + if (!root) return true; + return ( + /\/appdata\/local\/temp\//.test(root) || + /(^|\/)tmp\//.test(root) || + /\/(artifacts|\.token-optimizer)\/(?:[^/]*\/)*(?:eval|scenario|study|fixture|debug)[^/]*\//.test(`${root}/`) + ); +} + +export function quarantineSharedSource(projectRoot) { + if (process.env.TOKEN_OPTIMIZER_ALLOW_EPHEMERAL_SHARED === '1') return false; + if (!isEphemeralProject(projectRoot)) return false; + // Isolated studies deliberately point the shared tier at scratch storage. + // Their findings may cross their own fixture projects, but cannot escape into + // the user's default machine-wide graph. Strict mode exists for a regression + // probe that verifies the guard without writing to a real user directory. + return ( + !process.env.TOKEN_OPTIMIZER_SHARED_DIR || + process.env.TOKEN_OPTIMIZER_STRICT_SHARED_PROVENANCE === '1' + ); +} + /** Claim text, normalised enough that the same lesson learned twice is one row. */ const claimFingerprint = (claim) => String(claim || '').toLowerCase().replace(/\s+/g, ' ').trim().slice(0, 300); @@ -72,6 +103,7 @@ function promoteToShared(finding, { projectRoot, sessionId, provenance, key }) { if (!projectRoot) return false; const root = canonicalPath(projectRoot); + if (quarantineSharedSource(root)) return false; const rootId = nodeId('file', root); // Same claim, already carried up from anywhere: keep the first. Two repos @@ -367,11 +399,21 @@ export function writeHarvested( && !node.retired && node.type === finding.type && claimFingerprint(node.claim) === fingerprint - && resolved.every((target) => currentGraph.edges.some((edge) => - edge.from === node.id && edge.edge === 'derived_from' && edge.to === target - )) ); if (duplicate) { + // Enrich, do not duplicate. A symbol extractor may learn how to resolve a + // previously file-only anchor (or a later session may add another real + // anchor) after the claim was first stored. Repeating the same semantic + // conclusion should connect that existing node to the newly available + // evidence, not create a second dashboard card with the same text. + for (const target of resolved) { + const alreadyLinked = currentGraph.edges.some((edge) => + edge.from === duplicate.id + && edge.edge === 'derived_from' + && edge.to === target + ); + if (!alreadyLinked) putEdge(dir, duplicate.id, 'derived_from', target); + } written.push(duplicate.key); continue; } @@ -437,7 +479,12 @@ export function writeHarvested( if ( id && !isSharedDir(dir) - && (finding.scope === undefined || finding.scope === 'organization' || finding.scope === 'global') + // Cross-project publication is explicit. `writeHarvested` stores an + // omitted scope as project-local above, so treating `undefined` as global + // here contradicted the stored record and promoted fixture/evaluation + // seeds into real user sessions. This was observed live as "learned in + // scenario" advice while working in AiDotNet. + && (finding.scope === 'organization' || finding.scope === 'global') ) { promoteToShared(finding, { projectRoot, sessionId, provenance, key }); } diff --git a/integrations/codex/plugin/hooks/lib/inject.mjs b/integrations/codex/plugin/hooks/lib/inject.mjs index b00494a01..c0aa2f1bf 100644 --- a/integrations/codex/plugin/hooks/lib/inject.mjs +++ b/integrations/codex/plugin/hooks/lib/inject.mjs @@ -31,6 +31,7 @@ import { canonicalPath, resolvableCandidates } from './paths.mjs'; import { annotatedSkeleton } from './skeleton.mjs'; import { substitutionBudget } from './metrics.mjs'; import { assessFindings } from './utility.mjs'; +import { quarantineSharedSource } from './harvest-write.mjs'; // Read per call for the same reason as the holdout fraction in metrics.mjs. const touchBudget = () => Number(process.env.TOKEN_OPTIMIZER_TOUCH_BUDGET) || 500; @@ -562,6 +563,7 @@ export function forRepeatedAct( const home = projectRoot ? canonicalPath(projectRoot) : null; for (const node of graph.nodes.values()) { if (node.kind !== 'finding' || node.retired || !node.claim) continue; + if (quarantineSharedSource(node.sourceProject)) continue; if (home && node.sourceProject && canonicalPath(node.sourceProject) === home) continue; const claimClasses = classesOf(node.claim, 'claim'); if (![...crossedClasses].some((c) => claimClasses.has(c))) continue; @@ -667,6 +669,7 @@ export function forSharedCommand( const candidates = []; for (const node of graph.nodes.values()) { if (node.kind !== 'finding' || node.retired) continue; + if (quarantineSharedSource(node.sourceProject)) continue; if (alreadyInjected.has(node.key)) continue; // ITS OWN LESSON IS NOT NEWS. A finding this project contributed is already // served by the local path; repeating it under a "from elsewhere" label @@ -1054,7 +1057,13 @@ export function standingRules(dir, graph, { budget = standingBudget(), episode = * informative response available -- more useful than the file, not a lossier * version of it. */ -export function substitutionFor(dir, graph, rawPath, source, { sessionId } = {}) { +export function substitutionFor( + dir, + graph, + rawPath, + source, + { sessionId, client = null, clientVersion = null, model = null, modelVersion = null } = {} +) { const filePath = canonicalPath(rawPath); const budget = substitutionBudget(dir, filePath); const built = annotatedSkeleton(graph, rawPath, source, { budget }); @@ -1089,6 +1098,10 @@ export function substitutionFor(dir, graph, rawPath, source, { sessionId } = {}) // on this machine turned out to be the enforcement suite's own fixture and // nothing distinguished them from real work. sessionId, + client, + clientVersion, + model, + modelVersion, holdout, tokens: holdout ? 0 : built.tokens, findings: built.findings, diff --git a/integrations/codex/plugin/hooks/lib/metrics.mjs b/integrations/codex/plugin/hooks/lib/metrics.mjs index 0bb08f1bc..09f822630 100644 --- a/integrations/codex/plugin/hooks/lib/metrics.mjs +++ b/integrations/codex/plugin/hooks/lib/metrics.mjs @@ -19,8 +19,15 @@ */ import { - appendFileSync, readFileSync, existsSync, mkdirSync, chmodSync, - statSync, openSync, readSync, closeSync, + appendFileSync, + readFileSync, + existsSync, + mkdirSync, + chmodSync, + statSync, + openSync, + readSync, + closeSync, } from 'node:fs'; import { join } from 'node:path'; import { createHash, randomBytes } from 'node:crypto'; @@ -86,6 +93,10 @@ export const BALANCE_KINDS = new Set([ 'keepwarm', ]); +/** Latest timestamp without passing an unbounded event log as function arguments. */ +export const latestEventTimestamp = (rows) => + rows.reduce((latest, event) => Math.max(latest, Number(event.at) || 0), 0); + /** * Causal records have their own bounded log. Tool outcomes are much rarer * than read/capture telemetry but more frequent than balance records; mixing @@ -130,7 +141,7 @@ export function inHoldout(anchorKey, now = Date.now()) { // other side than before. With nine holdout records in existence that costs // nothing, and stratification is a fresh draw either way. const digest = createHash('sha256').update(`${anchorKey}:${epoch}`).digest(); - return (digest[0] / 256) < fraction; + return digest[0] / 256 < fraction; } /** @@ -258,7 +269,8 @@ export function record(dir, event) { * It is also the right statistics. A saving measured months ago says little * about the code as it stands now. */ -const MAX_BYTES = Number(process.env.TOKEN_OPTIMIZER_METRICS_BYTES) || 2_000_000; +const MAX_BYTES = + Number(process.env.TOKEN_OPTIMIZER_METRICS_BYTES) || 2_000_000; const MAX_EVENTS = Number(process.env.TOKEN_OPTIMIZER_METRICS_WINDOW) || 5000; /** @@ -432,7 +444,10 @@ export function readBalance(dir) { // path for records written before ids existed; it is lossy by nature, which // is precisely why new records carry an id instead. const id = - e.id ?? [e.kind, e.at, e.anchor ?? '', e.sessionId ?? '', e.tokens ?? ''].join('|'); + e.id ?? + [e.kind, e.at, e.anchor ?? '', e.sessionId ?? '', e.tokens ?? ''].join( + '|' + ); if (seen.has(id)) continue; seen.add(id); out.push(e); @@ -493,16 +508,24 @@ export function recordToolOutcome(dir, outcome) { const evidence = readEvidence(dir); const anchor = String(outcome.anchor || '').slice(0, 120); const candidates = evidence - .filter((event) => event.kind === 'inject' && event.episodeId === outcome.episodeId) + .filter( + (event) => + event.kind === 'inject' && event.episodeId === outcome.episodeId + ) .filter((event) => { if (outcome.toolCallId && event.toolCallId) return String(event.toolCallId) === String(outcome.toolCallId); - return event.surface === outcome.surface && String(event.anchor || '').slice(0, 120) === anchor; + return ( + event.surface === outcome.surface && + String(event.anchor || '').slice(0, 120) === anchor + ); }) .sort((a, b) => (b.at || 0) - (a.at || 0)); const injection = candidates[0] || null; const joinMethod = injection - ? (outcome.toolCallId && injection.toolCallId ? 'tool-call-id' : 'episode-anchor') + ? outcome.toolCallId && injection.toolCallId + ? 'tool-call-id' + : 'episode-anchor' : 'none'; return record(dir, { @@ -542,12 +565,11 @@ export function isFixtureAnchor(anchor) { // code: it excluded ALL temp paths -- including a real project checked out // under /tmp -- and on macOS excluded nothing at all, because tmpdir() there // is /var/folders///T/ and did not match. - const underTemp = - new RegExp( - '[\\\\/](AppData[\\\\/]Local[\\\\/])?(Temp|tmp)[\\\\/]' + - '|[\\\\/]var[\\\\/]folders[\\\\/][^\\\\/]+[\\\\/][^\\\\/]+[\\\\/]T[\\\\/]', - 'i' - ).test(p); + const underTemp = new RegExp( + '[\\\\/](AppData[\\\\/]Local[\\\\/])?(Temp|tmp)[\\\\/]' + + '|[\\\\/]var[\\\\/]folders[\\\\/][^\\\\/]+[\\\\/][^\\\\/]+[\\\\/]T[\\\\/]', + 'i' + ).test(p); if (!underTemp) return false; // Only names this suite actually creates. A user's own scratch checkout under @@ -686,8 +708,12 @@ export function balanceSheet(dir) { // THE CONTROL ARM'S ACTUAL COST. `tokensFullFile` was recorded and read // by nothing, so the comparison the holdout exists for was not // computable from the report. - controlArmTokens: withheldSubs.reduce((sum, e) => sum + (e.tokensFullFile || 0), 0), - assumption: 'that the model would have read the file it explicitly requested', + controlArmTokens: withheldSubs.reduce( + (sum, e) => sum + (e.tokensFullFile || 0), + 0 + ), + assumption: + 'that the model would have read the file it explicitly requested', }, estimatedCausal: { what: 'findings injection, from the stratified holdout', @@ -702,7 +728,12 @@ export function balanceSheet(dir) { }; })(), }, - costs: { injection: injectCost, harvest: harvestCost, standing: standingCost, substitution: substitutionCost }, + costs: { + injection: injectCost, + harvest: harvestCost, + standing: standingCost, + substitution: substitutionCost, + }, waste, // Deliberately NOT a single number. The two benefit lines are known // differently; adding them would launder an assumption into a measurement. @@ -727,7 +758,9 @@ export function balanceSheet(dir) { /** Cheap path normalisation for grouping reads; not the identity canonicaliser. */ function canonicalKeyish(p) { - return String(p || '').replace(/\\/g, '/').toLowerCase(); + return String(p || '') + .replace(/\\/g, '/') + .toLowerCase(); } /** @@ -738,7 +771,43 @@ function canonicalKeyish(p) { * arms, multiplied by treated touches. Reporting it as a measured fact would be * the same overclaiming this project criticises competitors for. */ -function buildReport(events, balance) { +function buildReport(events, balance, sourceCoverage = {}) { + const substitutions = balance.filter( + (event) => event.kind === 'substitute' && !isFixtureAnchor(event.anchor) + ); + const servedSubstitutions = substitutions.filter((event) => !event.holdout); + const substitutionTokensSaved = servedSubstitutions.reduce( + (sum, event) => + sum + + (event.tokensNetAvoided ?? + Math.max( + 0, + Math.ceil((event.bytesAvoided || 0) / 4) - (event.tokens || 0) + )), + 0 + ); + const substitutionTokensReturned = servedSubstitutions.reduce( + (sum, event) => sum + (event.tokens || 0), + 0 + ); + const substitutionByClient = {}; + for (const event of servedSubstitutions) { + const name = event.client || 'Historical — client not recorded'; + const current = substitutionByClient[name] || { + substitutions: 0, + tokensReturned: 0, + tokensSaved: 0, + }; + current.substitutions += 1; + current.tokensReturned += event.tokens || 0; + current.tokensSaved += + event.tokensNetAvoided ?? + Math.max( + 0, + Math.ceil((event.bytesAvoided || 0) / 4) - (event.tokens || 0) + ); + substitutionByClient[name] = current; + } // COMMAND INJECTIONS ARE COUNTED SEPARATELY, not mixed into the balance. // @@ -762,13 +831,19 @@ function buildReport(events, balance) { // Report its cost independently so it cannot dilute the file-touch estimate. const isSessionStart = (e) => e.surface === 'session-start'; const sessionStartInjections = allInjections.filter(isSessionStart); - const commandInjections = allInjections.filter((e) => !isSessionStart(e) && isCommand(e)); - const injections = allInjections.filter((e) => !isSessionStart(e) && !isCommand(e)); + const commandInjections = allInjections.filter( + (e) => !isSessionStart(e) && isCommand(e) + ); + const injections = allInjections.filter( + (e) => !isSessionStart(e) && !isCommand(e) + ); const treated = injections.filter((e) => !e.holdout); const withheld = injections.filter((e) => e.holdout); const mean = (rows, field) => - rows.length ? rows.reduce((sum, r) => sum + (r[field] || 0), 0) / rows.length : 0; + rows.length + ? rows.reduce((sum, r) => sum + (r[field] || 0), 0) / rows.length + : 0; const injectedTokens = treated.reduce((sum, e) => sum + (e.tokens || 0), 0); const harvestTokens = balance @@ -819,7 +894,9 @@ function buildReport(events, balance) { const after = event.at ?? 0; const total = bucket.reduce( - (sum, r) => sum + ((r.at ?? 0) >= after ? (r.tokens || 0) : 0), 0); + (sum, r) => sum + ((r.at ?? 0) >= after ? r.tokens || 0 : 0), + 0 + ); // Split across the injections that share this key. Each touch is one // observation, so charging every one the full total would count the same @@ -828,52 +905,198 @@ function buildReport(events, balance) { }; const meanDownstream = (rows) => - rows.length ? rows.reduce((sum, r) => sum + downstreamOf(r), 0) / rows.length : 0; + rows.length + ? rows.reduce((sum, r) => sum + downstreamOf(r), 0) / rows.length + : 0; const treatedCost = meanDownstream(treated); const withheldCost = meanDownstream(withheld); const perTouchSaving = withheldCost - treatedCost; - const estimatedAvoided = Math.max(0, Math.round(perTouchSaving * treated.length)); + const estimatedAvoided = Math.max( + 0, + Math.round(perTouchSaving * treated.length) + ); + + const downstreamSamples = injections.filter((event) => { + if (event.downstream != null) return true; + const key = `${event.sessionId || ''}|${event.anchor}`; + return (reads.get(key) || []).some( + (read) => (read.at ?? 0) >= (event.at ?? 0) + ); + }).length; + const downstreamMeasured = downstreamSamples > 0; // Below this, arm means are noise and a ratio would be theatre. - const sufficient = treated.length >= 20 && withheld.length >= 5; + const sufficient = + downstreamMeasured && treated.length >= 20 && withheld.length >= 5; + + const coverageNumber = (value, fallback) => { + if (value === null || value === undefined || value === '') return fallback; + const number = Number(value); + return Number.isFinite(number) ? Math.max(0, number) : fallback; + }; + const projectCount = coverageNumber(sourceCoverage.projects, 1); + const telemetryProjects = coverageNumber( + sourceCoverage.projectsWithTelemetry, + 0 + ); + const balanceProjects = coverageNumber( + sourceCoverage.projectsWithBalanceEvents, + balance.length ? 1 : 0 + ); + const lastEventAt = Math.max( + latestEventTimestamp(events), + latestEventTimestamp(balance) + ); + const measured = + telemetryProjects > 0 || events.length > 0 || balance.length > 0; + const balanceMeasured = balanceProjects > 0 || balance.length > 0; + const metric = (status, source, samples, extra = {}) => ({ + status, + source, + samples, + ...extra, + }); return { + nativeOptimizer: { + substitutions: servedSubstitutions.length, + holdouts: substitutions.filter((event) => event.holdout).length, + tokensReturned: substitutionTokensReturned, + tokensSaved: substitutionTokensSaved, + byClient: substitutionByClient, + recent: servedSubstitutions + .slice() + .sort((a, b) => (b.at || 0) - (a.at || 0)) + .slice(0, 40) + .map((event) => { + const tokensSaved = + event.tokensNetAvoided ?? + Math.max( + 0, + Math.ceil((event.bytesAvoided || 0) / 4) - (event.tokens || 0) + ); + return { + name: 'live_graph_substitution', + client: event.client || 'Historical — client not recorded', + originalTokens: (event.tokens || 0) + tokensSaved, + optimizedTokens: event.tokens || 0, + tokensSaved, + savingsMeasured: false, + classification: 'modeled-counterfactual', + reportedTokensSaved: tokensSaved, + timestamp: event.at ? new Date(event.at).toISOString() : null, + }; + }), + source: + 'balance.jsonl: modeled full-file counterfactual minus annotated-skeleton tokens; excluded from verified MCP savings', + }, memoryDeliveries: allInjections.filter((event) => !event.holdout).length, memoryHoldouts: allInjections.filter((event) => event.holdout).length, deliveryTokens: allInjections .filter((event) => !event.holdout) .reduce( - (sum, event) => - sum + (event.deliveredTokens ?? event.tokens ?? 0), + (sum, event) => sum + (event.deliveredTokens ?? event.tokens ?? 0), 0 ), injections: treated.length, sessionStartInjections: sessionStartInjections.length, sessionStartInjectedTokens: sessionStartInjections.reduce( - (sum, event) => sum + (event.deliveredTokens ?? event.tokens ?? 0), 0 + (sum, event) => sum + (event.deliveredTokens ?? event.tokens ?? 0), + 0 ), commandInjections: commandInjections.length, commandHoldouts: commandInjections.filter((e) => e.holdout).length, holdouts: withheld.length, staleServed: injections.filter((e) => e.stale).length, - staleRate: injections.length ? injections.filter((e) => e.stale).length / injections.length : 0, + staleRate: injections.length + ? injections.filter((e) => e.stale).length / injections.length + : 0, injectedTokens, harvestTokens, estimatedTokensAvoided: sufficient ? estimatedAvoided : null, - netTokens: sufficient ? estimatedAvoided - injectedTokens - harvestTokens : null, + netTokens: sufficient + ? estimatedAvoided - injectedTokens - harvestTokens + : null, sufficientData: sufficient, verdict: !sufficient ? `insufficient data (${treated.length} treated, ${withheld.length} holdout; need 20 and 5)` : estimatedAvoided > injectedTokens + harvestTokens ? 'the graph is saving more than it costs' : 'the graph is NOT yet paying for itself', + measurement: { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + sourceCoverage: { + projects: projectCount, + projectsWithTelemetry: telemetryProjects || (measured ? 1 : 0), + projectsWithBalanceEvents: balanceProjects, + projectsWithoutTelemetry: Math.max( + 0, + projectCount - (telemetryProjects || (measured ? 1 : 0)) + ), + }, + freshness: { + lastEventAt: lastEventAt || null, + ageMs: lastEventAt ? Math.max(0, Date.now() - lastEventAt) : null, + status: !lastEventAt + ? 'not-measured' + : Date.now() - lastEventAt <= 7 * 86_400_000 + ? 'fresh' + : 'stale', + }, + metrics: { + nativeSubstitutions: metric( + balanceMeasured ? 'measured' : 'not-measured', + 'balance.jsonl: modeled full-file counterfactual minus annotated-skeleton tokens; not a materialized MCP before/after pair', + servedSubstitutions.length, + { classification: 'modeled-counterfactual' } + ), + memoryDeliveries: metric( + balanceMeasured ? 'measured' : 'not-measured', + 'balance.jsonl: inject events in the treated arm', + allInjections.filter((event) => !event.holdout).length + ), + memoryHoldouts: metric( + balanceMeasured ? 'measured' : 'not-measured', + 'balance.jsonl: inject events in the withheld arm', + allInjections.filter((event) => event.holdout).length + ), + rememberingCost: metric( + balanceMeasured ? 'measured' : 'not-measured', + 'balance.jsonl: delivered injection tokens plus semantic harvest tokens', + allInjections.length + + balance.filter((event) => event.kind === 'harvest').length + ), + readingAvoided: metric( + !downstreamMeasured + ? 'not-measured' + : sufficient + ? 'measured' + : 'collecting', + 'stratified file-touch holdout joined to downstream read events', + downstreamSamples, + { + treated: treated.length, + holdouts: withheld.length, + requiredTreated: 20, + requiredHoldouts: 5, + } + ), + }, + }, }; } export function report(dir) { - return buildReport(readAll(dir), readBalance(dir)); + const events = readAll(dir); + const balance = readBalance(dir); + return buildReport(events, balance, { + projects: 1, + projectsWithTelemetry: events.length || balance.length ? 1 : 0, + projectsWithBalanceEvents: balance.length ? 1 : 0, + }); } /** @@ -886,10 +1109,16 @@ export function report(dir) { */ export function reportMany(dirs) { const unique = [...new Set((dirs || []).map((dir) => String(dir)))]; - const report = buildReport( - unique.flatMap((dir) => readAll(dir)), - unique.flatMap((dir) => readBalance(dir)) - ); + const eventSources = unique.map((dir) => readAll(dir)); + const balanceSources = unique.map((dir) => readBalance(dir)); + const report = buildReport(eventSources.flat(), balanceSources.flat(), { + projects: unique.length, + projectsWithTelemetry: eventSources.filter( + (events, index) => events.length || balanceSources[index].length + ).length, + projectsWithBalanceEvents: balanceSources.filter((events) => events.length) + .length, + }); return { ...report, projects: unique.length }; } @@ -907,7 +1136,11 @@ export function reportMany(dirs) { * costs almost nothing. Without a control arm there is no way to tell those two * apart, which is why nobody else can do this. */ -export function substitutionBudget(dir, anchor, { floor = 300, base = 1200, ceiling = 3000 } = {}) { +export function substitutionBudget( + dir, + anchor, + { floor = 300, base = 1200, ceiling = 3000 } = {} +) { const events = readAll(dir); const mine = events.filter((e) => e.kind === 'inject' && e.anchor === anchor); @@ -932,13 +1165,17 @@ export function substitutionBudget(dir, anchor, { floor = 300, base = 1200, ceil for (const row of rows) { const bucket = reads.get(row.sessionId || '') || []; const after = row.at ?? 0; - total += bucket.reduce((sum, r) => sum + ((r.at ?? 0) >= after ? (r.tokens || 0) : 0), 0); + total += bucket.reduce( + (sum, r) => sum + ((r.at ?? 0) >= after ? r.tokens || 0 : 0), + 0 + ); } return total / rows.length; }; const saved = downstream(withheld) - downstream(treated); - const spent = treated.reduce((sum, e) => sum + (e.tokens || 0), 0) / treated.length; + const spent = + treated.reduce((sum, e) => sum + (e.tokens || 0), 0) / treated.length; // Ratio of what annotating this file avoided to what annotating it cost. // Above 1 it is paying for itself and earns room; below, it shrinks. @@ -959,7 +1196,10 @@ export function substitutionBudget(dir, anchor, { floor = 300, base = 1200, ceil * graph whose index leads to queries grows its allowance; a noisy one shrinks * back toward the floor. Nobody configures it, and it cannot run away. */ -export function indexBudget(dir, { floor = 150, base = 300, ceiling = 1200 } = {}) { +export function indexBudget( + dir, + { floor = 150, base = 300, ceiling = 1200 } = {} +) { const events = readAll(dir); const listed = events.filter((e) => e.kind === 'index').length; const queries = events.filter((e) => e.kind === 'query').length; @@ -968,7 +1208,9 @@ export function indexBudget(dir, { floor = 150, base = 300, ceiling = 1200 } = { const hitRate = queries / listed; // 0% hit rate falls to the floor; ~50% and above reaches the ceiling. - const scaled = Math.round(floor + (ceiling - floor) * Math.min(1, hitRate * 2)); + const scaled = Math.round( + floor + (ceiling - floor) * Math.min(1, hitRate * 2) + ); return Math.max(floor, Math.min(ceiling, scaled)); } @@ -1005,7 +1247,10 @@ function seededRandom(seedText) { } /** Percentile bootstrap interval for a mean, paired delta, or other scalar. */ -export function bootstrapMeanInterval(values, { samples = 1000, seed = 'token-optimizer' } = {}) { +export function bootstrapMeanInterval( + values, + { samples = 1000, seed = 'token-optimizer' } = {} +) { const finite = values.map(numeric).filter((value) => value !== null); if (!finite.length) return { mean: null, low: null, high: null, n: 0 }; if (finite.length === 1) { @@ -1037,24 +1282,32 @@ export function proportionInterval(successes, total, z = 1.96) { const denominator = 1 + (z * z) / total; const centre = (rate + (z * z) / (2 * total)) / denominator; const radius = - (z / denominator) * Math.sqrt((rate * (1 - rate)) / total + (z * z) / (4 * total * total)); - return { rate, low: Math.max(0, centre - radius), high: Math.min(1, centre + radius), n: total }; + (z / denominator) * + Math.sqrt((rate * (1 - rate)) / total + (z * z) / (4 * total * total)); + return { + rate, + low: Math.max(0, centre - radius), + high: Math.min(1, centre + radius), + n: total, + }; } -const cohortKey = (run) => [ - run.client || 'unknown', - run.clientVersion || 'unknown', - run.model || 'unknown', - run.modelVersion || 'unknown', - run.taskId || 'unknown', -].join('|'); +const cohortKey = (run) => + [ + run.client || 'unknown', + run.clientVersion || 'unknown', + run.model || 'unknown', + run.modelVersion || 'unknown', + run.taskId || 'unknown', + ].join('|'); function armMetrics(runs) { const correct = runs.filter((run) => run.correct === true).length; - const interval = (field) => bootstrapMeanInterval( - runs.map((run) => run[field]).filter((value) => numeric(value) !== null), - { seed: field } - ); + const interval = (field) => + bootstrapMeanInterval( + runs.map((run) => run[field]).filter((value) => numeric(value) !== null), + { seed: field } + ); return { runs: runs.length, correctness: proportionInterval(correct, runs.length), @@ -1068,29 +1321,40 @@ function armMetrics(runs) { latencyMs: interval('latencyMs'), costUsd: interval('costUsd'), injectedTokens: interval('injectedTokens'), - harmfulFindings: runs.reduce((sum, run) => sum + (numeric(run.harmfulFindings) || 0), 0), + harmfulFindings: runs.reduce( + (sum, run) => sum + (numeric(run.harmfulFindings) || 0), + 0 + ), }; } -function pairedEffects(runs, controlArm, treatmentArm, contrastType = 'incremental') { +function pairedEffects( + runs, + controlArm, + treatmentArm, + contrastType = 'incremental' +) { const pairs = new Map(); for (const run of runs) { if (!run.pairId || ![controlArm, treatmentArm].includes(run.arm)) continue; if (!pairs.has(run.pairId)) pairs.set(run.pairId, {}); pairs.get(run.pairId)[run.arm] = run; } - const complete = [...pairs.values()].filter((pair) => pair[controlArm] && pair[treatmentArm]); - const delta = (field, lowerIsBetter = true) => bootstrapMeanInterval( - complete - .map((pair) => { - const baseline = numeric(pair[controlArm][field]); - const treatment = numeric(pair[treatmentArm][field]); - if (baseline === null || treatment === null) return null; - return lowerIsBetter ? baseline - treatment : treatment - baseline; - }) - .filter((value) => value !== null), - { seed: `${controlArm}:${treatmentArm}:${field}` } + const complete = [...pairs.values()].filter( + (pair) => pair[controlArm] && pair[treatmentArm] ); + const delta = (field, lowerIsBetter = true) => + bootstrapMeanInterval( + complete + .map((pair) => { + const baseline = numeric(pair[controlArm][field]); + const treatment = numeric(pair[treatmentArm][field]); + if (baseline === null || treatment === null) return null; + return lowerIsBetter ? baseline - treatment : treatment - baseline; + }) + .filter((value) => value !== null), + { seed: `${controlArm}:${treatmentArm}:${field}` } + ); return { arm: treatmentArm, controlArm, @@ -1111,21 +1375,32 @@ function pairedEffects(runs, controlArm, treatmentArm, contrastType = 'increment } function matchesFilters(event, filters) { - for (const field of ['client', 'clientVersion', 'model', 'modelVersion', 'taskId', 'arm']) { + for (const field of [ + 'client', + 'clientVersion', + 'model', + 'modelVersion', + 'taskId', + 'arm', + ]) { if (!filters[field]) continue; let actual = event[field]; if (event.kind === 'handoff-run') { - actual = field === 'taskId' - ? event.scenarioId - : field === 'arm' - ? event.arm - : (event.consumer?.[field] ?? event.producer?.[field] ?? event[field]); + actual = + field === 'taskId' + ? event.scenarioId + : field === 'arm' + ? event.arm + : (event.consumer?.[field] ?? + event.producer?.[field] ?? + event[field]); } else if (event.kind === 'concurrency-run') { - actual = field === 'taskId' - ? 'concurrent-combined' - : field === 'arm' - ? event.arm - : (event.consumer?.[field] ?? event[field]); + actual = + field === 'taskId' + ? 'concurrent-combined' + : field === 'arm' + ? event.arm + : (event.consumer?.[field] ?? event[field]); } if (String(actual || '') !== String(filters[field])) return false; } @@ -1134,11 +1409,15 @@ function matchesFilters(event, filters) { function handoffArmMetrics(runs) { const consumers = runs.map((run) => run.consumer || {}); - const count = (field) => consumers.filter((consumer) => consumer[field] === true).length; - const interval = (field) => bootstrapMeanInterval( - consumers.map((consumer) => consumer[field]).filter((value) => numeric(value) !== null), - { seed: `handoff:${field}` } - ); + const count = (field) => + consumers.filter((consumer) => consumer[field] === true).length; + const interval = (field) => + bootstrapMeanInterval( + consumers + .map((consumer) => consumer[field]) + .filter((value) => numeric(value) !== null), + { seed: `handoff:${field}` } + ); return { runs: runs.length, delivery: proportionInterval( @@ -1147,7 +1426,10 @@ function handoffArmMetrics(runs) { ), correctness: proportionInterval(count('correct'), runs.length), firstPass: proportionInterval(count('firstPass'), runs.length), - mistakeAttempted: proportionInterval(count('mistakeAttempted'), runs.length), + mistakeAttempted: proportionInterval( + count('mistakeAttempted'), + runs.length + ), mistakeExecuted: proportionInterval(count('mistakeExecuted'), runs.length), totalTokens: interval('totalTokens'), toolCalls: interval('toolCalls'), @@ -1163,16 +1445,21 @@ function handoffPairedEffect(runs, controlArm, treatmentArm) { if (!pairs.has(run.pairId)) pairs.set(run.pairId, {}); pairs.get(run.pairId)[run.arm] = run; } - const complete = [...pairs.values()].filter((pair) => pair[controlArm] && pair[treatmentArm]); - const effect = (field, positiveWhenLower = true) => bootstrapMeanInterval( - complete.map((pair) => { - const control = numeric(pair[controlArm].consumer?.[field]); - const treatment = numeric(pair[treatmentArm].consumer?.[field]); - if (control === null || treatment === null) return null; - return positiveWhenLower ? control - treatment : treatment - control; - }).filter((value) => value !== null), - { seed: `handoff:${controlArm}:${treatmentArm}:${field}` } + const complete = [...pairs.values()].filter( + (pair) => pair[controlArm] && pair[treatmentArm] ); + const effect = (field, positiveWhenLower = true) => + bootstrapMeanInterval( + complete + .map((pair) => { + const control = numeric(pair[controlArm].consumer?.[field]); + const treatment = numeric(pair[treatmentArm].consumer?.[field]); + if (control === null || treatment === null) return null; + return positiveWhenLower ? control - treatment : treatment - control; + }) + .filter((value) => value !== null), + { seed: `handoff:${controlArm}:${treatmentArm}:${field}` } + ); return { comparison: `${treatmentArm} vs ${controlArm}`, pairs: complete.length, @@ -1187,13 +1474,22 @@ function handoffPairedEffect(runs, controlArm, treatmentArm) { }; } -const handoffKey = (run) => [ - run.producer?.client || 'unknown', run.producer?.model || 'unknown', - run.consumer?.client || 'unknown', run.consumer?.model || 'unknown', - run.scenarioId || 'unknown', -].join('|'); - -const HANDOFF_REPORT_ARMS = ['empty', 'natural', 'oracle', 'irrelevant', 'stale']; +const handoffKey = (run) => + [ + run.producer?.client || 'unknown', + run.producer?.model || 'unknown', + run.consumer?.client || 'unknown', + run.consumer?.model || 'unknown', + run.scenarioId || 'unknown', + ].join('|'); + +const HANDOFF_REPORT_ARMS = [ + 'empty', + 'natural', + 'oracle', + 'irrelevant', + 'stale', +]; const handoffMinimumPairs = () => Math.max(2, Number(process.env.TOKEN_OPTIMIZER_HANDOFF_MIN_PAIRS) || 10); @@ -1206,43 +1502,54 @@ function handoffCohorts(runs) { } const minimumPairs = handoffMinimumPairs(); return [...grouped.entries()].map(([key, rows]) => { - const arms = Object.fromEntries(HANDOFF_REPORT_ARMS.map((arm) => [ - arm, handoffArmMetrics(rows.filter((row) => row.arm === arm)), - ])); + const arms = Object.fromEntries( + HANDOFF_REPORT_ARMS.map((arm) => [ + arm, + handoffArmMetrics(rows.filter((row) => row.arm === arm)), + ]) + ); const naturalRows = rows.filter((row) => row.arm === 'natural'); const naturalVsEmpty = handoffPairedEffect(rows, 'empty', 'natural'); const naturalVsOracle = handoffPairedEffect(rows, 'oracle', 'natural'); const captureRate = naturalRows.length - ? naturalRows.filter((row) => row.producer?.captureSuccess).length / naturalRows.length + ? naturalRows.filter((row) => row.producer?.captureSuccess).length / + naturalRows.length : null; const preActionDeliveryRate = naturalRows.length - ? naturalRows.filter((row) => row.delivery?.beforeFirstExecutedMistake).length / naturalRows.length + ? naturalRows.filter((row) => row.delivery?.beforeFirstExecutedMistake) + .length / naturalRows.length : null; const emptyRecurrence = arms.empty.mistakeExecuted.rate; const naturalRecurrence = arms.natural.mistakeExecuted.rate; - const relativeRecurrenceReduction = emptyRecurrence !== null - && naturalRecurrence !== null - && emptyRecurrence !== 0 - ? (emptyRecurrence - naturalRecurrence) / emptyRecurrence - : null; - const controlsSafe = ['irrelevant', 'stale'].every((arm) => - arms[arm].correctness.rate !== null - && arms.empty.correctness.rate !== null - && arms[arm].correctness.rate >= arms.empty.correctness.rate - 0.1 + const relativeRecurrenceReduction = + emptyRecurrence !== null && + naturalRecurrence !== null && + emptyRecurrence !== 0 + ? (emptyRecurrence - naturalRecurrence) / emptyRecurrence + : null; + const controlsSafe = ['irrelevant', 'stale'].every( + (arm) => + arms[arm].correctness.rate !== null && + arms.empty.correctness.rate !== null && + arms[arm].correctness.rate >= arms.empty.correctness.rate - 0.1 ); - const irrelevantSuppressed = arms.irrelevant.delivery.rate !== null - && arms.irrelevant.delivery.rate === 0; + const irrelevantSuppressed = + arms.irrelevant.delivery.rate !== null && + arms.irrelevant.delivery.rate === 0; const gates = { minimumPairs: naturalVsEmpty.pairs >= minimumPairs, capture: captureRate !== null && captureRate >= 0.8, recurrenceMagnitude: - relativeRecurrenceReduction !== null && relativeRecurrenceReduction >= 0.5, - recurrenceInterval: (naturalVsEmpty.executedMistakesPrevented.low ?? -Infinity) > 0, + relativeRecurrenceReduction !== null && + relativeRecurrenceReduction >= 0.5, + recurrenceInterval: + (naturalVsEmpty.executedMistakesPrevented.low ?? -Infinity) > 0, correctness: - arms.natural.correctness.rate !== null - && arms.empty.correctness.rate !== null - && arms.natural.correctness.rate >= arms.empty.correctness.rate - 0.1, - preActionDelivery: preActionDeliveryRate !== null && preActionDeliveryRate >= 0.8, + arms.natural.correctness.rate !== null && + arms.empty.correctness.rate !== null && + arms.natural.correctness.rate >= arms.empty.correctness.rate - 0.1, + preActionDelivery: + preActionDeliveryRate !== null && preActionDeliveryRate >= 0.8, negativeControls: controlsSafe && irrelevantSuppressed, }; const claimReady = Object.values(gates).every(Boolean); @@ -1269,14 +1576,24 @@ function handoffCohorts(runs) { function concurrencySummary(runs) { const natural = runs.filter((run) => run.arm === 'natural'); const writers = natural.reduce((sum, run) => sum + (run.writerCount || 0), 0); - const captures = natural.reduce((sum, run) => sum + (run.captureSuccesses || 0), 0); - const integrityPasses = natural.filter((run) => - run.integrity?.zeroLoss - && run.integrity?.parseable - && run.integrity?.orphanedFindings === 0 + const captures = natural.reduce( + (sum, run) => sum + (run.captureSuccesses || 0), + 0 + ); + const integrityPasses = natural.filter( + (run) => + run.integrity?.zeroLoss && + run.integrity?.parseable && + run.integrity?.orphanedFindings === 0 ).length; - const delivered = natural.reduce((sum, run) => sum + (run.delivery?.delivered || 0), 0); - const expected = natural.reduce((sum, run) => sum + (run.delivery?.expected || 0), 0); + const delivered = natural.reduce( + (sum, run) => sum + (run.delivery?.delivered || 0), + 0 + ); + const expected = natural.reduce( + (sum, run) => sum + (run.delivery?.expected || 0), + 0 + ); const effect = handoffPairedEffect(runs, 'empty', 'natural'); return { runs: runs.length, @@ -1286,7 +1603,8 @@ function concurrencySummary(runs) { integrityPassRate: natural.length ? integrityPasses / natural.length : null, deliveryCoverage: expected ? delivered / expected : null, naturalCorrectness: proportionInterval( - natural.filter((run) => run.consumer?.correct).length, natural.length + natural.filter((run) => run.consumer?.correct).length, + natural.length ), effect, }; @@ -1297,17 +1615,26 @@ function concurrencySummary(runs) { * live hook traces, and randomized model evals remain visibly distinct: only * `eval-run` records with paired arms can produce a causal effect interval. */ -function buildEvidenceReport(evidence, { filters = {}, episodeLimit = 100 } = {}) { +function buildEvidenceReport( + evidence, + { filters = {}, episodeLimit = 100 } = {} +) { evidence = evidence.filter((event) => matchesFilters(event, filters)); const runs = evidence.filter((event) => event.kind === 'eval-run'); const handoffRuns = evidence.filter((event) => event.kind === 'handoff-run'); - const concurrencyRuns = evidence.filter((event) => event.kind === 'concurrency-run'); + const concurrencyRuns = evidence.filter( + (event) => event.kind === 'concurrency-run' + ); const injections = evidence.filter((event) => event.kind === 'inject'); const outcomes = evidence.filter((event) => event.kind === 'tool-outcome'); - const feedback = evidence.filter((event) => event.kind === 'finding-feedback'); + const feedback = evidence.filter( + (event) => event.kind === 'finding-feedback' + ); const byInjection = new Map( - outcomes.filter((event) => event.injectionId).map((event) => [event.injectionId, event]) + outcomes + .filter((event) => event.injectionId) + .map((event) => [event.injectionId, event]) ); const traced = injections .slice() @@ -1337,7 +1664,10 @@ function buildEvidenceReport(evidence, { filters = {}, episodeLimit = 100 } = {} grouped.get(key).push(run); } - const minimumPairs = Math.max(2, Number(process.env.TOKEN_OPTIMIZER_EVAL_MIN_PAIRS) || 5); + const minimumPairs = Math.max( + 2, + Number(process.env.TOKEN_OPTIMIZER_EVAL_MIN_PAIRS) || 5 + ); const cohorts = [...grouped.entries()].map(([key, cohortRuns]) => { const arms = Object.fromEntries( ['baseline', 'optimizer', 'retrieval', 'full'].map((arm) => [ @@ -1387,7 +1717,9 @@ function buildEvidenceReport(evidence, { filters = {}, episodeLimit = 100 } = {} cohorts: cohorts.length, harmfulFeedback: harmful, harmRate: feedback.length ? harmful / feedback.length : null, - evidenceStatus: cohorts.some((cohort) => cohort.evidenceStatus === 'causal estimate available') + evidenceStatus: cohorts.some( + (cohort) => cohort.evidenceStatus === 'causal estimate available' + ) ? 'causal estimates available' : 'insufficient randomized evidence', }, @@ -1396,7 +1728,8 @@ function buildEvidenceReport(evidence, { filters = {}, episodeLimit = 100 } = {} concurrency, episodes: traced, methodology: { - intervals: 'deterministic percentile bootstrap (95%); Wilson interval for correctness', + intervals: + 'deterministic percentile bootstrap (95%); Wilson interval for correctness', causalRule: 'matched pairs estimate optimizer vs baseline, retrieval vs optimizer, full vs retrieval, and full vs baseline', minimumPairs, diff --git a/integrations/codex/plugin/hooks/lib/observability.mjs b/integrations/codex/plugin/hooks/lib/observability.mjs index b9980076e..ef1c037af 100644 --- a/integrations/codex/plugin/hooks/lib/observability.mjs +++ b/integrations/codex/plugin/hooks/lib/observability.mjs @@ -26,7 +26,7 @@ import { createHash, randomUUID } from 'node:crypto'; import { homedir } from 'node:os'; import { join } from 'node:path'; -export const HOOK_LOG_SCHEMA_VERSION = 1; +export const HOOK_LOG_SCHEMA_VERSION = 2; const DEFAULT_MAX_BYTES = 5 * 1024 * 1024; const DEFAULT_RETENTION_DAYS = 14; const DEFAULT_MAX_FILES = 40; @@ -197,6 +197,8 @@ export function beginHookInvocation(client, event, options = {}) { turnIdHash: null, toolUseIdHash: null, toolName: null, + rawToolName: null, + codeModeEnvelope: false, clientVersion: null, model: null, cwdHash: null, @@ -208,12 +210,29 @@ export function beginHookInvocation(client, event, options = {}) { const spanId = hash(invocationId, 16); let traceId = hash(invocationId, 32); + // Write the start before reading stdin or importing project state. If the + // host kills the process, Node aborts, or a native call terminates without a + // JavaScript exception, diagnostics can now reconcile this record with the + // missing completion instead of reporting a false success rate. + writeHookEvent({ + level: 'info', + event: 'hook.started', + invocationId, + traceId, + spanId, + client, + hookEvent: event, + processId: process.pid, + }); + let deadline; const finish = (nextOutcome = outcome, nextReason = reason, nextError = error) => { if (ended) return; ended = true; if (deadline) clearTimeout(deadline); process.removeListener('exit', onExit); + process.removeListener('uncaughtException', onUncaughtException); + process.removeListener('unhandledRejection', onUnhandledRejection); writeHookEvent({ level: nextOutcome === 'failure' || nextOutcome === 'timeout' ? 'error' : 'info', event: 'hook.completed', @@ -231,7 +250,17 @@ export function beginHookInvocation(client, event, options = {}) { }); }; const onExit = () => finish(); + const onUncaughtException = (nextError) => { + finish('failure', 'uncaught_exception', nextError); + process.exit(0); + }; + const onUnhandledRejection = (nextError) => { + finish('failure', 'unhandled_rejection', nextError); + process.exit(0); + }; process.once('exit', onExit); + process.once('uncaughtException', onUncaughtException); + process.once('unhandledRejection', onUnhandledRejection); deadline = setTimeout(() => { outcome = 'timeout'; @@ -250,6 +279,11 @@ export function beginHookInvocation(client, event, options = {}) { dimensions.turnIdHash = hash(raw.turn_id ?? raw.turnId ?? null); dimensions.toolUseIdHash = hash(raw.tool_use_id ?? raw.toolUseId ?? null); dimensions.toolName = payload?.tool_name ?? raw.tool_name ?? raw.toolName ?? null; + dimensions.rawToolName = dimension( + raw.tool_name ?? raw.toolName ?? raw.tool ?? null + ); + dimensions.codeModeEnvelope = + payload?.tool_input?.code_mode_envelope === true; dimensions.clientVersion = dimension(raw.client_version ?? raw.clientVersion ?? null); dimensions.model = dimension(raw.model ?? raw.model_name ?? raw.modelName ?? null); dimensions.cwdHash = hash(raw.cwd ?? raw.working_directory ?? process.cwd()); @@ -291,6 +325,11 @@ export function beginHookInvocation(client, event, options = {}) { error = nextError; finish(outcome, reason, error); }, + block(nextReason = 'policy_denied') { + outcome = 'blocked'; + reason = nextReason; + finish(outcome, reason); + }, succeed(nextReason = 'completed') { outcome = 'success'; reason = nextReason; @@ -321,7 +360,11 @@ export function recordHookBootstrapFailure(client, event, error) { }); } -export function readHookEvents({ limit = 200, sinceMs = 24 * 60 * 60 * 1000 } = {}) { +export function readHookEvents({ + limit = 200, + sinceMs = 24 * 60 * 60 * 1000, + includeLifecycle = false, +} = {}) { try { const dir = hookLogDirectory(); if (!existsSync(dir)) return []; @@ -338,6 +381,7 @@ export function readHookEvents({ limit = 200, sinceMs = 24 * 60 * 60 * 1000 } = try { const event = JSON.parse(line); if (Date.parse(event.timestamp) < cutoff) continue; + if (!includeLifecycle && event.event === 'hook.started') continue; events.push(event); if (events.length >= limit) return events; } catch { @@ -353,38 +397,102 @@ export function readHookEvents({ limit = 200, sinceMs = 24 * 60 * 60 * 1000 } = export function hookHealthSummary(options = {}) { const sinceMs = options.sinceMs ?? 24 * 60 * 60 * 1000; - const events = readHookEvents({ limit: options.limit || 5000, sinceMs }); - const durations = events + const events = readHookEvents({ + limit: options.limit || 10000, + sinceMs, + includeLifecycle: true, + }); + const starts = events.filter((event) => event.event === 'hook.started'); + const completed = events.filter( + (event) => event.event === 'hook.completed' || !event.event + ); + const completedIds = new Set( + completed.map((event) => event.invocationId).filter(Boolean) + ); + const abandoned = starts + .filter( + (event) => + event.invocationId && + !completedIds.has(event.invocationId) && + Date.now() - Date.parse(event.timestamp) > 10_000 + ) + .map((event) => ({ + ...event, + event: 'hook.completed', + outcome: 'failure', + reason: 'process_terminated_before_completion', + durationMs: null, + })); + const runs = [...completed, ...abandoned]; + const durations = runs .map((event) => Number(event.durationMs)) .filter(Number.isFinite) .sort((a, b) => a - b); - const failures = events.filter((event) => event.outcome === 'failure'); - const timeouts = events.filter((event) => event.outcome === 'timeout'); - const skipped = events.filter((event) => event.outcome === 'skipped'); - const successes = events.filter((event) => event.outcome === 'success'); + const failures = runs.filter((event) => event.outcome === 'failure'); + const timeouts = runs.filter((event) => event.outcome === 'timeout'); + const skipped = runs.filter((event) => event.outcome === 'skipped'); + const blocked = runs.filter((event) => event.outcome === 'blocked'); + const successes = runs.filter((event) => event.outcome === 'success'); + const actions = runs.filter( + (event) => event.hookEvent === 'pre-tool' && event.toolName + ); + const byTool = {}; + for (const event of actions) { + const name = String(event.toolName || 'Unknown action'); + byTool[name] = (byTool[name] || 0) + 1; + } const byClient = {}; - for (const event of events) { + for (const event of runs) { const key = event.client || 'unknown'; - const current = byClient[key] || { total: 0, failures: 0, timeouts: 0, skipped: 0 }; + const current = byClient[key] || { + total: 0, + failures: 0, + timeouts: 0, + skipped: 0, + blocked: 0, + hookEvents: [], + }; current.total++; if (event.outcome === 'failure') current.failures++; if (event.outcome === 'timeout') current.timeouts++; if (event.outcome === 'skipped') current.skipped++; + if (event.outcome === 'blocked') current.blocked++; + if (event.hookEvent && !current.hookEvents.includes(event.hookEvent)) + current.hookEvents.push(event.hookEvent); byClient[key] = current; } + for (const current of Object.values(byClient)) current.hookEvents.sort(); const percentile = (p) => durations.length ? durations[Math.min(durations.length - 1, Math.floor(durations.length * p))] : null; const summary = { schemaVersion: HOOK_LOG_SCHEMA_VERSION, - available: events.length > 0, + available: runs.length > 0, windowHours: sinceMs / 3_600_000, - total: events.length, + total: runs.length, failures: failures.length, timeouts: timeouts.length, skipped: skipped.length, - successRate: events.length ? successes.length / events.length : null, + blocked: blocked.length, + abandoned: abandoned.length, + successes: successes.length, + actions: actions.length, + byTool, + // A skipped or policy-blocked hook completed its protocol successfully. + // Runtime health answers whether the hook process worked, not whether the + // optimizer chose to allow the host operation. + successRate: runs.length + ? (successes.length + skipped.length + blocked.length) / runs.length + : null, + healthStatus: + failures.length || timeouts.length + ? 'failing' + : skipped.length + ? 'degraded' + : runs.length + ? 'healthy' + : 'unavailable', p50DurationMs: percentile(0.5), p95DurationMs: percentile(0.95), byClient, diff --git a/integrations/codex/plugin/hooks/lib/pricing.mjs b/integrations/codex/plugin/hooks/lib/pricing.mjs index a4b09ebed..50b23b9cc 100644 --- a/integrations/codex/plugin/hooks/lib/pricing.mjs +++ b/integrations/codex/plugin/hooks/lib/pricing.mjs @@ -1,101 +1,72 @@ // GENERATED FILE -- do not edit. // Source of truth: hooks-core/pricing.mjs. Regenerate with `npm run sync:hooks`. /** - * Tokens in the unit people actually feel. + * Optional cost equivalents for token measurements. * - * The same data as everywhere else in this project, converted once, in one - * place, under three rules that keep a dollar figure honest: - * - * THE TABLE IS VISIBLE AND OVERRIDABLE. Prices change. A figure computed from - * rates buried in code goes stale silently and keeps looking authoritative, - * which is worse than no figure. The rates are printed next to the number and - * can be replaced without a release. - * - * DOLLARS BESIDE TOKENS, NOT INSTEAD OF THEM. The token count is exact and - * never goes out of date; the dollar figure is the token count times a rate - * that will. Showing both means the durable number survives the perishable - * one. - * - * NEVER A PRICE ON AN UNMEASURED SAVING. An invented number is worse in - * dollars than in tokens, because dollars get quoted to other people. A - * saving we cannot measure returns null and renders as "not yet measurable", - * never as "$0.00" -- which reads as "saved nothing" and is a different - * claim entirely. + * The runtime cannot infer a user's bill from a client name. A CLI may route + * through a subscription, direct API account, cloud marketplace, enterprise + * agreement, included credits, or a provider whose cache-read/write prices + * differ. Provider usage records are the source of truth when available. + * Everywhere else this module fails closed until the user supplies an + * effective blended input-token rate. */ -/** Cache multipliers, relative to an input token. */ -const CACHE_WRITE = 1.25; -const CACHE_READ = 0.1; +const RATE_ENV = 'TOKEN_OPTIMIZER_EFFECTIVE_INPUT_USD_PER_MILLION'; -/** - * Dollars per million tokens, by tier. - * - * Approximate published list rates as of 2026-07, carried with their date so a - * stale table is visible rather than silent. Override wholesale with - * TOKEN_OPTIMIZER_PRICES as JSON, e.g. - * {"asOf":"2026-09","opus":{"input":15,"output":75}}. - */ -const DEFAULT_PRICES = { - asOf: '2026-07', - haiku: { input: 1, output: 5 }, - sonnet: { input: 3, output: 15 }, - opus: { input: 15, output: 75 }, -}; - -export function prices() { - const raw = process.env.TOKEN_OPTIMIZER_PRICES; - if (raw) { - try { - const parsed = JSON.parse(raw); - if (parsed && typeof parsed === 'object') return { ...DEFAULT_PRICES, ...parsed, overridden: true }; - } catch { /* an unparseable override is ignored rather than fatal */ } - } - return DEFAULT_PRICES; +/** The configured effective input rate, or null when billing is unobservable. */ +export function effectiveRate() { + const rate = Number(process.env[RATE_ENV]); + return Number.isFinite(rate) && rate > 0 ? rate : null; } /** - * What a number of tokens costs. + * Dollars for a measured token quantity. * - * Returns null for a null input and says why: this is the single place the - * "unmeasured must not become $0.00" rule is enforced, so every caller inherits - * it rather than each remembering. + * `kind` is accepted for backwards compatibility but is not multiplied by a + * universal cache constant. The configured rate must already reflect the + * user's actual provider/model/cache/tier/credit mix. */ -export function dollars(tokens, { tier = 'opus', kind = 'input' } = {}) { - if (!Number.isFinite(tokens)) return null; - - const table = prices(); - const rate = table[tier]?.[kind === 'output' ? 'output' : 'input']; - if (!Number.isFinite(rate)) return null; - - const multiplier = kind === 'cacheWrite' ? CACHE_WRITE : kind === 'cacheRead' ? CACHE_READ : 1; - return (tokens / 1_000_000) * rate * multiplier; +export function dollars(tokens, { rate = effectiveRate() } = {}) { + if (!Number.isFinite(tokens) || !Number.isFinite(rate) || rate <= 0) { + return null; + } + return (tokens / 1_000_000) * rate; } /** A dollar figure, or an honest absence of one. */ -export function money(amount, { unmeasured = 'not yet measurable' } = {}) { +export function money(amount, { unmeasured = 'not priced' } = {}) { if (amount == null || !Number.isFinite(amount)) return unmeasured; if (amount === 0) return '$0.00'; if (Math.abs(amount) < 0.01) return '<$0.01'; return `$${amount.toFixed(2)}`; } -/** - * Per-session tokens expressed as a monthly cost. - * - * `sessionsPerMonth` is an assumption, so it is stated in the output rather - * than folded invisibly into the number. - */ -export function monthly(tokensPerSession, { tier = 'opus', sessionsPerMonth = 60 } = {}) { +/** Per-session tokens expressed with a stated monthly-frequency assumption. */ +export function monthly(tokensPerSession, { sessionsPerMonth = 60 } = {}) { if (!Number.isFinite(tokensPerSession)) return null; - const amount = dollars(tokensPerSession * sessionsPerMonth, { tier }); - return amount == null ? null : { amount, sessionsPerMonth, tier }; + const amount = dollars(tokensPerSession * sessionsPerMonth); + return amount == null + ? null + : { + amount, + sessionsPerMonth, + effectiveInputUsdPerMillion: effectiveRate(), + }; +} + +/** The pricing basis printed beside any cost equivalent. */ +export function priceNote() { + const rate = effectiveRate(); + return rate == null + ? `cost not priced: set ${RATE_ENV} to your effective blended input rate after provider, model, cache, tier, plan, and credits` + : `cost equivalent: configured effective input rate $${rate}/M via ${RATE_ENV}`; } -/** The rates behind the figures, for printing beside them. */ -export function priceNote(tier = 'opus') { - const table = prices(); - const rate = table[tier]; - if (!rate) return null; - return `prices: ${tier} $${rate.input}/$${rate.output} per Mtok (${table.asOf}` + - `${table.overridden ? ', overridden' : ''}); override with TOKEN_OPTIMIZER_PRICES`; +/** Compatibility surface: no built-in provider table is claimed. */ +export function prices() { + return { + source: + effectiveRate() == null ? 'unavailable' : 'configured-effective-rate', + effectiveInputUsdPerMillion: effectiveRate(), + }; } diff --git a/integrations/codex/plugin/hooks/lib/routing.mjs b/integrations/codex/plugin/hooks/lib/routing.mjs index 9b4642e0e..84ac866e4 100644 --- a/integrations/codex/plugin/hooks/lib/routing.mjs +++ b/integrations/codex/plugin/hooks/lib/routing.mjs @@ -65,7 +65,9 @@ export function modelCosts() { if (raw) { try { return JSON.parse(raw); - } catch { /* fall through to the defaults */ } + } catch { + /* fall through to the defaults */ + } } return { haiku: 1, sonnet: 3, opus: 15 }; } @@ -106,7 +108,12 @@ export function readEpisodes(path, { maxBytes = 6_000_000 } = {}) { let current = null; const close = () => { - if (current && current.turns > 0) episodes.push({ ...current, files: [...current.files], tools: [...current.tools] }); + if (current && current.turns > 0) + episodes.push({ + ...current, + files: [...current.files], + tools: [...current.tools], + }); current = null; }; @@ -123,7 +130,15 @@ export function readEpisodes(path, { maxBytes = 6_000_000 } = {}) { // a new episode. if (row.type === 'user' && typeof row.message?.content === 'string') { close(); - current = { at: Date.parse(row.timestamp) || null, models: new Set(), tools: new Set(), files: new Set(), turns: 0, errors: 0, results: 0 }; + current = { + at: Date.parse(row.timestamp) || null, + models: new Set(), + tools: new Set(), + files: new Set(), + turns: 0, + errors: 0, + results: 0, + }; continue; } if (!current) continue; @@ -157,8 +172,16 @@ export function readEpisodes(path, { maxBytes = 6_000_000 } = {}) { /* ---------------------------------------------------------------- SHAPES */ -const EDIT_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit', 'smart_edit', 'smart_write']); -const TEST_PATTERN = /\b(test|jest|pytest|vitest|dotnet test|npm test|go test)\b/i; +const EDIT_TOOLS = new Set([ + 'Edit', + 'MultiEdit', + 'Write', + 'NotebookEdit', + 'smart_edit', + 'smart_write', +]); +const TEST_PATTERN = + /\b(test|jest|pytest|vitest|dotnet test|npm test|go test)\b/i; /** * What kind of work this was. @@ -171,11 +194,19 @@ export function classifyShape(episode) { const tools = new Set(episode.tools || []); const edited = [...tools].some((t) => EDIT_TOOLS.has(t)); const files = (episode.files || []).length; - const ranTests = [...tools].some((t) => TEST_PATTERN.test(t)) || (episode.tools || []).includes('Bash'); + const ranTests = + [...tools].some((t) => TEST_PATTERN.test(t)) || + (episode.tools || []).includes('Bash'); if (edited && files >= 3) return 'multi-file-change'; if (edited) return 'single-file-change'; - if (files > 0 || tools.has('Grep') || tools.has('Glob') || tools.has('smart_grep')) return 'investigation'; + if ( + files > 0 || + tools.has('Grep') || + tools.has('Glob') || + tools.has('smart_grep') + ) + return 'investigation'; if (ranTests) return 'build-or-test'; return 'conversation'; } @@ -208,7 +239,13 @@ export function outcomeTable(episodes) { const tier = [...tiers][0]; table[shape] = table[shape] || {}; - const cell = table[shape][tier] = table[shape][tier] || { episodes: 0, turns: 0, errors: 0, results: 0, errored: 0 }; + const cell = (table[shape][tier] = table[shape][tier] || { + episodes: 0, + turns: 0, + errors: 0, + results: 0, + errored: 0, + }); cell.episodes += 1; cell.turns += episode.turns; cell.errors += episode.errors; @@ -287,7 +324,8 @@ export function route(shape, table = {}) { shape, recommend: heuristic, basis: 'heuristic', - reason: `every measured tier for ${shape} needed a retry in more than ` + + reason: + `every measured tier for ${shape} needed a retry in more than ` + `${Math.round(MAX_ERROR_RATE * 100)}% of episodes; falling back to the shipped default`, excluded: excluded.map((o) => ({ tier: o.tier, errorRate: o.errorRate })), options, @@ -306,17 +344,24 @@ export function route(shape, table = {}) { recommend: best.tier, basis: 'measured', // What each mistake would cost, from this project's own rates. - overpowered: strongest.tier === best.tier ? null : { - tier: strongest.tier, - wasted: Number((strongest.cost - best.cost).toFixed(2)), - }, - underpowered: cheapest.tier === best.tier ? null : { - tier: cheapest.tier, - expectedCost: Number(cheapest.cost.toFixed(2)), - errorRate: cheapest.errorRate, - excluded: cheapest.errorRate > MAX_ERROR_RATE, - }, - reason: `${shape}: ${best.tier} costs ${best.cost.toFixed(2)} in expectation over ${best.episodes} episodes ` + + overpowered: + strongest.tier === best.tier + ? null + : { + tier: strongest.tier, + wasted: Number((strongest.cost - best.cost).toFixed(2)), + }, + underpowered: + cheapest.tier === best.tier + ? null + : { + tier: cheapest.tier, + expectedCost: Number(cheapest.cost.toFixed(2)), + errorRate: cheapest.errorRate, + excluded: cheapest.errorRate > MAX_ERROR_RATE, + }, + reason: + `${shape}: ${best.tier} costs ${best.cost.toFixed(2)} in expectation over ${best.episodes} episodes ` + `(${Math.round(best.errorRate * 100)}% needed a retry)`, options, }; @@ -340,20 +385,31 @@ export function routingNote(shape, table, { currentModel, switchCost } = {}) { const lines = [decision.reason]; if (decision.underpowered) { - lines.push(` ${decision.underpowered.tier} looks cheaper per token, but needed a retry in ` + - `${Math.round(decision.underpowered.errorRate * 100)}% of episodes -- expected cost ${decision.underpowered.expectedCost}` + - `${decision.underpowered.excluded ? ', which is why it is not a candidate here at any price' : ''}.`); + lines.push( + ` ${decision.underpowered.tier} looks cheaper per token, but needed a retry in ` + + `${Math.round(decision.underpowered.errorRate * 100)}% of episodes -- expected cost ${decision.underpowered.expectedCost}` + + `${decision.underpowered.excluded ? ', which is why it is not a candidate here at any price' : ''}.` + ); } if (decision.overpowered) { - lines.push(` ${decision.overpowered.tier} would spend ${decision.overpowered.wasted} more than needed here.`); + lines.push( + ` ${decision.overpowered.tier} would spend ${decision.overpowered.wasted} more than needed here.` + ); } if (switchCost?.prefixTokens) { - lines.push(` Switching now also discards a ${switchCost.prefixTokens.toLocaleString()}-token warm prefix ` + - `(~${switchCost.rewriteCost.toLocaleString()} tokens to re-write); consider switching at the next break.`); + lines.push( + ` Switching now also discards a ${switchCost.prefixTokens.toLocaleString()}-token warm prefix ` + + `(~${switchCost.rewriteInputCostEquivalent.toLocaleString()} Anthropic uncached-input cost-equivalent tokens to re-write); consider switching at the next break.` + ); } - return { shape, recommend: decision.recommend, basis: decision.basis, text: lines.join('\n') }; + return { + shape, + recommend: decision.recommend, + basis: decision.basis, + text: lines.join('\n'), + }; } /** @@ -371,7 +427,9 @@ export function routingBriefing(table) { const decision = route(shape, table); if (decision.basis !== 'measured') continue; if (decision.recommend === HEURISTIC[shape]) continue; // nothing learned worth saying - lines.push(`${shape.replace(/-/g, ' ')} work goes better on ${decision.recommend} in this project.`); + lines.push( + `${shape.replace(/-/g, ' ')} work goes better on ${decision.recommend} in this project.` + ); } return lines.length ? lines.join('\n') : null; } @@ -389,7 +447,11 @@ export function routingBriefing(table) { * few more episodes accumulated would invalidate the cache behind it. Stability * is the feature; the memo is how it is bought. */ -export function cachedRoutingBriefing(dir, transcriptPath, { growthBytes = 4_000_000, maxAgeMs = 24 * 60 * 60 * 1000 } = {}) { +export function cachedRoutingBriefing( + dir, + transcriptPath, + { growthBytes = 4_000_000, maxAgeMs = 24 * 60 * 60 * 1000 } = {} +) { const memoPath = join(dir, 'routing-brief.json'); let stat; try { @@ -400,17 +462,31 @@ export function cachedRoutingBriefing(dir, transcriptPath, { growthBytes = 4_000 try { const memo = JSON.parse(readFileSync(memoPath, 'utf8')); - const fresh = memo.path === transcriptPath - && Math.abs(stat.size - memo.size) < growthBytes - && Date.now() - memo.at < maxAgeMs; + const fresh = + memo.path === transcriptPath && + Math.abs(stat.size - memo.size) < growthBytes && + Date.now() - memo.at < maxAgeMs; if (fresh) return memo.text || null; - } catch { /* no memo, or an unreadable one; recompute */ } + } catch { + /* no memo, or an unreadable one; recompute */ + } const text = routingBriefing(outcomeTable(readEpisodes(transcriptPath))); try { mkdirSync(dir, { recursive: true, mode: 0o700 }); - writeFileSync(memoPath, JSON.stringify({ path: transcriptPath, size: stat.size, at: Date.now(), text }), { mode: 0o600 }); - } catch { /* the memo is an optimisation; failing to write it costs latency, not correctness */ } + writeFileSync( + memoPath, + JSON.stringify({ + path: transcriptPath, + size: stat.size, + at: Date.now(), + text, + }), + { mode: 0o600 } + ); + } catch { + /* the memo is an optimisation; failing to write it costs latency, not correctness */ + } return text; } @@ -427,9 +503,11 @@ export function routingReport(table) { for (const tier of Object.keys(table[shape])) { const cell = table[shape][tier]; const cost = expectedCost(tier, cell); - lines.push(` ${tier.padEnd(7)} ${cell.episodes} episode(s), ` + - `${cell.meanTurns.toFixed(1)} turns avg, ${Math.round(cell.errorRate * 100)}% needed a retry, ` + - `expected cost ${cost.cost.toFixed(2)}${cell.measured ? '' : ' [too few episodes to trust]'}`); + lines.push( + ` ${tier.padEnd(7)} ${cell.episodes} episode(s), ` + + `${cell.meanTurns.toFixed(1)} turns avg, ${Math.round(cell.errorRate * 100)}% needed a retry, ` + + `expected cost ${cost.cost.toFixed(2)}${cell.measured ? '' : ' [too few episodes to trust]'}` + ); } } return lines.join('\n'); diff --git a/integrations/codex/plugin/hooks/lib/symbols.mjs b/integrations/codex/plugin/hooks/lib/symbols.mjs index 2a80e1e42..4d80666ac 100644 --- a/integrations/codex/plugin/hooks/lib/symbols.mjs +++ b/integrations/codex/plugin/hooks/lib/symbols.mjs @@ -50,7 +50,11 @@ const PATTERNS = { /^\s*(?:pub(?:\([^)]*\))?\s+)?(?:struct|enum|trait|impl)\s+([A-Za-z_]\w*)/, ], clike: [ - /^\s*(?:public|private|protected|internal|static|final|abstract|virtual|override|async|\s)*(?:[\w<>\[\],.?]+\s+)([A-Za-z_]\w*)\s*\([^;]*\)\s*\{/, + // Accept both K&R (`Run() {`) and Allman (`Run()` then `{`) declarations. + // Requiring the opening brace on the declaration line silently discarded + // nearly every method in idiomatic C# projects, leaving file nodes but no + // symbol nodes and making valid `file.cs#Method` anchors unresolved. + /^\s*(?:public|private|protected|internal|static|final|abstract|virtual|override|async|sealed|new|extern|unsafe|partial|\s)*(?:[\w<>\[\],.?]+\s+)([A-Za-z_]\w*)(?:<[^;()]+>)?\s*\([^;]*\)\s*(?:\{|=>|$)/, /^\s*(?:public|private|protected|internal|static|abstract|sealed|partial|\s)*(?:class|struct|interface|enum|record)\s+([A-Za-z_]\w*)/, ], ruby: [ diff --git a/integrations/copilot/.github/hooks/lib/adapter.mjs b/integrations/copilot/.github/hooks/lib/adapter.mjs index fee0102b9..9f76bbdea 100644 --- a/integrations/copilot/.github/hooks/lib/adapter.mjs +++ b/integrations/copilot/.github/hooks/lib/adapter.mjs @@ -188,8 +188,143 @@ function contextOutput(client, eventName, additionalContext) { }; } +/** Convert one static JavaScript string literal without evaluating code. */ +function codexStringLiteral(literal) { + if (typeof literal !== 'string' || literal.length < 2) return null; + const quote = literal[0]; + if (!['"', "'", '`'].includes(quote) || literal.at(-1) !== quote) return null; + + let decoded = ''; + for (let index = 1; index < literal.length - 1; index += 1) { + const character = literal[index]; + if (quote === '`' && character === '$' && literal[index + 1] === '{') + return null; + if (character !== '\\') { + decoded += character; + continue; + } + + index += 1; + if (index >= literal.length - 1) return null; + const escaped = literal[index]; + const simple = { + b: '\b', f: '\f', n: '\n', r: '\r', t: '\t', v: '\v', 0: '\0', + '\\': '\\', "'": "'", '"': '"', '`': '`', '$': '$', + }; + if (Object.hasOwn(simple, escaped)) { + decoded += simple[escaped]; + continue; + } + if (escaped === '\n') continue; + if (escaped === '\r') { + if (literal[index + 1] === '\n') index += 1; + continue; + } + if (escaped === 'x') { + const digits = literal.slice(index + 1, index + 3); + if (!/^[0-9a-f]{2}$/i.test(digits)) return null; + decoded += String.fromCodePoint(Number.parseInt(digits, 16)); + index += 2; + continue; + } + if (escaped === 'u') { + const braced = literal[index + 1] === '{'; + const close = braced ? literal.indexOf('}', index + 2) : index + 5; + const digits = braced + ? literal.slice(index + 2, close) + : literal.slice(index + 1, close); + if ( + close < 0 || + !(braced ? /^[0-9a-f]{1,6}$/i : /^[0-9a-f]{4}$/i).test(digits) + ) return null; + const codePoint = Number.parseInt(digits, 16); + if (codePoint > 0x10ffff) return null; + decoded += String.fromCodePoint(codePoint); + index = close; + continue; + } + // JavaScript identity escapes drop the slash (for example, '\q' is 'q'). + decoded += escaped; + } + return decoded; +} + /** Convert client-specific lifecycle envelopes into the common tool shape. */ +function codexStringBindings(source) { + const values = new Map(); + const declaration = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*((?:"(?:\\.|[^"\\])*")|(?:'(?:\\.|[^'\\])*')|(?:`(?:\\.|[^`\\])*`))/gs; + for (const match of String(source || '').matchAll(declaration)) { + try { + const value = codexStringLiteral(match[2]); + if (value !== null) values.set(match[1], value); + } catch { + // A computed or malformed JavaScript expression is not safe to execute + // merely to understand a hook envelope. Leave it opaque. + } + } + return values; +} + +function codexCallArguments(source, method) { + const calls = []; + const pattern = new RegExp(`(?:tools\\.)?${method}\\s*\\(\\s*([^),]+)`, 'gi'); + for (const match of String(source || '').matchAll(pattern)) + calls.push(match[1].trim()); + return calls; +} + +function codexLiteral(value, bindings) { + if (bindings.has(value)) return String(bindings.get(value)); + return codexStringLiteral(value); +} + +function codexExecCommands(source, bindings) { + const commands = []; + for (const call of String(source || '').matchAll( + /(?:tools\.)?exec_command\s*\(\s*\{([\s\S]*?)\}\s*\)/gi + )) { + const property = /\bcmd\s*:\s*((?:"(?:\\.|[^"\\])*")|(?:'(?:\\.|[^'\\])*')|(?:`(?:\\.|[^`\\])*`)|(?:[A-Za-z_$][\w$]*))/s.exec(call[1]); + if (!property) continue; + const command = codexLiteral(property[1], bindings); + if (command) commands.push(command); + } + return commands; +} + export function normalizeClientPayload(clientName, event, raw) { + if (clientName === 'codex') { + const outerName = String(raw.tool_name ?? raw.toolName ?? raw.tool ?? ''); + if (/^(?:functions\.)?exec$/i.test(outerName)) { + const envelope = raw.tool_input ?? raw.toolInput ?? raw.arguments ?? raw.args; + const source = + typeof envelope === 'string' + ? envelope + : envelope?.code ?? envelope?.input ?? envelope?.source ?? ''; + const bindings = codexStringBindings(source); + const patchCalls = codexCallArguments(source, 'apply_patch'); + const patch = patchCalls + .map((argument) => codexLiteral(argument, bindings)) + .find(Boolean); + const mutation = patchCalls.length > 0; + const commands = codexExecCommands(source, bindings); + + // `functions.exec` is also the orchestration envelope for web, image and + // other non-filesystem tools. Do not reinterpret those as shell commands: + // doing so produced irrelevant cross-project advice and fabricated graph + // activity for operations the filesystem hooks never observed. + if (!mutation && !commands.length) return raw; + return { + ...raw, + tool_name: mutation ? 'apply_patch' : 'run_command', + tool_input: { + ...(envelope && typeof envelope === 'object' ? envelope : {}), + command: String(patch || commands.join('\n') || source || ''), + code_mode_envelope: true, + }, + }; + } + } + if (clientName === 'cline') { const body = event === 'post-tool' ? raw.postToolUse : raw.preToolUse; if (!body) return raw; @@ -385,6 +520,22 @@ function projectBriefing() { function observeAndInject(payload, state, episode, features) { const touched = touchedFiles(payload); const dirFor = (path) => wikiDir(projectRootFor(path, payload.cwd)); + const registerRoot = (root) => { + const dir = wikiDir(root); + registerProject({ + root, + graphDir: dir, + client: episode.client || 'unknown', + }); + return dir; + }; + + // SessionStart is not delivered consistently by every host/version. The + // graph was still written in that case, but the dashboard registry never + // learned it existed. Register every repository observed by a real tool call + // as a second, idempotent production path. + for (const { path } of touched) + registerRoot(projectRootFor(path, payload.cwd)); const bytes = readCostBytes(payload); if (bytes && payload.tool_input.file_path) { @@ -425,7 +576,7 @@ function observeAndInject(payload, state, episode, features) { const command = payload.tool_input?.command; if (command) { const root = commandProjectRoot(payload, payload.cwd); - const dir = wikiDir(root); + const dir = registerRoot(root); const local = forCommand(dir, load(dir), command, { sessionId: payload.session_id, alreadyInjected, @@ -750,7 +901,14 @@ async function runHook(clientName, event, invocation) { client.canDeny && event === 'pre-tool' && !repeat && - mode() !== MODE_ADVISE + mode() !== MODE_ADVISE && + // Codex code mode presents an orchestration program as one outer + // `functions.exec` call. Refusing that envelope is rendered by the host + // as a failed script and can discard several otherwise-safe nested + // operations. Observe it, capture it and advise, but reserve a hard veto + // for legacy one-operation Codex tool calls where the denied operation is + // unambiguous. + !(clientName === 'codex' && payload.tool_input?.code_mode_envelope) ); if (canRefuse) { @@ -759,19 +917,23 @@ async function runHook(clientName, event, invocation) { // Every client's refusal carries the off switch, for the same reason Claude // Code's does: enforcement that hides its own disable is coercive. if (client.denyStyle === 'top-level') { + invocation.block('policy_denied'); emit({ decision: 'deny', reason: withEscape(verdict.reason) }); } else if (client.denyStyle === 'top-level-permission') { + invocation.block('policy_denied'); emit({ permissionDecision: 'deny', permissionDecisionReason: withEscape(verdict.reason), }); } else if (client.denyStyle === 'cline') { + invocation.block('policy_denied'); emit({ cancel: true, contextModification: '', errorMessage: withEscape(verdict.reason), }); } else if (client.denyStyle === 'cursor') { + invocation.block('policy_denied'); emit({ continue: true, permission: 'deny', @@ -780,9 +942,11 @@ async function runHook(clientName, event, invocation) { 'Token Optimizer redirected an expensive built-in operation.', }); } else if (client.denyStyle === 'exit-2') { + invocation.block('policy_denied'); process.stderr.write(withEscape(verdict.reason)); process.exit(2); } else { + invocation.block('policy_denied'); emit({ hookSpecificOutput: { hookEventName: eventName, diff --git a/integrations/copilot/.github/hooks/lib/audit.mjs b/integrations/copilot/.github/hooks/lib/audit.mjs index b82186586..df8a08b82 100644 --- a/integrations/copilot/.github/hooks/lib/audit.mjs +++ b/integrations/copilot/.github/hooks/lib/audit.mjs @@ -50,15 +50,17 @@ export function decline(dir, id) { export function declines(dir, { events = readMetrics(dir) } = {}) { const counts = new Map(); for (const event of events) { - if (event.kind !== 'advice' || event.action !== 'declined' || !event.id) continue; + if (event.kind !== 'advice' || event.action !== 'declined' || !event.id) + continue; counts.set(event.id, (counts.get(event.id) || 0) + 1); } return counts; } -const idOf = (finding) => finding.remedy - ? `${finding.remedy.type}:${finding.remedy.anchor || finding.remedy.file || (finding.remedy.anchors || []).join(',')}` - : `${finding.id}:${finding.anchor || finding.file || ''}`; +const idOf = (finding) => + finding.remedy + ? `${finding.remedy.type}:${finding.remedy.anchor || finding.remedy.file || (finding.remedy.anchors || []).join(',')}` + : `${finding.id}:${finding.anchor || finding.file || ''}`; /** * Whether a habit improved after we said something about it. @@ -68,12 +70,18 @@ const idOf = (finding) => finding.remedy * -- a trend computed from one session on each side is a coin flip with a * percentage sign. */ -export function habitTrend(dir, detector, { events = readMetrics(dir), minSessions = 2, anchors = null } = {}) { +export function habitTrend( + dir, + detector, + { events = readMetrics(dir), minSessions = 2, anchors = null } = {} +) { // SPLIT BY POSITION, NOT BY TIMESTAMP. The log is append-only, so its order is // authoritative -- while `at` has millisecond granularity, and a burst of // events inside one millisecond would land on the wrong side of the split or // collapse one side to nothing. Position is exact where the clock is not. - const raisedAt = events.findIndex((e) => e.kind === 'advice' && e.detector === detector); + const raisedAt = events.findIndex( + (e) => e.kind === 'advice' && e.detector === detector + ); if (raisedAt === -1) return null; // SCOPED TO THE FILES THIS DETECTOR NAMED. Counting every read in the window @@ -114,7 +122,12 @@ export function habitTrend(dir, detector, { events = readMetrics(dir), minSessio /** Marks a recommendation as having been raised, so its effect can be scored. */ export function raise(dir, finding) { - record(dir, { kind: 'advice', action: 'raised', id: idOf(finding), detector: finding.id }); + record(dir, { + kind: 'advice', + action: 'raised', + id: idOf(finding), + detector: finding.id, + }); } /** @@ -123,7 +136,11 @@ export function raise(dir, finding) { * `findings` comes from the detectors; `extra` lets the cache and routing * modules contribute without this file importing the world. */ -export function buildQueue(dir, findings = [], { events = readMetrics(dir) } = {}) { +export function buildQueue( + dir, + findings = [], + { events = readMetrics(dir) } = {} +) { const declined = declines(dir, { events }); const ledger = remedyLedger(dir); const applied = new Set(ledger.filter((r) => !r.revertedAt).map((r) => r.id)); @@ -146,7 +163,9 @@ export function buildQueue(dir, findings = [], { events = readMetrics(dir) } = { } return { - queue: queue.sort((a, b) => (b.costPerSession ?? 0) - (a.costPerSession ?? 0)), + queue: queue.sort( + (a, b) => (b.costPerSession ?? 0) - (a.costPerSession ?? 0) + ), suppressed, done: ledger.filter((r) => !r.revertedAt), }; @@ -161,7 +180,11 @@ export function buildQueue(dir, findings = [], { events = readMetrics(dir) } = { * many were withheld -- and what they are collectively worth -- keeps that from * being a silent cap. */ -export function renderAudit(dir, findings = [], { tier = 'opus', full = false, sessionsPerMonth = 60 } = {}) { +export function renderAudit( + dir, + findings = [], + { tier = 'opus', full = false, sessionsPerMonth = 60 } = {} +) { const { queue, suppressed, done } = buildQueue(dir, findings); const lines = []; @@ -175,22 +198,29 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s // read as "this costs nothing" beside a finding whose cost we simply do not // know -- the same unknown-becomes-zero error the rest of this project // corrects, in the one unit that gets quoted to other people. - const priced = item.costPerSession == null ? null : monthly(cost, { tier, sessionsPerMonth }); + const priced = + item.costPerSession == null + ? null + : monthly(cost, { tier, sessionsPerMonth }); // The id appears on EVERY actionable line, not just the appliable ones. It is the only // handle `decline` accepts and it cannot be guessed from the title, so omitting it made the // advertised decline path unreachable for precisely the findings most likely to be // unwanted -- model-routing findings carry no remedy, so they could never be suppressed no // matter how many times a user declined them. - const action = item.remedy?.kind === 'ours' ? `apply: waste_audit action="apply" id="${item.id}"` - : item.remedy?.kind === 'yours' ? `proposed edit -- needs your yes, nothing changed; decline: ${item.id}` - : `advice only -- no automatic fix; decline: ${item.id}`; + const action = + item.remedy?.kind === 'ours' + ? `apply: waste_audit action="apply" id="${item.id}"` + : item.remedy?.kind === 'yours' + ? `proposed edit -- needs your yes, nothing changed; decline: ${item.id}` + : `advice only -- no automatic fix; decline: ${item.id}`; // MEASURED ZERO IS NOT UNMEASURED. Testing `cost` for truthiness sent a real 0 down the // "not yet measurable" branch while `priced` above, which tests for null, still rendered // $0.00 beside it -- producing `cost not yet measurable (~$0.00/month)`, the exact string // the test suite declares must never appear. waste.mjs defaults every finding to 0 and // hard-sets it on the co-occurrence detector, so this is reachable, not theoretical. - const text = ` ${item.title}\n ${item.costPerSession != null ? `${cost.toLocaleString()} tokens/session` : 'cost not yet measurable'}` + + const text = + ` ${item.title}\n ${item.costPerSession != null ? `${cost.toLocaleString()} tokens/session` : 'cost not yet measurable'}` + `${priced ? ` (~${money(priced.amount)}/month)` : ''}; ${action}`; const printCost = estimate(text); @@ -211,14 +241,19 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s lines.push(text); } - const head = shown.length ? ['What to do next, most expensive first:', ...lines] : ['Nothing addressable found.']; + const head = shown.length + ? ['What to do next, most expensive first:', ...lines] + : ['Nothing addressable found.']; const body = [...head]; if (withheld.length) { const worth = withheld.reduce((sum, w) => sum + (w.costPerSession || 0), 0); - body.push('', ` ... ${withheld.length} more finding(s) worth ${worth.toLocaleString()} tokens/session in total, ` + - 'not shown -- printing them costs more than they are worth. Pass full=true to see all.'); + body.push( + '', + ` ... ${withheld.length} more finding(s) worth ${worth.toLocaleString()} tokens/session in total, ` + + 'not shown -- printing them costs more than they are worth. Pass full=true to see all.' + ); } // What we already told you, and what it bought. @@ -226,23 +261,35 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s body.push('', 'Already applied, and what it actually saved:'); for (const rule of done.slice(0, 6)) { const saved = rule.savedPerSession; - const priced = saved == null ? null : monthly(saved, { tier, sessionsPerMonth }); - body.push(` + ${rule.id} -- ${saved == null - ? 'not yet measurable (fewer than two sessions since)' - : `measured ${saved.toLocaleString()} tokens/session${priced ? `, ~${money(priced.amount)}/month` : ''}`}`); + const priced = + saved == null ? null : monthly(saved, { tier, sessionsPerMonth }); + body.push( + ` + ${rule.id} -- ${ + saved == null + ? 'not yet measurable (fewer than two sessions since)' + : `measured ${saved.toLocaleString()} tokens/session${priced ? `, ~${money(priced.amount)}/month` : ''}` + }` + ); } // Never a silent cap: the withheld block above states its remainder and so must this one. // Truncating to six with no disclosure showed a user six savings and let them conclude that // was everything the tool had bought them, which understates its own measured value. if (done.length > 6) { - body.push(` ... and ${done.length - 6} more applied fix(es), not shown.`); + body.push( + ` ... and ${done.length - 6} more applied fix(es), not shown.` + ); } } if (suppressed.length) { - body.push('', `Not repeating ${suppressed.length} recommendation(s) you have declined ` + - `${DECLINE_LIMIT}+ times. Ask for full=true to see them.`); - if (full) for (const item of suppressed) body.push(` - ${item.title} (declined ${item.times}x)`); + body.push( + '', + `Not repeating ${suppressed.length} recommendation(s) you have declined ` + + `${DECLINE_LIMIT}+ times. Ask for full=true to see them.` + ); + if (full) + for (const item of suppressed) + body.push(` - ${item.title} (declined ${item.times}x)`); } // Whether the advice changed anything. @@ -256,20 +303,30 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s for (const a of anchors) anchorsBy.get(f.id).add(a); } const trends = [...new Set(findings.map((f) => f.id))] - .map((detector) => habitTrend(dir, detector, { - anchors: anchorsBy.has(detector) ? [...anchorsBy.get(detector)] : null, - })) + .map((detector) => + habitTrend(dir, detector, { + anchors: anchorsBy.has(detector) ? [...anchorsBy.get(detector)] : null, + }) + ) .filter(Boolean); if (trends.length) { body.push('', 'Since the advice was given:'); for (const trend of trends) { - body.push(` ${trend.detector}: ${trend.before.toLocaleString()} -> ${trend.after.toLocaleString()} tokens/session ` + - `(${trend.improved ? 'improved' : 'worse'})`); + body.push( + ` ${trend.detector}: ${trend.before.toLocaleString()} -> ${trend.after.toLocaleString()} tokens/session ` + + `(${trend.improved ? 'improved' : 'worse'})` + ); } } - const addressable = queue.reduce((sum, item) => sum + (item.costPerSession || 0), 0); + const addressable = queue.reduce( + (sum, item) => sum + (item.costPerSession || 0), + 0 + ); + const addressableMonthly = addressable + ? monthly(addressable, { tier, sessionsPerMonth }) + : null; const note = priceNote(tier); if (note) body.push('', note); @@ -278,9 +335,10 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s // pushing it left the report's longest line out of its own reported cost, understating it by // 10-20% on a short audit. This figure exists to prove the audit is not a net loss, so // understating the cost side biases exactly the comparison it was written to be honest about. - const closing = (n) => `This report cost about ${n.toLocaleString()} tokens and names ` + + const closing = (n) => + `This report cost about ${n.toLocaleString()} tokens and names ` + `${addressable.toLocaleString()} tokens/session of addressable waste` + - `${addressable ? ` (~${money(monthly(addressable, { tier, sessionsPerMonth })?.amount)}/month across ${sessionsPerMonth} sessions)` : ''}.`; + `${addressableMonthly ? ` (~${money(addressableMonthly.amount)}/month across ${sessionsPerMonth} sessions)` : ''}.`; const selfCost = estimate([...body, '', closing(0)].join('\n')); body.push('', closing(selfCost)); diff --git a/integrations/copilot/.github/hooks/lib/cache.mjs b/integrations/copilot/.github/hooks/lib/cache.mjs index 9b11173ff..9782c3262 100644 --- a/integrations/copilot/.github/hooks/lib/cache.mjs +++ b/integrations/copilot/.github/hooks/lib/cache.mjs @@ -3,12 +3,12 @@ /** * Prompt-cache economics. * - * Cache economics is real money in a way most compression is not: a cache read - * costs a tenth of a fresh token and a cache WRITE costs a quarter more than - * one, so a prefix that keeps invalidating can cost more than every saving this - * product makes elsewhere. It is also the one area where we do not have to - * model anything -- the client's own transcript records cache_read_input_tokens - * and cache_creation_input_tokens per turn, which is ground truth. + * Anthropic prompt-cache usage, read from Claude Code transcripts. A cache read + * and a cache write are both billable, at different published multipliers from + * uncached input. Raw cache_read_input_tokens and + * cache_creation_input_tokens are observations. Any multiplier-based value is + * an Anthropic input-cost equivalent, not a universally comparable token count + * and not a provider invoice. * * MEASUREMENT ALONE IS NOT ACTIONABLE. A hit rate tells you the cache missed; * it cannot tell you why, and an unactionable number is the same dead thing as @@ -30,7 +30,15 @@ * cache-safety-by-construction half of this file. */ -import { readFileSync, readdirSync, statSync, existsSync, openSync, readSync, closeSync } from 'node:fs'; +import { + readFileSync, + readdirSync, + statSync, + existsSync, + openSync, + readSync, + closeSync, +} from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; @@ -51,7 +59,8 @@ export const READ_MULTIPLIER = 0.1; const SYNTHETIC_MODEL = ''; /** True for a turn naming a model an inference actually ran under. */ -const realModel = (turn) => Boolean(turn.model) && turn.model !== SYNTHETIC_MODEL; +const realModel = (turn) => + Boolean(turn.model) && turn.model !== SYNTHETIC_MODEL; /* -------------------------------------------------------------- MEASUREMENT */ @@ -72,7 +81,10 @@ export function transcriptFor(cwd = process.cwd()) { try { const files = readdirSync(dir) .filter((name) => name.endsWith('.jsonl')) - .map((name) => ({ path: join(dir, name), at: statSync(join(dir, name)).mtimeMs })) + .map((name) => ({ + path: join(dir, name), + at: statSync(join(dir, name)).mtimeMs, + })) .sort((a, b) => b.at - a.at); return files.length ? files[0].path : null; } catch { @@ -157,7 +169,8 @@ export function cacheHealth(turns) { read += turn.read; written += turn.written; if (!realModel(turn)) continue; - if (!models.has(turn.model)) models.set(turn.model, { read: 0, written: 0, turns: 0 }); + if (!models.has(turn.model)) + models.set(turn.model, { read: 0, written: 0, turns: 0 }); const entry = models.get(turn.model); entry.read += turn.read; entry.written += turn.written; @@ -171,20 +184,28 @@ export function cacheHealth(turns) { // attribution price, made keepWarm answer "unknown", and returned null from // modelSwitchCost under a guard its caller does not share, which crashed // cache_audit outright with "Cannot read properties of null". - const last = turns.reduce((best, turn) => (turn.read + turn.written > 0 ? turn : best), turns[turns.length - 1]); + const last = turns.reduce( + (best, turn) => (turn.read + turn.written > 0 ? turn : best), + turns[turns.length - 1] + ); const total = read + written; return { + provider: 'anthropic', + source: 'claude-code-transcript', turns: turns.length, read, written, hitRate: total ? read / total : null, prefixTokens: last.read + last.written, - // Priced in plain-token equivalents, which is what makes it comparable with - // every other saving this product reports. - writeCost: Math.round(written * WRITE_MULTIPLIER), - readCost: Math.round(read * READ_MULTIPLIER), - savedVersusNoCache: Math.round(read * (1 - READ_MULTIPLIER)), + // These are Anthropic uncached-input cost equivalents. They stay outside + // the verified MCP transport ledger and must not be relabeled as tokens + // saved across clients. + writeInputCostEquivalent: Math.round(written * WRITE_MULTIPLIER), + readInputCostEquivalent: Math.round(read * READ_MULTIPLIER), + inputCostEquivalentAvoidedVersusUncached: Math.round( + read * (1 - READ_MULTIPLIER) + ), models: Object.fromEntries(models), }; } @@ -200,13 +221,17 @@ export function modelSwitchCost(turns) { const health = cacheHealth(turns); if (!health?.prefixTokens) return null; - const switched = new Set(turns.filter(realModel).map((t) => t.model)).size > 1; + const switched = + new Set(turns.filter(realModel).map((t) => t.model)).size > 1; return { prefixTokens: health.prefixTokens, - rewriteCost: Math.round(health.prefixTokens * WRITE_MULTIPLIER), + rewriteInputCostEquivalent: Math.round( + health.prefixTokens * WRITE_MULTIPLIER + ), alreadySwitched: switched, - text: `Switching model now discards a ${health.prefixTokens.toLocaleString()}-token warm prefix; ` + - `re-writing it costs about ${Math.round(health.prefixTokens * WRITE_MULTIPLIER).toLocaleString()} tokens.`, + text: + `Switching model now discards a ${health.prefixTokens.toLocaleString()}-token warm prefix; ` + + `under Anthropic's current 5-minute write multiplier, re-writing it is about ${Math.round(health.prefixTokens * WRITE_MULTIPLIER).toLocaleString()} uncached-input cost-equivalent tokens.`, }; } @@ -221,17 +246,38 @@ export function modelSwitchCost(turns) { * time. That is why this class of bug survives -- nothing about it looks wrong. */ const VOLATILE = [ - { id: 'timestamp', re: /\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/, why: 'an embedded timestamp' }, - { id: 'date', re: /\b(?:today|current date)\b[^\n]{0,40}\d{4}-\d{2}-\d{2}/i, why: 'an embedded current date' }, + { + id: 'timestamp', + re: /\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/, + why: 'an embedded timestamp', + }, + { + id: 'date', + re: /\b(?:today|current date)\b[^\n]{0,40}\d{4}-\d{2}-\d{2}/i, + why: 'an embedded current date', + }, // A bare date is a SUSPICION, not a measurement: "- 2025-10-31: reworked the // loader" is byte-identical every session. Kept, because stableText fails // closed over our own output and dropping a line there costs little -- but // NOT priced, because in the blame report a false positive invents a // per-session cost and tells the user to delete their changelog. - { id: 'iso-date', re: /\b\d{4}-\d{2}-\d{2}\b/, why: 'an embedded date', priced: false }, - { id: 'session-id', re: /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i, why: 'a session id' }, + { + id: 'iso-date', + re: /\b\d{4}-\d{2}-\d{2}\b/, + why: 'an embedded date', + priced: false, + }, + { + id: 'session-id', + re: /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i, + why: 'a session id', + }, { id: 'git-sha', re: /\b[0-9a-f]{40}\b/, why: 'a git sha' }, - { id: 'counter', re: /\b(?:run|build|attempt|iteration)\s*#?\d+\b/i, why: 'a run counter' }, + { + id: 'counter', + re: /\b(?:run|build|attempt|iteration)\s*#?\d+\b/i, + why: 'a run counter', + }, { id: 'epoch', re: /\b1[6-9]\d{11}\b/, why: 'an epoch timestamp' }, ]; @@ -242,7 +288,13 @@ export function volatileLines(text) { for (let i = 0; i < lines.length; i++) { for (const rule of VOLATILE) { if (!rule.re.test(lines[i])) continue; - out.push({ line: i + 1, id: rule.id, why: rule.why, priced: rule.priced !== false, text: lines[i].trim().slice(0, 120) }); + out.push({ + line: i + 1, + id: rule.id, + why: rule.why, + priced: rule.priced !== false, + text: lines[i].trim().slice(0, 120), + }); break; // one cause per line is enough to act on } } @@ -264,7 +316,11 @@ const estimate = (text) => Math.ceil(String(text || '').length / 4); * unknown, and this reports the construct WITHOUT a price rather than inventing * one. */ -export function attributeInvalidation(cwd, prefixTokens = null, { files = null } = {}) { +export function attributeInvalidation( + cwd, + prefixTokens = null, + { files = null } = {} +) { // Ordered as the client assembles them: the client preamble and tool // definitions come first, then project instructions, then the conversation. const candidates = files || ['CLAUDE.md', 'AGENTS.md', '.claude/CLAUDE.md']; @@ -292,7 +348,8 @@ export function attributeInvalidation(cwd, prefixTokens = null, { files = null } // prefix -- 3.75x the entire prefix. Conservative still: the preamble // ahead of the file is not counted, so this remains a floor. const ahead = offset + estimate(lines.slice(0, hit.line - 1).join('\n')); - const downstream = prefixTokens != null ? Math.max(0, prefixTokens - ahead) : null; + const downstream = + prefixTokens != null ? Math.max(0, prefixTokens - ahead) : null; // A prefix cache invalidates ONCE, at the earliest difference. A later // volatile line in the same file costs nothing extra until the first one // is fixed, so it is reported for context but not billed again. @@ -304,7 +361,9 @@ export function attributeInvalidation(cwd, prefixTokens = null, { files = null } // into a reported zero. const firstPriced = found.find((h) => h.priced); const subsumedBy = - hit.priced && firstPriced && firstPriced !== hit ? `${relative}:${firstPriced.line}` : null; + hit.priced && firstPriced && firstPriced !== hit + ? `${relative}:${firstPriced.line}` + : null; out.push({ file: relative, line: hit.line, @@ -316,9 +375,12 @@ export function attributeInvalidation(cwd, prefixTokens = null, { files = null } // decline to price. 0 means measured and genuinely free, which is what a // subsumed hit is: real, but already billed by the earlier line. Folding // those two into one value is the same mistake this file warns about. - costPerSession: downstream == null || !hit.priced - ? null - : (subsumedBy ? 0 : Math.round(downstream * WRITE_MULTIPLIER)), + costPerSession: + downstream == null || !hit.priced + ? null + : subsumedBy + ? 0 + : Math.round(downstream * WRITE_MULTIPLIER), remedy: { kind: 'yours', type: 'edit', @@ -369,7 +431,7 @@ export function stableText(text) { */ export function cacheOrdered(items) { return [...items].sort((a, b) => { - const stability = (item) => (item.volatility ?? (item.fresh ? 1 : 0)); + const stability = (item) => item.volatility ?? (item.fresh ? 1 : 0); return stability(a) - stability(b); }); } diff --git a/integrations/copilot/.github/hooks/lib/decide.mjs b/integrations/copilot/.github/hooks/lib/decide.mjs index caae7de2f..a61dafdd5 100644 --- a/integrations/copilot/.github/hooks/lib/decide.mjs +++ b/integrations/copilot/.github/hooks/lib/decide.mjs @@ -392,6 +392,21 @@ export function touchedFiles(payload) { add(input.path); add(input.notebook_path); + // Codex code mode carries apply_patch as the nested program text rather than + // as a first-class file_path. Without reading these headers, successful + // code-mode edits incremented the semantic-harvest counter but produced no + // structural file/symbol nodes, which is why the live dashboard showed very + // few nodes after substantial work. PostToolUse can resolve Add/Update paths + // because the file now exists; deleted paths deliberately remain absent. + for (const match of command.matchAll( + /^\*\*\* (?:Add|Update|Delete) File:\s*(.+)$/gm + )) { + add(match[1].trim().replace(/^['"]|['"]$/g, '')); + } + for (const match of command.matchAll(/^\*\*\* Move to:\s*(.+)$/gm)) { + add(match[1].trim().replace(/^['"]|['"]$/g, '')); + } + // EVERY pipeline segment, not just the first. `fileOperands` looks only at // the head of the pipeline because that is where the COST is -- `git log | // head` must not be mistaken for a file dump. But observation is a different diff --git a/integrations/copilot/.github/hooks/lib/experiment.mjs b/integrations/copilot/.github/hooks/lib/experiment.mjs index 1f64713f1..c35c6cf5c 100644 --- a/integrations/copilot/.github/hooks/lib/experiment.mjs +++ b/integrations/copilot/.github/hooks/lib/experiment.mjs @@ -67,16 +67,71 @@ export function episodeMeta({ client, raw = {}, payload = {}, env = process.env } export function usageFrom(raw = {}) { - const usage = raw.usage || raw.token_usage || raw.tokenUsage || raw.metrics?.usage || {}; + const usage = raw.usage || raw.token_usage || raw.tokenUsage || raw.usageMetadata || + raw.usage_metadata || raw.metrics?.usage || raw.response?.usage || + raw.response?.usageMetadata || {}; const number = (...values) => { const value = first(...values); const parsed = Number(value); return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; }; + const cachedInputTokens = number( + usage.cached_input_tokens, usage.cachedInputTokens, + usage.cache_read_input_tokens, usage.cachedContentTokenCount + ); + const cacheCreation = usage.cache_creation || usage.cacheCreation || {}; + const cacheWrite5mInputTokens = number( + usage.cache_write_5m_input_tokens, usage.cacheWrite5mInputTokens, + cacheCreation.ephemeral_5m_input_tokens, cacheCreation.ephemeral5mInputTokens + ); + const cacheWrite1hInputTokens = number( + usage.cache_write_1h_input_tokens, usage.cacheWrite1hInputTokens, + cacheCreation.ephemeral_1h_input_tokens, cacheCreation.ephemeral1hInputTokens + ); + const detailedWrites = (cacheWrite5mInputTokens || 0) + (cacheWrite1hInputTokens || 0); + const reportedWrites = number( + usage.cache_write_input_tokens, usage.cacheWriteInputTokens, + usage.cache_creation_input_tokens, usage.cacheCreationInputTokens + ); + const cacheWriteInputTokens = reportedWrites == null + ? null + : Math.max(0, reportedWrites - detailedWrites); + const explicitUncached = number(usage.uncached_input_tokens, usage.uncachedInputTokens); + const promptInput = number( + usage.promptTokenCount, usage.prompt_token_count, + usage.input_tokens, usage.inputTokens + ); + const isAnthropic = usage.cache_creation_input_tokens !== undefined || + usage.cache_read_input_tokens !== undefined; + const isGemini = usage.promptTokenCount !== undefined || usage.cachedContentTokenCount !== undefined; + const uncachedInputTokens = explicitUncached != null + ? explicitUncached + : promptInput == null + ? null + : isAnthropic + ? promptInput + : isGemini || cachedInputTokens != null || reportedWrites != null + ? Math.max(0, promptInput - (cachedInputTokens || 0) - (reportedWrites || 0)) + : promptInput; + const candidates = number(usage.candidatesTokenCount, usage.candidates_token_count); + const thoughts = number(usage.thoughtsTokenCount, usage.thoughts_token_count); + const outputTokens = candidates != null || thoughts != null + ? (candidates || 0) + (thoughts || 0) + : number(usage.output_tokens, usage.outputTokens); return { - uncachedInputTokens: number(usage.uncached_input_tokens, usage.uncachedInputTokens), - cachedInputTokens: number(usage.cached_input_tokens, usage.cachedInputTokens, usage.cache_read_input_tokens), - outputTokens: number(usage.output_tokens, usage.outputTokens), - totalTokens: number(usage.total_tokens, usage.totalTokens, raw.tokens_used, raw.tokensUsed), + uncachedInputTokens, + cachedInputTokens, + cacheWrite5mInputTokens, + cacheWrite1hInputTokens, + cacheWriteInputTokens, + outputTokens, + totalTokens: number( + usage.total_tokens, usage.totalTokens, usage.totalTokenCount, + usage.total_token_count, raw.tokens_used, raw.tokensUsed + ), + usageMeasurementId: first( + raw.requestId, raw.request_id, raw.responseId, raw.response_id, + usage.responseId, usage.response_id + ) ?? null, }; } diff --git a/integrations/copilot/.github/hooks/lib/harvest-write.mjs b/integrations/copilot/.github/hooks/lib/harvest-write.mjs index 30433d370..db436c1dc 100644 --- a/integrations/copilot/.github/hooks/lib/harvest-write.mjs +++ b/integrations/copilot/.github/hooks/lib/harvest-write.mjs @@ -24,7 +24,7 @@ */ import { - putNodeWithEdges, load, nodeId, sharedDir, isSharedDir, putNode, + putNodeWithEdges, load, nodeId, sharedDir, isSharedDir, putNode, putEdge, } from './wiki.mjs'; import { indexFile } from './staleness.mjs'; import { symbolKey } from './symbols.mjs'; @@ -47,6 +47,37 @@ import { ORIGIN_HARVESTED, ORIGIN_AGENT, ORIGIN_HUMAN } from './curate.mjs'; */ export const SHAREABLE_TYPES = new Set(['command', 'failure', 'decision', 'feedback']); +/** + * Synthetic study traffic must never become advice for an unrelated live repo. + * Evaluation roots are intentionally realistic and may carry their own `.git` + * marker, so repository discovery alone cannot distinguish them. Publication + * remains useful inside the isolated study store; this guard protects only the + * default machine-wide store when a harness was accidentally run without an + * isolated TOKEN_OPTIMIZER_SHARED_DIR. + */ +export function isEphemeralProject(projectRoot) { + const root = canonicalPath(projectRoot || '').toLowerCase(); + if (!root) return true; + return ( + /\/appdata\/local\/temp\//.test(root) || + /(^|\/)tmp\//.test(root) || + /\/(artifacts|\.token-optimizer)\/(?:[^/]*\/)*(?:eval|scenario|study|fixture|debug)[^/]*\//.test(`${root}/`) + ); +} + +export function quarantineSharedSource(projectRoot) { + if (process.env.TOKEN_OPTIMIZER_ALLOW_EPHEMERAL_SHARED === '1') return false; + if (!isEphemeralProject(projectRoot)) return false; + // Isolated studies deliberately point the shared tier at scratch storage. + // Their findings may cross their own fixture projects, but cannot escape into + // the user's default machine-wide graph. Strict mode exists for a regression + // probe that verifies the guard without writing to a real user directory. + return ( + !process.env.TOKEN_OPTIMIZER_SHARED_DIR || + process.env.TOKEN_OPTIMIZER_STRICT_SHARED_PROVENANCE === '1' + ); +} + /** Claim text, normalised enough that the same lesson learned twice is one row. */ const claimFingerprint = (claim) => String(claim || '').toLowerCase().replace(/\s+/g, ' ').trim().slice(0, 300); @@ -72,6 +103,7 @@ function promoteToShared(finding, { projectRoot, sessionId, provenance, key }) { if (!projectRoot) return false; const root = canonicalPath(projectRoot); + if (quarantineSharedSource(root)) return false; const rootId = nodeId('file', root); // Same claim, already carried up from anywhere: keep the first. Two repos @@ -367,11 +399,21 @@ export function writeHarvested( && !node.retired && node.type === finding.type && claimFingerprint(node.claim) === fingerprint - && resolved.every((target) => currentGraph.edges.some((edge) => - edge.from === node.id && edge.edge === 'derived_from' && edge.to === target - )) ); if (duplicate) { + // Enrich, do not duplicate. A symbol extractor may learn how to resolve a + // previously file-only anchor (or a later session may add another real + // anchor) after the claim was first stored. Repeating the same semantic + // conclusion should connect that existing node to the newly available + // evidence, not create a second dashboard card with the same text. + for (const target of resolved) { + const alreadyLinked = currentGraph.edges.some((edge) => + edge.from === duplicate.id + && edge.edge === 'derived_from' + && edge.to === target + ); + if (!alreadyLinked) putEdge(dir, duplicate.id, 'derived_from', target); + } written.push(duplicate.key); continue; } @@ -437,7 +479,12 @@ export function writeHarvested( if ( id && !isSharedDir(dir) - && (finding.scope === undefined || finding.scope === 'organization' || finding.scope === 'global') + // Cross-project publication is explicit. `writeHarvested` stores an + // omitted scope as project-local above, so treating `undefined` as global + // here contradicted the stored record and promoted fixture/evaluation + // seeds into real user sessions. This was observed live as "learned in + // scenario" advice while working in AiDotNet. + && (finding.scope === 'organization' || finding.scope === 'global') ) { promoteToShared(finding, { projectRoot, sessionId, provenance, key }); } diff --git a/integrations/copilot/.github/hooks/lib/inject.mjs b/integrations/copilot/.github/hooks/lib/inject.mjs index b00494a01..c0aa2f1bf 100644 --- a/integrations/copilot/.github/hooks/lib/inject.mjs +++ b/integrations/copilot/.github/hooks/lib/inject.mjs @@ -31,6 +31,7 @@ import { canonicalPath, resolvableCandidates } from './paths.mjs'; import { annotatedSkeleton } from './skeleton.mjs'; import { substitutionBudget } from './metrics.mjs'; import { assessFindings } from './utility.mjs'; +import { quarantineSharedSource } from './harvest-write.mjs'; // Read per call for the same reason as the holdout fraction in metrics.mjs. const touchBudget = () => Number(process.env.TOKEN_OPTIMIZER_TOUCH_BUDGET) || 500; @@ -562,6 +563,7 @@ export function forRepeatedAct( const home = projectRoot ? canonicalPath(projectRoot) : null; for (const node of graph.nodes.values()) { if (node.kind !== 'finding' || node.retired || !node.claim) continue; + if (quarantineSharedSource(node.sourceProject)) continue; if (home && node.sourceProject && canonicalPath(node.sourceProject) === home) continue; const claimClasses = classesOf(node.claim, 'claim'); if (![...crossedClasses].some((c) => claimClasses.has(c))) continue; @@ -667,6 +669,7 @@ export function forSharedCommand( const candidates = []; for (const node of graph.nodes.values()) { if (node.kind !== 'finding' || node.retired) continue; + if (quarantineSharedSource(node.sourceProject)) continue; if (alreadyInjected.has(node.key)) continue; // ITS OWN LESSON IS NOT NEWS. A finding this project contributed is already // served by the local path; repeating it under a "from elsewhere" label @@ -1054,7 +1057,13 @@ export function standingRules(dir, graph, { budget = standingBudget(), episode = * informative response available -- more useful than the file, not a lossier * version of it. */ -export function substitutionFor(dir, graph, rawPath, source, { sessionId } = {}) { +export function substitutionFor( + dir, + graph, + rawPath, + source, + { sessionId, client = null, clientVersion = null, model = null, modelVersion = null } = {} +) { const filePath = canonicalPath(rawPath); const budget = substitutionBudget(dir, filePath); const built = annotatedSkeleton(graph, rawPath, source, { budget }); @@ -1089,6 +1098,10 @@ export function substitutionFor(dir, graph, rawPath, source, { sessionId } = {}) // on this machine turned out to be the enforcement suite's own fixture and // nothing distinguished them from real work. sessionId, + client, + clientVersion, + model, + modelVersion, holdout, tokens: holdout ? 0 : built.tokens, findings: built.findings, diff --git a/integrations/copilot/.github/hooks/lib/metrics.mjs b/integrations/copilot/.github/hooks/lib/metrics.mjs index 0bb08f1bc..09f822630 100644 --- a/integrations/copilot/.github/hooks/lib/metrics.mjs +++ b/integrations/copilot/.github/hooks/lib/metrics.mjs @@ -19,8 +19,15 @@ */ import { - appendFileSync, readFileSync, existsSync, mkdirSync, chmodSync, - statSync, openSync, readSync, closeSync, + appendFileSync, + readFileSync, + existsSync, + mkdirSync, + chmodSync, + statSync, + openSync, + readSync, + closeSync, } from 'node:fs'; import { join } from 'node:path'; import { createHash, randomBytes } from 'node:crypto'; @@ -86,6 +93,10 @@ export const BALANCE_KINDS = new Set([ 'keepwarm', ]); +/** Latest timestamp without passing an unbounded event log as function arguments. */ +export const latestEventTimestamp = (rows) => + rows.reduce((latest, event) => Math.max(latest, Number(event.at) || 0), 0); + /** * Causal records have their own bounded log. Tool outcomes are much rarer * than read/capture telemetry but more frequent than balance records; mixing @@ -130,7 +141,7 @@ export function inHoldout(anchorKey, now = Date.now()) { // other side than before. With nine holdout records in existence that costs // nothing, and stratification is a fresh draw either way. const digest = createHash('sha256').update(`${anchorKey}:${epoch}`).digest(); - return (digest[0] / 256) < fraction; + return digest[0] / 256 < fraction; } /** @@ -258,7 +269,8 @@ export function record(dir, event) { * It is also the right statistics. A saving measured months ago says little * about the code as it stands now. */ -const MAX_BYTES = Number(process.env.TOKEN_OPTIMIZER_METRICS_BYTES) || 2_000_000; +const MAX_BYTES = + Number(process.env.TOKEN_OPTIMIZER_METRICS_BYTES) || 2_000_000; const MAX_EVENTS = Number(process.env.TOKEN_OPTIMIZER_METRICS_WINDOW) || 5000; /** @@ -432,7 +444,10 @@ export function readBalance(dir) { // path for records written before ids existed; it is lossy by nature, which // is precisely why new records carry an id instead. const id = - e.id ?? [e.kind, e.at, e.anchor ?? '', e.sessionId ?? '', e.tokens ?? ''].join('|'); + e.id ?? + [e.kind, e.at, e.anchor ?? '', e.sessionId ?? '', e.tokens ?? ''].join( + '|' + ); if (seen.has(id)) continue; seen.add(id); out.push(e); @@ -493,16 +508,24 @@ export function recordToolOutcome(dir, outcome) { const evidence = readEvidence(dir); const anchor = String(outcome.anchor || '').slice(0, 120); const candidates = evidence - .filter((event) => event.kind === 'inject' && event.episodeId === outcome.episodeId) + .filter( + (event) => + event.kind === 'inject' && event.episodeId === outcome.episodeId + ) .filter((event) => { if (outcome.toolCallId && event.toolCallId) return String(event.toolCallId) === String(outcome.toolCallId); - return event.surface === outcome.surface && String(event.anchor || '').slice(0, 120) === anchor; + return ( + event.surface === outcome.surface && + String(event.anchor || '').slice(0, 120) === anchor + ); }) .sort((a, b) => (b.at || 0) - (a.at || 0)); const injection = candidates[0] || null; const joinMethod = injection - ? (outcome.toolCallId && injection.toolCallId ? 'tool-call-id' : 'episode-anchor') + ? outcome.toolCallId && injection.toolCallId + ? 'tool-call-id' + : 'episode-anchor' : 'none'; return record(dir, { @@ -542,12 +565,11 @@ export function isFixtureAnchor(anchor) { // code: it excluded ALL temp paths -- including a real project checked out // under /tmp -- and on macOS excluded nothing at all, because tmpdir() there // is /var/folders///T/ and did not match. - const underTemp = - new RegExp( - '[\\\\/](AppData[\\\\/]Local[\\\\/])?(Temp|tmp)[\\\\/]' + - '|[\\\\/]var[\\\\/]folders[\\\\/][^\\\\/]+[\\\\/][^\\\\/]+[\\\\/]T[\\\\/]', - 'i' - ).test(p); + const underTemp = new RegExp( + '[\\\\/](AppData[\\\\/]Local[\\\\/])?(Temp|tmp)[\\\\/]' + + '|[\\\\/]var[\\\\/]folders[\\\\/][^\\\\/]+[\\\\/][^\\\\/]+[\\\\/]T[\\\\/]', + 'i' + ).test(p); if (!underTemp) return false; // Only names this suite actually creates. A user's own scratch checkout under @@ -686,8 +708,12 @@ export function balanceSheet(dir) { // THE CONTROL ARM'S ACTUAL COST. `tokensFullFile` was recorded and read // by nothing, so the comparison the holdout exists for was not // computable from the report. - controlArmTokens: withheldSubs.reduce((sum, e) => sum + (e.tokensFullFile || 0), 0), - assumption: 'that the model would have read the file it explicitly requested', + controlArmTokens: withheldSubs.reduce( + (sum, e) => sum + (e.tokensFullFile || 0), + 0 + ), + assumption: + 'that the model would have read the file it explicitly requested', }, estimatedCausal: { what: 'findings injection, from the stratified holdout', @@ -702,7 +728,12 @@ export function balanceSheet(dir) { }; })(), }, - costs: { injection: injectCost, harvest: harvestCost, standing: standingCost, substitution: substitutionCost }, + costs: { + injection: injectCost, + harvest: harvestCost, + standing: standingCost, + substitution: substitutionCost, + }, waste, // Deliberately NOT a single number. The two benefit lines are known // differently; adding them would launder an assumption into a measurement. @@ -727,7 +758,9 @@ export function balanceSheet(dir) { /** Cheap path normalisation for grouping reads; not the identity canonicaliser. */ function canonicalKeyish(p) { - return String(p || '').replace(/\\/g, '/').toLowerCase(); + return String(p || '') + .replace(/\\/g, '/') + .toLowerCase(); } /** @@ -738,7 +771,43 @@ function canonicalKeyish(p) { * arms, multiplied by treated touches. Reporting it as a measured fact would be * the same overclaiming this project criticises competitors for. */ -function buildReport(events, balance) { +function buildReport(events, balance, sourceCoverage = {}) { + const substitutions = balance.filter( + (event) => event.kind === 'substitute' && !isFixtureAnchor(event.anchor) + ); + const servedSubstitutions = substitutions.filter((event) => !event.holdout); + const substitutionTokensSaved = servedSubstitutions.reduce( + (sum, event) => + sum + + (event.tokensNetAvoided ?? + Math.max( + 0, + Math.ceil((event.bytesAvoided || 0) / 4) - (event.tokens || 0) + )), + 0 + ); + const substitutionTokensReturned = servedSubstitutions.reduce( + (sum, event) => sum + (event.tokens || 0), + 0 + ); + const substitutionByClient = {}; + for (const event of servedSubstitutions) { + const name = event.client || 'Historical — client not recorded'; + const current = substitutionByClient[name] || { + substitutions: 0, + tokensReturned: 0, + tokensSaved: 0, + }; + current.substitutions += 1; + current.tokensReturned += event.tokens || 0; + current.tokensSaved += + event.tokensNetAvoided ?? + Math.max( + 0, + Math.ceil((event.bytesAvoided || 0) / 4) - (event.tokens || 0) + ); + substitutionByClient[name] = current; + } // COMMAND INJECTIONS ARE COUNTED SEPARATELY, not mixed into the balance. // @@ -762,13 +831,19 @@ function buildReport(events, balance) { // Report its cost independently so it cannot dilute the file-touch estimate. const isSessionStart = (e) => e.surface === 'session-start'; const sessionStartInjections = allInjections.filter(isSessionStart); - const commandInjections = allInjections.filter((e) => !isSessionStart(e) && isCommand(e)); - const injections = allInjections.filter((e) => !isSessionStart(e) && !isCommand(e)); + const commandInjections = allInjections.filter( + (e) => !isSessionStart(e) && isCommand(e) + ); + const injections = allInjections.filter( + (e) => !isSessionStart(e) && !isCommand(e) + ); const treated = injections.filter((e) => !e.holdout); const withheld = injections.filter((e) => e.holdout); const mean = (rows, field) => - rows.length ? rows.reduce((sum, r) => sum + (r[field] || 0), 0) / rows.length : 0; + rows.length + ? rows.reduce((sum, r) => sum + (r[field] || 0), 0) / rows.length + : 0; const injectedTokens = treated.reduce((sum, e) => sum + (e.tokens || 0), 0); const harvestTokens = balance @@ -819,7 +894,9 @@ function buildReport(events, balance) { const after = event.at ?? 0; const total = bucket.reduce( - (sum, r) => sum + ((r.at ?? 0) >= after ? (r.tokens || 0) : 0), 0); + (sum, r) => sum + ((r.at ?? 0) >= after ? r.tokens || 0 : 0), + 0 + ); // Split across the injections that share this key. Each touch is one // observation, so charging every one the full total would count the same @@ -828,52 +905,198 @@ function buildReport(events, balance) { }; const meanDownstream = (rows) => - rows.length ? rows.reduce((sum, r) => sum + downstreamOf(r), 0) / rows.length : 0; + rows.length + ? rows.reduce((sum, r) => sum + downstreamOf(r), 0) / rows.length + : 0; const treatedCost = meanDownstream(treated); const withheldCost = meanDownstream(withheld); const perTouchSaving = withheldCost - treatedCost; - const estimatedAvoided = Math.max(0, Math.round(perTouchSaving * treated.length)); + const estimatedAvoided = Math.max( + 0, + Math.round(perTouchSaving * treated.length) + ); + + const downstreamSamples = injections.filter((event) => { + if (event.downstream != null) return true; + const key = `${event.sessionId || ''}|${event.anchor}`; + return (reads.get(key) || []).some( + (read) => (read.at ?? 0) >= (event.at ?? 0) + ); + }).length; + const downstreamMeasured = downstreamSamples > 0; // Below this, arm means are noise and a ratio would be theatre. - const sufficient = treated.length >= 20 && withheld.length >= 5; + const sufficient = + downstreamMeasured && treated.length >= 20 && withheld.length >= 5; + + const coverageNumber = (value, fallback) => { + if (value === null || value === undefined || value === '') return fallback; + const number = Number(value); + return Number.isFinite(number) ? Math.max(0, number) : fallback; + }; + const projectCount = coverageNumber(sourceCoverage.projects, 1); + const telemetryProjects = coverageNumber( + sourceCoverage.projectsWithTelemetry, + 0 + ); + const balanceProjects = coverageNumber( + sourceCoverage.projectsWithBalanceEvents, + balance.length ? 1 : 0 + ); + const lastEventAt = Math.max( + latestEventTimestamp(events), + latestEventTimestamp(balance) + ); + const measured = + telemetryProjects > 0 || events.length > 0 || balance.length > 0; + const balanceMeasured = balanceProjects > 0 || balance.length > 0; + const metric = (status, source, samples, extra = {}) => ({ + status, + source, + samples, + ...extra, + }); return { + nativeOptimizer: { + substitutions: servedSubstitutions.length, + holdouts: substitutions.filter((event) => event.holdout).length, + tokensReturned: substitutionTokensReturned, + tokensSaved: substitutionTokensSaved, + byClient: substitutionByClient, + recent: servedSubstitutions + .slice() + .sort((a, b) => (b.at || 0) - (a.at || 0)) + .slice(0, 40) + .map((event) => { + const tokensSaved = + event.tokensNetAvoided ?? + Math.max( + 0, + Math.ceil((event.bytesAvoided || 0) / 4) - (event.tokens || 0) + ); + return { + name: 'live_graph_substitution', + client: event.client || 'Historical — client not recorded', + originalTokens: (event.tokens || 0) + tokensSaved, + optimizedTokens: event.tokens || 0, + tokensSaved, + savingsMeasured: false, + classification: 'modeled-counterfactual', + reportedTokensSaved: tokensSaved, + timestamp: event.at ? new Date(event.at).toISOString() : null, + }; + }), + source: + 'balance.jsonl: modeled full-file counterfactual minus annotated-skeleton tokens; excluded from verified MCP savings', + }, memoryDeliveries: allInjections.filter((event) => !event.holdout).length, memoryHoldouts: allInjections.filter((event) => event.holdout).length, deliveryTokens: allInjections .filter((event) => !event.holdout) .reduce( - (sum, event) => - sum + (event.deliveredTokens ?? event.tokens ?? 0), + (sum, event) => sum + (event.deliveredTokens ?? event.tokens ?? 0), 0 ), injections: treated.length, sessionStartInjections: sessionStartInjections.length, sessionStartInjectedTokens: sessionStartInjections.reduce( - (sum, event) => sum + (event.deliveredTokens ?? event.tokens ?? 0), 0 + (sum, event) => sum + (event.deliveredTokens ?? event.tokens ?? 0), + 0 ), commandInjections: commandInjections.length, commandHoldouts: commandInjections.filter((e) => e.holdout).length, holdouts: withheld.length, staleServed: injections.filter((e) => e.stale).length, - staleRate: injections.length ? injections.filter((e) => e.stale).length / injections.length : 0, + staleRate: injections.length + ? injections.filter((e) => e.stale).length / injections.length + : 0, injectedTokens, harvestTokens, estimatedTokensAvoided: sufficient ? estimatedAvoided : null, - netTokens: sufficient ? estimatedAvoided - injectedTokens - harvestTokens : null, + netTokens: sufficient + ? estimatedAvoided - injectedTokens - harvestTokens + : null, sufficientData: sufficient, verdict: !sufficient ? `insufficient data (${treated.length} treated, ${withheld.length} holdout; need 20 and 5)` : estimatedAvoided > injectedTokens + harvestTokens ? 'the graph is saving more than it costs' : 'the graph is NOT yet paying for itself', + measurement: { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + sourceCoverage: { + projects: projectCount, + projectsWithTelemetry: telemetryProjects || (measured ? 1 : 0), + projectsWithBalanceEvents: balanceProjects, + projectsWithoutTelemetry: Math.max( + 0, + projectCount - (telemetryProjects || (measured ? 1 : 0)) + ), + }, + freshness: { + lastEventAt: lastEventAt || null, + ageMs: lastEventAt ? Math.max(0, Date.now() - lastEventAt) : null, + status: !lastEventAt + ? 'not-measured' + : Date.now() - lastEventAt <= 7 * 86_400_000 + ? 'fresh' + : 'stale', + }, + metrics: { + nativeSubstitutions: metric( + balanceMeasured ? 'measured' : 'not-measured', + 'balance.jsonl: modeled full-file counterfactual minus annotated-skeleton tokens; not a materialized MCP before/after pair', + servedSubstitutions.length, + { classification: 'modeled-counterfactual' } + ), + memoryDeliveries: metric( + balanceMeasured ? 'measured' : 'not-measured', + 'balance.jsonl: inject events in the treated arm', + allInjections.filter((event) => !event.holdout).length + ), + memoryHoldouts: metric( + balanceMeasured ? 'measured' : 'not-measured', + 'balance.jsonl: inject events in the withheld arm', + allInjections.filter((event) => event.holdout).length + ), + rememberingCost: metric( + balanceMeasured ? 'measured' : 'not-measured', + 'balance.jsonl: delivered injection tokens plus semantic harvest tokens', + allInjections.length + + balance.filter((event) => event.kind === 'harvest').length + ), + readingAvoided: metric( + !downstreamMeasured + ? 'not-measured' + : sufficient + ? 'measured' + : 'collecting', + 'stratified file-touch holdout joined to downstream read events', + downstreamSamples, + { + treated: treated.length, + holdouts: withheld.length, + requiredTreated: 20, + requiredHoldouts: 5, + } + ), + }, + }, }; } export function report(dir) { - return buildReport(readAll(dir), readBalance(dir)); + const events = readAll(dir); + const balance = readBalance(dir); + return buildReport(events, balance, { + projects: 1, + projectsWithTelemetry: events.length || balance.length ? 1 : 0, + projectsWithBalanceEvents: balance.length ? 1 : 0, + }); } /** @@ -886,10 +1109,16 @@ export function report(dir) { */ export function reportMany(dirs) { const unique = [...new Set((dirs || []).map((dir) => String(dir)))]; - const report = buildReport( - unique.flatMap((dir) => readAll(dir)), - unique.flatMap((dir) => readBalance(dir)) - ); + const eventSources = unique.map((dir) => readAll(dir)); + const balanceSources = unique.map((dir) => readBalance(dir)); + const report = buildReport(eventSources.flat(), balanceSources.flat(), { + projects: unique.length, + projectsWithTelemetry: eventSources.filter( + (events, index) => events.length || balanceSources[index].length + ).length, + projectsWithBalanceEvents: balanceSources.filter((events) => events.length) + .length, + }); return { ...report, projects: unique.length }; } @@ -907,7 +1136,11 @@ export function reportMany(dirs) { * costs almost nothing. Without a control arm there is no way to tell those two * apart, which is why nobody else can do this. */ -export function substitutionBudget(dir, anchor, { floor = 300, base = 1200, ceiling = 3000 } = {}) { +export function substitutionBudget( + dir, + anchor, + { floor = 300, base = 1200, ceiling = 3000 } = {} +) { const events = readAll(dir); const mine = events.filter((e) => e.kind === 'inject' && e.anchor === anchor); @@ -932,13 +1165,17 @@ export function substitutionBudget(dir, anchor, { floor = 300, base = 1200, ceil for (const row of rows) { const bucket = reads.get(row.sessionId || '') || []; const after = row.at ?? 0; - total += bucket.reduce((sum, r) => sum + ((r.at ?? 0) >= after ? (r.tokens || 0) : 0), 0); + total += bucket.reduce( + (sum, r) => sum + ((r.at ?? 0) >= after ? r.tokens || 0 : 0), + 0 + ); } return total / rows.length; }; const saved = downstream(withheld) - downstream(treated); - const spent = treated.reduce((sum, e) => sum + (e.tokens || 0), 0) / treated.length; + const spent = + treated.reduce((sum, e) => sum + (e.tokens || 0), 0) / treated.length; // Ratio of what annotating this file avoided to what annotating it cost. // Above 1 it is paying for itself and earns room; below, it shrinks. @@ -959,7 +1196,10 @@ export function substitutionBudget(dir, anchor, { floor = 300, base = 1200, ceil * graph whose index leads to queries grows its allowance; a noisy one shrinks * back toward the floor. Nobody configures it, and it cannot run away. */ -export function indexBudget(dir, { floor = 150, base = 300, ceiling = 1200 } = {}) { +export function indexBudget( + dir, + { floor = 150, base = 300, ceiling = 1200 } = {} +) { const events = readAll(dir); const listed = events.filter((e) => e.kind === 'index').length; const queries = events.filter((e) => e.kind === 'query').length; @@ -968,7 +1208,9 @@ export function indexBudget(dir, { floor = 150, base = 300, ceiling = 1200 } = { const hitRate = queries / listed; // 0% hit rate falls to the floor; ~50% and above reaches the ceiling. - const scaled = Math.round(floor + (ceiling - floor) * Math.min(1, hitRate * 2)); + const scaled = Math.round( + floor + (ceiling - floor) * Math.min(1, hitRate * 2) + ); return Math.max(floor, Math.min(ceiling, scaled)); } @@ -1005,7 +1247,10 @@ function seededRandom(seedText) { } /** Percentile bootstrap interval for a mean, paired delta, or other scalar. */ -export function bootstrapMeanInterval(values, { samples = 1000, seed = 'token-optimizer' } = {}) { +export function bootstrapMeanInterval( + values, + { samples = 1000, seed = 'token-optimizer' } = {} +) { const finite = values.map(numeric).filter((value) => value !== null); if (!finite.length) return { mean: null, low: null, high: null, n: 0 }; if (finite.length === 1) { @@ -1037,24 +1282,32 @@ export function proportionInterval(successes, total, z = 1.96) { const denominator = 1 + (z * z) / total; const centre = (rate + (z * z) / (2 * total)) / denominator; const radius = - (z / denominator) * Math.sqrt((rate * (1 - rate)) / total + (z * z) / (4 * total * total)); - return { rate, low: Math.max(0, centre - radius), high: Math.min(1, centre + radius), n: total }; + (z / denominator) * + Math.sqrt((rate * (1 - rate)) / total + (z * z) / (4 * total * total)); + return { + rate, + low: Math.max(0, centre - radius), + high: Math.min(1, centre + radius), + n: total, + }; } -const cohortKey = (run) => [ - run.client || 'unknown', - run.clientVersion || 'unknown', - run.model || 'unknown', - run.modelVersion || 'unknown', - run.taskId || 'unknown', -].join('|'); +const cohortKey = (run) => + [ + run.client || 'unknown', + run.clientVersion || 'unknown', + run.model || 'unknown', + run.modelVersion || 'unknown', + run.taskId || 'unknown', + ].join('|'); function armMetrics(runs) { const correct = runs.filter((run) => run.correct === true).length; - const interval = (field) => bootstrapMeanInterval( - runs.map((run) => run[field]).filter((value) => numeric(value) !== null), - { seed: field } - ); + const interval = (field) => + bootstrapMeanInterval( + runs.map((run) => run[field]).filter((value) => numeric(value) !== null), + { seed: field } + ); return { runs: runs.length, correctness: proportionInterval(correct, runs.length), @@ -1068,29 +1321,40 @@ function armMetrics(runs) { latencyMs: interval('latencyMs'), costUsd: interval('costUsd'), injectedTokens: interval('injectedTokens'), - harmfulFindings: runs.reduce((sum, run) => sum + (numeric(run.harmfulFindings) || 0), 0), + harmfulFindings: runs.reduce( + (sum, run) => sum + (numeric(run.harmfulFindings) || 0), + 0 + ), }; } -function pairedEffects(runs, controlArm, treatmentArm, contrastType = 'incremental') { +function pairedEffects( + runs, + controlArm, + treatmentArm, + contrastType = 'incremental' +) { const pairs = new Map(); for (const run of runs) { if (!run.pairId || ![controlArm, treatmentArm].includes(run.arm)) continue; if (!pairs.has(run.pairId)) pairs.set(run.pairId, {}); pairs.get(run.pairId)[run.arm] = run; } - const complete = [...pairs.values()].filter((pair) => pair[controlArm] && pair[treatmentArm]); - const delta = (field, lowerIsBetter = true) => bootstrapMeanInterval( - complete - .map((pair) => { - const baseline = numeric(pair[controlArm][field]); - const treatment = numeric(pair[treatmentArm][field]); - if (baseline === null || treatment === null) return null; - return lowerIsBetter ? baseline - treatment : treatment - baseline; - }) - .filter((value) => value !== null), - { seed: `${controlArm}:${treatmentArm}:${field}` } + const complete = [...pairs.values()].filter( + (pair) => pair[controlArm] && pair[treatmentArm] ); + const delta = (field, lowerIsBetter = true) => + bootstrapMeanInterval( + complete + .map((pair) => { + const baseline = numeric(pair[controlArm][field]); + const treatment = numeric(pair[treatmentArm][field]); + if (baseline === null || treatment === null) return null; + return lowerIsBetter ? baseline - treatment : treatment - baseline; + }) + .filter((value) => value !== null), + { seed: `${controlArm}:${treatmentArm}:${field}` } + ); return { arm: treatmentArm, controlArm, @@ -1111,21 +1375,32 @@ function pairedEffects(runs, controlArm, treatmentArm, contrastType = 'increment } function matchesFilters(event, filters) { - for (const field of ['client', 'clientVersion', 'model', 'modelVersion', 'taskId', 'arm']) { + for (const field of [ + 'client', + 'clientVersion', + 'model', + 'modelVersion', + 'taskId', + 'arm', + ]) { if (!filters[field]) continue; let actual = event[field]; if (event.kind === 'handoff-run') { - actual = field === 'taskId' - ? event.scenarioId - : field === 'arm' - ? event.arm - : (event.consumer?.[field] ?? event.producer?.[field] ?? event[field]); + actual = + field === 'taskId' + ? event.scenarioId + : field === 'arm' + ? event.arm + : (event.consumer?.[field] ?? + event.producer?.[field] ?? + event[field]); } else if (event.kind === 'concurrency-run') { - actual = field === 'taskId' - ? 'concurrent-combined' - : field === 'arm' - ? event.arm - : (event.consumer?.[field] ?? event[field]); + actual = + field === 'taskId' + ? 'concurrent-combined' + : field === 'arm' + ? event.arm + : (event.consumer?.[field] ?? event[field]); } if (String(actual || '') !== String(filters[field])) return false; } @@ -1134,11 +1409,15 @@ function matchesFilters(event, filters) { function handoffArmMetrics(runs) { const consumers = runs.map((run) => run.consumer || {}); - const count = (field) => consumers.filter((consumer) => consumer[field] === true).length; - const interval = (field) => bootstrapMeanInterval( - consumers.map((consumer) => consumer[field]).filter((value) => numeric(value) !== null), - { seed: `handoff:${field}` } - ); + const count = (field) => + consumers.filter((consumer) => consumer[field] === true).length; + const interval = (field) => + bootstrapMeanInterval( + consumers + .map((consumer) => consumer[field]) + .filter((value) => numeric(value) !== null), + { seed: `handoff:${field}` } + ); return { runs: runs.length, delivery: proportionInterval( @@ -1147,7 +1426,10 @@ function handoffArmMetrics(runs) { ), correctness: proportionInterval(count('correct'), runs.length), firstPass: proportionInterval(count('firstPass'), runs.length), - mistakeAttempted: proportionInterval(count('mistakeAttempted'), runs.length), + mistakeAttempted: proportionInterval( + count('mistakeAttempted'), + runs.length + ), mistakeExecuted: proportionInterval(count('mistakeExecuted'), runs.length), totalTokens: interval('totalTokens'), toolCalls: interval('toolCalls'), @@ -1163,16 +1445,21 @@ function handoffPairedEffect(runs, controlArm, treatmentArm) { if (!pairs.has(run.pairId)) pairs.set(run.pairId, {}); pairs.get(run.pairId)[run.arm] = run; } - const complete = [...pairs.values()].filter((pair) => pair[controlArm] && pair[treatmentArm]); - const effect = (field, positiveWhenLower = true) => bootstrapMeanInterval( - complete.map((pair) => { - const control = numeric(pair[controlArm].consumer?.[field]); - const treatment = numeric(pair[treatmentArm].consumer?.[field]); - if (control === null || treatment === null) return null; - return positiveWhenLower ? control - treatment : treatment - control; - }).filter((value) => value !== null), - { seed: `handoff:${controlArm}:${treatmentArm}:${field}` } + const complete = [...pairs.values()].filter( + (pair) => pair[controlArm] && pair[treatmentArm] ); + const effect = (field, positiveWhenLower = true) => + bootstrapMeanInterval( + complete + .map((pair) => { + const control = numeric(pair[controlArm].consumer?.[field]); + const treatment = numeric(pair[treatmentArm].consumer?.[field]); + if (control === null || treatment === null) return null; + return positiveWhenLower ? control - treatment : treatment - control; + }) + .filter((value) => value !== null), + { seed: `handoff:${controlArm}:${treatmentArm}:${field}` } + ); return { comparison: `${treatmentArm} vs ${controlArm}`, pairs: complete.length, @@ -1187,13 +1474,22 @@ function handoffPairedEffect(runs, controlArm, treatmentArm) { }; } -const handoffKey = (run) => [ - run.producer?.client || 'unknown', run.producer?.model || 'unknown', - run.consumer?.client || 'unknown', run.consumer?.model || 'unknown', - run.scenarioId || 'unknown', -].join('|'); - -const HANDOFF_REPORT_ARMS = ['empty', 'natural', 'oracle', 'irrelevant', 'stale']; +const handoffKey = (run) => + [ + run.producer?.client || 'unknown', + run.producer?.model || 'unknown', + run.consumer?.client || 'unknown', + run.consumer?.model || 'unknown', + run.scenarioId || 'unknown', + ].join('|'); + +const HANDOFF_REPORT_ARMS = [ + 'empty', + 'natural', + 'oracle', + 'irrelevant', + 'stale', +]; const handoffMinimumPairs = () => Math.max(2, Number(process.env.TOKEN_OPTIMIZER_HANDOFF_MIN_PAIRS) || 10); @@ -1206,43 +1502,54 @@ function handoffCohorts(runs) { } const minimumPairs = handoffMinimumPairs(); return [...grouped.entries()].map(([key, rows]) => { - const arms = Object.fromEntries(HANDOFF_REPORT_ARMS.map((arm) => [ - arm, handoffArmMetrics(rows.filter((row) => row.arm === arm)), - ])); + const arms = Object.fromEntries( + HANDOFF_REPORT_ARMS.map((arm) => [ + arm, + handoffArmMetrics(rows.filter((row) => row.arm === arm)), + ]) + ); const naturalRows = rows.filter((row) => row.arm === 'natural'); const naturalVsEmpty = handoffPairedEffect(rows, 'empty', 'natural'); const naturalVsOracle = handoffPairedEffect(rows, 'oracle', 'natural'); const captureRate = naturalRows.length - ? naturalRows.filter((row) => row.producer?.captureSuccess).length / naturalRows.length + ? naturalRows.filter((row) => row.producer?.captureSuccess).length / + naturalRows.length : null; const preActionDeliveryRate = naturalRows.length - ? naturalRows.filter((row) => row.delivery?.beforeFirstExecutedMistake).length / naturalRows.length + ? naturalRows.filter((row) => row.delivery?.beforeFirstExecutedMistake) + .length / naturalRows.length : null; const emptyRecurrence = arms.empty.mistakeExecuted.rate; const naturalRecurrence = arms.natural.mistakeExecuted.rate; - const relativeRecurrenceReduction = emptyRecurrence !== null - && naturalRecurrence !== null - && emptyRecurrence !== 0 - ? (emptyRecurrence - naturalRecurrence) / emptyRecurrence - : null; - const controlsSafe = ['irrelevant', 'stale'].every((arm) => - arms[arm].correctness.rate !== null - && arms.empty.correctness.rate !== null - && arms[arm].correctness.rate >= arms.empty.correctness.rate - 0.1 + const relativeRecurrenceReduction = + emptyRecurrence !== null && + naturalRecurrence !== null && + emptyRecurrence !== 0 + ? (emptyRecurrence - naturalRecurrence) / emptyRecurrence + : null; + const controlsSafe = ['irrelevant', 'stale'].every( + (arm) => + arms[arm].correctness.rate !== null && + arms.empty.correctness.rate !== null && + arms[arm].correctness.rate >= arms.empty.correctness.rate - 0.1 ); - const irrelevantSuppressed = arms.irrelevant.delivery.rate !== null - && arms.irrelevant.delivery.rate === 0; + const irrelevantSuppressed = + arms.irrelevant.delivery.rate !== null && + arms.irrelevant.delivery.rate === 0; const gates = { minimumPairs: naturalVsEmpty.pairs >= minimumPairs, capture: captureRate !== null && captureRate >= 0.8, recurrenceMagnitude: - relativeRecurrenceReduction !== null && relativeRecurrenceReduction >= 0.5, - recurrenceInterval: (naturalVsEmpty.executedMistakesPrevented.low ?? -Infinity) > 0, + relativeRecurrenceReduction !== null && + relativeRecurrenceReduction >= 0.5, + recurrenceInterval: + (naturalVsEmpty.executedMistakesPrevented.low ?? -Infinity) > 0, correctness: - arms.natural.correctness.rate !== null - && arms.empty.correctness.rate !== null - && arms.natural.correctness.rate >= arms.empty.correctness.rate - 0.1, - preActionDelivery: preActionDeliveryRate !== null && preActionDeliveryRate >= 0.8, + arms.natural.correctness.rate !== null && + arms.empty.correctness.rate !== null && + arms.natural.correctness.rate >= arms.empty.correctness.rate - 0.1, + preActionDelivery: + preActionDeliveryRate !== null && preActionDeliveryRate >= 0.8, negativeControls: controlsSafe && irrelevantSuppressed, }; const claimReady = Object.values(gates).every(Boolean); @@ -1269,14 +1576,24 @@ function handoffCohorts(runs) { function concurrencySummary(runs) { const natural = runs.filter((run) => run.arm === 'natural'); const writers = natural.reduce((sum, run) => sum + (run.writerCount || 0), 0); - const captures = natural.reduce((sum, run) => sum + (run.captureSuccesses || 0), 0); - const integrityPasses = natural.filter((run) => - run.integrity?.zeroLoss - && run.integrity?.parseable - && run.integrity?.orphanedFindings === 0 + const captures = natural.reduce( + (sum, run) => sum + (run.captureSuccesses || 0), + 0 + ); + const integrityPasses = natural.filter( + (run) => + run.integrity?.zeroLoss && + run.integrity?.parseable && + run.integrity?.orphanedFindings === 0 ).length; - const delivered = natural.reduce((sum, run) => sum + (run.delivery?.delivered || 0), 0); - const expected = natural.reduce((sum, run) => sum + (run.delivery?.expected || 0), 0); + const delivered = natural.reduce( + (sum, run) => sum + (run.delivery?.delivered || 0), + 0 + ); + const expected = natural.reduce( + (sum, run) => sum + (run.delivery?.expected || 0), + 0 + ); const effect = handoffPairedEffect(runs, 'empty', 'natural'); return { runs: runs.length, @@ -1286,7 +1603,8 @@ function concurrencySummary(runs) { integrityPassRate: natural.length ? integrityPasses / natural.length : null, deliveryCoverage: expected ? delivered / expected : null, naturalCorrectness: proportionInterval( - natural.filter((run) => run.consumer?.correct).length, natural.length + natural.filter((run) => run.consumer?.correct).length, + natural.length ), effect, }; @@ -1297,17 +1615,26 @@ function concurrencySummary(runs) { * live hook traces, and randomized model evals remain visibly distinct: only * `eval-run` records with paired arms can produce a causal effect interval. */ -function buildEvidenceReport(evidence, { filters = {}, episodeLimit = 100 } = {}) { +function buildEvidenceReport( + evidence, + { filters = {}, episodeLimit = 100 } = {} +) { evidence = evidence.filter((event) => matchesFilters(event, filters)); const runs = evidence.filter((event) => event.kind === 'eval-run'); const handoffRuns = evidence.filter((event) => event.kind === 'handoff-run'); - const concurrencyRuns = evidence.filter((event) => event.kind === 'concurrency-run'); + const concurrencyRuns = evidence.filter( + (event) => event.kind === 'concurrency-run' + ); const injections = evidence.filter((event) => event.kind === 'inject'); const outcomes = evidence.filter((event) => event.kind === 'tool-outcome'); - const feedback = evidence.filter((event) => event.kind === 'finding-feedback'); + const feedback = evidence.filter( + (event) => event.kind === 'finding-feedback' + ); const byInjection = new Map( - outcomes.filter((event) => event.injectionId).map((event) => [event.injectionId, event]) + outcomes + .filter((event) => event.injectionId) + .map((event) => [event.injectionId, event]) ); const traced = injections .slice() @@ -1337,7 +1664,10 @@ function buildEvidenceReport(evidence, { filters = {}, episodeLimit = 100 } = {} grouped.get(key).push(run); } - const minimumPairs = Math.max(2, Number(process.env.TOKEN_OPTIMIZER_EVAL_MIN_PAIRS) || 5); + const minimumPairs = Math.max( + 2, + Number(process.env.TOKEN_OPTIMIZER_EVAL_MIN_PAIRS) || 5 + ); const cohorts = [...grouped.entries()].map(([key, cohortRuns]) => { const arms = Object.fromEntries( ['baseline', 'optimizer', 'retrieval', 'full'].map((arm) => [ @@ -1387,7 +1717,9 @@ function buildEvidenceReport(evidence, { filters = {}, episodeLimit = 100 } = {} cohorts: cohorts.length, harmfulFeedback: harmful, harmRate: feedback.length ? harmful / feedback.length : null, - evidenceStatus: cohorts.some((cohort) => cohort.evidenceStatus === 'causal estimate available') + evidenceStatus: cohorts.some( + (cohort) => cohort.evidenceStatus === 'causal estimate available' + ) ? 'causal estimates available' : 'insufficient randomized evidence', }, @@ -1396,7 +1728,8 @@ function buildEvidenceReport(evidence, { filters = {}, episodeLimit = 100 } = {} concurrency, episodes: traced, methodology: { - intervals: 'deterministic percentile bootstrap (95%); Wilson interval for correctness', + intervals: + 'deterministic percentile bootstrap (95%); Wilson interval for correctness', causalRule: 'matched pairs estimate optimizer vs baseline, retrieval vs optimizer, full vs retrieval, and full vs baseline', minimumPairs, diff --git a/integrations/copilot/.github/hooks/lib/observability.mjs b/integrations/copilot/.github/hooks/lib/observability.mjs index b9980076e..ef1c037af 100644 --- a/integrations/copilot/.github/hooks/lib/observability.mjs +++ b/integrations/copilot/.github/hooks/lib/observability.mjs @@ -26,7 +26,7 @@ import { createHash, randomUUID } from 'node:crypto'; import { homedir } from 'node:os'; import { join } from 'node:path'; -export const HOOK_LOG_SCHEMA_VERSION = 1; +export const HOOK_LOG_SCHEMA_VERSION = 2; const DEFAULT_MAX_BYTES = 5 * 1024 * 1024; const DEFAULT_RETENTION_DAYS = 14; const DEFAULT_MAX_FILES = 40; @@ -197,6 +197,8 @@ export function beginHookInvocation(client, event, options = {}) { turnIdHash: null, toolUseIdHash: null, toolName: null, + rawToolName: null, + codeModeEnvelope: false, clientVersion: null, model: null, cwdHash: null, @@ -208,12 +210,29 @@ export function beginHookInvocation(client, event, options = {}) { const spanId = hash(invocationId, 16); let traceId = hash(invocationId, 32); + // Write the start before reading stdin or importing project state. If the + // host kills the process, Node aborts, or a native call terminates without a + // JavaScript exception, diagnostics can now reconcile this record with the + // missing completion instead of reporting a false success rate. + writeHookEvent({ + level: 'info', + event: 'hook.started', + invocationId, + traceId, + spanId, + client, + hookEvent: event, + processId: process.pid, + }); + let deadline; const finish = (nextOutcome = outcome, nextReason = reason, nextError = error) => { if (ended) return; ended = true; if (deadline) clearTimeout(deadline); process.removeListener('exit', onExit); + process.removeListener('uncaughtException', onUncaughtException); + process.removeListener('unhandledRejection', onUnhandledRejection); writeHookEvent({ level: nextOutcome === 'failure' || nextOutcome === 'timeout' ? 'error' : 'info', event: 'hook.completed', @@ -231,7 +250,17 @@ export function beginHookInvocation(client, event, options = {}) { }); }; const onExit = () => finish(); + const onUncaughtException = (nextError) => { + finish('failure', 'uncaught_exception', nextError); + process.exit(0); + }; + const onUnhandledRejection = (nextError) => { + finish('failure', 'unhandled_rejection', nextError); + process.exit(0); + }; process.once('exit', onExit); + process.once('uncaughtException', onUncaughtException); + process.once('unhandledRejection', onUnhandledRejection); deadline = setTimeout(() => { outcome = 'timeout'; @@ -250,6 +279,11 @@ export function beginHookInvocation(client, event, options = {}) { dimensions.turnIdHash = hash(raw.turn_id ?? raw.turnId ?? null); dimensions.toolUseIdHash = hash(raw.tool_use_id ?? raw.toolUseId ?? null); dimensions.toolName = payload?.tool_name ?? raw.tool_name ?? raw.toolName ?? null; + dimensions.rawToolName = dimension( + raw.tool_name ?? raw.toolName ?? raw.tool ?? null + ); + dimensions.codeModeEnvelope = + payload?.tool_input?.code_mode_envelope === true; dimensions.clientVersion = dimension(raw.client_version ?? raw.clientVersion ?? null); dimensions.model = dimension(raw.model ?? raw.model_name ?? raw.modelName ?? null); dimensions.cwdHash = hash(raw.cwd ?? raw.working_directory ?? process.cwd()); @@ -291,6 +325,11 @@ export function beginHookInvocation(client, event, options = {}) { error = nextError; finish(outcome, reason, error); }, + block(nextReason = 'policy_denied') { + outcome = 'blocked'; + reason = nextReason; + finish(outcome, reason); + }, succeed(nextReason = 'completed') { outcome = 'success'; reason = nextReason; @@ -321,7 +360,11 @@ export function recordHookBootstrapFailure(client, event, error) { }); } -export function readHookEvents({ limit = 200, sinceMs = 24 * 60 * 60 * 1000 } = {}) { +export function readHookEvents({ + limit = 200, + sinceMs = 24 * 60 * 60 * 1000, + includeLifecycle = false, +} = {}) { try { const dir = hookLogDirectory(); if (!existsSync(dir)) return []; @@ -338,6 +381,7 @@ export function readHookEvents({ limit = 200, sinceMs = 24 * 60 * 60 * 1000 } = try { const event = JSON.parse(line); if (Date.parse(event.timestamp) < cutoff) continue; + if (!includeLifecycle && event.event === 'hook.started') continue; events.push(event); if (events.length >= limit) return events; } catch { @@ -353,38 +397,102 @@ export function readHookEvents({ limit = 200, sinceMs = 24 * 60 * 60 * 1000 } = export function hookHealthSummary(options = {}) { const sinceMs = options.sinceMs ?? 24 * 60 * 60 * 1000; - const events = readHookEvents({ limit: options.limit || 5000, sinceMs }); - const durations = events + const events = readHookEvents({ + limit: options.limit || 10000, + sinceMs, + includeLifecycle: true, + }); + const starts = events.filter((event) => event.event === 'hook.started'); + const completed = events.filter( + (event) => event.event === 'hook.completed' || !event.event + ); + const completedIds = new Set( + completed.map((event) => event.invocationId).filter(Boolean) + ); + const abandoned = starts + .filter( + (event) => + event.invocationId && + !completedIds.has(event.invocationId) && + Date.now() - Date.parse(event.timestamp) > 10_000 + ) + .map((event) => ({ + ...event, + event: 'hook.completed', + outcome: 'failure', + reason: 'process_terminated_before_completion', + durationMs: null, + })); + const runs = [...completed, ...abandoned]; + const durations = runs .map((event) => Number(event.durationMs)) .filter(Number.isFinite) .sort((a, b) => a - b); - const failures = events.filter((event) => event.outcome === 'failure'); - const timeouts = events.filter((event) => event.outcome === 'timeout'); - const skipped = events.filter((event) => event.outcome === 'skipped'); - const successes = events.filter((event) => event.outcome === 'success'); + const failures = runs.filter((event) => event.outcome === 'failure'); + const timeouts = runs.filter((event) => event.outcome === 'timeout'); + const skipped = runs.filter((event) => event.outcome === 'skipped'); + const blocked = runs.filter((event) => event.outcome === 'blocked'); + const successes = runs.filter((event) => event.outcome === 'success'); + const actions = runs.filter( + (event) => event.hookEvent === 'pre-tool' && event.toolName + ); + const byTool = {}; + for (const event of actions) { + const name = String(event.toolName || 'Unknown action'); + byTool[name] = (byTool[name] || 0) + 1; + } const byClient = {}; - for (const event of events) { + for (const event of runs) { const key = event.client || 'unknown'; - const current = byClient[key] || { total: 0, failures: 0, timeouts: 0, skipped: 0 }; + const current = byClient[key] || { + total: 0, + failures: 0, + timeouts: 0, + skipped: 0, + blocked: 0, + hookEvents: [], + }; current.total++; if (event.outcome === 'failure') current.failures++; if (event.outcome === 'timeout') current.timeouts++; if (event.outcome === 'skipped') current.skipped++; + if (event.outcome === 'blocked') current.blocked++; + if (event.hookEvent && !current.hookEvents.includes(event.hookEvent)) + current.hookEvents.push(event.hookEvent); byClient[key] = current; } + for (const current of Object.values(byClient)) current.hookEvents.sort(); const percentile = (p) => durations.length ? durations[Math.min(durations.length - 1, Math.floor(durations.length * p))] : null; const summary = { schemaVersion: HOOK_LOG_SCHEMA_VERSION, - available: events.length > 0, + available: runs.length > 0, windowHours: sinceMs / 3_600_000, - total: events.length, + total: runs.length, failures: failures.length, timeouts: timeouts.length, skipped: skipped.length, - successRate: events.length ? successes.length / events.length : null, + blocked: blocked.length, + abandoned: abandoned.length, + successes: successes.length, + actions: actions.length, + byTool, + // A skipped or policy-blocked hook completed its protocol successfully. + // Runtime health answers whether the hook process worked, not whether the + // optimizer chose to allow the host operation. + successRate: runs.length + ? (successes.length + skipped.length + blocked.length) / runs.length + : null, + healthStatus: + failures.length || timeouts.length + ? 'failing' + : skipped.length + ? 'degraded' + : runs.length + ? 'healthy' + : 'unavailable', p50DurationMs: percentile(0.5), p95DurationMs: percentile(0.95), byClient, diff --git a/integrations/copilot/.github/hooks/lib/pricing.mjs b/integrations/copilot/.github/hooks/lib/pricing.mjs index a4b09ebed..50b23b9cc 100644 --- a/integrations/copilot/.github/hooks/lib/pricing.mjs +++ b/integrations/copilot/.github/hooks/lib/pricing.mjs @@ -1,101 +1,72 @@ // GENERATED FILE -- do not edit. // Source of truth: hooks-core/pricing.mjs. Regenerate with `npm run sync:hooks`. /** - * Tokens in the unit people actually feel. + * Optional cost equivalents for token measurements. * - * The same data as everywhere else in this project, converted once, in one - * place, under three rules that keep a dollar figure honest: - * - * THE TABLE IS VISIBLE AND OVERRIDABLE. Prices change. A figure computed from - * rates buried in code goes stale silently and keeps looking authoritative, - * which is worse than no figure. The rates are printed next to the number and - * can be replaced without a release. - * - * DOLLARS BESIDE TOKENS, NOT INSTEAD OF THEM. The token count is exact and - * never goes out of date; the dollar figure is the token count times a rate - * that will. Showing both means the durable number survives the perishable - * one. - * - * NEVER A PRICE ON AN UNMEASURED SAVING. An invented number is worse in - * dollars than in tokens, because dollars get quoted to other people. A - * saving we cannot measure returns null and renders as "not yet measurable", - * never as "$0.00" -- which reads as "saved nothing" and is a different - * claim entirely. + * The runtime cannot infer a user's bill from a client name. A CLI may route + * through a subscription, direct API account, cloud marketplace, enterprise + * agreement, included credits, or a provider whose cache-read/write prices + * differ. Provider usage records are the source of truth when available. + * Everywhere else this module fails closed until the user supplies an + * effective blended input-token rate. */ -/** Cache multipliers, relative to an input token. */ -const CACHE_WRITE = 1.25; -const CACHE_READ = 0.1; +const RATE_ENV = 'TOKEN_OPTIMIZER_EFFECTIVE_INPUT_USD_PER_MILLION'; -/** - * Dollars per million tokens, by tier. - * - * Approximate published list rates as of 2026-07, carried with their date so a - * stale table is visible rather than silent. Override wholesale with - * TOKEN_OPTIMIZER_PRICES as JSON, e.g. - * {"asOf":"2026-09","opus":{"input":15,"output":75}}. - */ -const DEFAULT_PRICES = { - asOf: '2026-07', - haiku: { input: 1, output: 5 }, - sonnet: { input: 3, output: 15 }, - opus: { input: 15, output: 75 }, -}; - -export function prices() { - const raw = process.env.TOKEN_OPTIMIZER_PRICES; - if (raw) { - try { - const parsed = JSON.parse(raw); - if (parsed && typeof parsed === 'object') return { ...DEFAULT_PRICES, ...parsed, overridden: true }; - } catch { /* an unparseable override is ignored rather than fatal */ } - } - return DEFAULT_PRICES; +/** The configured effective input rate, or null when billing is unobservable. */ +export function effectiveRate() { + const rate = Number(process.env[RATE_ENV]); + return Number.isFinite(rate) && rate > 0 ? rate : null; } /** - * What a number of tokens costs. + * Dollars for a measured token quantity. * - * Returns null for a null input and says why: this is the single place the - * "unmeasured must not become $0.00" rule is enforced, so every caller inherits - * it rather than each remembering. + * `kind` is accepted for backwards compatibility but is not multiplied by a + * universal cache constant. The configured rate must already reflect the + * user's actual provider/model/cache/tier/credit mix. */ -export function dollars(tokens, { tier = 'opus', kind = 'input' } = {}) { - if (!Number.isFinite(tokens)) return null; - - const table = prices(); - const rate = table[tier]?.[kind === 'output' ? 'output' : 'input']; - if (!Number.isFinite(rate)) return null; - - const multiplier = kind === 'cacheWrite' ? CACHE_WRITE : kind === 'cacheRead' ? CACHE_READ : 1; - return (tokens / 1_000_000) * rate * multiplier; +export function dollars(tokens, { rate = effectiveRate() } = {}) { + if (!Number.isFinite(tokens) || !Number.isFinite(rate) || rate <= 0) { + return null; + } + return (tokens / 1_000_000) * rate; } /** A dollar figure, or an honest absence of one. */ -export function money(amount, { unmeasured = 'not yet measurable' } = {}) { +export function money(amount, { unmeasured = 'not priced' } = {}) { if (amount == null || !Number.isFinite(amount)) return unmeasured; if (amount === 0) return '$0.00'; if (Math.abs(amount) < 0.01) return '<$0.01'; return `$${amount.toFixed(2)}`; } -/** - * Per-session tokens expressed as a monthly cost. - * - * `sessionsPerMonth` is an assumption, so it is stated in the output rather - * than folded invisibly into the number. - */ -export function monthly(tokensPerSession, { tier = 'opus', sessionsPerMonth = 60 } = {}) { +/** Per-session tokens expressed with a stated monthly-frequency assumption. */ +export function monthly(tokensPerSession, { sessionsPerMonth = 60 } = {}) { if (!Number.isFinite(tokensPerSession)) return null; - const amount = dollars(tokensPerSession * sessionsPerMonth, { tier }); - return amount == null ? null : { amount, sessionsPerMonth, tier }; + const amount = dollars(tokensPerSession * sessionsPerMonth); + return amount == null + ? null + : { + amount, + sessionsPerMonth, + effectiveInputUsdPerMillion: effectiveRate(), + }; +} + +/** The pricing basis printed beside any cost equivalent. */ +export function priceNote() { + const rate = effectiveRate(); + return rate == null + ? `cost not priced: set ${RATE_ENV} to your effective blended input rate after provider, model, cache, tier, plan, and credits` + : `cost equivalent: configured effective input rate $${rate}/M via ${RATE_ENV}`; } -/** The rates behind the figures, for printing beside them. */ -export function priceNote(tier = 'opus') { - const table = prices(); - const rate = table[tier]; - if (!rate) return null; - return `prices: ${tier} $${rate.input}/$${rate.output} per Mtok (${table.asOf}` + - `${table.overridden ? ', overridden' : ''}); override with TOKEN_OPTIMIZER_PRICES`; +/** Compatibility surface: no built-in provider table is claimed. */ +export function prices() { + return { + source: + effectiveRate() == null ? 'unavailable' : 'configured-effective-rate', + effectiveInputUsdPerMillion: effectiveRate(), + }; } diff --git a/integrations/copilot/.github/hooks/lib/routing.mjs b/integrations/copilot/.github/hooks/lib/routing.mjs index 9b4642e0e..84ac866e4 100644 --- a/integrations/copilot/.github/hooks/lib/routing.mjs +++ b/integrations/copilot/.github/hooks/lib/routing.mjs @@ -65,7 +65,9 @@ export function modelCosts() { if (raw) { try { return JSON.parse(raw); - } catch { /* fall through to the defaults */ } + } catch { + /* fall through to the defaults */ + } } return { haiku: 1, sonnet: 3, opus: 15 }; } @@ -106,7 +108,12 @@ export function readEpisodes(path, { maxBytes = 6_000_000 } = {}) { let current = null; const close = () => { - if (current && current.turns > 0) episodes.push({ ...current, files: [...current.files], tools: [...current.tools] }); + if (current && current.turns > 0) + episodes.push({ + ...current, + files: [...current.files], + tools: [...current.tools], + }); current = null; }; @@ -123,7 +130,15 @@ export function readEpisodes(path, { maxBytes = 6_000_000 } = {}) { // a new episode. if (row.type === 'user' && typeof row.message?.content === 'string') { close(); - current = { at: Date.parse(row.timestamp) || null, models: new Set(), tools: new Set(), files: new Set(), turns: 0, errors: 0, results: 0 }; + current = { + at: Date.parse(row.timestamp) || null, + models: new Set(), + tools: new Set(), + files: new Set(), + turns: 0, + errors: 0, + results: 0, + }; continue; } if (!current) continue; @@ -157,8 +172,16 @@ export function readEpisodes(path, { maxBytes = 6_000_000 } = {}) { /* ---------------------------------------------------------------- SHAPES */ -const EDIT_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit', 'smart_edit', 'smart_write']); -const TEST_PATTERN = /\b(test|jest|pytest|vitest|dotnet test|npm test|go test)\b/i; +const EDIT_TOOLS = new Set([ + 'Edit', + 'MultiEdit', + 'Write', + 'NotebookEdit', + 'smart_edit', + 'smart_write', +]); +const TEST_PATTERN = + /\b(test|jest|pytest|vitest|dotnet test|npm test|go test)\b/i; /** * What kind of work this was. @@ -171,11 +194,19 @@ export function classifyShape(episode) { const tools = new Set(episode.tools || []); const edited = [...tools].some((t) => EDIT_TOOLS.has(t)); const files = (episode.files || []).length; - const ranTests = [...tools].some((t) => TEST_PATTERN.test(t)) || (episode.tools || []).includes('Bash'); + const ranTests = + [...tools].some((t) => TEST_PATTERN.test(t)) || + (episode.tools || []).includes('Bash'); if (edited && files >= 3) return 'multi-file-change'; if (edited) return 'single-file-change'; - if (files > 0 || tools.has('Grep') || tools.has('Glob') || tools.has('smart_grep')) return 'investigation'; + if ( + files > 0 || + tools.has('Grep') || + tools.has('Glob') || + tools.has('smart_grep') + ) + return 'investigation'; if (ranTests) return 'build-or-test'; return 'conversation'; } @@ -208,7 +239,13 @@ export function outcomeTable(episodes) { const tier = [...tiers][0]; table[shape] = table[shape] || {}; - const cell = table[shape][tier] = table[shape][tier] || { episodes: 0, turns: 0, errors: 0, results: 0, errored: 0 }; + const cell = (table[shape][tier] = table[shape][tier] || { + episodes: 0, + turns: 0, + errors: 0, + results: 0, + errored: 0, + }); cell.episodes += 1; cell.turns += episode.turns; cell.errors += episode.errors; @@ -287,7 +324,8 @@ export function route(shape, table = {}) { shape, recommend: heuristic, basis: 'heuristic', - reason: `every measured tier for ${shape} needed a retry in more than ` + + reason: + `every measured tier for ${shape} needed a retry in more than ` + `${Math.round(MAX_ERROR_RATE * 100)}% of episodes; falling back to the shipped default`, excluded: excluded.map((o) => ({ tier: o.tier, errorRate: o.errorRate })), options, @@ -306,17 +344,24 @@ export function route(shape, table = {}) { recommend: best.tier, basis: 'measured', // What each mistake would cost, from this project's own rates. - overpowered: strongest.tier === best.tier ? null : { - tier: strongest.tier, - wasted: Number((strongest.cost - best.cost).toFixed(2)), - }, - underpowered: cheapest.tier === best.tier ? null : { - tier: cheapest.tier, - expectedCost: Number(cheapest.cost.toFixed(2)), - errorRate: cheapest.errorRate, - excluded: cheapest.errorRate > MAX_ERROR_RATE, - }, - reason: `${shape}: ${best.tier} costs ${best.cost.toFixed(2)} in expectation over ${best.episodes} episodes ` + + overpowered: + strongest.tier === best.tier + ? null + : { + tier: strongest.tier, + wasted: Number((strongest.cost - best.cost).toFixed(2)), + }, + underpowered: + cheapest.tier === best.tier + ? null + : { + tier: cheapest.tier, + expectedCost: Number(cheapest.cost.toFixed(2)), + errorRate: cheapest.errorRate, + excluded: cheapest.errorRate > MAX_ERROR_RATE, + }, + reason: + `${shape}: ${best.tier} costs ${best.cost.toFixed(2)} in expectation over ${best.episodes} episodes ` + `(${Math.round(best.errorRate * 100)}% needed a retry)`, options, }; @@ -340,20 +385,31 @@ export function routingNote(shape, table, { currentModel, switchCost } = {}) { const lines = [decision.reason]; if (decision.underpowered) { - lines.push(` ${decision.underpowered.tier} looks cheaper per token, but needed a retry in ` + - `${Math.round(decision.underpowered.errorRate * 100)}% of episodes -- expected cost ${decision.underpowered.expectedCost}` + - `${decision.underpowered.excluded ? ', which is why it is not a candidate here at any price' : ''}.`); + lines.push( + ` ${decision.underpowered.tier} looks cheaper per token, but needed a retry in ` + + `${Math.round(decision.underpowered.errorRate * 100)}% of episodes -- expected cost ${decision.underpowered.expectedCost}` + + `${decision.underpowered.excluded ? ', which is why it is not a candidate here at any price' : ''}.` + ); } if (decision.overpowered) { - lines.push(` ${decision.overpowered.tier} would spend ${decision.overpowered.wasted} more than needed here.`); + lines.push( + ` ${decision.overpowered.tier} would spend ${decision.overpowered.wasted} more than needed here.` + ); } if (switchCost?.prefixTokens) { - lines.push(` Switching now also discards a ${switchCost.prefixTokens.toLocaleString()}-token warm prefix ` + - `(~${switchCost.rewriteCost.toLocaleString()} tokens to re-write); consider switching at the next break.`); + lines.push( + ` Switching now also discards a ${switchCost.prefixTokens.toLocaleString()}-token warm prefix ` + + `(~${switchCost.rewriteInputCostEquivalent.toLocaleString()} Anthropic uncached-input cost-equivalent tokens to re-write); consider switching at the next break.` + ); } - return { shape, recommend: decision.recommend, basis: decision.basis, text: lines.join('\n') }; + return { + shape, + recommend: decision.recommend, + basis: decision.basis, + text: lines.join('\n'), + }; } /** @@ -371,7 +427,9 @@ export function routingBriefing(table) { const decision = route(shape, table); if (decision.basis !== 'measured') continue; if (decision.recommend === HEURISTIC[shape]) continue; // nothing learned worth saying - lines.push(`${shape.replace(/-/g, ' ')} work goes better on ${decision.recommend} in this project.`); + lines.push( + `${shape.replace(/-/g, ' ')} work goes better on ${decision.recommend} in this project.` + ); } return lines.length ? lines.join('\n') : null; } @@ -389,7 +447,11 @@ export function routingBriefing(table) { * few more episodes accumulated would invalidate the cache behind it. Stability * is the feature; the memo is how it is bought. */ -export function cachedRoutingBriefing(dir, transcriptPath, { growthBytes = 4_000_000, maxAgeMs = 24 * 60 * 60 * 1000 } = {}) { +export function cachedRoutingBriefing( + dir, + transcriptPath, + { growthBytes = 4_000_000, maxAgeMs = 24 * 60 * 60 * 1000 } = {} +) { const memoPath = join(dir, 'routing-brief.json'); let stat; try { @@ -400,17 +462,31 @@ export function cachedRoutingBriefing(dir, transcriptPath, { growthBytes = 4_000 try { const memo = JSON.parse(readFileSync(memoPath, 'utf8')); - const fresh = memo.path === transcriptPath - && Math.abs(stat.size - memo.size) < growthBytes - && Date.now() - memo.at < maxAgeMs; + const fresh = + memo.path === transcriptPath && + Math.abs(stat.size - memo.size) < growthBytes && + Date.now() - memo.at < maxAgeMs; if (fresh) return memo.text || null; - } catch { /* no memo, or an unreadable one; recompute */ } + } catch { + /* no memo, or an unreadable one; recompute */ + } const text = routingBriefing(outcomeTable(readEpisodes(transcriptPath))); try { mkdirSync(dir, { recursive: true, mode: 0o700 }); - writeFileSync(memoPath, JSON.stringify({ path: transcriptPath, size: stat.size, at: Date.now(), text }), { mode: 0o600 }); - } catch { /* the memo is an optimisation; failing to write it costs latency, not correctness */ } + writeFileSync( + memoPath, + JSON.stringify({ + path: transcriptPath, + size: stat.size, + at: Date.now(), + text, + }), + { mode: 0o600 } + ); + } catch { + /* the memo is an optimisation; failing to write it costs latency, not correctness */ + } return text; } @@ -427,9 +503,11 @@ export function routingReport(table) { for (const tier of Object.keys(table[shape])) { const cell = table[shape][tier]; const cost = expectedCost(tier, cell); - lines.push(` ${tier.padEnd(7)} ${cell.episodes} episode(s), ` + - `${cell.meanTurns.toFixed(1)} turns avg, ${Math.round(cell.errorRate * 100)}% needed a retry, ` + - `expected cost ${cost.cost.toFixed(2)}${cell.measured ? '' : ' [too few episodes to trust]'}`); + lines.push( + ` ${tier.padEnd(7)} ${cell.episodes} episode(s), ` + + `${cell.meanTurns.toFixed(1)} turns avg, ${Math.round(cell.errorRate * 100)}% needed a retry, ` + + `expected cost ${cost.cost.toFixed(2)}${cell.measured ? '' : ' [too few episodes to trust]'}` + ); } } return lines.join('\n'); diff --git a/integrations/copilot/.github/hooks/lib/symbols.mjs b/integrations/copilot/.github/hooks/lib/symbols.mjs index 2a80e1e42..4d80666ac 100644 --- a/integrations/copilot/.github/hooks/lib/symbols.mjs +++ b/integrations/copilot/.github/hooks/lib/symbols.mjs @@ -50,7 +50,11 @@ const PATTERNS = { /^\s*(?:pub(?:\([^)]*\))?\s+)?(?:struct|enum|trait|impl)\s+([A-Za-z_]\w*)/, ], clike: [ - /^\s*(?:public|private|protected|internal|static|final|abstract|virtual|override|async|\s)*(?:[\w<>\[\],.?]+\s+)([A-Za-z_]\w*)\s*\([^;]*\)\s*\{/, + // Accept both K&R (`Run() {`) and Allman (`Run()` then `{`) declarations. + // Requiring the opening brace on the declaration line silently discarded + // nearly every method in idiomatic C# projects, leaving file nodes but no + // symbol nodes and making valid `file.cs#Method` anchors unresolved. + /^\s*(?:public|private|protected|internal|static|final|abstract|virtual|override|async|sealed|new|extern|unsafe|partial|\s)*(?:[\w<>\[\],.?]+\s+)([A-Za-z_]\w*)(?:<[^;()]+>)?\s*\([^;]*\)\s*(?:\{|=>|$)/, /^\s*(?:public|private|protected|internal|static|abstract|sealed|partial|\s)*(?:class|struct|interface|enum|record)\s+([A-Za-z_]\w*)/, ], ruby: [ diff --git a/integrations/cursor/hooks/lib/adapter.mjs b/integrations/cursor/hooks/lib/adapter.mjs index fee0102b9..9f76bbdea 100644 --- a/integrations/cursor/hooks/lib/adapter.mjs +++ b/integrations/cursor/hooks/lib/adapter.mjs @@ -188,8 +188,143 @@ function contextOutput(client, eventName, additionalContext) { }; } +/** Convert one static JavaScript string literal without evaluating code. */ +function codexStringLiteral(literal) { + if (typeof literal !== 'string' || literal.length < 2) return null; + const quote = literal[0]; + if (!['"', "'", '`'].includes(quote) || literal.at(-1) !== quote) return null; + + let decoded = ''; + for (let index = 1; index < literal.length - 1; index += 1) { + const character = literal[index]; + if (quote === '`' && character === '$' && literal[index + 1] === '{') + return null; + if (character !== '\\') { + decoded += character; + continue; + } + + index += 1; + if (index >= literal.length - 1) return null; + const escaped = literal[index]; + const simple = { + b: '\b', f: '\f', n: '\n', r: '\r', t: '\t', v: '\v', 0: '\0', + '\\': '\\', "'": "'", '"': '"', '`': '`', '$': '$', + }; + if (Object.hasOwn(simple, escaped)) { + decoded += simple[escaped]; + continue; + } + if (escaped === '\n') continue; + if (escaped === '\r') { + if (literal[index + 1] === '\n') index += 1; + continue; + } + if (escaped === 'x') { + const digits = literal.slice(index + 1, index + 3); + if (!/^[0-9a-f]{2}$/i.test(digits)) return null; + decoded += String.fromCodePoint(Number.parseInt(digits, 16)); + index += 2; + continue; + } + if (escaped === 'u') { + const braced = literal[index + 1] === '{'; + const close = braced ? literal.indexOf('}', index + 2) : index + 5; + const digits = braced + ? literal.slice(index + 2, close) + : literal.slice(index + 1, close); + if ( + close < 0 || + !(braced ? /^[0-9a-f]{1,6}$/i : /^[0-9a-f]{4}$/i).test(digits) + ) return null; + const codePoint = Number.parseInt(digits, 16); + if (codePoint > 0x10ffff) return null; + decoded += String.fromCodePoint(codePoint); + index = close; + continue; + } + // JavaScript identity escapes drop the slash (for example, '\q' is 'q'). + decoded += escaped; + } + return decoded; +} + /** Convert client-specific lifecycle envelopes into the common tool shape. */ +function codexStringBindings(source) { + const values = new Map(); + const declaration = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*((?:"(?:\\.|[^"\\])*")|(?:'(?:\\.|[^'\\])*')|(?:`(?:\\.|[^`\\])*`))/gs; + for (const match of String(source || '').matchAll(declaration)) { + try { + const value = codexStringLiteral(match[2]); + if (value !== null) values.set(match[1], value); + } catch { + // A computed or malformed JavaScript expression is not safe to execute + // merely to understand a hook envelope. Leave it opaque. + } + } + return values; +} + +function codexCallArguments(source, method) { + const calls = []; + const pattern = new RegExp(`(?:tools\\.)?${method}\\s*\\(\\s*([^),]+)`, 'gi'); + for (const match of String(source || '').matchAll(pattern)) + calls.push(match[1].trim()); + return calls; +} + +function codexLiteral(value, bindings) { + if (bindings.has(value)) return String(bindings.get(value)); + return codexStringLiteral(value); +} + +function codexExecCommands(source, bindings) { + const commands = []; + for (const call of String(source || '').matchAll( + /(?:tools\.)?exec_command\s*\(\s*\{([\s\S]*?)\}\s*\)/gi + )) { + const property = /\bcmd\s*:\s*((?:"(?:\\.|[^"\\])*")|(?:'(?:\\.|[^'\\])*')|(?:`(?:\\.|[^`\\])*`)|(?:[A-Za-z_$][\w$]*))/s.exec(call[1]); + if (!property) continue; + const command = codexLiteral(property[1], bindings); + if (command) commands.push(command); + } + return commands; +} + export function normalizeClientPayload(clientName, event, raw) { + if (clientName === 'codex') { + const outerName = String(raw.tool_name ?? raw.toolName ?? raw.tool ?? ''); + if (/^(?:functions\.)?exec$/i.test(outerName)) { + const envelope = raw.tool_input ?? raw.toolInput ?? raw.arguments ?? raw.args; + const source = + typeof envelope === 'string' + ? envelope + : envelope?.code ?? envelope?.input ?? envelope?.source ?? ''; + const bindings = codexStringBindings(source); + const patchCalls = codexCallArguments(source, 'apply_patch'); + const patch = patchCalls + .map((argument) => codexLiteral(argument, bindings)) + .find(Boolean); + const mutation = patchCalls.length > 0; + const commands = codexExecCommands(source, bindings); + + // `functions.exec` is also the orchestration envelope for web, image and + // other non-filesystem tools. Do not reinterpret those as shell commands: + // doing so produced irrelevant cross-project advice and fabricated graph + // activity for operations the filesystem hooks never observed. + if (!mutation && !commands.length) return raw; + return { + ...raw, + tool_name: mutation ? 'apply_patch' : 'run_command', + tool_input: { + ...(envelope && typeof envelope === 'object' ? envelope : {}), + command: String(patch || commands.join('\n') || source || ''), + code_mode_envelope: true, + }, + }; + } + } + if (clientName === 'cline') { const body = event === 'post-tool' ? raw.postToolUse : raw.preToolUse; if (!body) return raw; @@ -385,6 +520,22 @@ function projectBriefing() { function observeAndInject(payload, state, episode, features) { const touched = touchedFiles(payload); const dirFor = (path) => wikiDir(projectRootFor(path, payload.cwd)); + const registerRoot = (root) => { + const dir = wikiDir(root); + registerProject({ + root, + graphDir: dir, + client: episode.client || 'unknown', + }); + return dir; + }; + + // SessionStart is not delivered consistently by every host/version. The + // graph was still written in that case, but the dashboard registry never + // learned it existed. Register every repository observed by a real tool call + // as a second, idempotent production path. + for (const { path } of touched) + registerRoot(projectRootFor(path, payload.cwd)); const bytes = readCostBytes(payload); if (bytes && payload.tool_input.file_path) { @@ -425,7 +576,7 @@ function observeAndInject(payload, state, episode, features) { const command = payload.tool_input?.command; if (command) { const root = commandProjectRoot(payload, payload.cwd); - const dir = wikiDir(root); + const dir = registerRoot(root); const local = forCommand(dir, load(dir), command, { sessionId: payload.session_id, alreadyInjected, @@ -750,7 +901,14 @@ async function runHook(clientName, event, invocation) { client.canDeny && event === 'pre-tool' && !repeat && - mode() !== MODE_ADVISE + mode() !== MODE_ADVISE && + // Codex code mode presents an orchestration program as one outer + // `functions.exec` call. Refusing that envelope is rendered by the host + // as a failed script and can discard several otherwise-safe nested + // operations. Observe it, capture it and advise, but reserve a hard veto + // for legacy one-operation Codex tool calls where the denied operation is + // unambiguous. + !(clientName === 'codex' && payload.tool_input?.code_mode_envelope) ); if (canRefuse) { @@ -759,19 +917,23 @@ async function runHook(clientName, event, invocation) { // Every client's refusal carries the off switch, for the same reason Claude // Code's does: enforcement that hides its own disable is coercive. if (client.denyStyle === 'top-level') { + invocation.block('policy_denied'); emit({ decision: 'deny', reason: withEscape(verdict.reason) }); } else if (client.denyStyle === 'top-level-permission') { + invocation.block('policy_denied'); emit({ permissionDecision: 'deny', permissionDecisionReason: withEscape(verdict.reason), }); } else if (client.denyStyle === 'cline') { + invocation.block('policy_denied'); emit({ cancel: true, contextModification: '', errorMessage: withEscape(verdict.reason), }); } else if (client.denyStyle === 'cursor') { + invocation.block('policy_denied'); emit({ continue: true, permission: 'deny', @@ -780,9 +942,11 @@ async function runHook(clientName, event, invocation) { 'Token Optimizer redirected an expensive built-in operation.', }); } else if (client.denyStyle === 'exit-2') { + invocation.block('policy_denied'); process.stderr.write(withEscape(verdict.reason)); process.exit(2); } else { + invocation.block('policy_denied'); emit({ hookSpecificOutput: { hookEventName: eventName, diff --git a/integrations/cursor/hooks/lib/audit.mjs b/integrations/cursor/hooks/lib/audit.mjs index b82186586..df8a08b82 100644 --- a/integrations/cursor/hooks/lib/audit.mjs +++ b/integrations/cursor/hooks/lib/audit.mjs @@ -50,15 +50,17 @@ export function decline(dir, id) { export function declines(dir, { events = readMetrics(dir) } = {}) { const counts = new Map(); for (const event of events) { - if (event.kind !== 'advice' || event.action !== 'declined' || !event.id) continue; + if (event.kind !== 'advice' || event.action !== 'declined' || !event.id) + continue; counts.set(event.id, (counts.get(event.id) || 0) + 1); } return counts; } -const idOf = (finding) => finding.remedy - ? `${finding.remedy.type}:${finding.remedy.anchor || finding.remedy.file || (finding.remedy.anchors || []).join(',')}` - : `${finding.id}:${finding.anchor || finding.file || ''}`; +const idOf = (finding) => + finding.remedy + ? `${finding.remedy.type}:${finding.remedy.anchor || finding.remedy.file || (finding.remedy.anchors || []).join(',')}` + : `${finding.id}:${finding.anchor || finding.file || ''}`; /** * Whether a habit improved after we said something about it. @@ -68,12 +70,18 @@ const idOf = (finding) => finding.remedy * -- a trend computed from one session on each side is a coin flip with a * percentage sign. */ -export function habitTrend(dir, detector, { events = readMetrics(dir), minSessions = 2, anchors = null } = {}) { +export function habitTrend( + dir, + detector, + { events = readMetrics(dir), minSessions = 2, anchors = null } = {} +) { // SPLIT BY POSITION, NOT BY TIMESTAMP. The log is append-only, so its order is // authoritative -- while `at` has millisecond granularity, and a burst of // events inside one millisecond would land on the wrong side of the split or // collapse one side to nothing. Position is exact where the clock is not. - const raisedAt = events.findIndex((e) => e.kind === 'advice' && e.detector === detector); + const raisedAt = events.findIndex( + (e) => e.kind === 'advice' && e.detector === detector + ); if (raisedAt === -1) return null; // SCOPED TO THE FILES THIS DETECTOR NAMED. Counting every read in the window @@ -114,7 +122,12 @@ export function habitTrend(dir, detector, { events = readMetrics(dir), minSessio /** Marks a recommendation as having been raised, so its effect can be scored. */ export function raise(dir, finding) { - record(dir, { kind: 'advice', action: 'raised', id: idOf(finding), detector: finding.id }); + record(dir, { + kind: 'advice', + action: 'raised', + id: idOf(finding), + detector: finding.id, + }); } /** @@ -123,7 +136,11 @@ export function raise(dir, finding) { * `findings` comes from the detectors; `extra` lets the cache and routing * modules contribute without this file importing the world. */ -export function buildQueue(dir, findings = [], { events = readMetrics(dir) } = {}) { +export function buildQueue( + dir, + findings = [], + { events = readMetrics(dir) } = {} +) { const declined = declines(dir, { events }); const ledger = remedyLedger(dir); const applied = new Set(ledger.filter((r) => !r.revertedAt).map((r) => r.id)); @@ -146,7 +163,9 @@ export function buildQueue(dir, findings = [], { events = readMetrics(dir) } = { } return { - queue: queue.sort((a, b) => (b.costPerSession ?? 0) - (a.costPerSession ?? 0)), + queue: queue.sort( + (a, b) => (b.costPerSession ?? 0) - (a.costPerSession ?? 0) + ), suppressed, done: ledger.filter((r) => !r.revertedAt), }; @@ -161,7 +180,11 @@ export function buildQueue(dir, findings = [], { events = readMetrics(dir) } = { * many were withheld -- and what they are collectively worth -- keeps that from * being a silent cap. */ -export function renderAudit(dir, findings = [], { tier = 'opus', full = false, sessionsPerMonth = 60 } = {}) { +export function renderAudit( + dir, + findings = [], + { tier = 'opus', full = false, sessionsPerMonth = 60 } = {} +) { const { queue, suppressed, done } = buildQueue(dir, findings); const lines = []; @@ -175,22 +198,29 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s // read as "this costs nothing" beside a finding whose cost we simply do not // know -- the same unknown-becomes-zero error the rest of this project // corrects, in the one unit that gets quoted to other people. - const priced = item.costPerSession == null ? null : monthly(cost, { tier, sessionsPerMonth }); + const priced = + item.costPerSession == null + ? null + : monthly(cost, { tier, sessionsPerMonth }); // The id appears on EVERY actionable line, not just the appliable ones. It is the only // handle `decline` accepts and it cannot be guessed from the title, so omitting it made the // advertised decline path unreachable for precisely the findings most likely to be // unwanted -- model-routing findings carry no remedy, so they could never be suppressed no // matter how many times a user declined them. - const action = item.remedy?.kind === 'ours' ? `apply: waste_audit action="apply" id="${item.id}"` - : item.remedy?.kind === 'yours' ? `proposed edit -- needs your yes, nothing changed; decline: ${item.id}` - : `advice only -- no automatic fix; decline: ${item.id}`; + const action = + item.remedy?.kind === 'ours' + ? `apply: waste_audit action="apply" id="${item.id}"` + : item.remedy?.kind === 'yours' + ? `proposed edit -- needs your yes, nothing changed; decline: ${item.id}` + : `advice only -- no automatic fix; decline: ${item.id}`; // MEASURED ZERO IS NOT UNMEASURED. Testing `cost` for truthiness sent a real 0 down the // "not yet measurable" branch while `priced` above, which tests for null, still rendered // $0.00 beside it -- producing `cost not yet measurable (~$0.00/month)`, the exact string // the test suite declares must never appear. waste.mjs defaults every finding to 0 and // hard-sets it on the co-occurrence detector, so this is reachable, not theoretical. - const text = ` ${item.title}\n ${item.costPerSession != null ? `${cost.toLocaleString()} tokens/session` : 'cost not yet measurable'}` + + const text = + ` ${item.title}\n ${item.costPerSession != null ? `${cost.toLocaleString()} tokens/session` : 'cost not yet measurable'}` + `${priced ? ` (~${money(priced.amount)}/month)` : ''}; ${action}`; const printCost = estimate(text); @@ -211,14 +241,19 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s lines.push(text); } - const head = shown.length ? ['What to do next, most expensive first:', ...lines] : ['Nothing addressable found.']; + const head = shown.length + ? ['What to do next, most expensive first:', ...lines] + : ['Nothing addressable found.']; const body = [...head]; if (withheld.length) { const worth = withheld.reduce((sum, w) => sum + (w.costPerSession || 0), 0); - body.push('', ` ... ${withheld.length} more finding(s) worth ${worth.toLocaleString()} tokens/session in total, ` + - 'not shown -- printing them costs more than they are worth. Pass full=true to see all.'); + body.push( + '', + ` ... ${withheld.length} more finding(s) worth ${worth.toLocaleString()} tokens/session in total, ` + + 'not shown -- printing them costs more than they are worth. Pass full=true to see all.' + ); } // What we already told you, and what it bought. @@ -226,23 +261,35 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s body.push('', 'Already applied, and what it actually saved:'); for (const rule of done.slice(0, 6)) { const saved = rule.savedPerSession; - const priced = saved == null ? null : monthly(saved, { tier, sessionsPerMonth }); - body.push(` + ${rule.id} -- ${saved == null - ? 'not yet measurable (fewer than two sessions since)' - : `measured ${saved.toLocaleString()} tokens/session${priced ? `, ~${money(priced.amount)}/month` : ''}`}`); + const priced = + saved == null ? null : monthly(saved, { tier, sessionsPerMonth }); + body.push( + ` + ${rule.id} -- ${ + saved == null + ? 'not yet measurable (fewer than two sessions since)' + : `measured ${saved.toLocaleString()} tokens/session${priced ? `, ~${money(priced.amount)}/month` : ''}` + }` + ); } // Never a silent cap: the withheld block above states its remainder and so must this one. // Truncating to six with no disclosure showed a user six savings and let them conclude that // was everything the tool had bought them, which understates its own measured value. if (done.length > 6) { - body.push(` ... and ${done.length - 6} more applied fix(es), not shown.`); + body.push( + ` ... and ${done.length - 6} more applied fix(es), not shown.` + ); } } if (suppressed.length) { - body.push('', `Not repeating ${suppressed.length} recommendation(s) you have declined ` + - `${DECLINE_LIMIT}+ times. Ask for full=true to see them.`); - if (full) for (const item of suppressed) body.push(` - ${item.title} (declined ${item.times}x)`); + body.push( + '', + `Not repeating ${suppressed.length} recommendation(s) you have declined ` + + `${DECLINE_LIMIT}+ times. Ask for full=true to see them.` + ); + if (full) + for (const item of suppressed) + body.push(` - ${item.title} (declined ${item.times}x)`); } // Whether the advice changed anything. @@ -256,20 +303,30 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s for (const a of anchors) anchorsBy.get(f.id).add(a); } const trends = [...new Set(findings.map((f) => f.id))] - .map((detector) => habitTrend(dir, detector, { - anchors: anchorsBy.has(detector) ? [...anchorsBy.get(detector)] : null, - })) + .map((detector) => + habitTrend(dir, detector, { + anchors: anchorsBy.has(detector) ? [...anchorsBy.get(detector)] : null, + }) + ) .filter(Boolean); if (trends.length) { body.push('', 'Since the advice was given:'); for (const trend of trends) { - body.push(` ${trend.detector}: ${trend.before.toLocaleString()} -> ${trend.after.toLocaleString()} tokens/session ` + - `(${trend.improved ? 'improved' : 'worse'})`); + body.push( + ` ${trend.detector}: ${trend.before.toLocaleString()} -> ${trend.after.toLocaleString()} tokens/session ` + + `(${trend.improved ? 'improved' : 'worse'})` + ); } } - const addressable = queue.reduce((sum, item) => sum + (item.costPerSession || 0), 0); + const addressable = queue.reduce( + (sum, item) => sum + (item.costPerSession || 0), + 0 + ); + const addressableMonthly = addressable + ? monthly(addressable, { tier, sessionsPerMonth }) + : null; const note = priceNote(tier); if (note) body.push('', note); @@ -278,9 +335,10 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s // pushing it left the report's longest line out of its own reported cost, understating it by // 10-20% on a short audit. This figure exists to prove the audit is not a net loss, so // understating the cost side biases exactly the comparison it was written to be honest about. - const closing = (n) => `This report cost about ${n.toLocaleString()} tokens and names ` + + const closing = (n) => + `This report cost about ${n.toLocaleString()} tokens and names ` + `${addressable.toLocaleString()} tokens/session of addressable waste` + - `${addressable ? ` (~${money(monthly(addressable, { tier, sessionsPerMonth })?.amount)}/month across ${sessionsPerMonth} sessions)` : ''}.`; + `${addressableMonthly ? ` (~${money(addressableMonthly.amount)}/month across ${sessionsPerMonth} sessions)` : ''}.`; const selfCost = estimate([...body, '', closing(0)].join('\n')); body.push('', closing(selfCost)); diff --git a/integrations/cursor/hooks/lib/cache.mjs b/integrations/cursor/hooks/lib/cache.mjs index 9b11173ff..9782c3262 100644 --- a/integrations/cursor/hooks/lib/cache.mjs +++ b/integrations/cursor/hooks/lib/cache.mjs @@ -3,12 +3,12 @@ /** * Prompt-cache economics. * - * Cache economics is real money in a way most compression is not: a cache read - * costs a tenth of a fresh token and a cache WRITE costs a quarter more than - * one, so a prefix that keeps invalidating can cost more than every saving this - * product makes elsewhere. It is also the one area where we do not have to - * model anything -- the client's own transcript records cache_read_input_tokens - * and cache_creation_input_tokens per turn, which is ground truth. + * Anthropic prompt-cache usage, read from Claude Code transcripts. A cache read + * and a cache write are both billable, at different published multipliers from + * uncached input. Raw cache_read_input_tokens and + * cache_creation_input_tokens are observations. Any multiplier-based value is + * an Anthropic input-cost equivalent, not a universally comparable token count + * and not a provider invoice. * * MEASUREMENT ALONE IS NOT ACTIONABLE. A hit rate tells you the cache missed; * it cannot tell you why, and an unactionable number is the same dead thing as @@ -30,7 +30,15 @@ * cache-safety-by-construction half of this file. */ -import { readFileSync, readdirSync, statSync, existsSync, openSync, readSync, closeSync } from 'node:fs'; +import { + readFileSync, + readdirSync, + statSync, + existsSync, + openSync, + readSync, + closeSync, +} from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; @@ -51,7 +59,8 @@ export const READ_MULTIPLIER = 0.1; const SYNTHETIC_MODEL = ''; /** True for a turn naming a model an inference actually ran under. */ -const realModel = (turn) => Boolean(turn.model) && turn.model !== SYNTHETIC_MODEL; +const realModel = (turn) => + Boolean(turn.model) && turn.model !== SYNTHETIC_MODEL; /* -------------------------------------------------------------- MEASUREMENT */ @@ -72,7 +81,10 @@ export function transcriptFor(cwd = process.cwd()) { try { const files = readdirSync(dir) .filter((name) => name.endsWith('.jsonl')) - .map((name) => ({ path: join(dir, name), at: statSync(join(dir, name)).mtimeMs })) + .map((name) => ({ + path: join(dir, name), + at: statSync(join(dir, name)).mtimeMs, + })) .sort((a, b) => b.at - a.at); return files.length ? files[0].path : null; } catch { @@ -157,7 +169,8 @@ export function cacheHealth(turns) { read += turn.read; written += turn.written; if (!realModel(turn)) continue; - if (!models.has(turn.model)) models.set(turn.model, { read: 0, written: 0, turns: 0 }); + if (!models.has(turn.model)) + models.set(turn.model, { read: 0, written: 0, turns: 0 }); const entry = models.get(turn.model); entry.read += turn.read; entry.written += turn.written; @@ -171,20 +184,28 @@ export function cacheHealth(turns) { // attribution price, made keepWarm answer "unknown", and returned null from // modelSwitchCost under a guard its caller does not share, which crashed // cache_audit outright with "Cannot read properties of null". - const last = turns.reduce((best, turn) => (turn.read + turn.written > 0 ? turn : best), turns[turns.length - 1]); + const last = turns.reduce( + (best, turn) => (turn.read + turn.written > 0 ? turn : best), + turns[turns.length - 1] + ); const total = read + written; return { + provider: 'anthropic', + source: 'claude-code-transcript', turns: turns.length, read, written, hitRate: total ? read / total : null, prefixTokens: last.read + last.written, - // Priced in plain-token equivalents, which is what makes it comparable with - // every other saving this product reports. - writeCost: Math.round(written * WRITE_MULTIPLIER), - readCost: Math.round(read * READ_MULTIPLIER), - savedVersusNoCache: Math.round(read * (1 - READ_MULTIPLIER)), + // These are Anthropic uncached-input cost equivalents. They stay outside + // the verified MCP transport ledger and must not be relabeled as tokens + // saved across clients. + writeInputCostEquivalent: Math.round(written * WRITE_MULTIPLIER), + readInputCostEquivalent: Math.round(read * READ_MULTIPLIER), + inputCostEquivalentAvoidedVersusUncached: Math.round( + read * (1 - READ_MULTIPLIER) + ), models: Object.fromEntries(models), }; } @@ -200,13 +221,17 @@ export function modelSwitchCost(turns) { const health = cacheHealth(turns); if (!health?.prefixTokens) return null; - const switched = new Set(turns.filter(realModel).map((t) => t.model)).size > 1; + const switched = + new Set(turns.filter(realModel).map((t) => t.model)).size > 1; return { prefixTokens: health.prefixTokens, - rewriteCost: Math.round(health.prefixTokens * WRITE_MULTIPLIER), + rewriteInputCostEquivalent: Math.round( + health.prefixTokens * WRITE_MULTIPLIER + ), alreadySwitched: switched, - text: `Switching model now discards a ${health.prefixTokens.toLocaleString()}-token warm prefix; ` + - `re-writing it costs about ${Math.round(health.prefixTokens * WRITE_MULTIPLIER).toLocaleString()} tokens.`, + text: + `Switching model now discards a ${health.prefixTokens.toLocaleString()}-token warm prefix; ` + + `under Anthropic's current 5-minute write multiplier, re-writing it is about ${Math.round(health.prefixTokens * WRITE_MULTIPLIER).toLocaleString()} uncached-input cost-equivalent tokens.`, }; } @@ -221,17 +246,38 @@ export function modelSwitchCost(turns) { * time. That is why this class of bug survives -- nothing about it looks wrong. */ const VOLATILE = [ - { id: 'timestamp', re: /\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/, why: 'an embedded timestamp' }, - { id: 'date', re: /\b(?:today|current date)\b[^\n]{0,40}\d{4}-\d{2}-\d{2}/i, why: 'an embedded current date' }, + { + id: 'timestamp', + re: /\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/, + why: 'an embedded timestamp', + }, + { + id: 'date', + re: /\b(?:today|current date)\b[^\n]{0,40}\d{4}-\d{2}-\d{2}/i, + why: 'an embedded current date', + }, // A bare date is a SUSPICION, not a measurement: "- 2025-10-31: reworked the // loader" is byte-identical every session. Kept, because stableText fails // closed over our own output and dropping a line there costs little -- but // NOT priced, because in the blame report a false positive invents a // per-session cost and tells the user to delete their changelog. - { id: 'iso-date', re: /\b\d{4}-\d{2}-\d{2}\b/, why: 'an embedded date', priced: false }, - { id: 'session-id', re: /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i, why: 'a session id' }, + { + id: 'iso-date', + re: /\b\d{4}-\d{2}-\d{2}\b/, + why: 'an embedded date', + priced: false, + }, + { + id: 'session-id', + re: /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i, + why: 'a session id', + }, { id: 'git-sha', re: /\b[0-9a-f]{40}\b/, why: 'a git sha' }, - { id: 'counter', re: /\b(?:run|build|attempt|iteration)\s*#?\d+\b/i, why: 'a run counter' }, + { + id: 'counter', + re: /\b(?:run|build|attempt|iteration)\s*#?\d+\b/i, + why: 'a run counter', + }, { id: 'epoch', re: /\b1[6-9]\d{11}\b/, why: 'an epoch timestamp' }, ]; @@ -242,7 +288,13 @@ export function volatileLines(text) { for (let i = 0; i < lines.length; i++) { for (const rule of VOLATILE) { if (!rule.re.test(lines[i])) continue; - out.push({ line: i + 1, id: rule.id, why: rule.why, priced: rule.priced !== false, text: lines[i].trim().slice(0, 120) }); + out.push({ + line: i + 1, + id: rule.id, + why: rule.why, + priced: rule.priced !== false, + text: lines[i].trim().slice(0, 120), + }); break; // one cause per line is enough to act on } } @@ -264,7 +316,11 @@ const estimate = (text) => Math.ceil(String(text || '').length / 4); * unknown, and this reports the construct WITHOUT a price rather than inventing * one. */ -export function attributeInvalidation(cwd, prefixTokens = null, { files = null } = {}) { +export function attributeInvalidation( + cwd, + prefixTokens = null, + { files = null } = {} +) { // Ordered as the client assembles them: the client preamble and tool // definitions come first, then project instructions, then the conversation. const candidates = files || ['CLAUDE.md', 'AGENTS.md', '.claude/CLAUDE.md']; @@ -292,7 +348,8 @@ export function attributeInvalidation(cwd, prefixTokens = null, { files = null } // prefix -- 3.75x the entire prefix. Conservative still: the preamble // ahead of the file is not counted, so this remains a floor. const ahead = offset + estimate(lines.slice(0, hit.line - 1).join('\n')); - const downstream = prefixTokens != null ? Math.max(0, prefixTokens - ahead) : null; + const downstream = + prefixTokens != null ? Math.max(0, prefixTokens - ahead) : null; // A prefix cache invalidates ONCE, at the earliest difference. A later // volatile line in the same file costs nothing extra until the first one // is fixed, so it is reported for context but not billed again. @@ -304,7 +361,9 @@ export function attributeInvalidation(cwd, prefixTokens = null, { files = null } // into a reported zero. const firstPriced = found.find((h) => h.priced); const subsumedBy = - hit.priced && firstPriced && firstPriced !== hit ? `${relative}:${firstPriced.line}` : null; + hit.priced && firstPriced && firstPriced !== hit + ? `${relative}:${firstPriced.line}` + : null; out.push({ file: relative, line: hit.line, @@ -316,9 +375,12 @@ export function attributeInvalidation(cwd, prefixTokens = null, { files = null } // decline to price. 0 means measured and genuinely free, which is what a // subsumed hit is: real, but already billed by the earlier line. Folding // those two into one value is the same mistake this file warns about. - costPerSession: downstream == null || !hit.priced - ? null - : (subsumedBy ? 0 : Math.round(downstream * WRITE_MULTIPLIER)), + costPerSession: + downstream == null || !hit.priced + ? null + : subsumedBy + ? 0 + : Math.round(downstream * WRITE_MULTIPLIER), remedy: { kind: 'yours', type: 'edit', @@ -369,7 +431,7 @@ export function stableText(text) { */ export function cacheOrdered(items) { return [...items].sort((a, b) => { - const stability = (item) => (item.volatility ?? (item.fresh ? 1 : 0)); + const stability = (item) => item.volatility ?? (item.fresh ? 1 : 0); return stability(a) - stability(b); }); } diff --git a/integrations/cursor/hooks/lib/decide.mjs b/integrations/cursor/hooks/lib/decide.mjs index caae7de2f..a61dafdd5 100644 --- a/integrations/cursor/hooks/lib/decide.mjs +++ b/integrations/cursor/hooks/lib/decide.mjs @@ -392,6 +392,21 @@ export function touchedFiles(payload) { add(input.path); add(input.notebook_path); + // Codex code mode carries apply_patch as the nested program text rather than + // as a first-class file_path. Without reading these headers, successful + // code-mode edits incremented the semantic-harvest counter but produced no + // structural file/symbol nodes, which is why the live dashboard showed very + // few nodes after substantial work. PostToolUse can resolve Add/Update paths + // because the file now exists; deleted paths deliberately remain absent. + for (const match of command.matchAll( + /^\*\*\* (?:Add|Update|Delete) File:\s*(.+)$/gm + )) { + add(match[1].trim().replace(/^['"]|['"]$/g, '')); + } + for (const match of command.matchAll(/^\*\*\* Move to:\s*(.+)$/gm)) { + add(match[1].trim().replace(/^['"]|['"]$/g, '')); + } + // EVERY pipeline segment, not just the first. `fileOperands` looks only at // the head of the pipeline because that is where the COST is -- `git log | // head` must not be mistaken for a file dump. But observation is a different diff --git a/integrations/cursor/hooks/lib/experiment.mjs b/integrations/cursor/hooks/lib/experiment.mjs index 1f64713f1..c35c6cf5c 100644 --- a/integrations/cursor/hooks/lib/experiment.mjs +++ b/integrations/cursor/hooks/lib/experiment.mjs @@ -67,16 +67,71 @@ export function episodeMeta({ client, raw = {}, payload = {}, env = process.env } export function usageFrom(raw = {}) { - const usage = raw.usage || raw.token_usage || raw.tokenUsage || raw.metrics?.usage || {}; + const usage = raw.usage || raw.token_usage || raw.tokenUsage || raw.usageMetadata || + raw.usage_metadata || raw.metrics?.usage || raw.response?.usage || + raw.response?.usageMetadata || {}; const number = (...values) => { const value = first(...values); const parsed = Number(value); return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; }; + const cachedInputTokens = number( + usage.cached_input_tokens, usage.cachedInputTokens, + usage.cache_read_input_tokens, usage.cachedContentTokenCount + ); + const cacheCreation = usage.cache_creation || usage.cacheCreation || {}; + const cacheWrite5mInputTokens = number( + usage.cache_write_5m_input_tokens, usage.cacheWrite5mInputTokens, + cacheCreation.ephemeral_5m_input_tokens, cacheCreation.ephemeral5mInputTokens + ); + const cacheWrite1hInputTokens = number( + usage.cache_write_1h_input_tokens, usage.cacheWrite1hInputTokens, + cacheCreation.ephemeral_1h_input_tokens, cacheCreation.ephemeral1hInputTokens + ); + const detailedWrites = (cacheWrite5mInputTokens || 0) + (cacheWrite1hInputTokens || 0); + const reportedWrites = number( + usage.cache_write_input_tokens, usage.cacheWriteInputTokens, + usage.cache_creation_input_tokens, usage.cacheCreationInputTokens + ); + const cacheWriteInputTokens = reportedWrites == null + ? null + : Math.max(0, reportedWrites - detailedWrites); + const explicitUncached = number(usage.uncached_input_tokens, usage.uncachedInputTokens); + const promptInput = number( + usage.promptTokenCount, usage.prompt_token_count, + usage.input_tokens, usage.inputTokens + ); + const isAnthropic = usage.cache_creation_input_tokens !== undefined || + usage.cache_read_input_tokens !== undefined; + const isGemini = usage.promptTokenCount !== undefined || usage.cachedContentTokenCount !== undefined; + const uncachedInputTokens = explicitUncached != null + ? explicitUncached + : promptInput == null + ? null + : isAnthropic + ? promptInput + : isGemini || cachedInputTokens != null || reportedWrites != null + ? Math.max(0, promptInput - (cachedInputTokens || 0) - (reportedWrites || 0)) + : promptInput; + const candidates = number(usage.candidatesTokenCount, usage.candidates_token_count); + const thoughts = number(usage.thoughtsTokenCount, usage.thoughts_token_count); + const outputTokens = candidates != null || thoughts != null + ? (candidates || 0) + (thoughts || 0) + : number(usage.output_tokens, usage.outputTokens); return { - uncachedInputTokens: number(usage.uncached_input_tokens, usage.uncachedInputTokens), - cachedInputTokens: number(usage.cached_input_tokens, usage.cachedInputTokens, usage.cache_read_input_tokens), - outputTokens: number(usage.output_tokens, usage.outputTokens), - totalTokens: number(usage.total_tokens, usage.totalTokens, raw.tokens_used, raw.tokensUsed), + uncachedInputTokens, + cachedInputTokens, + cacheWrite5mInputTokens, + cacheWrite1hInputTokens, + cacheWriteInputTokens, + outputTokens, + totalTokens: number( + usage.total_tokens, usage.totalTokens, usage.totalTokenCount, + usage.total_token_count, raw.tokens_used, raw.tokensUsed + ), + usageMeasurementId: first( + raw.requestId, raw.request_id, raw.responseId, raw.response_id, + usage.responseId, usage.response_id + ) ?? null, }; } diff --git a/integrations/cursor/hooks/lib/harvest-write.mjs b/integrations/cursor/hooks/lib/harvest-write.mjs index 30433d370..db436c1dc 100644 --- a/integrations/cursor/hooks/lib/harvest-write.mjs +++ b/integrations/cursor/hooks/lib/harvest-write.mjs @@ -24,7 +24,7 @@ */ import { - putNodeWithEdges, load, nodeId, sharedDir, isSharedDir, putNode, + putNodeWithEdges, load, nodeId, sharedDir, isSharedDir, putNode, putEdge, } from './wiki.mjs'; import { indexFile } from './staleness.mjs'; import { symbolKey } from './symbols.mjs'; @@ -47,6 +47,37 @@ import { ORIGIN_HARVESTED, ORIGIN_AGENT, ORIGIN_HUMAN } from './curate.mjs'; */ export const SHAREABLE_TYPES = new Set(['command', 'failure', 'decision', 'feedback']); +/** + * Synthetic study traffic must never become advice for an unrelated live repo. + * Evaluation roots are intentionally realistic and may carry their own `.git` + * marker, so repository discovery alone cannot distinguish them. Publication + * remains useful inside the isolated study store; this guard protects only the + * default machine-wide store when a harness was accidentally run without an + * isolated TOKEN_OPTIMIZER_SHARED_DIR. + */ +export function isEphemeralProject(projectRoot) { + const root = canonicalPath(projectRoot || '').toLowerCase(); + if (!root) return true; + return ( + /\/appdata\/local\/temp\//.test(root) || + /(^|\/)tmp\//.test(root) || + /\/(artifacts|\.token-optimizer)\/(?:[^/]*\/)*(?:eval|scenario|study|fixture|debug)[^/]*\//.test(`${root}/`) + ); +} + +export function quarantineSharedSource(projectRoot) { + if (process.env.TOKEN_OPTIMIZER_ALLOW_EPHEMERAL_SHARED === '1') return false; + if (!isEphemeralProject(projectRoot)) return false; + // Isolated studies deliberately point the shared tier at scratch storage. + // Their findings may cross their own fixture projects, but cannot escape into + // the user's default machine-wide graph. Strict mode exists for a regression + // probe that verifies the guard without writing to a real user directory. + return ( + !process.env.TOKEN_OPTIMIZER_SHARED_DIR || + process.env.TOKEN_OPTIMIZER_STRICT_SHARED_PROVENANCE === '1' + ); +} + /** Claim text, normalised enough that the same lesson learned twice is one row. */ const claimFingerprint = (claim) => String(claim || '').toLowerCase().replace(/\s+/g, ' ').trim().slice(0, 300); @@ -72,6 +103,7 @@ function promoteToShared(finding, { projectRoot, sessionId, provenance, key }) { if (!projectRoot) return false; const root = canonicalPath(projectRoot); + if (quarantineSharedSource(root)) return false; const rootId = nodeId('file', root); // Same claim, already carried up from anywhere: keep the first. Two repos @@ -367,11 +399,21 @@ export function writeHarvested( && !node.retired && node.type === finding.type && claimFingerprint(node.claim) === fingerprint - && resolved.every((target) => currentGraph.edges.some((edge) => - edge.from === node.id && edge.edge === 'derived_from' && edge.to === target - )) ); if (duplicate) { + // Enrich, do not duplicate. A symbol extractor may learn how to resolve a + // previously file-only anchor (or a later session may add another real + // anchor) after the claim was first stored. Repeating the same semantic + // conclusion should connect that existing node to the newly available + // evidence, not create a second dashboard card with the same text. + for (const target of resolved) { + const alreadyLinked = currentGraph.edges.some((edge) => + edge.from === duplicate.id + && edge.edge === 'derived_from' + && edge.to === target + ); + if (!alreadyLinked) putEdge(dir, duplicate.id, 'derived_from', target); + } written.push(duplicate.key); continue; } @@ -437,7 +479,12 @@ export function writeHarvested( if ( id && !isSharedDir(dir) - && (finding.scope === undefined || finding.scope === 'organization' || finding.scope === 'global') + // Cross-project publication is explicit. `writeHarvested` stores an + // omitted scope as project-local above, so treating `undefined` as global + // here contradicted the stored record and promoted fixture/evaluation + // seeds into real user sessions. This was observed live as "learned in + // scenario" advice while working in AiDotNet. + && (finding.scope === 'organization' || finding.scope === 'global') ) { promoteToShared(finding, { projectRoot, sessionId, provenance, key }); } diff --git a/integrations/cursor/hooks/lib/inject.mjs b/integrations/cursor/hooks/lib/inject.mjs index b00494a01..c0aa2f1bf 100644 --- a/integrations/cursor/hooks/lib/inject.mjs +++ b/integrations/cursor/hooks/lib/inject.mjs @@ -31,6 +31,7 @@ import { canonicalPath, resolvableCandidates } from './paths.mjs'; import { annotatedSkeleton } from './skeleton.mjs'; import { substitutionBudget } from './metrics.mjs'; import { assessFindings } from './utility.mjs'; +import { quarantineSharedSource } from './harvest-write.mjs'; // Read per call for the same reason as the holdout fraction in metrics.mjs. const touchBudget = () => Number(process.env.TOKEN_OPTIMIZER_TOUCH_BUDGET) || 500; @@ -562,6 +563,7 @@ export function forRepeatedAct( const home = projectRoot ? canonicalPath(projectRoot) : null; for (const node of graph.nodes.values()) { if (node.kind !== 'finding' || node.retired || !node.claim) continue; + if (quarantineSharedSource(node.sourceProject)) continue; if (home && node.sourceProject && canonicalPath(node.sourceProject) === home) continue; const claimClasses = classesOf(node.claim, 'claim'); if (![...crossedClasses].some((c) => claimClasses.has(c))) continue; @@ -667,6 +669,7 @@ export function forSharedCommand( const candidates = []; for (const node of graph.nodes.values()) { if (node.kind !== 'finding' || node.retired) continue; + if (quarantineSharedSource(node.sourceProject)) continue; if (alreadyInjected.has(node.key)) continue; // ITS OWN LESSON IS NOT NEWS. A finding this project contributed is already // served by the local path; repeating it under a "from elsewhere" label @@ -1054,7 +1057,13 @@ export function standingRules(dir, graph, { budget = standingBudget(), episode = * informative response available -- more useful than the file, not a lossier * version of it. */ -export function substitutionFor(dir, graph, rawPath, source, { sessionId } = {}) { +export function substitutionFor( + dir, + graph, + rawPath, + source, + { sessionId, client = null, clientVersion = null, model = null, modelVersion = null } = {} +) { const filePath = canonicalPath(rawPath); const budget = substitutionBudget(dir, filePath); const built = annotatedSkeleton(graph, rawPath, source, { budget }); @@ -1089,6 +1098,10 @@ export function substitutionFor(dir, graph, rawPath, source, { sessionId } = {}) // on this machine turned out to be the enforcement suite's own fixture and // nothing distinguished them from real work. sessionId, + client, + clientVersion, + model, + modelVersion, holdout, tokens: holdout ? 0 : built.tokens, findings: built.findings, diff --git a/integrations/cursor/hooks/lib/metrics.mjs b/integrations/cursor/hooks/lib/metrics.mjs index 0bb08f1bc..09f822630 100644 --- a/integrations/cursor/hooks/lib/metrics.mjs +++ b/integrations/cursor/hooks/lib/metrics.mjs @@ -19,8 +19,15 @@ */ import { - appendFileSync, readFileSync, existsSync, mkdirSync, chmodSync, - statSync, openSync, readSync, closeSync, + appendFileSync, + readFileSync, + existsSync, + mkdirSync, + chmodSync, + statSync, + openSync, + readSync, + closeSync, } from 'node:fs'; import { join } from 'node:path'; import { createHash, randomBytes } from 'node:crypto'; @@ -86,6 +93,10 @@ export const BALANCE_KINDS = new Set([ 'keepwarm', ]); +/** Latest timestamp without passing an unbounded event log as function arguments. */ +export const latestEventTimestamp = (rows) => + rows.reduce((latest, event) => Math.max(latest, Number(event.at) || 0), 0); + /** * Causal records have their own bounded log. Tool outcomes are much rarer * than read/capture telemetry but more frequent than balance records; mixing @@ -130,7 +141,7 @@ export function inHoldout(anchorKey, now = Date.now()) { // other side than before. With nine holdout records in existence that costs // nothing, and stratification is a fresh draw either way. const digest = createHash('sha256').update(`${anchorKey}:${epoch}`).digest(); - return (digest[0] / 256) < fraction; + return digest[0] / 256 < fraction; } /** @@ -258,7 +269,8 @@ export function record(dir, event) { * It is also the right statistics. A saving measured months ago says little * about the code as it stands now. */ -const MAX_BYTES = Number(process.env.TOKEN_OPTIMIZER_METRICS_BYTES) || 2_000_000; +const MAX_BYTES = + Number(process.env.TOKEN_OPTIMIZER_METRICS_BYTES) || 2_000_000; const MAX_EVENTS = Number(process.env.TOKEN_OPTIMIZER_METRICS_WINDOW) || 5000; /** @@ -432,7 +444,10 @@ export function readBalance(dir) { // path for records written before ids existed; it is lossy by nature, which // is precisely why new records carry an id instead. const id = - e.id ?? [e.kind, e.at, e.anchor ?? '', e.sessionId ?? '', e.tokens ?? ''].join('|'); + e.id ?? + [e.kind, e.at, e.anchor ?? '', e.sessionId ?? '', e.tokens ?? ''].join( + '|' + ); if (seen.has(id)) continue; seen.add(id); out.push(e); @@ -493,16 +508,24 @@ export function recordToolOutcome(dir, outcome) { const evidence = readEvidence(dir); const anchor = String(outcome.anchor || '').slice(0, 120); const candidates = evidence - .filter((event) => event.kind === 'inject' && event.episodeId === outcome.episodeId) + .filter( + (event) => + event.kind === 'inject' && event.episodeId === outcome.episodeId + ) .filter((event) => { if (outcome.toolCallId && event.toolCallId) return String(event.toolCallId) === String(outcome.toolCallId); - return event.surface === outcome.surface && String(event.anchor || '').slice(0, 120) === anchor; + return ( + event.surface === outcome.surface && + String(event.anchor || '').slice(0, 120) === anchor + ); }) .sort((a, b) => (b.at || 0) - (a.at || 0)); const injection = candidates[0] || null; const joinMethod = injection - ? (outcome.toolCallId && injection.toolCallId ? 'tool-call-id' : 'episode-anchor') + ? outcome.toolCallId && injection.toolCallId + ? 'tool-call-id' + : 'episode-anchor' : 'none'; return record(dir, { @@ -542,12 +565,11 @@ export function isFixtureAnchor(anchor) { // code: it excluded ALL temp paths -- including a real project checked out // under /tmp -- and on macOS excluded nothing at all, because tmpdir() there // is /var/folders///T/ and did not match. - const underTemp = - new RegExp( - '[\\\\/](AppData[\\\\/]Local[\\\\/])?(Temp|tmp)[\\\\/]' + - '|[\\\\/]var[\\\\/]folders[\\\\/][^\\\\/]+[\\\\/][^\\\\/]+[\\\\/]T[\\\\/]', - 'i' - ).test(p); + const underTemp = new RegExp( + '[\\\\/](AppData[\\\\/]Local[\\\\/])?(Temp|tmp)[\\\\/]' + + '|[\\\\/]var[\\\\/]folders[\\\\/][^\\\\/]+[\\\\/][^\\\\/]+[\\\\/]T[\\\\/]', + 'i' + ).test(p); if (!underTemp) return false; // Only names this suite actually creates. A user's own scratch checkout under @@ -686,8 +708,12 @@ export function balanceSheet(dir) { // THE CONTROL ARM'S ACTUAL COST. `tokensFullFile` was recorded and read // by nothing, so the comparison the holdout exists for was not // computable from the report. - controlArmTokens: withheldSubs.reduce((sum, e) => sum + (e.tokensFullFile || 0), 0), - assumption: 'that the model would have read the file it explicitly requested', + controlArmTokens: withheldSubs.reduce( + (sum, e) => sum + (e.tokensFullFile || 0), + 0 + ), + assumption: + 'that the model would have read the file it explicitly requested', }, estimatedCausal: { what: 'findings injection, from the stratified holdout', @@ -702,7 +728,12 @@ export function balanceSheet(dir) { }; })(), }, - costs: { injection: injectCost, harvest: harvestCost, standing: standingCost, substitution: substitutionCost }, + costs: { + injection: injectCost, + harvest: harvestCost, + standing: standingCost, + substitution: substitutionCost, + }, waste, // Deliberately NOT a single number. The two benefit lines are known // differently; adding them would launder an assumption into a measurement. @@ -727,7 +758,9 @@ export function balanceSheet(dir) { /** Cheap path normalisation for grouping reads; not the identity canonicaliser. */ function canonicalKeyish(p) { - return String(p || '').replace(/\\/g, '/').toLowerCase(); + return String(p || '') + .replace(/\\/g, '/') + .toLowerCase(); } /** @@ -738,7 +771,43 @@ function canonicalKeyish(p) { * arms, multiplied by treated touches. Reporting it as a measured fact would be * the same overclaiming this project criticises competitors for. */ -function buildReport(events, balance) { +function buildReport(events, balance, sourceCoverage = {}) { + const substitutions = balance.filter( + (event) => event.kind === 'substitute' && !isFixtureAnchor(event.anchor) + ); + const servedSubstitutions = substitutions.filter((event) => !event.holdout); + const substitutionTokensSaved = servedSubstitutions.reduce( + (sum, event) => + sum + + (event.tokensNetAvoided ?? + Math.max( + 0, + Math.ceil((event.bytesAvoided || 0) / 4) - (event.tokens || 0) + )), + 0 + ); + const substitutionTokensReturned = servedSubstitutions.reduce( + (sum, event) => sum + (event.tokens || 0), + 0 + ); + const substitutionByClient = {}; + for (const event of servedSubstitutions) { + const name = event.client || 'Historical — client not recorded'; + const current = substitutionByClient[name] || { + substitutions: 0, + tokensReturned: 0, + tokensSaved: 0, + }; + current.substitutions += 1; + current.tokensReturned += event.tokens || 0; + current.tokensSaved += + event.tokensNetAvoided ?? + Math.max( + 0, + Math.ceil((event.bytesAvoided || 0) / 4) - (event.tokens || 0) + ); + substitutionByClient[name] = current; + } // COMMAND INJECTIONS ARE COUNTED SEPARATELY, not mixed into the balance. // @@ -762,13 +831,19 @@ function buildReport(events, balance) { // Report its cost independently so it cannot dilute the file-touch estimate. const isSessionStart = (e) => e.surface === 'session-start'; const sessionStartInjections = allInjections.filter(isSessionStart); - const commandInjections = allInjections.filter((e) => !isSessionStart(e) && isCommand(e)); - const injections = allInjections.filter((e) => !isSessionStart(e) && !isCommand(e)); + const commandInjections = allInjections.filter( + (e) => !isSessionStart(e) && isCommand(e) + ); + const injections = allInjections.filter( + (e) => !isSessionStart(e) && !isCommand(e) + ); const treated = injections.filter((e) => !e.holdout); const withheld = injections.filter((e) => e.holdout); const mean = (rows, field) => - rows.length ? rows.reduce((sum, r) => sum + (r[field] || 0), 0) / rows.length : 0; + rows.length + ? rows.reduce((sum, r) => sum + (r[field] || 0), 0) / rows.length + : 0; const injectedTokens = treated.reduce((sum, e) => sum + (e.tokens || 0), 0); const harvestTokens = balance @@ -819,7 +894,9 @@ function buildReport(events, balance) { const after = event.at ?? 0; const total = bucket.reduce( - (sum, r) => sum + ((r.at ?? 0) >= after ? (r.tokens || 0) : 0), 0); + (sum, r) => sum + ((r.at ?? 0) >= after ? r.tokens || 0 : 0), + 0 + ); // Split across the injections that share this key. Each touch is one // observation, so charging every one the full total would count the same @@ -828,52 +905,198 @@ function buildReport(events, balance) { }; const meanDownstream = (rows) => - rows.length ? rows.reduce((sum, r) => sum + downstreamOf(r), 0) / rows.length : 0; + rows.length + ? rows.reduce((sum, r) => sum + downstreamOf(r), 0) / rows.length + : 0; const treatedCost = meanDownstream(treated); const withheldCost = meanDownstream(withheld); const perTouchSaving = withheldCost - treatedCost; - const estimatedAvoided = Math.max(0, Math.round(perTouchSaving * treated.length)); + const estimatedAvoided = Math.max( + 0, + Math.round(perTouchSaving * treated.length) + ); + + const downstreamSamples = injections.filter((event) => { + if (event.downstream != null) return true; + const key = `${event.sessionId || ''}|${event.anchor}`; + return (reads.get(key) || []).some( + (read) => (read.at ?? 0) >= (event.at ?? 0) + ); + }).length; + const downstreamMeasured = downstreamSamples > 0; // Below this, arm means are noise and a ratio would be theatre. - const sufficient = treated.length >= 20 && withheld.length >= 5; + const sufficient = + downstreamMeasured && treated.length >= 20 && withheld.length >= 5; + + const coverageNumber = (value, fallback) => { + if (value === null || value === undefined || value === '') return fallback; + const number = Number(value); + return Number.isFinite(number) ? Math.max(0, number) : fallback; + }; + const projectCount = coverageNumber(sourceCoverage.projects, 1); + const telemetryProjects = coverageNumber( + sourceCoverage.projectsWithTelemetry, + 0 + ); + const balanceProjects = coverageNumber( + sourceCoverage.projectsWithBalanceEvents, + balance.length ? 1 : 0 + ); + const lastEventAt = Math.max( + latestEventTimestamp(events), + latestEventTimestamp(balance) + ); + const measured = + telemetryProjects > 0 || events.length > 0 || balance.length > 0; + const balanceMeasured = balanceProjects > 0 || balance.length > 0; + const metric = (status, source, samples, extra = {}) => ({ + status, + source, + samples, + ...extra, + }); return { + nativeOptimizer: { + substitutions: servedSubstitutions.length, + holdouts: substitutions.filter((event) => event.holdout).length, + tokensReturned: substitutionTokensReturned, + tokensSaved: substitutionTokensSaved, + byClient: substitutionByClient, + recent: servedSubstitutions + .slice() + .sort((a, b) => (b.at || 0) - (a.at || 0)) + .slice(0, 40) + .map((event) => { + const tokensSaved = + event.tokensNetAvoided ?? + Math.max( + 0, + Math.ceil((event.bytesAvoided || 0) / 4) - (event.tokens || 0) + ); + return { + name: 'live_graph_substitution', + client: event.client || 'Historical — client not recorded', + originalTokens: (event.tokens || 0) + tokensSaved, + optimizedTokens: event.tokens || 0, + tokensSaved, + savingsMeasured: false, + classification: 'modeled-counterfactual', + reportedTokensSaved: tokensSaved, + timestamp: event.at ? new Date(event.at).toISOString() : null, + }; + }), + source: + 'balance.jsonl: modeled full-file counterfactual minus annotated-skeleton tokens; excluded from verified MCP savings', + }, memoryDeliveries: allInjections.filter((event) => !event.holdout).length, memoryHoldouts: allInjections.filter((event) => event.holdout).length, deliveryTokens: allInjections .filter((event) => !event.holdout) .reduce( - (sum, event) => - sum + (event.deliveredTokens ?? event.tokens ?? 0), + (sum, event) => sum + (event.deliveredTokens ?? event.tokens ?? 0), 0 ), injections: treated.length, sessionStartInjections: sessionStartInjections.length, sessionStartInjectedTokens: sessionStartInjections.reduce( - (sum, event) => sum + (event.deliveredTokens ?? event.tokens ?? 0), 0 + (sum, event) => sum + (event.deliveredTokens ?? event.tokens ?? 0), + 0 ), commandInjections: commandInjections.length, commandHoldouts: commandInjections.filter((e) => e.holdout).length, holdouts: withheld.length, staleServed: injections.filter((e) => e.stale).length, - staleRate: injections.length ? injections.filter((e) => e.stale).length / injections.length : 0, + staleRate: injections.length + ? injections.filter((e) => e.stale).length / injections.length + : 0, injectedTokens, harvestTokens, estimatedTokensAvoided: sufficient ? estimatedAvoided : null, - netTokens: sufficient ? estimatedAvoided - injectedTokens - harvestTokens : null, + netTokens: sufficient + ? estimatedAvoided - injectedTokens - harvestTokens + : null, sufficientData: sufficient, verdict: !sufficient ? `insufficient data (${treated.length} treated, ${withheld.length} holdout; need 20 and 5)` : estimatedAvoided > injectedTokens + harvestTokens ? 'the graph is saving more than it costs' : 'the graph is NOT yet paying for itself', + measurement: { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + sourceCoverage: { + projects: projectCount, + projectsWithTelemetry: telemetryProjects || (measured ? 1 : 0), + projectsWithBalanceEvents: balanceProjects, + projectsWithoutTelemetry: Math.max( + 0, + projectCount - (telemetryProjects || (measured ? 1 : 0)) + ), + }, + freshness: { + lastEventAt: lastEventAt || null, + ageMs: lastEventAt ? Math.max(0, Date.now() - lastEventAt) : null, + status: !lastEventAt + ? 'not-measured' + : Date.now() - lastEventAt <= 7 * 86_400_000 + ? 'fresh' + : 'stale', + }, + metrics: { + nativeSubstitutions: metric( + balanceMeasured ? 'measured' : 'not-measured', + 'balance.jsonl: modeled full-file counterfactual minus annotated-skeleton tokens; not a materialized MCP before/after pair', + servedSubstitutions.length, + { classification: 'modeled-counterfactual' } + ), + memoryDeliveries: metric( + balanceMeasured ? 'measured' : 'not-measured', + 'balance.jsonl: inject events in the treated arm', + allInjections.filter((event) => !event.holdout).length + ), + memoryHoldouts: metric( + balanceMeasured ? 'measured' : 'not-measured', + 'balance.jsonl: inject events in the withheld arm', + allInjections.filter((event) => event.holdout).length + ), + rememberingCost: metric( + balanceMeasured ? 'measured' : 'not-measured', + 'balance.jsonl: delivered injection tokens plus semantic harvest tokens', + allInjections.length + + balance.filter((event) => event.kind === 'harvest').length + ), + readingAvoided: metric( + !downstreamMeasured + ? 'not-measured' + : sufficient + ? 'measured' + : 'collecting', + 'stratified file-touch holdout joined to downstream read events', + downstreamSamples, + { + treated: treated.length, + holdouts: withheld.length, + requiredTreated: 20, + requiredHoldouts: 5, + } + ), + }, + }, }; } export function report(dir) { - return buildReport(readAll(dir), readBalance(dir)); + const events = readAll(dir); + const balance = readBalance(dir); + return buildReport(events, balance, { + projects: 1, + projectsWithTelemetry: events.length || balance.length ? 1 : 0, + projectsWithBalanceEvents: balance.length ? 1 : 0, + }); } /** @@ -886,10 +1109,16 @@ export function report(dir) { */ export function reportMany(dirs) { const unique = [...new Set((dirs || []).map((dir) => String(dir)))]; - const report = buildReport( - unique.flatMap((dir) => readAll(dir)), - unique.flatMap((dir) => readBalance(dir)) - ); + const eventSources = unique.map((dir) => readAll(dir)); + const balanceSources = unique.map((dir) => readBalance(dir)); + const report = buildReport(eventSources.flat(), balanceSources.flat(), { + projects: unique.length, + projectsWithTelemetry: eventSources.filter( + (events, index) => events.length || balanceSources[index].length + ).length, + projectsWithBalanceEvents: balanceSources.filter((events) => events.length) + .length, + }); return { ...report, projects: unique.length }; } @@ -907,7 +1136,11 @@ export function reportMany(dirs) { * costs almost nothing. Without a control arm there is no way to tell those two * apart, which is why nobody else can do this. */ -export function substitutionBudget(dir, anchor, { floor = 300, base = 1200, ceiling = 3000 } = {}) { +export function substitutionBudget( + dir, + anchor, + { floor = 300, base = 1200, ceiling = 3000 } = {} +) { const events = readAll(dir); const mine = events.filter((e) => e.kind === 'inject' && e.anchor === anchor); @@ -932,13 +1165,17 @@ export function substitutionBudget(dir, anchor, { floor = 300, base = 1200, ceil for (const row of rows) { const bucket = reads.get(row.sessionId || '') || []; const after = row.at ?? 0; - total += bucket.reduce((sum, r) => sum + ((r.at ?? 0) >= after ? (r.tokens || 0) : 0), 0); + total += bucket.reduce( + (sum, r) => sum + ((r.at ?? 0) >= after ? r.tokens || 0 : 0), + 0 + ); } return total / rows.length; }; const saved = downstream(withheld) - downstream(treated); - const spent = treated.reduce((sum, e) => sum + (e.tokens || 0), 0) / treated.length; + const spent = + treated.reduce((sum, e) => sum + (e.tokens || 0), 0) / treated.length; // Ratio of what annotating this file avoided to what annotating it cost. // Above 1 it is paying for itself and earns room; below, it shrinks. @@ -959,7 +1196,10 @@ export function substitutionBudget(dir, anchor, { floor = 300, base = 1200, ceil * graph whose index leads to queries grows its allowance; a noisy one shrinks * back toward the floor. Nobody configures it, and it cannot run away. */ -export function indexBudget(dir, { floor = 150, base = 300, ceiling = 1200 } = {}) { +export function indexBudget( + dir, + { floor = 150, base = 300, ceiling = 1200 } = {} +) { const events = readAll(dir); const listed = events.filter((e) => e.kind === 'index').length; const queries = events.filter((e) => e.kind === 'query').length; @@ -968,7 +1208,9 @@ export function indexBudget(dir, { floor = 150, base = 300, ceiling = 1200 } = { const hitRate = queries / listed; // 0% hit rate falls to the floor; ~50% and above reaches the ceiling. - const scaled = Math.round(floor + (ceiling - floor) * Math.min(1, hitRate * 2)); + const scaled = Math.round( + floor + (ceiling - floor) * Math.min(1, hitRate * 2) + ); return Math.max(floor, Math.min(ceiling, scaled)); } @@ -1005,7 +1247,10 @@ function seededRandom(seedText) { } /** Percentile bootstrap interval for a mean, paired delta, or other scalar. */ -export function bootstrapMeanInterval(values, { samples = 1000, seed = 'token-optimizer' } = {}) { +export function bootstrapMeanInterval( + values, + { samples = 1000, seed = 'token-optimizer' } = {} +) { const finite = values.map(numeric).filter((value) => value !== null); if (!finite.length) return { mean: null, low: null, high: null, n: 0 }; if (finite.length === 1) { @@ -1037,24 +1282,32 @@ export function proportionInterval(successes, total, z = 1.96) { const denominator = 1 + (z * z) / total; const centre = (rate + (z * z) / (2 * total)) / denominator; const radius = - (z / denominator) * Math.sqrt((rate * (1 - rate)) / total + (z * z) / (4 * total * total)); - return { rate, low: Math.max(0, centre - radius), high: Math.min(1, centre + radius), n: total }; + (z / denominator) * + Math.sqrt((rate * (1 - rate)) / total + (z * z) / (4 * total * total)); + return { + rate, + low: Math.max(0, centre - radius), + high: Math.min(1, centre + radius), + n: total, + }; } -const cohortKey = (run) => [ - run.client || 'unknown', - run.clientVersion || 'unknown', - run.model || 'unknown', - run.modelVersion || 'unknown', - run.taskId || 'unknown', -].join('|'); +const cohortKey = (run) => + [ + run.client || 'unknown', + run.clientVersion || 'unknown', + run.model || 'unknown', + run.modelVersion || 'unknown', + run.taskId || 'unknown', + ].join('|'); function armMetrics(runs) { const correct = runs.filter((run) => run.correct === true).length; - const interval = (field) => bootstrapMeanInterval( - runs.map((run) => run[field]).filter((value) => numeric(value) !== null), - { seed: field } - ); + const interval = (field) => + bootstrapMeanInterval( + runs.map((run) => run[field]).filter((value) => numeric(value) !== null), + { seed: field } + ); return { runs: runs.length, correctness: proportionInterval(correct, runs.length), @@ -1068,29 +1321,40 @@ function armMetrics(runs) { latencyMs: interval('latencyMs'), costUsd: interval('costUsd'), injectedTokens: interval('injectedTokens'), - harmfulFindings: runs.reduce((sum, run) => sum + (numeric(run.harmfulFindings) || 0), 0), + harmfulFindings: runs.reduce( + (sum, run) => sum + (numeric(run.harmfulFindings) || 0), + 0 + ), }; } -function pairedEffects(runs, controlArm, treatmentArm, contrastType = 'incremental') { +function pairedEffects( + runs, + controlArm, + treatmentArm, + contrastType = 'incremental' +) { const pairs = new Map(); for (const run of runs) { if (!run.pairId || ![controlArm, treatmentArm].includes(run.arm)) continue; if (!pairs.has(run.pairId)) pairs.set(run.pairId, {}); pairs.get(run.pairId)[run.arm] = run; } - const complete = [...pairs.values()].filter((pair) => pair[controlArm] && pair[treatmentArm]); - const delta = (field, lowerIsBetter = true) => bootstrapMeanInterval( - complete - .map((pair) => { - const baseline = numeric(pair[controlArm][field]); - const treatment = numeric(pair[treatmentArm][field]); - if (baseline === null || treatment === null) return null; - return lowerIsBetter ? baseline - treatment : treatment - baseline; - }) - .filter((value) => value !== null), - { seed: `${controlArm}:${treatmentArm}:${field}` } + const complete = [...pairs.values()].filter( + (pair) => pair[controlArm] && pair[treatmentArm] ); + const delta = (field, lowerIsBetter = true) => + bootstrapMeanInterval( + complete + .map((pair) => { + const baseline = numeric(pair[controlArm][field]); + const treatment = numeric(pair[treatmentArm][field]); + if (baseline === null || treatment === null) return null; + return lowerIsBetter ? baseline - treatment : treatment - baseline; + }) + .filter((value) => value !== null), + { seed: `${controlArm}:${treatmentArm}:${field}` } + ); return { arm: treatmentArm, controlArm, @@ -1111,21 +1375,32 @@ function pairedEffects(runs, controlArm, treatmentArm, contrastType = 'increment } function matchesFilters(event, filters) { - for (const field of ['client', 'clientVersion', 'model', 'modelVersion', 'taskId', 'arm']) { + for (const field of [ + 'client', + 'clientVersion', + 'model', + 'modelVersion', + 'taskId', + 'arm', + ]) { if (!filters[field]) continue; let actual = event[field]; if (event.kind === 'handoff-run') { - actual = field === 'taskId' - ? event.scenarioId - : field === 'arm' - ? event.arm - : (event.consumer?.[field] ?? event.producer?.[field] ?? event[field]); + actual = + field === 'taskId' + ? event.scenarioId + : field === 'arm' + ? event.arm + : (event.consumer?.[field] ?? + event.producer?.[field] ?? + event[field]); } else if (event.kind === 'concurrency-run') { - actual = field === 'taskId' - ? 'concurrent-combined' - : field === 'arm' - ? event.arm - : (event.consumer?.[field] ?? event[field]); + actual = + field === 'taskId' + ? 'concurrent-combined' + : field === 'arm' + ? event.arm + : (event.consumer?.[field] ?? event[field]); } if (String(actual || '') !== String(filters[field])) return false; } @@ -1134,11 +1409,15 @@ function matchesFilters(event, filters) { function handoffArmMetrics(runs) { const consumers = runs.map((run) => run.consumer || {}); - const count = (field) => consumers.filter((consumer) => consumer[field] === true).length; - const interval = (field) => bootstrapMeanInterval( - consumers.map((consumer) => consumer[field]).filter((value) => numeric(value) !== null), - { seed: `handoff:${field}` } - ); + const count = (field) => + consumers.filter((consumer) => consumer[field] === true).length; + const interval = (field) => + bootstrapMeanInterval( + consumers + .map((consumer) => consumer[field]) + .filter((value) => numeric(value) !== null), + { seed: `handoff:${field}` } + ); return { runs: runs.length, delivery: proportionInterval( @@ -1147,7 +1426,10 @@ function handoffArmMetrics(runs) { ), correctness: proportionInterval(count('correct'), runs.length), firstPass: proportionInterval(count('firstPass'), runs.length), - mistakeAttempted: proportionInterval(count('mistakeAttempted'), runs.length), + mistakeAttempted: proportionInterval( + count('mistakeAttempted'), + runs.length + ), mistakeExecuted: proportionInterval(count('mistakeExecuted'), runs.length), totalTokens: interval('totalTokens'), toolCalls: interval('toolCalls'), @@ -1163,16 +1445,21 @@ function handoffPairedEffect(runs, controlArm, treatmentArm) { if (!pairs.has(run.pairId)) pairs.set(run.pairId, {}); pairs.get(run.pairId)[run.arm] = run; } - const complete = [...pairs.values()].filter((pair) => pair[controlArm] && pair[treatmentArm]); - const effect = (field, positiveWhenLower = true) => bootstrapMeanInterval( - complete.map((pair) => { - const control = numeric(pair[controlArm].consumer?.[field]); - const treatment = numeric(pair[treatmentArm].consumer?.[field]); - if (control === null || treatment === null) return null; - return positiveWhenLower ? control - treatment : treatment - control; - }).filter((value) => value !== null), - { seed: `handoff:${controlArm}:${treatmentArm}:${field}` } + const complete = [...pairs.values()].filter( + (pair) => pair[controlArm] && pair[treatmentArm] ); + const effect = (field, positiveWhenLower = true) => + bootstrapMeanInterval( + complete + .map((pair) => { + const control = numeric(pair[controlArm].consumer?.[field]); + const treatment = numeric(pair[treatmentArm].consumer?.[field]); + if (control === null || treatment === null) return null; + return positiveWhenLower ? control - treatment : treatment - control; + }) + .filter((value) => value !== null), + { seed: `handoff:${controlArm}:${treatmentArm}:${field}` } + ); return { comparison: `${treatmentArm} vs ${controlArm}`, pairs: complete.length, @@ -1187,13 +1474,22 @@ function handoffPairedEffect(runs, controlArm, treatmentArm) { }; } -const handoffKey = (run) => [ - run.producer?.client || 'unknown', run.producer?.model || 'unknown', - run.consumer?.client || 'unknown', run.consumer?.model || 'unknown', - run.scenarioId || 'unknown', -].join('|'); - -const HANDOFF_REPORT_ARMS = ['empty', 'natural', 'oracle', 'irrelevant', 'stale']; +const handoffKey = (run) => + [ + run.producer?.client || 'unknown', + run.producer?.model || 'unknown', + run.consumer?.client || 'unknown', + run.consumer?.model || 'unknown', + run.scenarioId || 'unknown', + ].join('|'); + +const HANDOFF_REPORT_ARMS = [ + 'empty', + 'natural', + 'oracle', + 'irrelevant', + 'stale', +]; const handoffMinimumPairs = () => Math.max(2, Number(process.env.TOKEN_OPTIMIZER_HANDOFF_MIN_PAIRS) || 10); @@ -1206,43 +1502,54 @@ function handoffCohorts(runs) { } const minimumPairs = handoffMinimumPairs(); return [...grouped.entries()].map(([key, rows]) => { - const arms = Object.fromEntries(HANDOFF_REPORT_ARMS.map((arm) => [ - arm, handoffArmMetrics(rows.filter((row) => row.arm === arm)), - ])); + const arms = Object.fromEntries( + HANDOFF_REPORT_ARMS.map((arm) => [ + arm, + handoffArmMetrics(rows.filter((row) => row.arm === arm)), + ]) + ); const naturalRows = rows.filter((row) => row.arm === 'natural'); const naturalVsEmpty = handoffPairedEffect(rows, 'empty', 'natural'); const naturalVsOracle = handoffPairedEffect(rows, 'oracle', 'natural'); const captureRate = naturalRows.length - ? naturalRows.filter((row) => row.producer?.captureSuccess).length / naturalRows.length + ? naturalRows.filter((row) => row.producer?.captureSuccess).length / + naturalRows.length : null; const preActionDeliveryRate = naturalRows.length - ? naturalRows.filter((row) => row.delivery?.beforeFirstExecutedMistake).length / naturalRows.length + ? naturalRows.filter((row) => row.delivery?.beforeFirstExecutedMistake) + .length / naturalRows.length : null; const emptyRecurrence = arms.empty.mistakeExecuted.rate; const naturalRecurrence = arms.natural.mistakeExecuted.rate; - const relativeRecurrenceReduction = emptyRecurrence !== null - && naturalRecurrence !== null - && emptyRecurrence !== 0 - ? (emptyRecurrence - naturalRecurrence) / emptyRecurrence - : null; - const controlsSafe = ['irrelevant', 'stale'].every((arm) => - arms[arm].correctness.rate !== null - && arms.empty.correctness.rate !== null - && arms[arm].correctness.rate >= arms.empty.correctness.rate - 0.1 + const relativeRecurrenceReduction = + emptyRecurrence !== null && + naturalRecurrence !== null && + emptyRecurrence !== 0 + ? (emptyRecurrence - naturalRecurrence) / emptyRecurrence + : null; + const controlsSafe = ['irrelevant', 'stale'].every( + (arm) => + arms[arm].correctness.rate !== null && + arms.empty.correctness.rate !== null && + arms[arm].correctness.rate >= arms.empty.correctness.rate - 0.1 ); - const irrelevantSuppressed = arms.irrelevant.delivery.rate !== null - && arms.irrelevant.delivery.rate === 0; + const irrelevantSuppressed = + arms.irrelevant.delivery.rate !== null && + arms.irrelevant.delivery.rate === 0; const gates = { minimumPairs: naturalVsEmpty.pairs >= minimumPairs, capture: captureRate !== null && captureRate >= 0.8, recurrenceMagnitude: - relativeRecurrenceReduction !== null && relativeRecurrenceReduction >= 0.5, - recurrenceInterval: (naturalVsEmpty.executedMistakesPrevented.low ?? -Infinity) > 0, + relativeRecurrenceReduction !== null && + relativeRecurrenceReduction >= 0.5, + recurrenceInterval: + (naturalVsEmpty.executedMistakesPrevented.low ?? -Infinity) > 0, correctness: - arms.natural.correctness.rate !== null - && arms.empty.correctness.rate !== null - && arms.natural.correctness.rate >= arms.empty.correctness.rate - 0.1, - preActionDelivery: preActionDeliveryRate !== null && preActionDeliveryRate >= 0.8, + arms.natural.correctness.rate !== null && + arms.empty.correctness.rate !== null && + arms.natural.correctness.rate >= arms.empty.correctness.rate - 0.1, + preActionDelivery: + preActionDeliveryRate !== null && preActionDeliveryRate >= 0.8, negativeControls: controlsSafe && irrelevantSuppressed, }; const claimReady = Object.values(gates).every(Boolean); @@ -1269,14 +1576,24 @@ function handoffCohorts(runs) { function concurrencySummary(runs) { const natural = runs.filter((run) => run.arm === 'natural'); const writers = natural.reduce((sum, run) => sum + (run.writerCount || 0), 0); - const captures = natural.reduce((sum, run) => sum + (run.captureSuccesses || 0), 0); - const integrityPasses = natural.filter((run) => - run.integrity?.zeroLoss - && run.integrity?.parseable - && run.integrity?.orphanedFindings === 0 + const captures = natural.reduce( + (sum, run) => sum + (run.captureSuccesses || 0), + 0 + ); + const integrityPasses = natural.filter( + (run) => + run.integrity?.zeroLoss && + run.integrity?.parseable && + run.integrity?.orphanedFindings === 0 ).length; - const delivered = natural.reduce((sum, run) => sum + (run.delivery?.delivered || 0), 0); - const expected = natural.reduce((sum, run) => sum + (run.delivery?.expected || 0), 0); + const delivered = natural.reduce( + (sum, run) => sum + (run.delivery?.delivered || 0), + 0 + ); + const expected = natural.reduce( + (sum, run) => sum + (run.delivery?.expected || 0), + 0 + ); const effect = handoffPairedEffect(runs, 'empty', 'natural'); return { runs: runs.length, @@ -1286,7 +1603,8 @@ function concurrencySummary(runs) { integrityPassRate: natural.length ? integrityPasses / natural.length : null, deliveryCoverage: expected ? delivered / expected : null, naturalCorrectness: proportionInterval( - natural.filter((run) => run.consumer?.correct).length, natural.length + natural.filter((run) => run.consumer?.correct).length, + natural.length ), effect, }; @@ -1297,17 +1615,26 @@ function concurrencySummary(runs) { * live hook traces, and randomized model evals remain visibly distinct: only * `eval-run` records with paired arms can produce a causal effect interval. */ -function buildEvidenceReport(evidence, { filters = {}, episodeLimit = 100 } = {}) { +function buildEvidenceReport( + evidence, + { filters = {}, episodeLimit = 100 } = {} +) { evidence = evidence.filter((event) => matchesFilters(event, filters)); const runs = evidence.filter((event) => event.kind === 'eval-run'); const handoffRuns = evidence.filter((event) => event.kind === 'handoff-run'); - const concurrencyRuns = evidence.filter((event) => event.kind === 'concurrency-run'); + const concurrencyRuns = evidence.filter( + (event) => event.kind === 'concurrency-run' + ); const injections = evidence.filter((event) => event.kind === 'inject'); const outcomes = evidence.filter((event) => event.kind === 'tool-outcome'); - const feedback = evidence.filter((event) => event.kind === 'finding-feedback'); + const feedback = evidence.filter( + (event) => event.kind === 'finding-feedback' + ); const byInjection = new Map( - outcomes.filter((event) => event.injectionId).map((event) => [event.injectionId, event]) + outcomes + .filter((event) => event.injectionId) + .map((event) => [event.injectionId, event]) ); const traced = injections .slice() @@ -1337,7 +1664,10 @@ function buildEvidenceReport(evidence, { filters = {}, episodeLimit = 100 } = {} grouped.get(key).push(run); } - const minimumPairs = Math.max(2, Number(process.env.TOKEN_OPTIMIZER_EVAL_MIN_PAIRS) || 5); + const minimumPairs = Math.max( + 2, + Number(process.env.TOKEN_OPTIMIZER_EVAL_MIN_PAIRS) || 5 + ); const cohorts = [...grouped.entries()].map(([key, cohortRuns]) => { const arms = Object.fromEntries( ['baseline', 'optimizer', 'retrieval', 'full'].map((arm) => [ @@ -1387,7 +1717,9 @@ function buildEvidenceReport(evidence, { filters = {}, episodeLimit = 100 } = {} cohorts: cohorts.length, harmfulFeedback: harmful, harmRate: feedback.length ? harmful / feedback.length : null, - evidenceStatus: cohorts.some((cohort) => cohort.evidenceStatus === 'causal estimate available') + evidenceStatus: cohorts.some( + (cohort) => cohort.evidenceStatus === 'causal estimate available' + ) ? 'causal estimates available' : 'insufficient randomized evidence', }, @@ -1396,7 +1728,8 @@ function buildEvidenceReport(evidence, { filters = {}, episodeLimit = 100 } = {} concurrency, episodes: traced, methodology: { - intervals: 'deterministic percentile bootstrap (95%); Wilson interval for correctness', + intervals: + 'deterministic percentile bootstrap (95%); Wilson interval for correctness', causalRule: 'matched pairs estimate optimizer vs baseline, retrieval vs optimizer, full vs retrieval, and full vs baseline', minimumPairs, diff --git a/integrations/cursor/hooks/lib/observability.mjs b/integrations/cursor/hooks/lib/observability.mjs index b9980076e..ef1c037af 100644 --- a/integrations/cursor/hooks/lib/observability.mjs +++ b/integrations/cursor/hooks/lib/observability.mjs @@ -26,7 +26,7 @@ import { createHash, randomUUID } from 'node:crypto'; import { homedir } from 'node:os'; import { join } from 'node:path'; -export const HOOK_LOG_SCHEMA_VERSION = 1; +export const HOOK_LOG_SCHEMA_VERSION = 2; const DEFAULT_MAX_BYTES = 5 * 1024 * 1024; const DEFAULT_RETENTION_DAYS = 14; const DEFAULT_MAX_FILES = 40; @@ -197,6 +197,8 @@ export function beginHookInvocation(client, event, options = {}) { turnIdHash: null, toolUseIdHash: null, toolName: null, + rawToolName: null, + codeModeEnvelope: false, clientVersion: null, model: null, cwdHash: null, @@ -208,12 +210,29 @@ export function beginHookInvocation(client, event, options = {}) { const spanId = hash(invocationId, 16); let traceId = hash(invocationId, 32); + // Write the start before reading stdin or importing project state. If the + // host kills the process, Node aborts, or a native call terminates without a + // JavaScript exception, diagnostics can now reconcile this record with the + // missing completion instead of reporting a false success rate. + writeHookEvent({ + level: 'info', + event: 'hook.started', + invocationId, + traceId, + spanId, + client, + hookEvent: event, + processId: process.pid, + }); + let deadline; const finish = (nextOutcome = outcome, nextReason = reason, nextError = error) => { if (ended) return; ended = true; if (deadline) clearTimeout(deadline); process.removeListener('exit', onExit); + process.removeListener('uncaughtException', onUncaughtException); + process.removeListener('unhandledRejection', onUnhandledRejection); writeHookEvent({ level: nextOutcome === 'failure' || nextOutcome === 'timeout' ? 'error' : 'info', event: 'hook.completed', @@ -231,7 +250,17 @@ export function beginHookInvocation(client, event, options = {}) { }); }; const onExit = () => finish(); + const onUncaughtException = (nextError) => { + finish('failure', 'uncaught_exception', nextError); + process.exit(0); + }; + const onUnhandledRejection = (nextError) => { + finish('failure', 'unhandled_rejection', nextError); + process.exit(0); + }; process.once('exit', onExit); + process.once('uncaughtException', onUncaughtException); + process.once('unhandledRejection', onUnhandledRejection); deadline = setTimeout(() => { outcome = 'timeout'; @@ -250,6 +279,11 @@ export function beginHookInvocation(client, event, options = {}) { dimensions.turnIdHash = hash(raw.turn_id ?? raw.turnId ?? null); dimensions.toolUseIdHash = hash(raw.tool_use_id ?? raw.toolUseId ?? null); dimensions.toolName = payload?.tool_name ?? raw.tool_name ?? raw.toolName ?? null; + dimensions.rawToolName = dimension( + raw.tool_name ?? raw.toolName ?? raw.tool ?? null + ); + dimensions.codeModeEnvelope = + payload?.tool_input?.code_mode_envelope === true; dimensions.clientVersion = dimension(raw.client_version ?? raw.clientVersion ?? null); dimensions.model = dimension(raw.model ?? raw.model_name ?? raw.modelName ?? null); dimensions.cwdHash = hash(raw.cwd ?? raw.working_directory ?? process.cwd()); @@ -291,6 +325,11 @@ export function beginHookInvocation(client, event, options = {}) { error = nextError; finish(outcome, reason, error); }, + block(nextReason = 'policy_denied') { + outcome = 'blocked'; + reason = nextReason; + finish(outcome, reason); + }, succeed(nextReason = 'completed') { outcome = 'success'; reason = nextReason; @@ -321,7 +360,11 @@ export function recordHookBootstrapFailure(client, event, error) { }); } -export function readHookEvents({ limit = 200, sinceMs = 24 * 60 * 60 * 1000 } = {}) { +export function readHookEvents({ + limit = 200, + sinceMs = 24 * 60 * 60 * 1000, + includeLifecycle = false, +} = {}) { try { const dir = hookLogDirectory(); if (!existsSync(dir)) return []; @@ -338,6 +381,7 @@ export function readHookEvents({ limit = 200, sinceMs = 24 * 60 * 60 * 1000 } = try { const event = JSON.parse(line); if (Date.parse(event.timestamp) < cutoff) continue; + if (!includeLifecycle && event.event === 'hook.started') continue; events.push(event); if (events.length >= limit) return events; } catch { @@ -353,38 +397,102 @@ export function readHookEvents({ limit = 200, sinceMs = 24 * 60 * 60 * 1000 } = export function hookHealthSummary(options = {}) { const sinceMs = options.sinceMs ?? 24 * 60 * 60 * 1000; - const events = readHookEvents({ limit: options.limit || 5000, sinceMs }); - const durations = events + const events = readHookEvents({ + limit: options.limit || 10000, + sinceMs, + includeLifecycle: true, + }); + const starts = events.filter((event) => event.event === 'hook.started'); + const completed = events.filter( + (event) => event.event === 'hook.completed' || !event.event + ); + const completedIds = new Set( + completed.map((event) => event.invocationId).filter(Boolean) + ); + const abandoned = starts + .filter( + (event) => + event.invocationId && + !completedIds.has(event.invocationId) && + Date.now() - Date.parse(event.timestamp) > 10_000 + ) + .map((event) => ({ + ...event, + event: 'hook.completed', + outcome: 'failure', + reason: 'process_terminated_before_completion', + durationMs: null, + })); + const runs = [...completed, ...abandoned]; + const durations = runs .map((event) => Number(event.durationMs)) .filter(Number.isFinite) .sort((a, b) => a - b); - const failures = events.filter((event) => event.outcome === 'failure'); - const timeouts = events.filter((event) => event.outcome === 'timeout'); - const skipped = events.filter((event) => event.outcome === 'skipped'); - const successes = events.filter((event) => event.outcome === 'success'); + const failures = runs.filter((event) => event.outcome === 'failure'); + const timeouts = runs.filter((event) => event.outcome === 'timeout'); + const skipped = runs.filter((event) => event.outcome === 'skipped'); + const blocked = runs.filter((event) => event.outcome === 'blocked'); + const successes = runs.filter((event) => event.outcome === 'success'); + const actions = runs.filter( + (event) => event.hookEvent === 'pre-tool' && event.toolName + ); + const byTool = {}; + for (const event of actions) { + const name = String(event.toolName || 'Unknown action'); + byTool[name] = (byTool[name] || 0) + 1; + } const byClient = {}; - for (const event of events) { + for (const event of runs) { const key = event.client || 'unknown'; - const current = byClient[key] || { total: 0, failures: 0, timeouts: 0, skipped: 0 }; + const current = byClient[key] || { + total: 0, + failures: 0, + timeouts: 0, + skipped: 0, + blocked: 0, + hookEvents: [], + }; current.total++; if (event.outcome === 'failure') current.failures++; if (event.outcome === 'timeout') current.timeouts++; if (event.outcome === 'skipped') current.skipped++; + if (event.outcome === 'blocked') current.blocked++; + if (event.hookEvent && !current.hookEvents.includes(event.hookEvent)) + current.hookEvents.push(event.hookEvent); byClient[key] = current; } + for (const current of Object.values(byClient)) current.hookEvents.sort(); const percentile = (p) => durations.length ? durations[Math.min(durations.length - 1, Math.floor(durations.length * p))] : null; const summary = { schemaVersion: HOOK_LOG_SCHEMA_VERSION, - available: events.length > 0, + available: runs.length > 0, windowHours: sinceMs / 3_600_000, - total: events.length, + total: runs.length, failures: failures.length, timeouts: timeouts.length, skipped: skipped.length, - successRate: events.length ? successes.length / events.length : null, + blocked: blocked.length, + abandoned: abandoned.length, + successes: successes.length, + actions: actions.length, + byTool, + // A skipped or policy-blocked hook completed its protocol successfully. + // Runtime health answers whether the hook process worked, not whether the + // optimizer chose to allow the host operation. + successRate: runs.length + ? (successes.length + skipped.length + blocked.length) / runs.length + : null, + healthStatus: + failures.length || timeouts.length + ? 'failing' + : skipped.length + ? 'degraded' + : runs.length + ? 'healthy' + : 'unavailable', p50DurationMs: percentile(0.5), p95DurationMs: percentile(0.95), byClient, diff --git a/integrations/cursor/hooks/lib/pricing.mjs b/integrations/cursor/hooks/lib/pricing.mjs index a4b09ebed..50b23b9cc 100644 --- a/integrations/cursor/hooks/lib/pricing.mjs +++ b/integrations/cursor/hooks/lib/pricing.mjs @@ -1,101 +1,72 @@ // GENERATED FILE -- do not edit. // Source of truth: hooks-core/pricing.mjs. Regenerate with `npm run sync:hooks`. /** - * Tokens in the unit people actually feel. + * Optional cost equivalents for token measurements. * - * The same data as everywhere else in this project, converted once, in one - * place, under three rules that keep a dollar figure honest: - * - * THE TABLE IS VISIBLE AND OVERRIDABLE. Prices change. A figure computed from - * rates buried in code goes stale silently and keeps looking authoritative, - * which is worse than no figure. The rates are printed next to the number and - * can be replaced without a release. - * - * DOLLARS BESIDE TOKENS, NOT INSTEAD OF THEM. The token count is exact and - * never goes out of date; the dollar figure is the token count times a rate - * that will. Showing both means the durable number survives the perishable - * one. - * - * NEVER A PRICE ON AN UNMEASURED SAVING. An invented number is worse in - * dollars than in tokens, because dollars get quoted to other people. A - * saving we cannot measure returns null and renders as "not yet measurable", - * never as "$0.00" -- which reads as "saved nothing" and is a different - * claim entirely. + * The runtime cannot infer a user's bill from a client name. A CLI may route + * through a subscription, direct API account, cloud marketplace, enterprise + * agreement, included credits, or a provider whose cache-read/write prices + * differ. Provider usage records are the source of truth when available. + * Everywhere else this module fails closed until the user supplies an + * effective blended input-token rate. */ -/** Cache multipliers, relative to an input token. */ -const CACHE_WRITE = 1.25; -const CACHE_READ = 0.1; +const RATE_ENV = 'TOKEN_OPTIMIZER_EFFECTIVE_INPUT_USD_PER_MILLION'; -/** - * Dollars per million tokens, by tier. - * - * Approximate published list rates as of 2026-07, carried with their date so a - * stale table is visible rather than silent. Override wholesale with - * TOKEN_OPTIMIZER_PRICES as JSON, e.g. - * {"asOf":"2026-09","opus":{"input":15,"output":75}}. - */ -const DEFAULT_PRICES = { - asOf: '2026-07', - haiku: { input: 1, output: 5 }, - sonnet: { input: 3, output: 15 }, - opus: { input: 15, output: 75 }, -}; - -export function prices() { - const raw = process.env.TOKEN_OPTIMIZER_PRICES; - if (raw) { - try { - const parsed = JSON.parse(raw); - if (parsed && typeof parsed === 'object') return { ...DEFAULT_PRICES, ...parsed, overridden: true }; - } catch { /* an unparseable override is ignored rather than fatal */ } - } - return DEFAULT_PRICES; +/** The configured effective input rate, or null when billing is unobservable. */ +export function effectiveRate() { + const rate = Number(process.env[RATE_ENV]); + return Number.isFinite(rate) && rate > 0 ? rate : null; } /** - * What a number of tokens costs. + * Dollars for a measured token quantity. * - * Returns null for a null input and says why: this is the single place the - * "unmeasured must not become $0.00" rule is enforced, so every caller inherits - * it rather than each remembering. + * `kind` is accepted for backwards compatibility but is not multiplied by a + * universal cache constant. The configured rate must already reflect the + * user's actual provider/model/cache/tier/credit mix. */ -export function dollars(tokens, { tier = 'opus', kind = 'input' } = {}) { - if (!Number.isFinite(tokens)) return null; - - const table = prices(); - const rate = table[tier]?.[kind === 'output' ? 'output' : 'input']; - if (!Number.isFinite(rate)) return null; - - const multiplier = kind === 'cacheWrite' ? CACHE_WRITE : kind === 'cacheRead' ? CACHE_READ : 1; - return (tokens / 1_000_000) * rate * multiplier; +export function dollars(tokens, { rate = effectiveRate() } = {}) { + if (!Number.isFinite(tokens) || !Number.isFinite(rate) || rate <= 0) { + return null; + } + return (tokens / 1_000_000) * rate; } /** A dollar figure, or an honest absence of one. */ -export function money(amount, { unmeasured = 'not yet measurable' } = {}) { +export function money(amount, { unmeasured = 'not priced' } = {}) { if (amount == null || !Number.isFinite(amount)) return unmeasured; if (amount === 0) return '$0.00'; if (Math.abs(amount) < 0.01) return '<$0.01'; return `$${amount.toFixed(2)}`; } -/** - * Per-session tokens expressed as a monthly cost. - * - * `sessionsPerMonth` is an assumption, so it is stated in the output rather - * than folded invisibly into the number. - */ -export function monthly(tokensPerSession, { tier = 'opus', sessionsPerMonth = 60 } = {}) { +/** Per-session tokens expressed with a stated monthly-frequency assumption. */ +export function monthly(tokensPerSession, { sessionsPerMonth = 60 } = {}) { if (!Number.isFinite(tokensPerSession)) return null; - const amount = dollars(tokensPerSession * sessionsPerMonth, { tier }); - return amount == null ? null : { amount, sessionsPerMonth, tier }; + const amount = dollars(tokensPerSession * sessionsPerMonth); + return amount == null + ? null + : { + amount, + sessionsPerMonth, + effectiveInputUsdPerMillion: effectiveRate(), + }; +} + +/** The pricing basis printed beside any cost equivalent. */ +export function priceNote() { + const rate = effectiveRate(); + return rate == null + ? `cost not priced: set ${RATE_ENV} to your effective blended input rate after provider, model, cache, tier, plan, and credits` + : `cost equivalent: configured effective input rate $${rate}/M via ${RATE_ENV}`; } -/** The rates behind the figures, for printing beside them. */ -export function priceNote(tier = 'opus') { - const table = prices(); - const rate = table[tier]; - if (!rate) return null; - return `prices: ${tier} $${rate.input}/$${rate.output} per Mtok (${table.asOf}` + - `${table.overridden ? ', overridden' : ''}); override with TOKEN_OPTIMIZER_PRICES`; +/** Compatibility surface: no built-in provider table is claimed. */ +export function prices() { + return { + source: + effectiveRate() == null ? 'unavailable' : 'configured-effective-rate', + effectiveInputUsdPerMillion: effectiveRate(), + }; } diff --git a/integrations/cursor/hooks/lib/routing.mjs b/integrations/cursor/hooks/lib/routing.mjs index 9b4642e0e..84ac866e4 100644 --- a/integrations/cursor/hooks/lib/routing.mjs +++ b/integrations/cursor/hooks/lib/routing.mjs @@ -65,7 +65,9 @@ export function modelCosts() { if (raw) { try { return JSON.parse(raw); - } catch { /* fall through to the defaults */ } + } catch { + /* fall through to the defaults */ + } } return { haiku: 1, sonnet: 3, opus: 15 }; } @@ -106,7 +108,12 @@ export function readEpisodes(path, { maxBytes = 6_000_000 } = {}) { let current = null; const close = () => { - if (current && current.turns > 0) episodes.push({ ...current, files: [...current.files], tools: [...current.tools] }); + if (current && current.turns > 0) + episodes.push({ + ...current, + files: [...current.files], + tools: [...current.tools], + }); current = null; }; @@ -123,7 +130,15 @@ export function readEpisodes(path, { maxBytes = 6_000_000 } = {}) { // a new episode. if (row.type === 'user' && typeof row.message?.content === 'string') { close(); - current = { at: Date.parse(row.timestamp) || null, models: new Set(), tools: new Set(), files: new Set(), turns: 0, errors: 0, results: 0 }; + current = { + at: Date.parse(row.timestamp) || null, + models: new Set(), + tools: new Set(), + files: new Set(), + turns: 0, + errors: 0, + results: 0, + }; continue; } if (!current) continue; @@ -157,8 +172,16 @@ export function readEpisodes(path, { maxBytes = 6_000_000 } = {}) { /* ---------------------------------------------------------------- SHAPES */ -const EDIT_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit', 'smart_edit', 'smart_write']); -const TEST_PATTERN = /\b(test|jest|pytest|vitest|dotnet test|npm test|go test)\b/i; +const EDIT_TOOLS = new Set([ + 'Edit', + 'MultiEdit', + 'Write', + 'NotebookEdit', + 'smart_edit', + 'smart_write', +]); +const TEST_PATTERN = + /\b(test|jest|pytest|vitest|dotnet test|npm test|go test)\b/i; /** * What kind of work this was. @@ -171,11 +194,19 @@ export function classifyShape(episode) { const tools = new Set(episode.tools || []); const edited = [...tools].some((t) => EDIT_TOOLS.has(t)); const files = (episode.files || []).length; - const ranTests = [...tools].some((t) => TEST_PATTERN.test(t)) || (episode.tools || []).includes('Bash'); + const ranTests = + [...tools].some((t) => TEST_PATTERN.test(t)) || + (episode.tools || []).includes('Bash'); if (edited && files >= 3) return 'multi-file-change'; if (edited) return 'single-file-change'; - if (files > 0 || tools.has('Grep') || tools.has('Glob') || tools.has('smart_grep')) return 'investigation'; + if ( + files > 0 || + tools.has('Grep') || + tools.has('Glob') || + tools.has('smart_grep') + ) + return 'investigation'; if (ranTests) return 'build-or-test'; return 'conversation'; } @@ -208,7 +239,13 @@ export function outcomeTable(episodes) { const tier = [...tiers][0]; table[shape] = table[shape] || {}; - const cell = table[shape][tier] = table[shape][tier] || { episodes: 0, turns: 0, errors: 0, results: 0, errored: 0 }; + const cell = (table[shape][tier] = table[shape][tier] || { + episodes: 0, + turns: 0, + errors: 0, + results: 0, + errored: 0, + }); cell.episodes += 1; cell.turns += episode.turns; cell.errors += episode.errors; @@ -287,7 +324,8 @@ export function route(shape, table = {}) { shape, recommend: heuristic, basis: 'heuristic', - reason: `every measured tier for ${shape} needed a retry in more than ` + + reason: + `every measured tier for ${shape} needed a retry in more than ` + `${Math.round(MAX_ERROR_RATE * 100)}% of episodes; falling back to the shipped default`, excluded: excluded.map((o) => ({ tier: o.tier, errorRate: o.errorRate })), options, @@ -306,17 +344,24 @@ export function route(shape, table = {}) { recommend: best.tier, basis: 'measured', // What each mistake would cost, from this project's own rates. - overpowered: strongest.tier === best.tier ? null : { - tier: strongest.tier, - wasted: Number((strongest.cost - best.cost).toFixed(2)), - }, - underpowered: cheapest.tier === best.tier ? null : { - tier: cheapest.tier, - expectedCost: Number(cheapest.cost.toFixed(2)), - errorRate: cheapest.errorRate, - excluded: cheapest.errorRate > MAX_ERROR_RATE, - }, - reason: `${shape}: ${best.tier} costs ${best.cost.toFixed(2)} in expectation over ${best.episodes} episodes ` + + overpowered: + strongest.tier === best.tier + ? null + : { + tier: strongest.tier, + wasted: Number((strongest.cost - best.cost).toFixed(2)), + }, + underpowered: + cheapest.tier === best.tier + ? null + : { + tier: cheapest.tier, + expectedCost: Number(cheapest.cost.toFixed(2)), + errorRate: cheapest.errorRate, + excluded: cheapest.errorRate > MAX_ERROR_RATE, + }, + reason: + `${shape}: ${best.tier} costs ${best.cost.toFixed(2)} in expectation over ${best.episodes} episodes ` + `(${Math.round(best.errorRate * 100)}% needed a retry)`, options, }; @@ -340,20 +385,31 @@ export function routingNote(shape, table, { currentModel, switchCost } = {}) { const lines = [decision.reason]; if (decision.underpowered) { - lines.push(` ${decision.underpowered.tier} looks cheaper per token, but needed a retry in ` + - `${Math.round(decision.underpowered.errorRate * 100)}% of episodes -- expected cost ${decision.underpowered.expectedCost}` + - `${decision.underpowered.excluded ? ', which is why it is not a candidate here at any price' : ''}.`); + lines.push( + ` ${decision.underpowered.tier} looks cheaper per token, but needed a retry in ` + + `${Math.round(decision.underpowered.errorRate * 100)}% of episodes -- expected cost ${decision.underpowered.expectedCost}` + + `${decision.underpowered.excluded ? ', which is why it is not a candidate here at any price' : ''}.` + ); } if (decision.overpowered) { - lines.push(` ${decision.overpowered.tier} would spend ${decision.overpowered.wasted} more than needed here.`); + lines.push( + ` ${decision.overpowered.tier} would spend ${decision.overpowered.wasted} more than needed here.` + ); } if (switchCost?.prefixTokens) { - lines.push(` Switching now also discards a ${switchCost.prefixTokens.toLocaleString()}-token warm prefix ` + - `(~${switchCost.rewriteCost.toLocaleString()} tokens to re-write); consider switching at the next break.`); + lines.push( + ` Switching now also discards a ${switchCost.prefixTokens.toLocaleString()}-token warm prefix ` + + `(~${switchCost.rewriteInputCostEquivalent.toLocaleString()} Anthropic uncached-input cost-equivalent tokens to re-write); consider switching at the next break.` + ); } - return { shape, recommend: decision.recommend, basis: decision.basis, text: lines.join('\n') }; + return { + shape, + recommend: decision.recommend, + basis: decision.basis, + text: lines.join('\n'), + }; } /** @@ -371,7 +427,9 @@ export function routingBriefing(table) { const decision = route(shape, table); if (decision.basis !== 'measured') continue; if (decision.recommend === HEURISTIC[shape]) continue; // nothing learned worth saying - lines.push(`${shape.replace(/-/g, ' ')} work goes better on ${decision.recommend} in this project.`); + lines.push( + `${shape.replace(/-/g, ' ')} work goes better on ${decision.recommend} in this project.` + ); } return lines.length ? lines.join('\n') : null; } @@ -389,7 +447,11 @@ export function routingBriefing(table) { * few more episodes accumulated would invalidate the cache behind it. Stability * is the feature; the memo is how it is bought. */ -export function cachedRoutingBriefing(dir, transcriptPath, { growthBytes = 4_000_000, maxAgeMs = 24 * 60 * 60 * 1000 } = {}) { +export function cachedRoutingBriefing( + dir, + transcriptPath, + { growthBytes = 4_000_000, maxAgeMs = 24 * 60 * 60 * 1000 } = {} +) { const memoPath = join(dir, 'routing-brief.json'); let stat; try { @@ -400,17 +462,31 @@ export function cachedRoutingBriefing(dir, transcriptPath, { growthBytes = 4_000 try { const memo = JSON.parse(readFileSync(memoPath, 'utf8')); - const fresh = memo.path === transcriptPath - && Math.abs(stat.size - memo.size) < growthBytes - && Date.now() - memo.at < maxAgeMs; + const fresh = + memo.path === transcriptPath && + Math.abs(stat.size - memo.size) < growthBytes && + Date.now() - memo.at < maxAgeMs; if (fresh) return memo.text || null; - } catch { /* no memo, or an unreadable one; recompute */ } + } catch { + /* no memo, or an unreadable one; recompute */ + } const text = routingBriefing(outcomeTable(readEpisodes(transcriptPath))); try { mkdirSync(dir, { recursive: true, mode: 0o700 }); - writeFileSync(memoPath, JSON.stringify({ path: transcriptPath, size: stat.size, at: Date.now(), text }), { mode: 0o600 }); - } catch { /* the memo is an optimisation; failing to write it costs latency, not correctness */ } + writeFileSync( + memoPath, + JSON.stringify({ + path: transcriptPath, + size: stat.size, + at: Date.now(), + text, + }), + { mode: 0o600 } + ); + } catch { + /* the memo is an optimisation; failing to write it costs latency, not correctness */ + } return text; } @@ -427,9 +503,11 @@ export function routingReport(table) { for (const tier of Object.keys(table[shape])) { const cell = table[shape][tier]; const cost = expectedCost(tier, cell); - lines.push(` ${tier.padEnd(7)} ${cell.episodes} episode(s), ` + - `${cell.meanTurns.toFixed(1)} turns avg, ${Math.round(cell.errorRate * 100)}% needed a retry, ` + - `expected cost ${cost.cost.toFixed(2)}${cell.measured ? '' : ' [too few episodes to trust]'}`); + lines.push( + ` ${tier.padEnd(7)} ${cell.episodes} episode(s), ` + + `${cell.meanTurns.toFixed(1)} turns avg, ${Math.round(cell.errorRate * 100)}% needed a retry, ` + + `expected cost ${cost.cost.toFixed(2)}${cell.measured ? '' : ' [too few episodes to trust]'}` + ); } } return lines.join('\n'); diff --git a/integrations/cursor/hooks/lib/symbols.mjs b/integrations/cursor/hooks/lib/symbols.mjs index 2a80e1e42..4d80666ac 100644 --- a/integrations/cursor/hooks/lib/symbols.mjs +++ b/integrations/cursor/hooks/lib/symbols.mjs @@ -50,7 +50,11 @@ const PATTERNS = { /^\s*(?:pub(?:\([^)]*\))?\s+)?(?:struct|enum|trait|impl)\s+([A-Za-z_]\w*)/, ], clike: [ - /^\s*(?:public|private|protected|internal|static|final|abstract|virtual|override|async|\s)*(?:[\w<>\[\],.?]+\s+)([A-Za-z_]\w*)\s*\([^;]*\)\s*\{/, + // Accept both K&R (`Run() {`) and Allman (`Run()` then `{`) declarations. + // Requiring the opening brace on the declaration line silently discarded + // nearly every method in idiomatic C# projects, leaving file nodes but no + // symbol nodes and making valid `file.cs#Method` anchors unresolved. + /^\s*(?:public|private|protected|internal|static|final|abstract|virtual|override|async|sealed|new|extern|unsafe|partial|\s)*(?:[\w<>\[\],.?]+\s+)([A-Za-z_]\w*)(?:<[^;()]+>)?\s*\([^;]*\)\s*(?:\{|=>|$)/, /^\s*(?:public|private|protected|internal|static|abstract|sealed|partial|\s)*(?:class|struct|interface|enum|record)\s+([A-Za-z_]\w*)/, ], ruby: [ diff --git a/integrations/gemini/hooks/lib/adapter.mjs b/integrations/gemini/hooks/lib/adapter.mjs index fee0102b9..9f76bbdea 100644 --- a/integrations/gemini/hooks/lib/adapter.mjs +++ b/integrations/gemini/hooks/lib/adapter.mjs @@ -188,8 +188,143 @@ function contextOutput(client, eventName, additionalContext) { }; } +/** Convert one static JavaScript string literal without evaluating code. */ +function codexStringLiteral(literal) { + if (typeof literal !== 'string' || literal.length < 2) return null; + const quote = literal[0]; + if (!['"', "'", '`'].includes(quote) || literal.at(-1) !== quote) return null; + + let decoded = ''; + for (let index = 1; index < literal.length - 1; index += 1) { + const character = literal[index]; + if (quote === '`' && character === '$' && literal[index + 1] === '{') + return null; + if (character !== '\\') { + decoded += character; + continue; + } + + index += 1; + if (index >= literal.length - 1) return null; + const escaped = literal[index]; + const simple = { + b: '\b', f: '\f', n: '\n', r: '\r', t: '\t', v: '\v', 0: '\0', + '\\': '\\', "'": "'", '"': '"', '`': '`', '$': '$', + }; + if (Object.hasOwn(simple, escaped)) { + decoded += simple[escaped]; + continue; + } + if (escaped === '\n') continue; + if (escaped === '\r') { + if (literal[index + 1] === '\n') index += 1; + continue; + } + if (escaped === 'x') { + const digits = literal.slice(index + 1, index + 3); + if (!/^[0-9a-f]{2}$/i.test(digits)) return null; + decoded += String.fromCodePoint(Number.parseInt(digits, 16)); + index += 2; + continue; + } + if (escaped === 'u') { + const braced = literal[index + 1] === '{'; + const close = braced ? literal.indexOf('}', index + 2) : index + 5; + const digits = braced + ? literal.slice(index + 2, close) + : literal.slice(index + 1, close); + if ( + close < 0 || + !(braced ? /^[0-9a-f]{1,6}$/i : /^[0-9a-f]{4}$/i).test(digits) + ) return null; + const codePoint = Number.parseInt(digits, 16); + if (codePoint > 0x10ffff) return null; + decoded += String.fromCodePoint(codePoint); + index = close; + continue; + } + // JavaScript identity escapes drop the slash (for example, '\q' is 'q'). + decoded += escaped; + } + return decoded; +} + /** Convert client-specific lifecycle envelopes into the common tool shape. */ +function codexStringBindings(source) { + const values = new Map(); + const declaration = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*((?:"(?:\\.|[^"\\])*")|(?:'(?:\\.|[^'\\])*')|(?:`(?:\\.|[^`\\])*`))/gs; + for (const match of String(source || '').matchAll(declaration)) { + try { + const value = codexStringLiteral(match[2]); + if (value !== null) values.set(match[1], value); + } catch { + // A computed or malformed JavaScript expression is not safe to execute + // merely to understand a hook envelope. Leave it opaque. + } + } + return values; +} + +function codexCallArguments(source, method) { + const calls = []; + const pattern = new RegExp(`(?:tools\\.)?${method}\\s*\\(\\s*([^),]+)`, 'gi'); + for (const match of String(source || '').matchAll(pattern)) + calls.push(match[1].trim()); + return calls; +} + +function codexLiteral(value, bindings) { + if (bindings.has(value)) return String(bindings.get(value)); + return codexStringLiteral(value); +} + +function codexExecCommands(source, bindings) { + const commands = []; + for (const call of String(source || '').matchAll( + /(?:tools\.)?exec_command\s*\(\s*\{([\s\S]*?)\}\s*\)/gi + )) { + const property = /\bcmd\s*:\s*((?:"(?:\\.|[^"\\])*")|(?:'(?:\\.|[^'\\])*')|(?:`(?:\\.|[^`\\])*`)|(?:[A-Za-z_$][\w$]*))/s.exec(call[1]); + if (!property) continue; + const command = codexLiteral(property[1], bindings); + if (command) commands.push(command); + } + return commands; +} + export function normalizeClientPayload(clientName, event, raw) { + if (clientName === 'codex') { + const outerName = String(raw.tool_name ?? raw.toolName ?? raw.tool ?? ''); + if (/^(?:functions\.)?exec$/i.test(outerName)) { + const envelope = raw.tool_input ?? raw.toolInput ?? raw.arguments ?? raw.args; + const source = + typeof envelope === 'string' + ? envelope + : envelope?.code ?? envelope?.input ?? envelope?.source ?? ''; + const bindings = codexStringBindings(source); + const patchCalls = codexCallArguments(source, 'apply_patch'); + const patch = patchCalls + .map((argument) => codexLiteral(argument, bindings)) + .find(Boolean); + const mutation = patchCalls.length > 0; + const commands = codexExecCommands(source, bindings); + + // `functions.exec` is also the orchestration envelope for web, image and + // other non-filesystem tools. Do not reinterpret those as shell commands: + // doing so produced irrelevant cross-project advice and fabricated graph + // activity for operations the filesystem hooks never observed. + if (!mutation && !commands.length) return raw; + return { + ...raw, + tool_name: mutation ? 'apply_patch' : 'run_command', + tool_input: { + ...(envelope && typeof envelope === 'object' ? envelope : {}), + command: String(patch || commands.join('\n') || source || ''), + code_mode_envelope: true, + }, + }; + } + } + if (clientName === 'cline') { const body = event === 'post-tool' ? raw.postToolUse : raw.preToolUse; if (!body) return raw; @@ -385,6 +520,22 @@ function projectBriefing() { function observeAndInject(payload, state, episode, features) { const touched = touchedFiles(payload); const dirFor = (path) => wikiDir(projectRootFor(path, payload.cwd)); + const registerRoot = (root) => { + const dir = wikiDir(root); + registerProject({ + root, + graphDir: dir, + client: episode.client || 'unknown', + }); + return dir; + }; + + // SessionStart is not delivered consistently by every host/version. The + // graph was still written in that case, but the dashboard registry never + // learned it existed. Register every repository observed by a real tool call + // as a second, idempotent production path. + for (const { path } of touched) + registerRoot(projectRootFor(path, payload.cwd)); const bytes = readCostBytes(payload); if (bytes && payload.tool_input.file_path) { @@ -425,7 +576,7 @@ function observeAndInject(payload, state, episode, features) { const command = payload.tool_input?.command; if (command) { const root = commandProjectRoot(payload, payload.cwd); - const dir = wikiDir(root); + const dir = registerRoot(root); const local = forCommand(dir, load(dir), command, { sessionId: payload.session_id, alreadyInjected, @@ -750,7 +901,14 @@ async function runHook(clientName, event, invocation) { client.canDeny && event === 'pre-tool' && !repeat && - mode() !== MODE_ADVISE + mode() !== MODE_ADVISE && + // Codex code mode presents an orchestration program as one outer + // `functions.exec` call. Refusing that envelope is rendered by the host + // as a failed script and can discard several otherwise-safe nested + // operations. Observe it, capture it and advise, but reserve a hard veto + // for legacy one-operation Codex tool calls where the denied operation is + // unambiguous. + !(clientName === 'codex' && payload.tool_input?.code_mode_envelope) ); if (canRefuse) { @@ -759,19 +917,23 @@ async function runHook(clientName, event, invocation) { // Every client's refusal carries the off switch, for the same reason Claude // Code's does: enforcement that hides its own disable is coercive. if (client.denyStyle === 'top-level') { + invocation.block('policy_denied'); emit({ decision: 'deny', reason: withEscape(verdict.reason) }); } else if (client.denyStyle === 'top-level-permission') { + invocation.block('policy_denied'); emit({ permissionDecision: 'deny', permissionDecisionReason: withEscape(verdict.reason), }); } else if (client.denyStyle === 'cline') { + invocation.block('policy_denied'); emit({ cancel: true, contextModification: '', errorMessage: withEscape(verdict.reason), }); } else if (client.denyStyle === 'cursor') { + invocation.block('policy_denied'); emit({ continue: true, permission: 'deny', @@ -780,9 +942,11 @@ async function runHook(clientName, event, invocation) { 'Token Optimizer redirected an expensive built-in operation.', }); } else if (client.denyStyle === 'exit-2') { + invocation.block('policy_denied'); process.stderr.write(withEscape(verdict.reason)); process.exit(2); } else { + invocation.block('policy_denied'); emit({ hookSpecificOutput: { hookEventName: eventName, diff --git a/integrations/gemini/hooks/lib/audit.mjs b/integrations/gemini/hooks/lib/audit.mjs index b82186586..df8a08b82 100644 --- a/integrations/gemini/hooks/lib/audit.mjs +++ b/integrations/gemini/hooks/lib/audit.mjs @@ -50,15 +50,17 @@ export function decline(dir, id) { export function declines(dir, { events = readMetrics(dir) } = {}) { const counts = new Map(); for (const event of events) { - if (event.kind !== 'advice' || event.action !== 'declined' || !event.id) continue; + if (event.kind !== 'advice' || event.action !== 'declined' || !event.id) + continue; counts.set(event.id, (counts.get(event.id) || 0) + 1); } return counts; } -const idOf = (finding) => finding.remedy - ? `${finding.remedy.type}:${finding.remedy.anchor || finding.remedy.file || (finding.remedy.anchors || []).join(',')}` - : `${finding.id}:${finding.anchor || finding.file || ''}`; +const idOf = (finding) => + finding.remedy + ? `${finding.remedy.type}:${finding.remedy.anchor || finding.remedy.file || (finding.remedy.anchors || []).join(',')}` + : `${finding.id}:${finding.anchor || finding.file || ''}`; /** * Whether a habit improved after we said something about it. @@ -68,12 +70,18 @@ const idOf = (finding) => finding.remedy * -- a trend computed from one session on each side is a coin flip with a * percentage sign. */ -export function habitTrend(dir, detector, { events = readMetrics(dir), minSessions = 2, anchors = null } = {}) { +export function habitTrend( + dir, + detector, + { events = readMetrics(dir), minSessions = 2, anchors = null } = {} +) { // SPLIT BY POSITION, NOT BY TIMESTAMP. The log is append-only, so its order is // authoritative -- while `at` has millisecond granularity, and a burst of // events inside one millisecond would land on the wrong side of the split or // collapse one side to nothing. Position is exact where the clock is not. - const raisedAt = events.findIndex((e) => e.kind === 'advice' && e.detector === detector); + const raisedAt = events.findIndex( + (e) => e.kind === 'advice' && e.detector === detector + ); if (raisedAt === -1) return null; // SCOPED TO THE FILES THIS DETECTOR NAMED. Counting every read in the window @@ -114,7 +122,12 @@ export function habitTrend(dir, detector, { events = readMetrics(dir), minSessio /** Marks a recommendation as having been raised, so its effect can be scored. */ export function raise(dir, finding) { - record(dir, { kind: 'advice', action: 'raised', id: idOf(finding), detector: finding.id }); + record(dir, { + kind: 'advice', + action: 'raised', + id: idOf(finding), + detector: finding.id, + }); } /** @@ -123,7 +136,11 @@ export function raise(dir, finding) { * `findings` comes from the detectors; `extra` lets the cache and routing * modules contribute without this file importing the world. */ -export function buildQueue(dir, findings = [], { events = readMetrics(dir) } = {}) { +export function buildQueue( + dir, + findings = [], + { events = readMetrics(dir) } = {} +) { const declined = declines(dir, { events }); const ledger = remedyLedger(dir); const applied = new Set(ledger.filter((r) => !r.revertedAt).map((r) => r.id)); @@ -146,7 +163,9 @@ export function buildQueue(dir, findings = [], { events = readMetrics(dir) } = { } return { - queue: queue.sort((a, b) => (b.costPerSession ?? 0) - (a.costPerSession ?? 0)), + queue: queue.sort( + (a, b) => (b.costPerSession ?? 0) - (a.costPerSession ?? 0) + ), suppressed, done: ledger.filter((r) => !r.revertedAt), }; @@ -161,7 +180,11 @@ export function buildQueue(dir, findings = [], { events = readMetrics(dir) } = { * many were withheld -- and what they are collectively worth -- keeps that from * being a silent cap. */ -export function renderAudit(dir, findings = [], { tier = 'opus', full = false, sessionsPerMonth = 60 } = {}) { +export function renderAudit( + dir, + findings = [], + { tier = 'opus', full = false, sessionsPerMonth = 60 } = {} +) { const { queue, suppressed, done } = buildQueue(dir, findings); const lines = []; @@ -175,22 +198,29 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s // read as "this costs nothing" beside a finding whose cost we simply do not // know -- the same unknown-becomes-zero error the rest of this project // corrects, in the one unit that gets quoted to other people. - const priced = item.costPerSession == null ? null : monthly(cost, { tier, sessionsPerMonth }); + const priced = + item.costPerSession == null + ? null + : monthly(cost, { tier, sessionsPerMonth }); // The id appears on EVERY actionable line, not just the appliable ones. It is the only // handle `decline` accepts and it cannot be guessed from the title, so omitting it made the // advertised decline path unreachable for precisely the findings most likely to be // unwanted -- model-routing findings carry no remedy, so they could never be suppressed no // matter how many times a user declined them. - const action = item.remedy?.kind === 'ours' ? `apply: waste_audit action="apply" id="${item.id}"` - : item.remedy?.kind === 'yours' ? `proposed edit -- needs your yes, nothing changed; decline: ${item.id}` - : `advice only -- no automatic fix; decline: ${item.id}`; + const action = + item.remedy?.kind === 'ours' + ? `apply: waste_audit action="apply" id="${item.id}"` + : item.remedy?.kind === 'yours' + ? `proposed edit -- needs your yes, nothing changed; decline: ${item.id}` + : `advice only -- no automatic fix; decline: ${item.id}`; // MEASURED ZERO IS NOT UNMEASURED. Testing `cost` for truthiness sent a real 0 down the // "not yet measurable" branch while `priced` above, which tests for null, still rendered // $0.00 beside it -- producing `cost not yet measurable (~$0.00/month)`, the exact string // the test suite declares must never appear. waste.mjs defaults every finding to 0 and // hard-sets it on the co-occurrence detector, so this is reachable, not theoretical. - const text = ` ${item.title}\n ${item.costPerSession != null ? `${cost.toLocaleString()} tokens/session` : 'cost not yet measurable'}` + + const text = + ` ${item.title}\n ${item.costPerSession != null ? `${cost.toLocaleString()} tokens/session` : 'cost not yet measurable'}` + `${priced ? ` (~${money(priced.amount)}/month)` : ''}; ${action}`; const printCost = estimate(text); @@ -211,14 +241,19 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s lines.push(text); } - const head = shown.length ? ['What to do next, most expensive first:', ...lines] : ['Nothing addressable found.']; + const head = shown.length + ? ['What to do next, most expensive first:', ...lines] + : ['Nothing addressable found.']; const body = [...head]; if (withheld.length) { const worth = withheld.reduce((sum, w) => sum + (w.costPerSession || 0), 0); - body.push('', ` ... ${withheld.length} more finding(s) worth ${worth.toLocaleString()} tokens/session in total, ` + - 'not shown -- printing them costs more than they are worth. Pass full=true to see all.'); + body.push( + '', + ` ... ${withheld.length} more finding(s) worth ${worth.toLocaleString()} tokens/session in total, ` + + 'not shown -- printing them costs more than they are worth. Pass full=true to see all.' + ); } // What we already told you, and what it bought. @@ -226,23 +261,35 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s body.push('', 'Already applied, and what it actually saved:'); for (const rule of done.slice(0, 6)) { const saved = rule.savedPerSession; - const priced = saved == null ? null : monthly(saved, { tier, sessionsPerMonth }); - body.push(` + ${rule.id} -- ${saved == null - ? 'not yet measurable (fewer than two sessions since)' - : `measured ${saved.toLocaleString()} tokens/session${priced ? `, ~${money(priced.amount)}/month` : ''}`}`); + const priced = + saved == null ? null : monthly(saved, { tier, sessionsPerMonth }); + body.push( + ` + ${rule.id} -- ${ + saved == null + ? 'not yet measurable (fewer than two sessions since)' + : `measured ${saved.toLocaleString()} tokens/session${priced ? `, ~${money(priced.amount)}/month` : ''}` + }` + ); } // Never a silent cap: the withheld block above states its remainder and so must this one. // Truncating to six with no disclosure showed a user six savings and let them conclude that // was everything the tool had bought them, which understates its own measured value. if (done.length > 6) { - body.push(` ... and ${done.length - 6} more applied fix(es), not shown.`); + body.push( + ` ... and ${done.length - 6} more applied fix(es), not shown.` + ); } } if (suppressed.length) { - body.push('', `Not repeating ${suppressed.length} recommendation(s) you have declined ` + - `${DECLINE_LIMIT}+ times. Ask for full=true to see them.`); - if (full) for (const item of suppressed) body.push(` - ${item.title} (declined ${item.times}x)`); + body.push( + '', + `Not repeating ${suppressed.length} recommendation(s) you have declined ` + + `${DECLINE_LIMIT}+ times. Ask for full=true to see them.` + ); + if (full) + for (const item of suppressed) + body.push(` - ${item.title} (declined ${item.times}x)`); } // Whether the advice changed anything. @@ -256,20 +303,30 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s for (const a of anchors) anchorsBy.get(f.id).add(a); } const trends = [...new Set(findings.map((f) => f.id))] - .map((detector) => habitTrend(dir, detector, { - anchors: anchorsBy.has(detector) ? [...anchorsBy.get(detector)] : null, - })) + .map((detector) => + habitTrend(dir, detector, { + anchors: anchorsBy.has(detector) ? [...anchorsBy.get(detector)] : null, + }) + ) .filter(Boolean); if (trends.length) { body.push('', 'Since the advice was given:'); for (const trend of trends) { - body.push(` ${trend.detector}: ${trend.before.toLocaleString()} -> ${trend.after.toLocaleString()} tokens/session ` + - `(${trend.improved ? 'improved' : 'worse'})`); + body.push( + ` ${trend.detector}: ${trend.before.toLocaleString()} -> ${trend.after.toLocaleString()} tokens/session ` + + `(${trend.improved ? 'improved' : 'worse'})` + ); } } - const addressable = queue.reduce((sum, item) => sum + (item.costPerSession || 0), 0); + const addressable = queue.reduce( + (sum, item) => sum + (item.costPerSession || 0), + 0 + ); + const addressableMonthly = addressable + ? monthly(addressable, { tier, sessionsPerMonth }) + : null; const note = priceNote(tier); if (note) body.push('', note); @@ -278,9 +335,10 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s // pushing it left the report's longest line out of its own reported cost, understating it by // 10-20% on a short audit. This figure exists to prove the audit is not a net loss, so // understating the cost side biases exactly the comparison it was written to be honest about. - const closing = (n) => `This report cost about ${n.toLocaleString()} tokens and names ` + + const closing = (n) => + `This report cost about ${n.toLocaleString()} tokens and names ` + `${addressable.toLocaleString()} tokens/session of addressable waste` + - `${addressable ? ` (~${money(monthly(addressable, { tier, sessionsPerMonth })?.amount)}/month across ${sessionsPerMonth} sessions)` : ''}.`; + `${addressableMonthly ? ` (~${money(addressableMonthly.amount)}/month across ${sessionsPerMonth} sessions)` : ''}.`; const selfCost = estimate([...body, '', closing(0)].join('\n')); body.push('', closing(selfCost)); diff --git a/integrations/gemini/hooks/lib/cache.mjs b/integrations/gemini/hooks/lib/cache.mjs index 9b11173ff..9782c3262 100644 --- a/integrations/gemini/hooks/lib/cache.mjs +++ b/integrations/gemini/hooks/lib/cache.mjs @@ -3,12 +3,12 @@ /** * Prompt-cache economics. * - * Cache economics is real money in a way most compression is not: a cache read - * costs a tenth of a fresh token and a cache WRITE costs a quarter more than - * one, so a prefix that keeps invalidating can cost more than every saving this - * product makes elsewhere. It is also the one area where we do not have to - * model anything -- the client's own transcript records cache_read_input_tokens - * and cache_creation_input_tokens per turn, which is ground truth. + * Anthropic prompt-cache usage, read from Claude Code transcripts. A cache read + * and a cache write are both billable, at different published multipliers from + * uncached input. Raw cache_read_input_tokens and + * cache_creation_input_tokens are observations. Any multiplier-based value is + * an Anthropic input-cost equivalent, not a universally comparable token count + * and not a provider invoice. * * MEASUREMENT ALONE IS NOT ACTIONABLE. A hit rate tells you the cache missed; * it cannot tell you why, and an unactionable number is the same dead thing as @@ -30,7 +30,15 @@ * cache-safety-by-construction half of this file. */ -import { readFileSync, readdirSync, statSync, existsSync, openSync, readSync, closeSync } from 'node:fs'; +import { + readFileSync, + readdirSync, + statSync, + existsSync, + openSync, + readSync, + closeSync, +} from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; @@ -51,7 +59,8 @@ export const READ_MULTIPLIER = 0.1; const SYNTHETIC_MODEL = ''; /** True for a turn naming a model an inference actually ran under. */ -const realModel = (turn) => Boolean(turn.model) && turn.model !== SYNTHETIC_MODEL; +const realModel = (turn) => + Boolean(turn.model) && turn.model !== SYNTHETIC_MODEL; /* -------------------------------------------------------------- MEASUREMENT */ @@ -72,7 +81,10 @@ export function transcriptFor(cwd = process.cwd()) { try { const files = readdirSync(dir) .filter((name) => name.endsWith('.jsonl')) - .map((name) => ({ path: join(dir, name), at: statSync(join(dir, name)).mtimeMs })) + .map((name) => ({ + path: join(dir, name), + at: statSync(join(dir, name)).mtimeMs, + })) .sort((a, b) => b.at - a.at); return files.length ? files[0].path : null; } catch { @@ -157,7 +169,8 @@ export function cacheHealth(turns) { read += turn.read; written += turn.written; if (!realModel(turn)) continue; - if (!models.has(turn.model)) models.set(turn.model, { read: 0, written: 0, turns: 0 }); + if (!models.has(turn.model)) + models.set(turn.model, { read: 0, written: 0, turns: 0 }); const entry = models.get(turn.model); entry.read += turn.read; entry.written += turn.written; @@ -171,20 +184,28 @@ export function cacheHealth(turns) { // attribution price, made keepWarm answer "unknown", and returned null from // modelSwitchCost under a guard its caller does not share, which crashed // cache_audit outright with "Cannot read properties of null". - const last = turns.reduce((best, turn) => (turn.read + turn.written > 0 ? turn : best), turns[turns.length - 1]); + const last = turns.reduce( + (best, turn) => (turn.read + turn.written > 0 ? turn : best), + turns[turns.length - 1] + ); const total = read + written; return { + provider: 'anthropic', + source: 'claude-code-transcript', turns: turns.length, read, written, hitRate: total ? read / total : null, prefixTokens: last.read + last.written, - // Priced in plain-token equivalents, which is what makes it comparable with - // every other saving this product reports. - writeCost: Math.round(written * WRITE_MULTIPLIER), - readCost: Math.round(read * READ_MULTIPLIER), - savedVersusNoCache: Math.round(read * (1 - READ_MULTIPLIER)), + // These are Anthropic uncached-input cost equivalents. They stay outside + // the verified MCP transport ledger and must not be relabeled as tokens + // saved across clients. + writeInputCostEquivalent: Math.round(written * WRITE_MULTIPLIER), + readInputCostEquivalent: Math.round(read * READ_MULTIPLIER), + inputCostEquivalentAvoidedVersusUncached: Math.round( + read * (1 - READ_MULTIPLIER) + ), models: Object.fromEntries(models), }; } @@ -200,13 +221,17 @@ export function modelSwitchCost(turns) { const health = cacheHealth(turns); if (!health?.prefixTokens) return null; - const switched = new Set(turns.filter(realModel).map((t) => t.model)).size > 1; + const switched = + new Set(turns.filter(realModel).map((t) => t.model)).size > 1; return { prefixTokens: health.prefixTokens, - rewriteCost: Math.round(health.prefixTokens * WRITE_MULTIPLIER), + rewriteInputCostEquivalent: Math.round( + health.prefixTokens * WRITE_MULTIPLIER + ), alreadySwitched: switched, - text: `Switching model now discards a ${health.prefixTokens.toLocaleString()}-token warm prefix; ` + - `re-writing it costs about ${Math.round(health.prefixTokens * WRITE_MULTIPLIER).toLocaleString()} tokens.`, + text: + `Switching model now discards a ${health.prefixTokens.toLocaleString()}-token warm prefix; ` + + `under Anthropic's current 5-minute write multiplier, re-writing it is about ${Math.round(health.prefixTokens * WRITE_MULTIPLIER).toLocaleString()} uncached-input cost-equivalent tokens.`, }; } @@ -221,17 +246,38 @@ export function modelSwitchCost(turns) { * time. That is why this class of bug survives -- nothing about it looks wrong. */ const VOLATILE = [ - { id: 'timestamp', re: /\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/, why: 'an embedded timestamp' }, - { id: 'date', re: /\b(?:today|current date)\b[^\n]{0,40}\d{4}-\d{2}-\d{2}/i, why: 'an embedded current date' }, + { + id: 'timestamp', + re: /\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/, + why: 'an embedded timestamp', + }, + { + id: 'date', + re: /\b(?:today|current date)\b[^\n]{0,40}\d{4}-\d{2}-\d{2}/i, + why: 'an embedded current date', + }, // A bare date is a SUSPICION, not a measurement: "- 2025-10-31: reworked the // loader" is byte-identical every session. Kept, because stableText fails // closed over our own output and dropping a line there costs little -- but // NOT priced, because in the blame report a false positive invents a // per-session cost and tells the user to delete their changelog. - { id: 'iso-date', re: /\b\d{4}-\d{2}-\d{2}\b/, why: 'an embedded date', priced: false }, - { id: 'session-id', re: /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i, why: 'a session id' }, + { + id: 'iso-date', + re: /\b\d{4}-\d{2}-\d{2}\b/, + why: 'an embedded date', + priced: false, + }, + { + id: 'session-id', + re: /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i, + why: 'a session id', + }, { id: 'git-sha', re: /\b[0-9a-f]{40}\b/, why: 'a git sha' }, - { id: 'counter', re: /\b(?:run|build|attempt|iteration)\s*#?\d+\b/i, why: 'a run counter' }, + { + id: 'counter', + re: /\b(?:run|build|attempt|iteration)\s*#?\d+\b/i, + why: 'a run counter', + }, { id: 'epoch', re: /\b1[6-9]\d{11}\b/, why: 'an epoch timestamp' }, ]; @@ -242,7 +288,13 @@ export function volatileLines(text) { for (let i = 0; i < lines.length; i++) { for (const rule of VOLATILE) { if (!rule.re.test(lines[i])) continue; - out.push({ line: i + 1, id: rule.id, why: rule.why, priced: rule.priced !== false, text: lines[i].trim().slice(0, 120) }); + out.push({ + line: i + 1, + id: rule.id, + why: rule.why, + priced: rule.priced !== false, + text: lines[i].trim().slice(0, 120), + }); break; // one cause per line is enough to act on } } @@ -264,7 +316,11 @@ const estimate = (text) => Math.ceil(String(text || '').length / 4); * unknown, and this reports the construct WITHOUT a price rather than inventing * one. */ -export function attributeInvalidation(cwd, prefixTokens = null, { files = null } = {}) { +export function attributeInvalidation( + cwd, + prefixTokens = null, + { files = null } = {} +) { // Ordered as the client assembles them: the client preamble and tool // definitions come first, then project instructions, then the conversation. const candidates = files || ['CLAUDE.md', 'AGENTS.md', '.claude/CLAUDE.md']; @@ -292,7 +348,8 @@ export function attributeInvalidation(cwd, prefixTokens = null, { files = null } // prefix -- 3.75x the entire prefix. Conservative still: the preamble // ahead of the file is not counted, so this remains a floor. const ahead = offset + estimate(lines.slice(0, hit.line - 1).join('\n')); - const downstream = prefixTokens != null ? Math.max(0, prefixTokens - ahead) : null; + const downstream = + prefixTokens != null ? Math.max(0, prefixTokens - ahead) : null; // A prefix cache invalidates ONCE, at the earliest difference. A later // volatile line in the same file costs nothing extra until the first one // is fixed, so it is reported for context but not billed again. @@ -304,7 +361,9 @@ export function attributeInvalidation(cwd, prefixTokens = null, { files = null } // into a reported zero. const firstPriced = found.find((h) => h.priced); const subsumedBy = - hit.priced && firstPriced && firstPriced !== hit ? `${relative}:${firstPriced.line}` : null; + hit.priced && firstPriced && firstPriced !== hit + ? `${relative}:${firstPriced.line}` + : null; out.push({ file: relative, line: hit.line, @@ -316,9 +375,12 @@ export function attributeInvalidation(cwd, prefixTokens = null, { files = null } // decline to price. 0 means measured and genuinely free, which is what a // subsumed hit is: real, but already billed by the earlier line. Folding // those two into one value is the same mistake this file warns about. - costPerSession: downstream == null || !hit.priced - ? null - : (subsumedBy ? 0 : Math.round(downstream * WRITE_MULTIPLIER)), + costPerSession: + downstream == null || !hit.priced + ? null + : subsumedBy + ? 0 + : Math.round(downstream * WRITE_MULTIPLIER), remedy: { kind: 'yours', type: 'edit', @@ -369,7 +431,7 @@ export function stableText(text) { */ export function cacheOrdered(items) { return [...items].sort((a, b) => { - const stability = (item) => (item.volatility ?? (item.fresh ? 1 : 0)); + const stability = (item) => item.volatility ?? (item.fresh ? 1 : 0); return stability(a) - stability(b); }); } diff --git a/integrations/gemini/hooks/lib/decide.mjs b/integrations/gemini/hooks/lib/decide.mjs index caae7de2f..a61dafdd5 100644 --- a/integrations/gemini/hooks/lib/decide.mjs +++ b/integrations/gemini/hooks/lib/decide.mjs @@ -392,6 +392,21 @@ export function touchedFiles(payload) { add(input.path); add(input.notebook_path); + // Codex code mode carries apply_patch as the nested program text rather than + // as a first-class file_path. Without reading these headers, successful + // code-mode edits incremented the semantic-harvest counter but produced no + // structural file/symbol nodes, which is why the live dashboard showed very + // few nodes after substantial work. PostToolUse can resolve Add/Update paths + // because the file now exists; deleted paths deliberately remain absent. + for (const match of command.matchAll( + /^\*\*\* (?:Add|Update|Delete) File:\s*(.+)$/gm + )) { + add(match[1].trim().replace(/^['"]|['"]$/g, '')); + } + for (const match of command.matchAll(/^\*\*\* Move to:\s*(.+)$/gm)) { + add(match[1].trim().replace(/^['"]|['"]$/g, '')); + } + // EVERY pipeline segment, not just the first. `fileOperands` looks only at // the head of the pipeline because that is where the COST is -- `git log | // head` must not be mistaken for a file dump. But observation is a different diff --git a/integrations/gemini/hooks/lib/experiment.mjs b/integrations/gemini/hooks/lib/experiment.mjs index 1f64713f1..c35c6cf5c 100644 --- a/integrations/gemini/hooks/lib/experiment.mjs +++ b/integrations/gemini/hooks/lib/experiment.mjs @@ -67,16 +67,71 @@ export function episodeMeta({ client, raw = {}, payload = {}, env = process.env } export function usageFrom(raw = {}) { - const usage = raw.usage || raw.token_usage || raw.tokenUsage || raw.metrics?.usage || {}; + const usage = raw.usage || raw.token_usage || raw.tokenUsage || raw.usageMetadata || + raw.usage_metadata || raw.metrics?.usage || raw.response?.usage || + raw.response?.usageMetadata || {}; const number = (...values) => { const value = first(...values); const parsed = Number(value); return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; }; + const cachedInputTokens = number( + usage.cached_input_tokens, usage.cachedInputTokens, + usage.cache_read_input_tokens, usage.cachedContentTokenCount + ); + const cacheCreation = usage.cache_creation || usage.cacheCreation || {}; + const cacheWrite5mInputTokens = number( + usage.cache_write_5m_input_tokens, usage.cacheWrite5mInputTokens, + cacheCreation.ephemeral_5m_input_tokens, cacheCreation.ephemeral5mInputTokens + ); + const cacheWrite1hInputTokens = number( + usage.cache_write_1h_input_tokens, usage.cacheWrite1hInputTokens, + cacheCreation.ephemeral_1h_input_tokens, cacheCreation.ephemeral1hInputTokens + ); + const detailedWrites = (cacheWrite5mInputTokens || 0) + (cacheWrite1hInputTokens || 0); + const reportedWrites = number( + usage.cache_write_input_tokens, usage.cacheWriteInputTokens, + usage.cache_creation_input_tokens, usage.cacheCreationInputTokens + ); + const cacheWriteInputTokens = reportedWrites == null + ? null + : Math.max(0, reportedWrites - detailedWrites); + const explicitUncached = number(usage.uncached_input_tokens, usage.uncachedInputTokens); + const promptInput = number( + usage.promptTokenCount, usage.prompt_token_count, + usage.input_tokens, usage.inputTokens + ); + const isAnthropic = usage.cache_creation_input_tokens !== undefined || + usage.cache_read_input_tokens !== undefined; + const isGemini = usage.promptTokenCount !== undefined || usage.cachedContentTokenCount !== undefined; + const uncachedInputTokens = explicitUncached != null + ? explicitUncached + : promptInput == null + ? null + : isAnthropic + ? promptInput + : isGemini || cachedInputTokens != null || reportedWrites != null + ? Math.max(0, promptInput - (cachedInputTokens || 0) - (reportedWrites || 0)) + : promptInput; + const candidates = number(usage.candidatesTokenCount, usage.candidates_token_count); + const thoughts = number(usage.thoughtsTokenCount, usage.thoughts_token_count); + const outputTokens = candidates != null || thoughts != null + ? (candidates || 0) + (thoughts || 0) + : number(usage.output_tokens, usage.outputTokens); return { - uncachedInputTokens: number(usage.uncached_input_tokens, usage.uncachedInputTokens), - cachedInputTokens: number(usage.cached_input_tokens, usage.cachedInputTokens, usage.cache_read_input_tokens), - outputTokens: number(usage.output_tokens, usage.outputTokens), - totalTokens: number(usage.total_tokens, usage.totalTokens, raw.tokens_used, raw.tokensUsed), + uncachedInputTokens, + cachedInputTokens, + cacheWrite5mInputTokens, + cacheWrite1hInputTokens, + cacheWriteInputTokens, + outputTokens, + totalTokens: number( + usage.total_tokens, usage.totalTokens, usage.totalTokenCount, + usage.total_token_count, raw.tokens_used, raw.tokensUsed + ), + usageMeasurementId: first( + raw.requestId, raw.request_id, raw.responseId, raw.response_id, + usage.responseId, usage.response_id + ) ?? null, }; } diff --git a/integrations/gemini/hooks/lib/harvest-write.mjs b/integrations/gemini/hooks/lib/harvest-write.mjs index 30433d370..db436c1dc 100644 --- a/integrations/gemini/hooks/lib/harvest-write.mjs +++ b/integrations/gemini/hooks/lib/harvest-write.mjs @@ -24,7 +24,7 @@ */ import { - putNodeWithEdges, load, nodeId, sharedDir, isSharedDir, putNode, + putNodeWithEdges, load, nodeId, sharedDir, isSharedDir, putNode, putEdge, } from './wiki.mjs'; import { indexFile } from './staleness.mjs'; import { symbolKey } from './symbols.mjs'; @@ -47,6 +47,37 @@ import { ORIGIN_HARVESTED, ORIGIN_AGENT, ORIGIN_HUMAN } from './curate.mjs'; */ export const SHAREABLE_TYPES = new Set(['command', 'failure', 'decision', 'feedback']); +/** + * Synthetic study traffic must never become advice for an unrelated live repo. + * Evaluation roots are intentionally realistic and may carry their own `.git` + * marker, so repository discovery alone cannot distinguish them. Publication + * remains useful inside the isolated study store; this guard protects only the + * default machine-wide store when a harness was accidentally run without an + * isolated TOKEN_OPTIMIZER_SHARED_DIR. + */ +export function isEphemeralProject(projectRoot) { + const root = canonicalPath(projectRoot || '').toLowerCase(); + if (!root) return true; + return ( + /\/appdata\/local\/temp\//.test(root) || + /(^|\/)tmp\//.test(root) || + /\/(artifacts|\.token-optimizer)\/(?:[^/]*\/)*(?:eval|scenario|study|fixture|debug)[^/]*\//.test(`${root}/`) + ); +} + +export function quarantineSharedSource(projectRoot) { + if (process.env.TOKEN_OPTIMIZER_ALLOW_EPHEMERAL_SHARED === '1') return false; + if (!isEphemeralProject(projectRoot)) return false; + // Isolated studies deliberately point the shared tier at scratch storage. + // Their findings may cross their own fixture projects, but cannot escape into + // the user's default machine-wide graph. Strict mode exists for a regression + // probe that verifies the guard without writing to a real user directory. + return ( + !process.env.TOKEN_OPTIMIZER_SHARED_DIR || + process.env.TOKEN_OPTIMIZER_STRICT_SHARED_PROVENANCE === '1' + ); +} + /** Claim text, normalised enough that the same lesson learned twice is one row. */ const claimFingerprint = (claim) => String(claim || '').toLowerCase().replace(/\s+/g, ' ').trim().slice(0, 300); @@ -72,6 +103,7 @@ function promoteToShared(finding, { projectRoot, sessionId, provenance, key }) { if (!projectRoot) return false; const root = canonicalPath(projectRoot); + if (quarantineSharedSource(root)) return false; const rootId = nodeId('file', root); // Same claim, already carried up from anywhere: keep the first. Two repos @@ -367,11 +399,21 @@ export function writeHarvested( && !node.retired && node.type === finding.type && claimFingerprint(node.claim) === fingerprint - && resolved.every((target) => currentGraph.edges.some((edge) => - edge.from === node.id && edge.edge === 'derived_from' && edge.to === target - )) ); if (duplicate) { + // Enrich, do not duplicate. A symbol extractor may learn how to resolve a + // previously file-only anchor (or a later session may add another real + // anchor) after the claim was first stored. Repeating the same semantic + // conclusion should connect that existing node to the newly available + // evidence, not create a second dashboard card with the same text. + for (const target of resolved) { + const alreadyLinked = currentGraph.edges.some((edge) => + edge.from === duplicate.id + && edge.edge === 'derived_from' + && edge.to === target + ); + if (!alreadyLinked) putEdge(dir, duplicate.id, 'derived_from', target); + } written.push(duplicate.key); continue; } @@ -437,7 +479,12 @@ export function writeHarvested( if ( id && !isSharedDir(dir) - && (finding.scope === undefined || finding.scope === 'organization' || finding.scope === 'global') + // Cross-project publication is explicit. `writeHarvested` stores an + // omitted scope as project-local above, so treating `undefined` as global + // here contradicted the stored record and promoted fixture/evaluation + // seeds into real user sessions. This was observed live as "learned in + // scenario" advice while working in AiDotNet. + && (finding.scope === 'organization' || finding.scope === 'global') ) { promoteToShared(finding, { projectRoot, sessionId, provenance, key }); } diff --git a/integrations/gemini/hooks/lib/inject.mjs b/integrations/gemini/hooks/lib/inject.mjs index b00494a01..c0aa2f1bf 100644 --- a/integrations/gemini/hooks/lib/inject.mjs +++ b/integrations/gemini/hooks/lib/inject.mjs @@ -31,6 +31,7 @@ import { canonicalPath, resolvableCandidates } from './paths.mjs'; import { annotatedSkeleton } from './skeleton.mjs'; import { substitutionBudget } from './metrics.mjs'; import { assessFindings } from './utility.mjs'; +import { quarantineSharedSource } from './harvest-write.mjs'; // Read per call for the same reason as the holdout fraction in metrics.mjs. const touchBudget = () => Number(process.env.TOKEN_OPTIMIZER_TOUCH_BUDGET) || 500; @@ -562,6 +563,7 @@ export function forRepeatedAct( const home = projectRoot ? canonicalPath(projectRoot) : null; for (const node of graph.nodes.values()) { if (node.kind !== 'finding' || node.retired || !node.claim) continue; + if (quarantineSharedSource(node.sourceProject)) continue; if (home && node.sourceProject && canonicalPath(node.sourceProject) === home) continue; const claimClasses = classesOf(node.claim, 'claim'); if (![...crossedClasses].some((c) => claimClasses.has(c))) continue; @@ -667,6 +669,7 @@ export function forSharedCommand( const candidates = []; for (const node of graph.nodes.values()) { if (node.kind !== 'finding' || node.retired) continue; + if (quarantineSharedSource(node.sourceProject)) continue; if (alreadyInjected.has(node.key)) continue; // ITS OWN LESSON IS NOT NEWS. A finding this project contributed is already // served by the local path; repeating it under a "from elsewhere" label @@ -1054,7 +1057,13 @@ export function standingRules(dir, graph, { budget = standingBudget(), episode = * informative response available -- more useful than the file, not a lossier * version of it. */ -export function substitutionFor(dir, graph, rawPath, source, { sessionId } = {}) { +export function substitutionFor( + dir, + graph, + rawPath, + source, + { sessionId, client = null, clientVersion = null, model = null, modelVersion = null } = {} +) { const filePath = canonicalPath(rawPath); const budget = substitutionBudget(dir, filePath); const built = annotatedSkeleton(graph, rawPath, source, { budget }); @@ -1089,6 +1098,10 @@ export function substitutionFor(dir, graph, rawPath, source, { sessionId } = {}) // on this machine turned out to be the enforcement suite's own fixture and // nothing distinguished them from real work. sessionId, + client, + clientVersion, + model, + modelVersion, holdout, tokens: holdout ? 0 : built.tokens, findings: built.findings, diff --git a/integrations/gemini/hooks/lib/metrics.mjs b/integrations/gemini/hooks/lib/metrics.mjs index 0bb08f1bc..09f822630 100644 --- a/integrations/gemini/hooks/lib/metrics.mjs +++ b/integrations/gemini/hooks/lib/metrics.mjs @@ -19,8 +19,15 @@ */ import { - appendFileSync, readFileSync, existsSync, mkdirSync, chmodSync, - statSync, openSync, readSync, closeSync, + appendFileSync, + readFileSync, + existsSync, + mkdirSync, + chmodSync, + statSync, + openSync, + readSync, + closeSync, } from 'node:fs'; import { join } from 'node:path'; import { createHash, randomBytes } from 'node:crypto'; @@ -86,6 +93,10 @@ export const BALANCE_KINDS = new Set([ 'keepwarm', ]); +/** Latest timestamp without passing an unbounded event log as function arguments. */ +export const latestEventTimestamp = (rows) => + rows.reduce((latest, event) => Math.max(latest, Number(event.at) || 0), 0); + /** * Causal records have their own bounded log. Tool outcomes are much rarer * than read/capture telemetry but more frequent than balance records; mixing @@ -130,7 +141,7 @@ export function inHoldout(anchorKey, now = Date.now()) { // other side than before. With nine holdout records in existence that costs // nothing, and stratification is a fresh draw either way. const digest = createHash('sha256').update(`${anchorKey}:${epoch}`).digest(); - return (digest[0] / 256) < fraction; + return digest[0] / 256 < fraction; } /** @@ -258,7 +269,8 @@ export function record(dir, event) { * It is also the right statistics. A saving measured months ago says little * about the code as it stands now. */ -const MAX_BYTES = Number(process.env.TOKEN_OPTIMIZER_METRICS_BYTES) || 2_000_000; +const MAX_BYTES = + Number(process.env.TOKEN_OPTIMIZER_METRICS_BYTES) || 2_000_000; const MAX_EVENTS = Number(process.env.TOKEN_OPTIMIZER_METRICS_WINDOW) || 5000; /** @@ -432,7 +444,10 @@ export function readBalance(dir) { // path for records written before ids existed; it is lossy by nature, which // is precisely why new records carry an id instead. const id = - e.id ?? [e.kind, e.at, e.anchor ?? '', e.sessionId ?? '', e.tokens ?? ''].join('|'); + e.id ?? + [e.kind, e.at, e.anchor ?? '', e.sessionId ?? '', e.tokens ?? ''].join( + '|' + ); if (seen.has(id)) continue; seen.add(id); out.push(e); @@ -493,16 +508,24 @@ export function recordToolOutcome(dir, outcome) { const evidence = readEvidence(dir); const anchor = String(outcome.anchor || '').slice(0, 120); const candidates = evidence - .filter((event) => event.kind === 'inject' && event.episodeId === outcome.episodeId) + .filter( + (event) => + event.kind === 'inject' && event.episodeId === outcome.episodeId + ) .filter((event) => { if (outcome.toolCallId && event.toolCallId) return String(event.toolCallId) === String(outcome.toolCallId); - return event.surface === outcome.surface && String(event.anchor || '').slice(0, 120) === anchor; + return ( + event.surface === outcome.surface && + String(event.anchor || '').slice(0, 120) === anchor + ); }) .sort((a, b) => (b.at || 0) - (a.at || 0)); const injection = candidates[0] || null; const joinMethod = injection - ? (outcome.toolCallId && injection.toolCallId ? 'tool-call-id' : 'episode-anchor') + ? outcome.toolCallId && injection.toolCallId + ? 'tool-call-id' + : 'episode-anchor' : 'none'; return record(dir, { @@ -542,12 +565,11 @@ export function isFixtureAnchor(anchor) { // code: it excluded ALL temp paths -- including a real project checked out // under /tmp -- and on macOS excluded nothing at all, because tmpdir() there // is /var/folders///T/ and did not match. - const underTemp = - new RegExp( - '[\\\\/](AppData[\\\\/]Local[\\\\/])?(Temp|tmp)[\\\\/]' + - '|[\\\\/]var[\\\\/]folders[\\\\/][^\\\\/]+[\\\\/][^\\\\/]+[\\\\/]T[\\\\/]', - 'i' - ).test(p); + const underTemp = new RegExp( + '[\\\\/](AppData[\\\\/]Local[\\\\/])?(Temp|tmp)[\\\\/]' + + '|[\\\\/]var[\\\\/]folders[\\\\/][^\\\\/]+[\\\\/][^\\\\/]+[\\\\/]T[\\\\/]', + 'i' + ).test(p); if (!underTemp) return false; // Only names this suite actually creates. A user's own scratch checkout under @@ -686,8 +708,12 @@ export function balanceSheet(dir) { // THE CONTROL ARM'S ACTUAL COST. `tokensFullFile` was recorded and read // by nothing, so the comparison the holdout exists for was not // computable from the report. - controlArmTokens: withheldSubs.reduce((sum, e) => sum + (e.tokensFullFile || 0), 0), - assumption: 'that the model would have read the file it explicitly requested', + controlArmTokens: withheldSubs.reduce( + (sum, e) => sum + (e.tokensFullFile || 0), + 0 + ), + assumption: + 'that the model would have read the file it explicitly requested', }, estimatedCausal: { what: 'findings injection, from the stratified holdout', @@ -702,7 +728,12 @@ export function balanceSheet(dir) { }; })(), }, - costs: { injection: injectCost, harvest: harvestCost, standing: standingCost, substitution: substitutionCost }, + costs: { + injection: injectCost, + harvest: harvestCost, + standing: standingCost, + substitution: substitutionCost, + }, waste, // Deliberately NOT a single number. The two benefit lines are known // differently; adding them would launder an assumption into a measurement. @@ -727,7 +758,9 @@ export function balanceSheet(dir) { /** Cheap path normalisation for grouping reads; not the identity canonicaliser. */ function canonicalKeyish(p) { - return String(p || '').replace(/\\/g, '/').toLowerCase(); + return String(p || '') + .replace(/\\/g, '/') + .toLowerCase(); } /** @@ -738,7 +771,43 @@ function canonicalKeyish(p) { * arms, multiplied by treated touches. Reporting it as a measured fact would be * the same overclaiming this project criticises competitors for. */ -function buildReport(events, balance) { +function buildReport(events, balance, sourceCoverage = {}) { + const substitutions = balance.filter( + (event) => event.kind === 'substitute' && !isFixtureAnchor(event.anchor) + ); + const servedSubstitutions = substitutions.filter((event) => !event.holdout); + const substitutionTokensSaved = servedSubstitutions.reduce( + (sum, event) => + sum + + (event.tokensNetAvoided ?? + Math.max( + 0, + Math.ceil((event.bytesAvoided || 0) / 4) - (event.tokens || 0) + )), + 0 + ); + const substitutionTokensReturned = servedSubstitutions.reduce( + (sum, event) => sum + (event.tokens || 0), + 0 + ); + const substitutionByClient = {}; + for (const event of servedSubstitutions) { + const name = event.client || 'Historical — client not recorded'; + const current = substitutionByClient[name] || { + substitutions: 0, + tokensReturned: 0, + tokensSaved: 0, + }; + current.substitutions += 1; + current.tokensReturned += event.tokens || 0; + current.tokensSaved += + event.tokensNetAvoided ?? + Math.max( + 0, + Math.ceil((event.bytesAvoided || 0) / 4) - (event.tokens || 0) + ); + substitutionByClient[name] = current; + } // COMMAND INJECTIONS ARE COUNTED SEPARATELY, not mixed into the balance. // @@ -762,13 +831,19 @@ function buildReport(events, balance) { // Report its cost independently so it cannot dilute the file-touch estimate. const isSessionStart = (e) => e.surface === 'session-start'; const sessionStartInjections = allInjections.filter(isSessionStart); - const commandInjections = allInjections.filter((e) => !isSessionStart(e) && isCommand(e)); - const injections = allInjections.filter((e) => !isSessionStart(e) && !isCommand(e)); + const commandInjections = allInjections.filter( + (e) => !isSessionStart(e) && isCommand(e) + ); + const injections = allInjections.filter( + (e) => !isSessionStart(e) && !isCommand(e) + ); const treated = injections.filter((e) => !e.holdout); const withheld = injections.filter((e) => e.holdout); const mean = (rows, field) => - rows.length ? rows.reduce((sum, r) => sum + (r[field] || 0), 0) / rows.length : 0; + rows.length + ? rows.reduce((sum, r) => sum + (r[field] || 0), 0) / rows.length + : 0; const injectedTokens = treated.reduce((sum, e) => sum + (e.tokens || 0), 0); const harvestTokens = balance @@ -819,7 +894,9 @@ function buildReport(events, balance) { const after = event.at ?? 0; const total = bucket.reduce( - (sum, r) => sum + ((r.at ?? 0) >= after ? (r.tokens || 0) : 0), 0); + (sum, r) => sum + ((r.at ?? 0) >= after ? r.tokens || 0 : 0), + 0 + ); // Split across the injections that share this key. Each touch is one // observation, so charging every one the full total would count the same @@ -828,52 +905,198 @@ function buildReport(events, balance) { }; const meanDownstream = (rows) => - rows.length ? rows.reduce((sum, r) => sum + downstreamOf(r), 0) / rows.length : 0; + rows.length + ? rows.reduce((sum, r) => sum + downstreamOf(r), 0) / rows.length + : 0; const treatedCost = meanDownstream(treated); const withheldCost = meanDownstream(withheld); const perTouchSaving = withheldCost - treatedCost; - const estimatedAvoided = Math.max(0, Math.round(perTouchSaving * treated.length)); + const estimatedAvoided = Math.max( + 0, + Math.round(perTouchSaving * treated.length) + ); + + const downstreamSamples = injections.filter((event) => { + if (event.downstream != null) return true; + const key = `${event.sessionId || ''}|${event.anchor}`; + return (reads.get(key) || []).some( + (read) => (read.at ?? 0) >= (event.at ?? 0) + ); + }).length; + const downstreamMeasured = downstreamSamples > 0; // Below this, arm means are noise and a ratio would be theatre. - const sufficient = treated.length >= 20 && withheld.length >= 5; + const sufficient = + downstreamMeasured && treated.length >= 20 && withheld.length >= 5; + + const coverageNumber = (value, fallback) => { + if (value === null || value === undefined || value === '') return fallback; + const number = Number(value); + return Number.isFinite(number) ? Math.max(0, number) : fallback; + }; + const projectCount = coverageNumber(sourceCoverage.projects, 1); + const telemetryProjects = coverageNumber( + sourceCoverage.projectsWithTelemetry, + 0 + ); + const balanceProjects = coverageNumber( + sourceCoverage.projectsWithBalanceEvents, + balance.length ? 1 : 0 + ); + const lastEventAt = Math.max( + latestEventTimestamp(events), + latestEventTimestamp(balance) + ); + const measured = + telemetryProjects > 0 || events.length > 0 || balance.length > 0; + const balanceMeasured = balanceProjects > 0 || balance.length > 0; + const metric = (status, source, samples, extra = {}) => ({ + status, + source, + samples, + ...extra, + }); return { + nativeOptimizer: { + substitutions: servedSubstitutions.length, + holdouts: substitutions.filter((event) => event.holdout).length, + tokensReturned: substitutionTokensReturned, + tokensSaved: substitutionTokensSaved, + byClient: substitutionByClient, + recent: servedSubstitutions + .slice() + .sort((a, b) => (b.at || 0) - (a.at || 0)) + .slice(0, 40) + .map((event) => { + const tokensSaved = + event.tokensNetAvoided ?? + Math.max( + 0, + Math.ceil((event.bytesAvoided || 0) / 4) - (event.tokens || 0) + ); + return { + name: 'live_graph_substitution', + client: event.client || 'Historical — client not recorded', + originalTokens: (event.tokens || 0) + tokensSaved, + optimizedTokens: event.tokens || 0, + tokensSaved, + savingsMeasured: false, + classification: 'modeled-counterfactual', + reportedTokensSaved: tokensSaved, + timestamp: event.at ? new Date(event.at).toISOString() : null, + }; + }), + source: + 'balance.jsonl: modeled full-file counterfactual minus annotated-skeleton tokens; excluded from verified MCP savings', + }, memoryDeliveries: allInjections.filter((event) => !event.holdout).length, memoryHoldouts: allInjections.filter((event) => event.holdout).length, deliveryTokens: allInjections .filter((event) => !event.holdout) .reduce( - (sum, event) => - sum + (event.deliveredTokens ?? event.tokens ?? 0), + (sum, event) => sum + (event.deliveredTokens ?? event.tokens ?? 0), 0 ), injections: treated.length, sessionStartInjections: sessionStartInjections.length, sessionStartInjectedTokens: sessionStartInjections.reduce( - (sum, event) => sum + (event.deliveredTokens ?? event.tokens ?? 0), 0 + (sum, event) => sum + (event.deliveredTokens ?? event.tokens ?? 0), + 0 ), commandInjections: commandInjections.length, commandHoldouts: commandInjections.filter((e) => e.holdout).length, holdouts: withheld.length, staleServed: injections.filter((e) => e.stale).length, - staleRate: injections.length ? injections.filter((e) => e.stale).length / injections.length : 0, + staleRate: injections.length + ? injections.filter((e) => e.stale).length / injections.length + : 0, injectedTokens, harvestTokens, estimatedTokensAvoided: sufficient ? estimatedAvoided : null, - netTokens: sufficient ? estimatedAvoided - injectedTokens - harvestTokens : null, + netTokens: sufficient + ? estimatedAvoided - injectedTokens - harvestTokens + : null, sufficientData: sufficient, verdict: !sufficient ? `insufficient data (${treated.length} treated, ${withheld.length} holdout; need 20 and 5)` : estimatedAvoided > injectedTokens + harvestTokens ? 'the graph is saving more than it costs' : 'the graph is NOT yet paying for itself', + measurement: { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + sourceCoverage: { + projects: projectCount, + projectsWithTelemetry: telemetryProjects || (measured ? 1 : 0), + projectsWithBalanceEvents: balanceProjects, + projectsWithoutTelemetry: Math.max( + 0, + projectCount - (telemetryProjects || (measured ? 1 : 0)) + ), + }, + freshness: { + lastEventAt: lastEventAt || null, + ageMs: lastEventAt ? Math.max(0, Date.now() - lastEventAt) : null, + status: !lastEventAt + ? 'not-measured' + : Date.now() - lastEventAt <= 7 * 86_400_000 + ? 'fresh' + : 'stale', + }, + metrics: { + nativeSubstitutions: metric( + balanceMeasured ? 'measured' : 'not-measured', + 'balance.jsonl: modeled full-file counterfactual minus annotated-skeleton tokens; not a materialized MCP before/after pair', + servedSubstitutions.length, + { classification: 'modeled-counterfactual' } + ), + memoryDeliveries: metric( + balanceMeasured ? 'measured' : 'not-measured', + 'balance.jsonl: inject events in the treated arm', + allInjections.filter((event) => !event.holdout).length + ), + memoryHoldouts: metric( + balanceMeasured ? 'measured' : 'not-measured', + 'balance.jsonl: inject events in the withheld arm', + allInjections.filter((event) => event.holdout).length + ), + rememberingCost: metric( + balanceMeasured ? 'measured' : 'not-measured', + 'balance.jsonl: delivered injection tokens plus semantic harvest tokens', + allInjections.length + + balance.filter((event) => event.kind === 'harvest').length + ), + readingAvoided: metric( + !downstreamMeasured + ? 'not-measured' + : sufficient + ? 'measured' + : 'collecting', + 'stratified file-touch holdout joined to downstream read events', + downstreamSamples, + { + treated: treated.length, + holdouts: withheld.length, + requiredTreated: 20, + requiredHoldouts: 5, + } + ), + }, + }, }; } export function report(dir) { - return buildReport(readAll(dir), readBalance(dir)); + const events = readAll(dir); + const balance = readBalance(dir); + return buildReport(events, balance, { + projects: 1, + projectsWithTelemetry: events.length || balance.length ? 1 : 0, + projectsWithBalanceEvents: balance.length ? 1 : 0, + }); } /** @@ -886,10 +1109,16 @@ export function report(dir) { */ export function reportMany(dirs) { const unique = [...new Set((dirs || []).map((dir) => String(dir)))]; - const report = buildReport( - unique.flatMap((dir) => readAll(dir)), - unique.flatMap((dir) => readBalance(dir)) - ); + const eventSources = unique.map((dir) => readAll(dir)); + const balanceSources = unique.map((dir) => readBalance(dir)); + const report = buildReport(eventSources.flat(), balanceSources.flat(), { + projects: unique.length, + projectsWithTelemetry: eventSources.filter( + (events, index) => events.length || balanceSources[index].length + ).length, + projectsWithBalanceEvents: balanceSources.filter((events) => events.length) + .length, + }); return { ...report, projects: unique.length }; } @@ -907,7 +1136,11 @@ export function reportMany(dirs) { * costs almost nothing. Without a control arm there is no way to tell those two * apart, which is why nobody else can do this. */ -export function substitutionBudget(dir, anchor, { floor = 300, base = 1200, ceiling = 3000 } = {}) { +export function substitutionBudget( + dir, + anchor, + { floor = 300, base = 1200, ceiling = 3000 } = {} +) { const events = readAll(dir); const mine = events.filter((e) => e.kind === 'inject' && e.anchor === anchor); @@ -932,13 +1165,17 @@ export function substitutionBudget(dir, anchor, { floor = 300, base = 1200, ceil for (const row of rows) { const bucket = reads.get(row.sessionId || '') || []; const after = row.at ?? 0; - total += bucket.reduce((sum, r) => sum + ((r.at ?? 0) >= after ? (r.tokens || 0) : 0), 0); + total += bucket.reduce( + (sum, r) => sum + ((r.at ?? 0) >= after ? r.tokens || 0 : 0), + 0 + ); } return total / rows.length; }; const saved = downstream(withheld) - downstream(treated); - const spent = treated.reduce((sum, e) => sum + (e.tokens || 0), 0) / treated.length; + const spent = + treated.reduce((sum, e) => sum + (e.tokens || 0), 0) / treated.length; // Ratio of what annotating this file avoided to what annotating it cost. // Above 1 it is paying for itself and earns room; below, it shrinks. @@ -959,7 +1196,10 @@ export function substitutionBudget(dir, anchor, { floor = 300, base = 1200, ceil * graph whose index leads to queries grows its allowance; a noisy one shrinks * back toward the floor. Nobody configures it, and it cannot run away. */ -export function indexBudget(dir, { floor = 150, base = 300, ceiling = 1200 } = {}) { +export function indexBudget( + dir, + { floor = 150, base = 300, ceiling = 1200 } = {} +) { const events = readAll(dir); const listed = events.filter((e) => e.kind === 'index').length; const queries = events.filter((e) => e.kind === 'query').length; @@ -968,7 +1208,9 @@ export function indexBudget(dir, { floor = 150, base = 300, ceiling = 1200 } = { const hitRate = queries / listed; // 0% hit rate falls to the floor; ~50% and above reaches the ceiling. - const scaled = Math.round(floor + (ceiling - floor) * Math.min(1, hitRate * 2)); + const scaled = Math.round( + floor + (ceiling - floor) * Math.min(1, hitRate * 2) + ); return Math.max(floor, Math.min(ceiling, scaled)); } @@ -1005,7 +1247,10 @@ function seededRandom(seedText) { } /** Percentile bootstrap interval for a mean, paired delta, or other scalar. */ -export function bootstrapMeanInterval(values, { samples = 1000, seed = 'token-optimizer' } = {}) { +export function bootstrapMeanInterval( + values, + { samples = 1000, seed = 'token-optimizer' } = {} +) { const finite = values.map(numeric).filter((value) => value !== null); if (!finite.length) return { mean: null, low: null, high: null, n: 0 }; if (finite.length === 1) { @@ -1037,24 +1282,32 @@ export function proportionInterval(successes, total, z = 1.96) { const denominator = 1 + (z * z) / total; const centre = (rate + (z * z) / (2 * total)) / denominator; const radius = - (z / denominator) * Math.sqrt((rate * (1 - rate)) / total + (z * z) / (4 * total * total)); - return { rate, low: Math.max(0, centre - radius), high: Math.min(1, centre + radius), n: total }; + (z / denominator) * + Math.sqrt((rate * (1 - rate)) / total + (z * z) / (4 * total * total)); + return { + rate, + low: Math.max(0, centre - radius), + high: Math.min(1, centre + radius), + n: total, + }; } -const cohortKey = (run) => [ - run.client || 'unknown', - run.clientVersion || 'unknown', - run.model || 'unknown', - run.modelVersion || 'unknown', - run.taskId || 'unknown', -].join('|'); +const cohortKey = (run) => + [ + run.client || 'unknown', + run.clientVersion || 'unknown', + run.model || 'unknown', + run.modelVersion || 'unknown', + run.taskId || 'unknown', + ].join('|'); function armMetrics(runs) { const correct = runs.filter((run) => run.correct === true).length; - const interval = (field) => bootstrapMeanInterval( - runs.map((run) => run[field]).filter((value) => numeric(value) !== null), - { seed: field } - ); + const interval = (field) => + bootstrapMeanInterval( + runs.map((run) => run[field]).filter((value) => numeric(value) !== null), + { seed: field } + ); return { runs: runs.length, correctness: proportionInterval(correct, runs.length), @@ -1068,29 +1321,40 @@ function armMetrics(runs) { latencyMs: interval('latencyMs'), costUsd: interval('costUsd'), injectedTokens: interval('injectedTokens'), - harmfulFindings: runs.reduce((sum, run) => sum + (numeric(run.harmfulFindings) || 0), 0), + harmfulFindings: runs.reduce( + (sum, run) => sum + (numeric(run.harmfulFindings) || 0), + 0 + ), }; } -function pairedEffects(runs, controlArm, treatmentArm, contrastType = 'incremental') { +function pairedEffects( + runs, + controlArm, + treatmentArm, + contrastType = 'incremental' +) { const pairs = new Map(); for (const run of runs) { if (!run.pairId || ![controlArm, treatmentArm].includes(run.arm)) continue; if (!pairs.has(run.pairId)) pairs.set(run.pairId, {}); pairs.get(run.pairId)[run.arm] = run; } - const complete = [...pairs.values()].filter((pair) => pair[controlArm] && pair[treatmentArm]); - const delta = (field, lowerIsBetter = true) => bootstrapMeanInterval( - complete - .map((pair) => { - const baseline = numeric(pair[controlArm][field]); - const treatment = numeric(pair[treatmentArm][field]); - if (baseline === null || treatment === null) return null; - return lowerIsBetter ? baseline - treatment : treatment - baseline; - }) - .filter((value) => value !== null), - { seed: `${controlArm}:${treatmentArm}:${field}` } + const complete = [...pairs.values()].filter( + (pair) => pair[controlArm] && pair[treatmentArm] ); + const delta = (field, lowerIsBetter = true) => + bootstrapMeanInterval( + complete + .map((pair) => { + const baseline = numeric(pair[controlArm][field]); + const treatment = numeric(pair[treatmentArm][field]); + if (baseline === null || treatment === null) return null; + return lowerIsBetter ? baseline - treatment : treatment - baseline; + }) + .filter((value) => value !== null), + { seed: `${controlArm}:${treatmentArm}:${field}` } + ); return { arm: treatmentArm, controlArm, @@ -1111,21 +1375,32 @@ function pairedEffects(runs, controlArm, treatmentArm, contrastType = 'increment } function matchesFilters(event, filters) { - for (const field of ['client', 'clientVersion', 'model', 'modelVersion', 'taskId', 'arm']) { + for (const field of [ + 'client', + 'clientVersion', + 'model', + 'modelVersion', + 'taskId', + 'arm', + ]) { if (!filters[field]) continue; let actual = event[field]; if (event.kind === 'handoff-run') { - actual = field === 'taskId' - ? event.scenarioId - : field === 'arm' - ? event.arm - : (event.consumer?.[field] ?? event.producer?.[field] ?? event[field]); + actual = + field === 'taskId' + ? event.scenarioId + : field === 'arm' + ? event.arm + : (event.consumer?.[field] ?? + event.producer?.[field] ?? + event[field]); } else if (event.kind === 'concurrency-run') { - actual = field === 'taskId' - ? 'concurrent-combined' - : field === 'arm' - ? event.arm - : (event.consumer?.[field] ?? event[field]); + actual = + field === 'taskId' + ? 'concurrent-combined' + : field === 'arm' + ? event.arm + : (event.consumer?.[field] ?? event[field]); } if (String(actual || '') !== String(filters[field])) return false; } @@ -1134,11 +1409,15 @@ function matchesFilters(event, filters) { function handoffArmMetrics(runs) { const consumers = runs.map((run) => run.consumer || {}); - const count = (field) => consumers.filter((consumer) => consumer[field] === true).length; - const interval = (field) => bootstrapMeanInterval( - consumers.map((consumer) => consumer[field]).filter((value) => numeric(value) !== null), - { seed: `handoff:${field}` } - ); + const count = (field) => + consumers.filter((consumer) => consumer[field] === true).length; + const interval = (field) => + bootstrapMeanInterval( + consumers + .map((consumer) => consumer[field]) + .filter((value) => numeric(value) !== null), + { seed: `handoff:${field}` } + ); return { runs: runs.length, delivery: proportionInterval( @@ -1147,7 +1426,10 @@ function handoffArmMetrics(runs) { ), correctness: proportionInterval(count('correct'), runs.length), firstPass: proportionInterval(count('firstPass'), runs.length), - mistakeAttempted: proportionInterval(count('mistakeAttempted'), runs.length), + mistakeAttempted: proportionInterval( + count('mistakeAttempted'), + runs.length + ), mistakeExecuted: proportionInterval(count('mistakeExecuted'), runs.length), totalTokens: interval('totalTokens'), toolCalls: interval('toolCalls'), @@ -1163,16 +1445,21 @@ function handoffPairedEffect(runs, controlArm, treatmentArm) { if (!pairs.has(run.pairId)) pairs.set(run.pairId, {}); pairs.get(run.pairId)[run.arm] = run; } - const complete = [...pairs.values()].filter((pair) => pair[controlArm] && pair[treatmentArm]); - const effect = (field, positiveWhenLower = true) => bootstrapMeanInterval( - complete.map((pair) => { - const control = numeric(pair[controlArm].consumer?.[field]); - const treatment = numeric(pair[treatmentArm].consumer?.[field]); - if (control === null || treatment === null) return null; - return positiveWhenLower ? control - treatment : treatment - control; - }).filter((value) => value !== null), - { seed: `handoff:${controlArm}:${treatmentArm}:${field}` } + const complete = [...pairs.values()].filter( + (pair) => pair[controlArm] && pair[treatmentArm] ); + const effect = (field, positiveWhenLower = true) => + bootstrapMeanInterval( + complete + .map((pair) => { + const control = numeric(pair[controlArm].consumer?.[field]); + const treatment = numeric(pair[treatmentArm].consumer?.[field]); + if (control === null || treatment === null) return null; + return positiveWhenLower ? control - treatment : treatment - control; + }) + .filter((value) => value !== null), + { seed: `handoff:${controlArm}:${treatmentArm}:${field}` } + ); return { comparison: `${treatmentArm} vs ${controlArm}`, pairs: complete.length, @@ -1187,13 +1474,22 @@ function handoffPairedEffect(runs, controlArm, treatmentArm) { }; } -const handoffKey = (run) => [ - run.producer?.client || 'unknown', run.producer?.model || 'unknown', - run.consumer?.client || 'unknown', run.consumer?.model || 'unknown', - run.scenarioId || 'unknown', -].join('|'); - -const HANDOFF_REPORT_ARMS = ['empty', 'natural', 'oracle', 'irrelevant', 'stale']; +const handoffKey = (run) => + [ + run.producer?.client || 'unknown', + run.producer?.model || 'unknown', + run.consumer?.client || 'unknown', + run.consumer?.model || 'unknown', + run.scenarioId || 'unknown', + ].join('|'); + +const HANDOFF_REPORT_ARMS = [ + 'empty', + 'natural', + 'oracle', + 'irrelevant', + 'stale', +]; const handoffMinimumPairs = () => Math.max(2, Number(process.env.TOKEN_OPTIMIZER_HANDOFF_MIN_PAIRS) || 10); @@ -1206,43 +1502,54 @@ function handoffCohorts(runs) { } const minimumPairs = handoffMinimumPairs(); return [...grouped.entries()].map(([key, rows]) => { - const arms = Object.fromEntries(HANDOFF_REPORT_ARMS.map((arm) => [ - arm, handoffArmMetrics(rows.filter((row) => row.arm === arm)), - ])); + const arms = Object.fromEntries( + HANDOFF_REPORT_ARMS.map((arm) => [ + arm, + handoffArmMetrics(rows.filter((row) => row.arm === arm)), + ]) + ); const naturalRows = rows.filter((row) => row.arm === 'natural'); const naturalVsEmpty = handoffPairedEffect(rows, 'empty', 'natural'); const naturalVsOracle = handoffPairedEffect(rows, 'oracle', 'natural'); const captureRate = naturalRows.length - ? naturalRows.filter((row) => row.producer?.captureSuccess).length / naturalRows.length + ? naturalRows.filter((row) => row.producer?.captureSuccess).length / + naturalRows.length : null; const preActionDeliveryRate = naturalRows.length - ? naturalRows.filter((row) => row.delivery?.beforeFirstExecutedMistake).length / naturalRows.length + ? naturalRows.filter((row) => row.delivery?.beforeFirstExecutedMistake) + .length / naturalRows.length : null; const emptyRecurrence = arms.empty.mistakeExecuted.rate; const naturalRecurrence = arms.natural.mistakeExecuted.rate; - const relativeRecurrenceReduction = emptyRecurrence !== null - && naturalRecurrence !== null - && emptyRecurrence !== 0 - ? (emptyRecurrence - naturalRecurrence) / emptyRecurrence - : null; - const controlsSafe = ['irrelevant', 'stale'].every((arm) => - arms[arm].correctness.rate !== null - && arms.empty.correctness.rate !== null - && arms[arm].correctness.rate >= arms.empty.correctness.rate - 0.1 + const relativeRecurrenceReduction = + emptyRecurrence !== null && + naturalRecurrence !== null && + emptyRecurrence !== 0 + ? (emptyRecurrence - naturalRecurrence) / emptyRecurrence + : null; + const controlsSafe = ['irrelevant', 'stale'].every( + (arm) => + arms[arm].correctness.rate !== null && + arms.empty.correctness.rate !== null && + arms[arm].correctness.rate >= arms.empty.correctness.rate - 0.1 ); - const irrelevantSuppressed = arms.irrelevant.delivery.rate !== null - && arms.irrelevant.delivery.rate === 0; + const irrelevantSuppressed = + arms.irrelevant.delivery.rate !== null && + arms.irrelevant.delivery.rate === 0; const gates = { minimumPairs: naturalVsEmpty.pairs >= minimumPairs, capture: captureRate !== null && captureRate >= 0.8, recurrenceMagnitude: - relativeRecurrenceReduction !== null && relativeRecurrenceReduction >= 0.5, - recurrenceInterval: (naturalVsEmpty.executedMistakesPrevented.low ?? -Infinity) > 0, + relativeRecurrenceReduction !== null && + relativeRecurrenceReduction >= 0.5, + recurrenceInterval: + (naturalVsEmpty.executedMistakesPrevented.low ?? -Infinity) > 0, correctness: - arms.natural.correctness.rate !== null - && arms.empty.correctness.rate !== null - && arms.natural.correctness.rate >= arms.empty.correctness.rate - 0.1, - preActionDelivery: preActionDeliveryRate !== null && preActionDeliveryRate >= 0.8, + arms.natural.correctness.rate !== null && + arms.empty.correctness.rate !== null && + arms.natural.correctness.rate >= arms.empty.correctness.rate - 0.1, + preActionDelivery: + preActionDeliveryRate !== null && preActionDeliveryRate >= 0.8, negativeControls: controlsSafe && irrelevantSuppressed, }; const claimReady = Object.values(gates).every(Boolean); @@ -1269,14 +1576,24 @@ function handoffCohorts(runs) { function concurrencySummary(runs) { const natural = runs.filter((run) => run.arm === 'natural'); const writers = natural.reduce((sum, run) => sum + (run.writerCount || 0), 0); - const captures = natural.reduce((sum, run) => sum + (run.captureSuccesses || 0), 0); - const integrityPasses = natural.filter((run) => - run.integrity?.zeroLoss - && run.integrity?.parseable - && run.integrity?.orphanedFindings === 0 + const captures = natural.reduce( + (sum, run) => sum + (run.captureSuccesses || 0), + 0 + ); + const integrityPasses = natural.filter( + (run) => + run.integrity?.zeroLoss && + run.integrity?.parseable && + run.integrity?.orphanedFindings === 0 ).length; - const delivered = natural.reduce((sum, run) => sum + (run.delivery?.delivered || 0), 0); - const expected = natural.reduce((sum, run) => sum + (run.delivery?.expected || 0), 0); + const delivered = natural.reduce( + (sum, run) => sum + (run.delivery?.delivered || 0), + 0 + ); + const expected = natural.reduce( + (sum, run) => sum + (run.delivery?.expected || 0), + 0 + ); const effect = handoffPairedEffect(runs, 'empty', 'natural'); return { runs: runs.length, @@ -1286,7 +1603,8 @@ function concurrencySummary(runs) { integrityPassRate: natural.length ? integrityPasses / natural.length : null, deliveryCoverage: expected ? delivered / expected : null, naturalCorrectness: proportionInterval( - natural.filter((run) => run.consumer?.correct).length, natural.length + natural.filter((run) => run.consumer?.correct).length, + natural.length ), effect, }; @@ -1297,17 +1615,26 @@ function concurrencySummary(runs) { * live hook traces, and randomized model evals remain visibly distinct: only * `eval-run` records with paired arms can produce a causal effect interval. */ -function buildEvidenceReport(evidence, { filters = {}, episodeLimit = 100 } = {}) { +function buildEvidenceReport( + evidence, + { filters = {}, episodeLimit = 100 } = {} +) { evidence = evidence.filter((event) => matchesFilters(event, filters)); const runs = evidence.filter((event) => event.kind === 'eval-run'); const handoffRuns = evidence.filter((event) => event.kind === 'handoff-run'); - const concurrencyRuns = evidence.filter((event) => event.kind === 'concurrency-run'); + const concurrencyRuns = evidence.filter( + (event) => event.kind === 'concurrency-run' + ); const injections = evidence.filter((event) => event.kind === 'inject'); const outcomes = evidence.filter((event) => event.kind === 'tool-outcome'); - const feedback = evidence.filter((event) => event.kind === 'finding-feedback'); + const feedback = evidence.filter( + (event) => event.kind === 'finding-feedback' + ); const byInjection = new Map( - outcomes.filter((event) => event.injectionId).map((event) => [event.injectionId, event]) + outcomes + .filter((event) => event.injectionId) + .map((event) => [event.injectionId, event]) ); const traced = injections .slice() @@ -1337,7 +1664,10 @@ function buildEvidenceReport(evidence, { filters = {}, episodeLimit = 100 } = {} grouped.get(key).push(run); } - const minimumPairs = Math.max(2, Number(process.env.TOKEN_OPTIMIZER_EVAL_MIN_PAIRS) || 5); + const minimumPairs = Math.max( + 2, + Number(process.env.TOKEN_OPTIMIZER_EVAL_MIN_PAIRS) || 5 + ); const cohorts = [...grouped.entries()].map(([key, cohortRuns]) => { const arms = Object.fromEntries( ['baseline', 'optimizer', 'retrieval', 'full'].map((arm) => [ @@ -1387,7 +1717,9 @@ function buildEvidenceReport(evidence, { filters = {}, episodeLimit = 100 } = {} cohorts: cohorts.length, harmfulFeedback: harmful, harmRate: feedback.length ? harmful / feedback.length : null, - evidenceStatus: cohorts.some((cohort) => cohort.evidenceStatus === 'causal estimate available') + evidenceStatus: cohorts.some( + (cohort) => cohort.evidenceStatus === 'causal estimate available' + ) ? 'causal estimates available' : 'insufficient randomized evidence', }, @@ -1396,7 +1728,8 @@ function buildEvidenceReport(evidence, { filters = {}, episodeLimit = 100 } = {} concurrency, episodes: traced, methodology: { - intervals: 'deterministic percentile bootstrap (95%); Wilson interval for correctness', + intervals: + 'deterministic percentile bootstrap (95%); Wilson interval for correctness', causalRule: 'matched pairs estimate optimizer vs baseline, retrieval vs optimizer, full vs retrieval, and full vs baseline', minimumPairs, diff --git a/integrations/gemini/hooks/lib/observability.mjs b/integrations/gemini/hooks/lib/observability.mjs index b9980076e..ef1c037af 100644 --- a/integrations/gemini/hooks/lib/observability.mjs +++ b/integrations/gemini/hooks/lib/observability.mjs @@ -26,7 +26,7 @@ import { createHash, randomUUID } from 'node:crypto'; import { homedir } from 'node:os'; import { join } from 'node:path'; -export const HOOK_LOG_SCHEMA_VERSION = 1; +export const HOOK_LOG_SCHEMA_VERSION = 2; const DEFAULT_MAX_BYTES = 5 * 1024 * 1024; const DEFAULT_RETENTION_DAYS = 14; const DEFAULT_MAX_FILES = 40; @@ -197,6 +197,8 @@ export function beginHookInvocation(client, event, options = {}) { turnIdHash: null, toolUseIdHash: null, toolName: null, + rawToolName: null, + codeModeEnvelope: false, clientVersion: null, model: null, cwdHash: null, @@ -208,12 +210,29 @@ export function beginHookInvocation(client, event, options = {}) { const spanId = hash(invocationId, 16); let traceId = hash(invocationId, 32); + // Write the start before reading stdin or importing project state. If the + // host kills the process, Node aborts, or a native call terminates without a + // JavaScript exception, diagnostics can now reconcile this record with the + // missing completion instead of reporting a false success rate. + writeHookEvent({ + level: 'info', + event: 'hook.started', + invocationId, + traceId, + spanId, + client, + hookEvent: event, + processId: process.pid, + }); + let deadline; const finish = (nextOutcome = outcome, nextReason = reason, nextError = error) => { if (ended) return; ended = true; if (deadline) clearTimeout(deadline); process.removeListener('exit', onExit); + process.removeListener('uncaughtException', onUncaughtException); + process.removeListener('unhandledRejection', onUnhandledRejection); writeHookEvent({ level: nextOutcome === 'failure' || nextOutcome === 'timeout' ? 'error' : 'info', event: 'hook.completed', @@ -231,7 +250,17 @@ export function beginHookInvocation(client, event, options = {}) { }); }; const onExit = () => finish(); + const onUncaughtException = (nextError) => { + finish('failure', 'uncaught_exception', nextError); + process.exit(0); + }; + const onUnhandledRejection = (nextError) => { + finish('failure', 'unhandled_rejection', nextError); + process.exit(0); + }; process.once('exit', onExit); + process.once('uncaughtException', onUncaughtException); + process.once('unhandledRejection', onUnhandledRejection); deadline = setTimeout(() => { outcome = 'timeout'; @@ -250,6 +279,11 @@ export function beginHookInvocation(client, event, options = {}) { dimensions.turnIdHash = hash(raw.turn_id ?? raw.turnId ?? null); dimensions.toolUseIdHash = hash(raw.tool_use_id ?? raw.toolUseId ?? null); dimensions.toolName = payload?.tool_name ?? raw.tool_name ?? raw.toolName ?? null; + dimensions.rawToolName = dimension( + raw.tool_name ?? raw.toolName ?? raw.tool ?? null + ); + dimensions.codeModeEnvelope = + payload?.tool_input?.code_mode_envelope === true; dimensions.clientVersion = dimension(raw.client_version ?? raw.clientVersion ?? null); dimensions.model = dimension(raw.model ?? raw.model_name ?? raw.modelName ?? null); dimensions.cwdHash = hash(raw.cwd ?? raw.working_directory ?? process.cwd()); @@ -291,6 +325,11 @@ export function beginHookInvocation(client, event, options = {}) { error = nextError; finish(outcome, reason, error); }, + block(nextReason = 'policy_denied') { + outcome = 'blocked'; + reason = nextReason; + finish(outcome, reason); + }, succeed(nextReason = 'completed') { outcome = 'success'; reason = nextReason; @@ -321,7 +360,11 @@ export function recordHookBootstrapFailure(client, event, error) { }); } -export function readHookEvents({ limit = 200, sinceMs = 24 * 60 * 60 * 1000 } = {}) { +export function readHookEvents({ + limit = 200, + sinceMs = 24 * 60 * 60 * 1000, + includeLifecycle = false, +} = {}) { try { const dir = hookLogDirectory(); if (!existsSync(dir)) return []; @@ -338,6 +381,7 @@ export function readHookEvents({ limit = 200, sinceMs = 24 * 60 * 60 * 1000 } = try { const event = JSON.parse(line); if (Date.parse(event.timestamp) < cutoff) continue; + if (!includeLifecycle && event.event === 'hook.started') continue; events.push(event); if (events.length >= limit) return events; } catch { @@ -353,38 +397,102 @@ export function readHookEvents({ limit = 200, sinceMs = 24 * 60 * 60 * 1000 } = export function hookHealthSummary(options = {}) { const sinceMs = options.sinceMs ?? 24 * 60 * 60 * 1000; - const events = readHookEvents({ limit: options.limit || 5000, sinceMs }); - const durations = events + const events = readHookEvents({ + limit: options.limit || 10000, + sinceMs, + includeLifecycle: true, + }); + const starts = events.filter((event) => event.event === 'hook.started'); + const completed = events.filter( + (event) => event.event === 'hook.completed' || !event.event + ); + const completedIds = new Set( + completed.map((event) => event.invocationId).filter(Boolean) + ); + const abandoned = starts + .filter( + (event) => + event.invocationId && + !completedIds.has(event.invocationId) && + Date.now() - Date.parse(event.timestamp) > 10_000 + ) + .map((event) => ({ + ...event, + event: 'hook.completed', + outcome: 'failure', + reason: 'process_terminated_before_completion', + durationMs: null, + })); + const runs = [...completed, ...abandoned]; + const durations = runs .map((event) => Number(event.durationMs)) .filter(Number.isFinite) .sort((a, b) => a - b); - const failures = events.filter((event) => event.outcome === 'failure'); - const timeouts = events.filter((event) => event.outcome === 'timeout'); - const skipped = events.filter((event) => event.outcome === 'skipped'); - const successes = events.filter((event) => event.outcome === 'success'); + const failures = runs.filter((event) => event.outcome === 'failure'); + const timeouts = runs.filter((event) => event.outcome === 'timeout'); + const skipped = runs.filter((event) => event.outcome === 'skipped'); + const blocked = runs.filter((event) => event.outcome === 'blocked'); + const successes = runs.filter((event) => event.outcome === 'success'); + const actions = runs.filter( + (event) => event.hookEvent === 'pre-tool' && event.toolName + ); + const byTool = {}; + for (const event of actions) { + const name = String(event.toolName || 'Unknown action'); + byTool[name] = (byTool[name] || 0) + 1; + } const byClient = {}; - for (const event of events) { + for (const event of runs) { const key = event.client || 'unknown'; - const current = byClient[key] || { total: 0, failures: 0, timeouts: 0, skipped: 0 }; + const current = byClient[key] || { + total: 0, + failures: 0, + timeouts: 0, + skipped: 0, + blocked: 0, + hookEvents: [], + }; current.total++; if (event.outcome === 'failure') current.failures++; if (event.outcome === 'timeout') current.timeouts++; if (event.outcome === 'skipped') current.skipped++; + if (event.outcome === 'blocked') current.blocked++; + if (event.hookEvent && !current.hookEvents.includes(event.hookEvent)) + current.hookEvents.push(event.hookEvent); byClient[key] = current; } + for (const current of Object.values(byClient)) current.hookEvents.sort(); const percentile = (p) => durations.length ? durations[Math.min(durations.length - 1, Math.floor(durations.length * p))] : null; const summary = { schemaVersion: HOOK_LOG_SCHEMA_VERSION, - available: events.length > 0, + available: runs.length > 0, windowHours: sinceMs / 3_600_000, - total: events.length, + total: runs.length, failures: failures.length, timeouts: timeouts.length, skipped: skipped.length, - successRate: events.length ? successes.length / events.length : null, + blocked: blocked.length, + abandoned: abandoned.length, + successes: successes.length, + actions: actions.length, + byTool, + // A skipped or policy-blocked hook completed its protocol successfully. + // Runtime health answers whether the hook process worked, not whether the + // optimizer chose to allow the host operation. + successRate: runs.length + ? (successes.length + skipped.length + blocked.length) / runs.length + : null, + healthStatus: + failures.length || timeouts.length + ? 'failing' + : skipped.length + ? 'degraded' + : runs.length + ? 'healthy' + : 'unavailable', p50DurationMs: percentile(0.5), p95DurationMs: percentile(0.95), byClient, diff --git a/integrations/gemini/hooks/lib/pricing.mjs b/integrations/gemini/hooks/lib/pricing.mjs index a4b09ebed..50b23b9cc 100644 --- a/integrations/gemini/hooks/lib/pricing.mjs +++ b/integrations/gemini/hooks/lib/pricing.mjs @@ -1,101 +1,72 @@ // GENERATED FILE -- do not edit. // Source of truth: hooks-core/pricing.mjs. Regenerate with `npm run sync:hooks`. /** - * Tokens in the unit people actually feel. + * Optional cost equivalents for token measurements. * - * The same data as everywhere else in this project, converted once, in one - * place, under three rules that keep a dollar figure honest: - * - * THE TABLE IS VISIBLE AND OVERRIDABLE. Prices change. A figure computed from - * rates buried in code goes stale silently and keeps looking authoritative, - * which is worse than no figure. The rates are printed next to the number and - * can be replaced without a release. - * - * DOLLARS BESIDE TOKENS, NOT INSTEAD OF THEM. The token count is exact and - * never goes out of date; the dollar figure is the token count times a rate - * that will. Showing both means the durable number survives the perishable - * one. - * - * NEVER A PRICE ON AN UNMEASURED SAVING. An invented number is worse in - * dollars than in tokens, because dollars get quoted to other people. A - * saving we cannot measure returns null and renders as "not yet measurable", - * never as "$0.00" -- which reads as "saved nothing" and is a different - * claim entirely. + * The runtime cannot infer a user's bill from a client name. A CLI may route + * through a subscription, direct API account, cloud marketplace, enterprise + * agreement, included credits, or a provider whose cache-read/write prices + * differ. Provider usage records are the source of truth when available. + * Everywhere else this module fails closed until the user supplies an + * effective blended input-token rate. */ -/** Cache multipliers, relative to an input token. */ -const CACHE_WRITE = 1.25; -const CACHE_READ = 0.1; +const RATE_ENV = 'TOKEN_OPTIMIZER_EFFECTIVE_INPUT_USD_PER_MILLION'; -/** - * Dollars per million tokens, by tier. - * - * Approximate published list rates as of 2026-07, carried with their date so a - * stale table is visible rather than silent. Override wholesale with - * TOKEN_OPTIMIZER_PRICES as JSON, e.g. - * {"asOf":"2026-09","opus":{"input":15,"output":75}}. - */ -const DEFAULT_PRICES = { - asOf: '2026-07', - haiku: { input: 1, output: 5 }, - sonnet: { input: 3, output: 15 }, - opus: { input: 15, output: 75 }, -}; - -export function prices() { - const raw = process.env.TOKEN_OPTIMIZER_PRICES; - if (raw) { - try { - const parsed = JSON.parse(raw); - if (parsed && typeof parsed === 'object') return { ...DEFAULT_PRICES, ...parsed, overridden: true }; - } catch { /* an unparseable override is ignored rather than fatal */ } - } - return DEFAULT_PRICES; +/** The configured effective input rate, or null when billing is unobservable. */ +export function effectiveRate() { + const rate = Number(process.env[RATE_ENV]); + return Number.isFinite(rate) && rate > 0 ? rate : null; } /** - * What a number of tokens costs. + * Dollars for a measured token quantity. * - * Returns null for a null input and says why: this is the single place the - * "unmeasured must not become $0.00" rule is enforced, so every caller inherits - * it rather than each remembering. + * `kind` is accepted for backwards compatibility but is not multiplied by a + * universal cache constant. The configured rate must already reflect the + * user's actual provider/model/cache/tier/credit mix. */ -export function dollars(tokens, { tier = 'opus', kind = 'input' } = {}) { - if (!Number.isFinite(tokens)) return null; - - const table = prices(); - const rate = table[tier]?.[kind === 'output' ? 'output' : 'input']; - if (!Number.isFinite(rate)) return null; - - const multiplier = kind === 'cacheWrite' ? CACHE_WRITE : kind === 'cacheRead' ? CACHE_READ : 1; - return (tokens / 1_000_000) * rate * multiplier; +export function dollars(tokens, { rate = effectiveRate() } = {}) { + if (!Number.isFinite(tokens) || !Number.isFinite(rate) || rate <= 0) { + return null; + } + return (tokens / 1_000_000) * rate; } /** A dollar figure, or an honest absence of one. */ -export function money(amount, { unmeasured = 'not yet measurable' } = {}) { +export function money(amount, { unmeasured = 'not priced' } = {}) { if (amount == null || !Number.isFinite(amount)) return unmeasured; if (amount === 0) return '$0.00'; if (Math.abs(amount) < 0.01) return '<$0.01'; return `$${amount.toFixed(2)}`; } -/** - * Per-session tokens expressed as a monthly cost. - * - * `sessionsPerMonth` is an assumption, so it is stated in the output rather - * than folded invisibly into the number. - */ -export function monthly(tokensPerSession, { tier = 'opus', sessionsPerMonth = 60 } = {}) { +/** Per-session tokens expressed with a stated monthly-frequency assumption. */ +export function monthly(tokensPerSession, { sessionsPerMonth = 60 } = {}) { if (!Number.isFinite(tokensPerSession)) return null; - const amount = dollars(tokensPerSession * sessionsPerMonth, { tier }); - return amount == null ? null : { amount, sessionsPerMonth, tier }; + const amount = dollars(tokensPerSession * sessionsPerMonth); + return amount == null + ? null + : { + amount, + sessionsPerMonth, + effectiveInputUsdPerMillion: effectiveRate(), + }; +} + +/** The pricing basis printed beside any cost equivalent. */ +export function priceNote() { + const rate = effectiveRate(); + return rate == null + ? `cost not priced: set ${RATE_ENV} to your effective blended input rate after provider, model, cache, tier, plan, and credits` + : `cost equivalent: configured effective input rate $${rate}/M via ${RATE_ENV}`; } -/** The rates behind the figures, for printing beside them. */ -export function priceNote(tier = 'opus') { - const table = prices(); - const rate = table[tier]; - if (!rate) return null; - return `prices: ${tier} $${rate.input}/$${rate.output} per Mtok (${table.asOf}` + - `${table.overridden ? ', overridden' : ''}); override with TOKEN_OPTIMIZER_PRICES`; +/** Compatibility surface: no built-in provider table is claimed. */ +export function prices() { + return { + source: + effectiveRate() == null ? 'unavailable' : 'configured-effective-rate', + effectiveInputUsdPerMillion: effectiveRate(), + }; } diff --git a/integrations/gemini/hooks/lib/routing.mjs b/integrations/gemini/hooks/lib/routing.mjs index 9b4642e0e..84ac866e4 100644 --- a/integrations/gemini/hooks/lib/routing.mjs +++ b/integrations/gemini/hooks/lib/routing.mjs @@ -65,7 +65,9 @@ export function modelCosts() { if (raw) { try { return JSON.parse(raw); - } catch { /* fall through to the defaults */ } + } catch { + /* fall through to the defaults */ + } } return { haiku: 1, sonnet: 3, opus: 15 }; } @@ -106,7 +108,12 @@ export function readEpisodes(path, { maxBytes = 6_000_000 } = {}) { let current = null; const close = () => { - if (current && current.turns > 0) episodes.push({ ...current, files: [...current.files], tools: [...current.tools] }); + if (current && current.turns > 0) + episodes.push({ + ...current, + files: [...current.files], + tools: [...current.tools], + }); current = null; }; @@ -123,7 +130,15 @@ export function readEpisodes(path, { maxBytes = 6_000_000 } = {}) { // a new episode. if (row.type === 'user' && typeof row.message?.content === 'string') { close(); - current = { at: Date.parse(row.timestamp) || null, models: new Set(), tools: new Set(), files: new Set(), turns: 0, errors: 0, results: 0 }; + current = { + at: Date.parse(row.timestamp) || null, + models: new Set(), + tools: new Set(), + files: new Set(), + turns: 0, + errors: 0, + results: 0, + }; continue; } if (!current) continue; @@ -157,8 +172,16 @@ export function readEpisodes(path, { maxBytes = 6_000_000 } = {}) { /* ---------------------------------------------------------------- SHAPES */ -const EDIT_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit', 'smart_edit', 'smart_write']); -const TEST_PATTERN = /\b(test|jest|pytest|vitest|dotnet test|npm test|go test)\b/i; +const EDIT_TOOLS = new Set([ + 'Edit', + 'MultiEdit', + 'Write', + 'NotebookEdit', + 'smart_edit', + 'smart_write', +]); +const TEST_PATTERN = + /\b(test|jest|pytest|vitest|dotnet test|npm test|go test)\b/i; /** * What kind of work this was. @@ -171,11 +194,19 @@ export function classifyShape(episode) { const tools = new Set(episode.tools || []); const edited = [...tools].some((t) => EDIT_TOOLS.has(t)); const files = (episode.files || []).length; - const ranTests = [...tools].some((t) => TEST_PATTERN.test(t)) || (episode.tools || []).includes('Bash'); + const ranTests = + [...tools].some((t) => TEST_PATTERN.test(t)) || + (episode.tools || []).includes('Bash'); if (edited && files >= 3) return 'multi-file-change'; if (edited) return 'single-file-change'; - if (files > 0 || tools.has('Grep') || tools.has('Glob') || tools.has('smart_grep')) return 'investigation'; + if ( + files > 0 || + tools.has('Grep') || + tools.has('Glob') || + tools.has('smart_grep') + ) + return 'investigation'; if (ranTests) return 'build-or-test'; return 'conversation'; } @@ -208,7 +239,13 @@ export function outcomeTable(episodes) { const tier = [...tiers][0]; table[shape] = table[shape] || {}; - const cell = table[shape][tier] = table[shape][tier] || { episodes: 0, turns: 0, errors: 0, results: 0, errored: 0 }; + const cell = (table[shape][tier] = table[shape][tier] || { + episodes: 0, + turns: 0, + errors: 0, + results: 0, + errored: 0, + }); cell.episodes += 1; cell.turns += episode.turns; cell.errors += episode.errors; @@ -287,7 +324,8 @@ export function route(shape, table = {}) { shape, recommend: heuristic, basis: 'heuristic', - reason: `every measured tier for ${shape} needed a retry in more than ` + + reason: + `every measured tier for ${shape} needed a retry in more than ` + `${Math.round(MAX_ERROR_RATE * 100)}% of episodes; falling back to the shipped default`, excluded: excluded.map((o) => ({ tier: o.tier, errorRate: o.errorRate })), options, @@ -306,17 +344,24 @@ export function route(shape, table = {}) { recommend: best.tier, basis: 'measured', // What each mistake would cost, from this project's own rates. - overpowered: strongest.tier === best.tier ? null : { - tier: strongest.tier, - wasted: Number((strongest.cost - best.cost).toFixed(2)), - }, - underpowered: cheapest.tier === best.tier ? null : { - tier: cheapest.tier, - expectedCost: Number(cheapest.cost.toFixed(2)), - errorRate: cheapest.errorRate, - excluded: cheapest.errorRate > MAX_ERROR_RATE, - }, - reason: `${shape}: ${best.tier} costs ${best.cost.toFixed(2)} in expectation over ${best.episodes} episodes ` + + overpowered: + strongest.tier === best.tier + ? null + : { + tier: strongest.tier, + wasted: Number((strongest.cost - best.cost).toFixed(2)), + }, + underpowered: + cheapest.tier === best.tier + ? null + : { + tier: cheapest.tier, + expectedCost: Number(cheapest.cost.toFixed(2)), + errorRate: cheapest.errorRate, + excluded: cheapest.errorRate > MAX_ERROR_RATE, + }, + reason: + `${shape}: ${best.tier} costs ${best.cost.toFixed(2)} in expectation over ${best.episodes} episodes ` + `(${Math.round(best.errorRate * 100)}% needed a retry)`, options, }; @@ -340,20 +385,31 @@ export function routingNote(shape, table, { currentModel, switchCost } = {}) { const lines = [decision.reason]; if (decision.underpowered) { - lines.push(` ${decision.underpowered.tier} looks cheaper per token, but needed a retry in ` + - `${Math.round(decision.underpowered.errorRate * 100)}% of episodes -- expected cost ${decision.underpowered.expectedCost}` + - `${decision.underpowered.excluded ? ', which is why it is not a candidate here at any price' : ''}.`); + lines.push( + ` ${decision.underpowered.tier} looks cheaper per token, but needed a retry in ` + + `${Math.round(decision.underpowered.errorRate * 100)}% of episodes -- expected cost ${decision.underpowered.expectedCost}` + + `${decision.underpowered.excluded ? ', which is why it is not a candidate here at any price' : ''}.` + ); } if (decision.overpowered) { - lines.push(` ${decision.overpowered.tier} would spend ${decision.overpowered.wasted} more than needed here.`); + lines.push( + ` ${decision.overpowered.tier} would spend ${decision.overpowered.wasted} more than needed here.` + ); } if (switchCost?.prefixTokens) { - lines.push(` Switching now also discards a ${switchCost.prefixTokens.toLocaleString()}-token warm prefix ` + - `(~${switchCost.rewriteCost.toLocaleString()} tokens to re-write); consider switching at the next break.`); + lines.push( + ` Switching now also discards a ${switchCost.prefixTokens.toLocaleString()}-token warm prefix ` + + `(~${switchCost.rewriteInputCostEquivalent.toLocaleString()} Anthropic uncached-input cost-equivalent tokens to re-write); consider switching at the next break.` + ); } - return { shape, recommend: decision.recommend, basis: decision.basis, text: lines.join('\n') }; + return { + shape, + recommend: decision.recommend, + basis: decision.basis, + text: lines.join('\n'), + }; } /** @@ -371,7 +427,9 @@ export function routingBriefing(table) { const decision = route(shape, table); if (decision.basis !== 'measured') continue; if (decision.recommend === HEURISTIC[shape]) continue; // nothing learned worth saying - lines.push(`${shape.replace(/-/g, ' ')} work goes better on ${decision.recommend} in this project.`); + lines.push( + `${shape.replace(/-/g, ' ')} work goes better on ${decision.recommend} in this project.` + ); } return lines.length ? lines.join('\n') : null; } @@ -389,7 +447,11 @@ export function routingBriefing(table) { * few more episodes accumulated would invalidate the cache behind it. Stability * is the feature; the memo is how it is bought. */ -export function cachedRoutingBriefing(dir, transcriptPath, { growthBytes = 4_000_000, maxAgeMs = 24 * 60 * 60 * 1000 } = {}) { +export function cachedRoutingBriefing( + dir, + transcriptPath, + { growthBytes = 4_000_000, maxAgeMs = 24 * 60 * 60 * 1000 } = {} +) { const memoPath = join(dir, 'routing-brief.json'); let stat; try { @@ -400,17 +462,31 @@ export function cachedRoutingBriefing(dir, transcriptPath, { growthBytes = 4_000 try { const memo = JSON.parse(readFileSync(memoPath, 'utf8')); - const fresh = memo.path === transcriptPath - && Math.abs(stat.size - memo.size) < growthBytes - && Date.now() - memo.at < maxAgeMs; + const fresh = + memo.path === transcriptPath && + Math.abs(stat.size - memo.size) < growthBytes && + Date.now() - memo.at < maxAgeMs; if (fresh) return memo.text || null; - } catch { /* no memo, or an unreadable one; recompute */ } + } catch { + /* no memo, or an unreadable one; recompute */ + } const text = routingBriefing(outcomeTable(readEpisodes(transcriptPath))); try { mkdirSync(dir, { recursive: true, mode: 0o700 }); - writeFileSync(memoPath, JSON.stringify({ path: transcriptPath, size: stat.size, at: Date.now(), text }), { mode: 0o600 }); - } catch { /* the memo is an optimisation; failing to write it costs latency, not correctness */ } + writeFileSync( + memoPath, + JSON.stringify({ + path: transcriptPath, + size: stat.size, + at: Date.now(), + text, + }), + { mode: 0o600 } + ); + } catch { + /* the memo is an optimisation; failing to write it costs latency, not correctness */ + } return text; } @@ -427,9 +503,11 @@ export function routingReport(table) { for (const tier of Object.keys(table[shape])) { const cell = table[shape][tier]; const cost = expectedCost(tier, cell); - lines.push(` ${tier.padEnd(7)} ${cell.episodes} episode(s), ` + - `${cell.meanTurns.toFixed(1)} turns avg, ${Math.round(cell.errorRate * 100)}% needed a retry, ` + - `expected cost ${cost.cost.toFixed(2)}${cell.measured ? '' : ' [too few episodes to trust]'}`); + lines.push( + ` ${tier.padEnd(7)} ${cell.episodes} episode(s), ` + + `${cell.meanTurns.toFixed(1)} turns avg, ${Math.round(cell.errorRate * 100)}% needed a retry, ` + + `expected cost ${cost.cost.toFixed(2)}${cell.measured ? '' : ' [too few episodes to trust]'}` + ); } } return lines.join('\n'); diff --git a/integrations/gemini/hooks/lib/symbols.mjs b/integrations/gemini/hooks/lib/symbols.mjs index 2a80e1e42..4d80666ac 100644 --- a/integrations/gemini/hooks/lib/symbols.mjs +++ b/integrations/gemini/hooks/lib/symbols.mjs @@ -50,7 +50,11 @@ const PATTERNS = { /^\s*(?:pub(?:\([^)]*\))?\s+)?(?:struct|enum|trait|impl)\s+([A-Za-z_]\w*)/, ], clike: [ - /^\s*(?:public|private|protected|internal|static|final|abstract|virtual|override|async|\s)*(?:[\w<>\[\],.?]+\s+)([A-Za-z_]\w*)\s*\([^;]*\)\s*\{/, + // Accept both K&R (`Run() {`) and Allman (`Run()` then `{`) declarations. + // Requiring the opening brace on the declaration line silently discarded + // nearly every method in idiomatic C# projects, leaving file nodes but no + // symbol nodes and making valid `file.cs#Method` anchors unresolved. + /^\s*(?:public|private|protected|internal|static|final|abstract|virtual|override|async|sealed|new|extern|unsafe|partial|\s)*(?:[\w<>\[\],.?]+\s+)([A-Za-z_]\w*)(?:<[^;()]+>)?\s*\([^;]*\)\s*(?:\{|=>|$)/, /^\s*(?:public|private|protected|internal|static|abstract|sealed|partial|\s)*(?:class|struct|interface|enum|record)\s+([A-Za-z_]\w*)/, ], ruby: [ diff --git a/integrations/kilo/hooks/lib/adapter.mjs b/integrations/kilo/hooks/lib/adapter.mjs index fee0102b9..9f76bbdea 100644 --- a/integrations/kilo/hooks/lib/adapter.mjs +++ b/integrations/kilo/hooks/lib/adapter.mjs @@ -188,8 +188,143 @@ function contextOutput(client, eventName, additionalContext) { }; } +/** Convert one static JavaScript string literal without evaluating code. */ +function codexStringLiteral(literal) { + if (typeof literal !== 'string' || literal.length < 2) return null; + const quote = literal[0]; + if (!['"', "'", '`'].includes(quote) || literal.at(-1) !== quote) return null; + + let decoded = ''; + for (let index = 1; index < literal.length - 1; index += 1) { + const character = literal[index]; + if (quote === '`' && character === '$' && literal[index + 1] === '{') + return null; + if (character !== '\\') { + decoded += character; + continue; + } + + index += 1; + if (index >= literal.length - 1) return null; + const escaped = literal[index]; + const simple = { + b: '\b', f: '\f', n: '\n', r: '\r', t: '\t', v: '\v', 0: '\0', + '\\': '\\', "'": "'", '"': '"', '`': '`', '$': '$', + }; + if (Object.hasOwn(simple, escaped)) { + decoded += simple[escaped]; + continue; + } + if (escaped === '\n') continue; + if (escaped === '\r') { + if (literal[index + 1] === '\n') index += 1; + continue; + } + if (escaped === 'x') { + const digits = literal.slice(index + 1, index + 3); + if (!/^[0-9a-f]{2}$/i.test(digits)) return null; + decoded += String.fromCodePoint(Number.parseInt(digits, 16)); + index += 2; + continue; + } + if (escaped === 'u') { + const braced = literal[index + 1] === '{'; + const close = braced ? literal.indexOf('}', index + 2) : index + 5; + const digits = braced + ? literal.slice(index + 2, close) + : literal.slice(index + 1, close); + if ( + close < 0 || + !(braced ? /^[0-9a-f]{1,6}$/i : /^[0-9a-f]{4}$/i).test(digits) + ) return null; + const codePoint = Number.parseInt(digits, 16); + if (codePoint > 0x10ffff) return null; + decoded += String.fromCodePoint(codePoint); + index = close; + continue; + } + // JavaScript identity escapes drop the slash (for example, '\q' is 'q'). + decoded += escaped; + } + return decoded; +} + /** Convert client-specific lifecycle envelopes into the common tool shape. */ +function codexStringBindings(source) { + const values = new Map(); + const declaration = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*((?:"(?:\\.|[^"\\])*")|(?:'(?:\\.|[^'\\])*')|(?:`(?:\\.|[^`\\])*`))/gs; + for (const match of String(source || '').matchAll(declaration)) { + try { + const value = codexStringLiteral(match[2]); + if (value !== null) values.set(match[1], value); + } catch { + // A computed or malformed JavaScript expression is not safe to execute + // merely to understand a hook envelope. Leave it opaque. + } + } + return values; +} + +function codexCallArguments(source, method) { + const calls = []; + const pattern = new RegExp(`(?:tools\\.)?${method}\\s*\\(\\s*([^),]+)`, 'gi'); + for (const match of String(source || '').matchAll(pattern)) + calls.push(match[1].trim()); + return calls; +} + +function codexLiteral(value, bindings) { + if (bindings.has(value)) return String(bindings.get(value)); + return codexStringLiteral(value); +} + +function codexExecCommands(source, bindings) { + const commands = []; + for (const call of String(source || '').matchAll( + /(?:tools\.)?exec_command\s*\(\s*\{([\s\S]*?)\}\s*\)/gi + )) { + const property = /\bcmd\s*:\s*((?:"(?:\\.|[^"\\])*")|(?:'(?:\\.|[^'\\])*')|(?:`(?:\\.|[^`\\])*`)|(?:[A-Za-z_$][\w$]*))/s.exec(call[1]); + if (!property) continue; + const command = codexLiteral(property[1], bindings); + if (command) commands.push(command); + } + return commands; +} + export function normalizeClientPayload(clientName, event, raw) { + if (clientName === 'codex') { + const outerName = String(raw.tool_name ?? raw.toolName ?? raw.tool ?? ''); + if (/^(?:functions\.)?exec$/i.test(outerName)) { + const envelope = raw.tool_input ?? raw.toolInput ?? raw.arguments ?? raw.args; + const source = + typeof envelope === 'string' + ? envelope + : envelope?.code ?? envelope?.input ?? envelope?.source ?? ''; + const bindings = codexStringBindings(source); + const patchCalls = codexCallArguments(source, 'apply_patch'); + const patch = patchCalls + .map((argument) => codexLiteral(argument, bindings)) + .find(Boolean); + const mutation = patchCalls.length > 0; + const commands = codexExecCommands(source, bindings); + + // `functions.exec` is also the orchestration envelope for web, image and + // other non-filesystem tools. Do not reinterpret those as shell commands: + // doing so produced irrelevant cross-project advice and fabricated graph + // activity for operations the filesystem hooks never observed. + if (!mutation && !commands.length) return raw; + return { + ...raw, + tool_name: mutation ? 'apply_patch' : 'run_command', + tool_input: { + ...(envelope && typeof envelope === 'object' ? envelope : {}), + command: String(patch || commands.join('\n') || source || ''), + code_mode_envelope: true, + }, + }; + } + } + if (clientName === 'cline') { const body = event === 'post-tool' ? raw.postToolUse : raw.preToolUse; if (!body) return raw; @@ -385,6 +520,22 @@ function projectBriefing() { function observeAndInject(payload, state, episode, features) { const touched = touchedFiles(payload); const dirFor = (path) => wikiDir(projectRootFor(path, payload.cwd)); + const registerRoot = (root) => { + const dir = wikiDir(root); + registerProject({ + root, + graphDir: dir, + client: episode.client || 'unknown', + }); + return dir; + }; + + // SessionStart is not delivered consistently by every host/version. The + // graph was still written in that case, but the dashboard registry never + // learned it existed. Register every repository observed by a real tool call + // as a second, idempotent production path. + for (const { path } of touched) + registerRoot(projectRootFor(path, payload.cwd)); const bytes = readCostBytes(payload); if (bytes && payload.tool_input.file_path) { @@ -425,7 +576,7 @@ function observeAndInject(payload, state, episode, features) { const command = payload.tool_input?.command; if (command) { const root = commandProjectRoot(payload, payload.cwd); - const dir = wikiDir(root); + const dir = registerRoot(root); const local = forCommand(dir, load(dir), command, { sessionId: payload.session_id, alreadyInjected, @@ -750,7 +901,14 @@ async function runHook(clientName, event, invocation) { client.canDeny && event === 'pre-tool' && !repeat && - mode() !== MODE_ADVISE + mode() !== MODE_ADVISE && + // Codex code mode presents an orchestration program as one outer + // `functions.exec` call. Refusing that envelope is rendered by the host + // as a failed script and can discard several otherwise-safe nested + // operations. Observe it, capture it and advise, but reserve a hard veto + // for legacy one-operation Codex tool calls where the denied operation is + // unambiguous. + !(clientName === 'codex' && payload.tool_input?.code_mode_envelope) ); if (canRefuse) { @@ -759,19 +917,23 @@ async function runHook(clientName, event, invocation) { // Every client's refusal carries the off switch, for the same reason Claude // Code's does: enforcement that hides its own disable is coercive. if (client.denyStyle === 'top-level') { + invocation.block('policy_denied'); emit({ decision: 'deny', reason: withEscape(verdict.reason) }); } else if (client.denyStyle === 'top-level-permission') { + invocation.block('policy_denied'); emit({ permissionDecision: 'deny', permissionDecisionReason: withEscape(verdict.reason), }); } else if (client.denyStyle === 'cline') { + invocation.block('policy_denied'); emit({ cancel: true, contextModification: '', errorMessage: withEscape(verdict.reason), }); } else if (client.denyStyle === 'cursor') { + invocation.block('policy_denied'); emit({ continue: true, permission: 'deny', @@ -780,9 +942,11 @@ async function runHook(clientName, event, invocation) { 'Token Optimizer redirected an expensive built-in operation.', }); } else if (client.denyStyle === 'exit-2') { + invocation.block('policy_denied'); process.stderr.write(withEscape(verdict.reason)); process.exit(2); } else { + invocation.block('policy_denied'); emit({ hookSpecificOutput: { hookEventName: eventName, diff --git a/integrations/kilo/hooks/lib/audit.mjs b/integrations/kilo/hooks/lib/audit.mjs index b82186586..df8a08b82 100644 --- a/integrations/kilo/hooks/lib/audit.mjs +++ b/integrations/kilo/hooks/lib/audit.mjs @@ -50,15 +50,17 @@ export function decline(dir, id) { export function declines(dir, { events = readMetrics(dir) } = {}) { const counts = new Map(); for (const event of events) { - if (event.kind !== 'advice' || event.action !== 'declined' || !event.id) continue; + if (event.kind !== 'advice' || event.action !== 'declined' || !event.id) + continue; counts.set(event.id, (counts.get(event.id) || 0) + 1); } return counts; } -const idOf = (finding) => finding.remedy - ? `${finding.remedy.type}:${finding.remedy.anchor || finding.remedy.file || (finding.remedy.anchors || []).join(',')}` - : `${finding.id}:${finding.anchor || finding.file || ''}`; +const idOf = (finding) => + finding.remedy + ? `${finding.remedy.type}:${finding.remedy.anchor || finding.remedy.file || (finding.remedy.anchors || []).join(',')}` + : `${finding.id}:${finding.anchor || finding.file || ''}`; /** * Whether a habit improved after we said something about it. @@ -68,12 +70,18 @@ const idOf = (finding) => finding.remedy * -- a trend computed from one session on each side is a coin flip with a * percentage sign. */ -export function habitTrend(dir, detector, { events = readMetrics(dir), minSessions = 2, anchors = null } = {}) { +export function habitTrend( + dir, + detector, + { events = readMetrics(dir), minSessions = 2, anchors = null } = {} +) { // SPLIT BY POSITION, NOT BY TIMESTAMP. The log is append-only, so its order is // authoritative -- while `at` has millisecond granularity, and a burst of // events inside one millisecond would land on the wrong side of the split or // collapse one side to nothing. Position is exact where the clock is not. - const raisedAt = events.findIndex((e) => e.kind === 'advice' && e.detector === detector); + const raisedAt = events.findIndex( + (e) => e.kind === 'advice' && e.detector === detector + ); if (raisedAt === -1) return null; // SCOPED TO THE FILES THIS DETECTOR NAMED. Counting every read in the window @@ -114,7 +122,12 @@ export function habitTrend(dir, detector, { events = readMetrics(dir), minSessio /** Marks a recommendation as having been raised, so its effect can be scored. */ export function raise(dir, finding) { - record(dir, { kind: 'advice', action: 'raised', id: idOf(finding), detector: finding.id }); + record(dir, { + kind: 'advice', + action: 'raised', + id: idOf(finding), + detector: finding.id, + }); } /** @@ -123,7 +136,11 @@ export function raise(dir, finding) { * `findings` comes from the detectors; `extra` lets the cache and routing * modules contribute without this file importing the world. */ -export function buildQueue(dir, findings = [], { events = readMetrics(dir) } = {}) { +export function buildQueue( + dir, + findings = [], + { events = readMetrics(dir) } = {} +) { const declined = declines(dir, { events }); const ledger = remedyLedger(dir); const applied = new Set(ledger.filter((r) => !r.revertedAt).map((r) => r.id)); @@ -146,7 +163,9 @@ export function buildQueue(dir, findings = [], { events = readMetrics(dir) } = { } return { - queue: queue.sort((a, b) => (b.costPerSession ?? 0) - (a.costPerSession ?? 0)), + queue: queue.sort( + (a, b) => (b.costPerSession ?? 0) - (a.costPerSession ?? 0) + ), suppressed, done: ledger.filter((r) => !r.revertedAt), }; @@ -161,7 +180,11 @@ export function buildQueue(dir, findings = [], { events = readMetrics(dir) } = { * many were withheld -- and what they are collectively worth -- keeps that from * being a silent cap. */ -export function renderAudit(dir, findings = [], { tier = 'opus', full = false, sessionsPerMonth = 60 } = {}) { +export function renderAudit( + dir, + findings = [], + { tier = 'opus', full = false, sessionsPerMonth = 60 } = {} +) { const { queue, suppressed, done } = buildQueue(dir, findings); const lines = []; @@ -175,22 +198,29 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s // read as "this costs nothing" beside a finding whose cost we simply do not // know -- the same unknown-becomes-zero error the rest of this project // corrects, in the one unit that gets quoted to other people. - const priced = item.costPerSession == null ? null : monthly(cost, { tier, sessionsPerMonth }); + const priced = + item.costPerSession == null + ? null + : monthly(cost, { tier, sessionsPerMonth }); // The id appears on EVERY actionable line, not just the appliable ones. It is the only // handle `decline` accepts and it cannot be guessed from the title, so omitting it made the // advertised decline path unreachable for precisely the findings most likely to be // unwanted -- model-routing findings carry no remedy, so they could never be suppressed no // matter how many times a user declined them. - const action = item.remedy?.kind === 'ours' ? `apply: waste_audit action="apply" id="${item.id}"` - : item.remedy?.kind === 'yours' ? `proposed edit -- needs your yes, nothing changed; decline: ${item.id}` - : `advice only -- no automatic fix; decline: ${item.id}`; + const action = + item.remedy?.kind === 'ours' + ? `apply: waste_audit action="apply" id="${item.id}"` + : item.remedy?.kind === 'yours' + ? `proposed edit -- needs your yes, nothing changed; decline: ${item.id}` + : `advice only -- no automatic fix; decline: ${item.id}`; // MEASURED ZERO IS NOT UNMEASURED. Testing `cost` for truthiness sent a real 0 down the // "not yet measurable" branch while `priced` above, which tests for null, still rendered // $0.00 beside it -- producing `cost not yet measurable (~$0.00/month)`, the exact string // the test suite declares must never appear. waste.mjs defaults every finding to 0 and // hard-sets it on the co-occurrence detector, so this is reachable, not theoretical. - const text = ` ${item.title}\n ${item.costPerSession != null ? `${cost.toLocaleString()} tokens/session` : 'cost not yet measurable'}` + + const text = + ` ${item.title}\n ${item.costPerSession != null ? `${cost.toLocaleString()} tokens/session` : 'cost not yet measurable'}` + `${priced ? ` (~${money(priced.amount)}/month)` : ''}; ${action}`; const printCost = estimate(text); @@ -211,14 +241,19 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s lines.push(text); } - const head = shown.length ? ['What to do next, most expensive first:', ...lines] : ['Nothing addressable found.']; + const head = shown.length + ? ['What to do next, most expensive first:', ...lines] + : ['Nothing addressable found.']; const body = [...head]; if (withheld.length) { const worth = withheld.reduce((sum, w) => sum + (w.costPerSession || 0), 0); - body.push('', ` ... ${withheld.length} more finding(s) worth ${worth.toLocaleString()} tokens/session in total, ` + - 'not shown -- printing them costs more than they are worth. Pass full=true to see all.'); + body.push( + '', + ` ... ${withheld.length} more finding(s) worth ${worth.toLocaleString()} tokens/session in total, ` + + 'not shown -- printing them costs more than they are worth. Pass full=true to see all.' + ); } // What we already told you, and what it bought. @@ -226,23 +261,35 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s body.push('', 'Already applied, and what it actually saved:'); for (const rule of done.slice(0, 6)) { const saved = rule.savedPerSession; - const priced = saved == null ? null : monthly(saved, { tier, sessionsPerMonth }); - body.push(` + ${rule.id} -- ${saved == null - ? 'not yet measurable (fewer than two sessions since)' - : `measured ${saved.toLocaleString()} tokens/session${priced ? `, ~${money(priced.amount)}/month` : ''}`}`); + const priced = + saved == null ? null : monthly(saved, { tier, sessionsPerMonth }); + body.push( + ` + ${rule.id} -- ${ + saved == null + ? 'not yet measurable (fewer than two sessions since)' + : `measured ${saved.toLocaleString()} tokens/session${priced ? `, ~${money(priced.amount)}/month` : ''}` + }` + ); } // Never a silent cap: the withheld block above states its remainder and so must this one. // Truncating to six with no disclosure showed a user six savings and let them conclude that // was everything the tool had bought them, which understates its own measured value. if (done.length > 6) { - body.push(` ... and ${done.length - 6} more applied fix(es), not shown.`); + body.push( + ` ... and ${done.length - 6} more applied fix(es), not shown.` + ); } } if (suppressed.length) { - body.push('', `Not repeating ${suppressed.length} recommendation(s) you have declined ` + - `${DECLINE_LIMIT}+ times. Ask for full=true to see them.`); - if (full) for (const item of suppressed) body.push(` - ${item.title} (declined ${item.times}x)`); + body.push( + '', + `Not repeating ${suppressed.length} recommendation(s) you have declined ` + + `${DECLINE_LIMIT}+ times. Ask for full=true to see them.` + ); + if (full) + for (const item of suppressed) + body.push(` - ${item.title} (declined ${item.times}x)`); } // Whether the advice changed anything. @@ -256,20 +303,30 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s for (const a of anchors) anchorsBy.get(f.id).add(a); } const trends = [...new Set(findings.map((f) => f.id))] - .map((detector) => habitTrend(dir, detector, { - anchors: anchorsBy.has(detector) ? [...anchorsBy.get(detector)] : null, - })) + .map((detector) => + habitTrend(dir, detector, { + anchors: anchorsBy.has(detector) ? [...anchorsBy.get(detector)] : null, + }) + ) .filter(Boolean); if (trends.length) { body.push('', 'Since the advice was given:'); for (const trend of trends) { - body.push(` ${trend.detector}: ${trend.before.toLocaleString()} -> ${trend.after.toLocaleString()} tokens/session ` + - `(${trend.improved ? 'improved' : 'worse'})`); + body.push( + ` ${trend.detector}: ${trend.before.toLocaleString()} -> ${trend.after.toLocaleString()} tokens/session ` + + `(${trend.improved ? 'improved' : 'worse'})` + ); } } - const addressable = queue.reduce((sum, item) => sum + (item.costPerSession || 0), 0); + const addressable = queue.reduce( + (sum, item) => sum + (item.costPerSession || 0), + 0 + ); + const addressableMonthly = addressable + ? monthly(addressable, { tier, sessionsPerMonth }) + : null; const note = priceNote(tier); if (note) body.push('', note); @@ -278,9 +335,10 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s // pushing it left the report's longest line out of its own reported cost, understating it by // 10-20% on a short audit. This figure exists to prove the audit is not a net loss, so // understating the cost side biases exactly the comparison it was written to be honest about. - const closing = (n) => `This report cost about ${n.toLocaleString()} tokens and names ` + + const closing = (n) => + `This report cost about ${n.toLocaleString()} tokens and names ` + `${addressable.toLocaleString()} tokens/session of addressable waste` + - `${addressable ? ` (~${money(monthly(addressable, { tier, sessionsPerMonth })?.amount)}/month across ${sessionsPerMonth} sessions)` : ''}.`; + `${addressableMonthly ? ` (~${money(addressableMonthly.amount)}/month across ${sessionsPerMonth} sessions)` : ''}.`; const selfCost = estimate([...body, '', closing(0)].join('\n')); body.push('', closing(selfCost)); diff --git a/integrations/kilo/hooks/lib/cache.mjs b/integrations/kilo/hooks/lib/cache.mjs index 9b11173ff..9782c3262 100644 --- a/integrations/kilo/hooks/lib/cache.mjs +++ b/integrations/kilo/hooks/lib/cache.mjs @@ -3,12 +3,12 @@ /** * Prompt-cache economics. * - * Cache economics is real money in a way most compression is not: a cache read - * costs a tenth of a fresh token and a cache WRITE costs a quarter more than - * one, so a prefix that keeps invalidating can cost more than every saving this - * product makes elsewhere. It is also the one area where we do not have to - * model anything -- the client's own transcript records cache_read_input_tokens - * and cache_creation_input_tokens per turn, which is ground truth. + * Anthropic prompt-cache usage, read from Claude Code transcripts. A cache read + * and a cache write are both billable, at different published multipliers from + * uncached input. Raw cache_read_input_tokens and + * cache_creation_input_tokens are observations. Any multiplier-based value is + * an Anthropic input-cost equivalent, not a universally comparable token count + * and not a provider invoice. * * MEASUREMENT ALONE IS NOT ACTIONABLE. A hit rate tells you the cache missed; * it cannot tell you why, and an unactionable number is the same dead thing as @@ -30,7 +30,15 @@ * cache-safety-by-construction half of this file. */ -import { readFileSync, readdirSync, statSync, existsSync, openSync, readSync, closeSync } from 'node:fs'; +import { + readFileSync, + readdirSync, + statSync, + existsSync, + openSync, + readSync, + closeSync, +} from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; @@ -51,7 +59,8 @@ export const READ_MULTIPLIER = 0.1; const SYNTHETIC_MODEL = ''; /** True for a turn naming a model an inference actually ran under. */ -const realModel = (turn) => Boolean(turn.model) && turn.model !== SYNTHETIC_MODEL; +const realModel = (turn) => + Boolean(turn.model) && turn.model !== SYNTHETIC_MODEL; /* -------------------------------------------------------------- MEASUREMENT */ @@ -72,7 +81,10 @@ export function transcriptFor(cwd = process.cwd()) { try { const files = readdirSync(dir) .filter((name) => name.endsWith('.jsonl')) - .map((name) => ({ path: join(dir, name), at: statSync(join(dir, name)).mtimeMs })) + .map((name) => ({ + path: join(dir, name), + at: statSync(join(dir, name)).mtimeMs, + })) .sort((a, b) => b.at - a.at); return files.length ? files[0].path : null; } catch { @@ -157,7 +169,8 @@ export function cacheHealth(turns) { read += turn.read; written += turn.written; if (!realModel(turn)) continue; - if (!models.has(turn.model)) models.set(turn.model, { read: 0, written: 0, turns: 0 }); + if (!models.has(turn.model)) + models.set(turn.model, { read: 0, written: 0, turns: 0 }); const entry = models.get(turn.model); entry.read += turn.read; entry.written += turn.written; @@ -171,20 +184,28 @@ export function cacheHealth(turns) { // attribution price, made keepWarm answer "unknown", and returned null from // modelSwitchCost under a guard its caller does not share, which crashed // cache_audit outright with "Cannot read properties of null". - const last = turns.reduce((best, turn) => (turn.read + turn.written > 0 ? turn : best), turns[turns.length - 1]); + const last = turns.reduce( + (best, turn) => (turn.read + turn.written > 0 ? turn : best), + turns[turns.length - 1] + ); const total = read + written; return { + provider: 'anthropic', + source: 'claude-code-transcript', turns: turns.length, read, written, hitRate: total ? read / total : null, prefixTokens: last.read + last.written, - // Priced in plain-token equivalents, which is what makes it comparable with - // every other saving this product reports. - writeCost: Math.round(written * WRITE_MULTIPLIER), - readCost: Math.round(read * READ_MULTIPLIER), - savedVersusNoCache: Math.round(read * (1 - READ_MULTIPLIER)), + // These are Anthropic uncached-input cost equivalents. They stay outside + // the verified MCP transport ledger and must not be relabeled as tokens + // saved across clients. + writeInputCostEquivalent: Math.round(written * WRITE_MULTIPLIER), + readInputCostEquivalent: Math.round(read * READ_MULTIPLIER), + inputCostEquivalentAvoidedVersusUncached: Math.round( + read * (1 - READ_MULTIPLIER) + ), models: Object.fromEntries(models), }; } @@ -200,13 +221,17 @@ export function modelSwitchCost(turns) { const health = cacheHealth(turns); if (!health?.prefixTokens) return null; - const switched = new Set(turns.filter(realModel).map((t) => t.model)).size > 1; + const switched = + new Set(turns.filter(realModel).map((t) => t.model)).size > 1; return { prefixTokens: health.prefixTokens, - rewriteCost: Math.round(health.prefixTokens * WRITE_MULTIPLIER), + rewriteInputCostEquivalent: Math.round( + health.prefixTokens * WRITE_MULTIPLIER + ), alreadySwitched: switched, - text: `Switching model now discards a ${health.prefixTokens.toLocaleString()}-token warm prefix; ` + - `re-writing it costs about ${Math.round(health.prefixTokens * WRITE_MULTIPLIER).toLocaleString()} tokens.`, + text: + `Switching model now discards a ${health.prefixTokens.toLocaleString()}-token warm prefix; ` + + `under Anthropic's current 5-minute write multiplier, re-writing it is about ${Math.round(health.prefixTokens * WRITE_MULTIPLIER).toLocaleString()} uncached-input cost-equivalent tokens.`, }; } @@ -221,17 +246,38 @@ export function modelSwitchCost(turns) { * time. That is why this class of bug survives -- nothing about it looks wrong. */ const VOLATILE = [ - { id: 'timestamp', re: /\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/, why: 'an embedded timestamp' }, - { id: 'date', re: /\b(?:today|current date)\b[^\n]{0,40}\d{4}-\d{2}-\d{2}/i, why: 'an embedded current date' }, + { + id: 'timestamp', + re: /\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/, + why: 'an embedded timestamp', + }, + { + id: 'date', + re: /\b(?:today|current date)\b[^\n]{0,40}\d{4}-\d{2}-\d{2}/i, + why: 'an embedded current date', + }, // A bare date is a SUSPICION, not a measurement: "- 2025-10-31: reworked the // loader" is byte-identical every session. Kept, because stableText fails // closed over our own output and dropping a line there costs little -- but // NOT priced, because in the blame report a false positive invents a // per-session cost and tells the user to delete their changelog. - { id: 'iso-date', re: /\b\d{4}-\d{2}-\d{2}\b/, why: 'an embedded date', priced: false }, - { id: 'session-id', re: /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i, why: 'a session id' }, + { + id: 'iso-date', + re: /\b\d{4}-\d{2}-\d{2}\b/, + why: 'an embedded date', + priced: false, + }, + { + id: 'session-id', + re: /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i, + why: 'a session id', + }, { id: 'git-sha', re: /\b[0-9a-f]{40}\b/, why: 'a git sha' }, - { id: 'counter', re: /\b(?:run|build|attempt|iteration)\s*#?\d+\b/i, why: 'a run counter' }, + { + id: 'counter', + re: /\b(?:run|build|attempt|iteration)\s*#?\d+\b/i, + why: 'a run counter', + }, { id: 'epoch', re: /\b1[6-9]\d{11}\b/, why: 'an epoch timestamp' }, ]; @@ -242,7 +288,13 @@ export function volatileLines(text) { for (let i = 0; i < lines.length; i++) { for (const rule of VOLATILE) { if (!rule.re.test(lines[i])) continue; - out.push({ line: i + 1, id: rule.id, why: rule.why, priced: rule.priced !== false, text: lines[i].trim().slice(0, 120) }); + out.push({ + line: i + 1, + id: rule.id, + why: rule.why, + priced: rule.priced !== false, + text: lines[i].trim().slice(0, 120), + }); break; // one cause per line is enough to act on } } @@ -264,7 +316,11 @@ const estimate = (text) => Math.ceil(String(text || '').length / 4); * unknown, and this reports the construct WITHOUT a price rather than inventing * one. */ -export function attributeInvalidation(cwd, prefixTokens = null, { files = null } = {}) { +export function attributeInvalidation( + cwd, + prefixTokens = null, + { files = null } = {} +) { // Ordered as the client assembles them: the client preamble and tool // definitions come first, then project instructions, then the conversation. const candidates = files || ['CLAUDE.md', 'AGENTS.md', '.claude/CLAUDE.md']; @@ -292,7 +348,8 @@ export function attributeInvalidation(cwd, prefixTokens = null, { files = null } // prefix -- 3.75x the entire prefix. Conservative still: the preamble // ahead of the file is not counted, so this remains a floor. const ahead = offset + estimate(lines.slice(0, hit.line - 1).join('\n')); - const downstream = prefixTokens != null ? Math.max(0, prefixTokens - ahead) : null; + const downstream = + prefixTokens != null ? Math.max(0, prefixTokens - ahead) : null; // A prefix cache invalidates ONCE, at the earliest difference. A later // volatile line in the same file costs nothing extra until the first one // is fixed, so it is reported for context but not billed again. @@ -304,7 +361,9 @@ export function attributeInvalidation(cwd, prefixTokens = null, { files = null } // into a reported zero. const firstPriced = found.find((h) => h.priced); const subsumedBy = - hit.priced && firstPriced && firstPriced !== hit ? `${relative}:${firstPriced.line}` : null; + hit.priced && firstPriced && firstPriced !== hit + ? `${relative}:${firstPriced.line}` + : null; out.push({ file: relative, line: hit.line, @@ -316,9 +375,12 @@ export function attributeInvalidation(cwd, prefixTokens = null, { files = null } // decline to price. 0 means measured and genuinely free, which is what a // subsumed hit is: real, but already billed by the earlier line. Folding // those two into one value is the same mistake this file warns about. - costPerSession: downstream == null || !hit.priced - ? null - : (subsumedBy ? 0 : Math.round(downstream * WRITE_MULTIPLIER)), + costPerSession: + downstream == null || !hit.priced + ? null + : subsumedBy + ? 0 + : Math.round(downstream * WRITE_MULTIPLIER), remedy: { kind: 'yours', type: 'edit', @@ -369,7 +431,7 @@ export function stableText(text) { */ export function cacheOrdered(items) { return [...items].sort((a, b) => { - const stability = (item) => (item.volatility ?? (item.fresh ? 1 : 0)); + const stability = (item) => item.volatility ?? (item.fresh ? 1 : 0); return stability(a) - stability(b); }); } diff --git a/integrations/kilo/hooks/lib/decide.mjs b/integrations/kilo/hooks/lib/decide.mjs index caae7de2f..a61dafdd5 100644 --- a/integrations/kilo/hooks/lib/decide.mjs +++ b/integrations/kilo/hooks/lib/decide.mjs @@ -392,6 +392,21 @@ export function touchedFiles(payload) { add(input.path); add(input.notebook_path); + // Codex code mode carries apply_patch as the nested program text rather than + // as a first-class file_path. Without reading these headers, successful + // code-mode edits incremented the semantic-harvest counter but produced no + // structural file/symbol nodes, which is why the live dashboard showed very + // few nodes after substantial work. PostToolUse can resolve Add/Update paths + // because the file now exists; deleted paths deliberately remain absent. + for (const match of command.matchAll( + /^\*\*\* (?:Add|Update|Delete) File:\s*(.+)$/gm + )) { + add(match[1].trim().replace(/^['"]|['"]$/g, '')); + } + for (const match of command.matchAll(/^\*\*\* Move to:\s*(.+)$/gm)) { + add(match[1].trim().replace(/^['"]|['"]$/g, '')); + } + // EVERY pipeline segment, not just the first. `fileOperands` looks only at // the head of the pipeline because that is where the COST is -- `git log | // head` must not be mistaken for a file dump. But observation is a different diff --git a/integrations/kilo/hooks/lib/experiment.mjs b/integrations/kilo/hooks/lib/experiment.mjs index 1f64713f1..c35c6cf5c 100644 --- a/integrations/kilo/hooks/lib/experiment.mjs +++ b/integrations/kilo/hooks/lib/experiment.mjs @@ -67,16 +67,71 @@ export function episodeMeta({ client, raw = {}, payload = {}, env = process.env } export function usageFrom(raw = {}) { - const usage = raw.usage || raw.token_usage || raw.tokenUsage || raw.metrics?.usage || {}; + const usage = raw.usage || raw.token_usage || raw.tokenUsage || raw.usageMetadata || + raw.usage_metadata || raw.metrics?.usage || raw.response?.usage || + raw.response?.usageMetadata || {}; const number = (...values) => { const value = first(...values); const parsed = Number(value); return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; }; + const cachedInputTokens = number( + usage.cached_input_tokens, usage.cachedInputTokens, + usage.cache_read_input_tokens, usage.cachedContentTokenCount + ); + const cacheCreation = usage.cache_creation || usage.cacheCreation || {}; + const cacheWrite5mInputTokens = number( + usage.cache_write_5m_input_tokens, usage.cacheWrite5mInputTokens, + cacheCreation.ephemeral_5m_input_tokens, cacheCreation.ephemeral5mInputTokens + ); + const cacheWrite1hInputTokens = number( + usage.cache_write_1h_input_tokens, usage.cacheWrite1hInputTokens, + cacheCreation.ephemeral_1h_input_tokens, cacheCreation.ephemeral1hInputTokens + ); + const detailedWrites = (cacheWrite5mInputTokens || 0) + (cacheWrite1hInputTokens || 0); + const reportedWrites = number( + usage.cache_write_input_tokens, usage.cacheWriteInputTokens, + usage.cache_creation_input_tokens, usage.cacheCreationInputTokens + ); + const cacheWriteInputTokens = reportedWrites == null + ? null + : Math.max(0, reportedWrites - detailedWrites); + const explicitUncached = number(usage.uncached_input_tokens, usage.uncachedInputTokens); + const promptInput = number( + usage.promptTokenCount, usage.prompt_token_count, + usage.input_tokens, usage.inputTokens + ); + const isAnthropic = usage.cache_creation_input_tokens !== undefined || + usage.cache_read_input_tokens !== undefined; + const isGemini = usage.promptTokenCount !== undefined || usage.cachedContentTokenCount !== undefined; + const uncachedInputTokens = explicitUncached != null + ? explicitUncached + : promptInput == null + ? null + : isAnthropic + ? promptInput + : isGemini || cachedInputTokens != null || reportedWrites != null + ? Math.max(0, promptInput - (cachedInputTokens || 0) - (reportedWrites || 0)) + : promptInput; + const candidates = number(usage.candidatesTokenCount, usage.candidates_token_count); + const thoughts = number(usage.thoughtsTokenCount, usage.thoughts_token_count); + const outputTokens = candidates != null || thoughts != null + ? (candidates || 0) + (thoughts || 0) + : number(usage.output_tokens, usage.outputTokens); return { - uncachedInputTokens: number(usage.uncached_input_tokens, usage.uncachedInputTokens), - cachedInputTokens: number(usage.cached_input_tokens, usage.cachedInputTokens, usage.cache_read_input_tokens), - outputTokens: number(usage.output_tokens, usage.outputTokens), - totalTokens: number(usage.total_tokens, usage.totalTokens, raw.tokens_used, raw.tokensUsed), + uncachedInputTokens, + cachedInputTokens, + cacheWrite5mInputTokens, + cacheWrite1hInputTokens, + cacheWriteInputTokens, + outputTokens, + totalTokens: number( + usage.total_tokens, usage.totalTokens, usage.totalTokenCount, + usage.total_token_count, raw.tokens_used, raw.tokensUsed + ), + usageMeasurementId: first( + raw.requestId, raw.request_id, raw.responseId, raw.response_id, + usage.responseId, usage.response_id + ) ?? null, }; } diff --git a/integrations/kilo/hooks/lib/harvest-write.mjs b/integrations/kilo/hooks/lib/harvest-write.mjs index 30433d370..db436c1dc 100644 --- a/integrations/kilo/hooks/lib/harvest-write.mjs +++ b/integrations/kilo/hooks/lib/harvest-write.mjs @@ -24,7 +24,7 @@ */ import { - putNodeWithEdges, load, nodeId, sharedDir, isSharedDir, putNode, + putNodeWithEdges, load, nodeId, sharedDir, isSharedDir, putNode, putEdge, } from './wiki.mjs'; import { indexFile } from './staleness.mjs'; import { symbolKey } from './symbols.mjs'; @@ -47,6 +47,37 @@ import { ORIGIN_HARVESTED, ORIGIN_AGENT, ORIGIN_HUMAN } from './curate.mjs'; */ export const SHAREABLE_TYPES = new Set(['command', 'failure', 'decision', 'feedback']); +/** + * Synthetic study traffic must never become advice for an unrelated live repo. + * Evaluation roots are intentionally realistic and may carry their own `.git` + * marker, so repository discovery alone cannot distinguish them. Publication + * remains useful inside the isolated study store; this guard protects only the + * default machine-wide store when a harness was accidentally run without an + * isolated TOKEN_OPTIMIZER_SHARED_DIR. + */ +export function isEphemeralProject(projectRoot) { + const root = canonicalPath(projectRoot || '').toLowerCase(); + if (!root) return true; + return ( + /\/appdata\/local\/temp\//.test(root) || + /(^|\/)tmp\//.test(root) || + /\/(artifacts|\.token-optimizer)\/(?:[^/]*\/)*(?:eval|scenario|study|fixture|debug)[^/]*\//.test(`${root}/`) + ); +} + +export function quarantineSharedSource(projectRoot) { + if (process.env.TOKEN_OPTIMIZER_ALLOW_EPHEMERAL_SHARED === '1') return false; + if (!isEphemeralProject(projectRoot)) return false; + // Isolated studies deliberately point the shared tier at scratch storage. + // Their findings may cross their own fixture projects, but cannot escape into + // the user's default machine-wide graph. Strict mode exists for a regression + // probe that verifies the guard without writing to a real user directory. + return ( + !process.env.TOKEN_OPTIMIZER_SHARED_DIR || + process.env.TOKEN_OPTIMIZER_STRICT_SHARED_PROVENANCE === '1' + ); +} + /** Claim text, normalised enough that the same lesson learned twice is one row. */ const claimFingerprint = (claim) => String(claim || '').toLowerCase().replace(/\s+/g, ' ').trim().slice(0, 300); @@ -72,6 +103,7 @@ function promoteToShared(finding, { projectRoot, sessionId, provenance, key }) { if (!projectRoot) return false; const root = canonicalPath(projectRoot); + if (quarantineSharedSource(root)) return false; const rootId = nodeId('file', root); // Same claim, already carried up from anywhere: keep the first. Two repos @@ -367,11 +399,21 @@ export function writeHarvested( && !node.retired && node.type === finding.type && claimFingerprint(node.claim) === fingerprint - && resolved.every((target) => currentGraph.edges.some((edge) => - edge.from === node.id && edge.edge === 'derived_from' && edge.to === target - )) ); if (duplicate) { + // Enrich, do not duplicate. A symbol extractor may learn how to resolve a + // previously file-only anchor (or a later session may add another real + // anchor) after the claim was first stored. Repeating the same semantic + // conclusion should connect that existing node to the newly available + // evidence, not create a second dashboard card with the same text. + for (const target of resolved) { + const alreadyLinked = currentGraph.edges.some((edge) => + edge.from === duplicate.id + && edge.edge === 'derived_from' + && edge.to === target + ); + if (!alreadyLinked) putEdge(dir, duplicate.id, 'derived_from', target); + } written.push(duplicate.key); continue; } @@ -437,7 +479,12 @@ export function writeHarvested( if ( id && !isSharedDir(dir) - && (finding.scope === undefined || finding.scope === 'organization' || finding.scope === 'global') + // Cross-project publication is explicit. `writeHarvested` stores an + // omitted scope as project-local above, so treating `undefined` as global + // here contradicted the stored record and promoted fixture/evaluation + // seeds into real user sessions. This was observed live as "learned in + // scenario" advice while working in AiDotNet. + && (finding.scope === 'organization' || finding.scope === 'global') ) { promoteToShared(finding, { projectRoot, sessionId, provenance, key }); } diff --git a/integrations/kilo/hooks/lib/inject.mjs b/integrations/kilo/hooks/lib/inject.mjs index b00494a01..c0aa2f1bf 100644 --- a/integrations/kilo/hooks/lib/inject.mjs +++ b/integrations/kilo/hooks/lib/inject.mjs @@ -31,6 +31,7 @@ import { canonicalPath, resolvableCandidates } from './paths.mjs'; import { annotatedSkeleton } from './skeleton.mjs'; import { substitutionBudget } from './metrics.mjs'; import { assessFindings } from './utility.mjs'; +import { quarantineSharedSource } from './harvest-write.mjs'; // Read per call for the same reason as the holdout fraction in metrics.mjs. const touchBudget = () => Number(process.env.TOKEN_OPTIMIZER_TOUCH_BUDGET) || 500; @@ -562,6 +563,7 @@ export function forRepeatedAct( const home = projectRoot ? canonicalPath(projectRoot) : null; for (const node of graph.nodes.values()) { if (node.kind !== 'finding' || node.retired || !node.claim) continue; + if (quarantineSharedSource(node.sourceProject)) continue; if (home && node.sourceProject && canonicalPath(node.sourceProject) === home) continue; const claimClasses = classesOf(node.claim, 'claim'); if (![...crossedClasses].some((c) => claimClasses.has(c))) continue; @@ -667,6 +669,7 @@ export function forSharedCommand( const candidates = []; for (const node of graph.nodes.values()) { if (node.kind !== 'finding' || node.retired) continue; + if (quarantineSharedSource(node.sourceProject)) continue; if (alreadyInjected.has(node.key)) continue; // ITS OWN LESSON IS NOT NEWS. A finding this project contributed is already // served by the local path; repeating it under a "from elsewhere" label @@ -1054,7 +1057,13 @@ export function standingRules(dir, graph, { budget = standingBudget(), episode = * informative response available -- more useful than the file, not a lossier * version of it. */ -export function substitutionFor(dir, graph, rawPath, source, { sessionId } = {}) { +export function substitutionFor( + dir, + graph, + rawPath, + source, + { sessionId, client = null, clientVersion = null, model = null, modelVersion = null } = {} +) { const filePath = canonicalPath(rawPath); const budget = substitutionBudget(dir, filePath); const built = annotatedSkeleton(graph, rawPath, source, { budget }); @@ -1089,6 +1098,10 @@ export function substitutionFor(dir, graph, rawPath, source, { sessionId } = {}) // on this machine turned out to be the enforcement suite's own fixture and // nothing distinguished them from real work. sessionId, + client, + clientVersion, + model, + modelVersion, holdout, tokens: holdout ? 0 : built.tokens, findings: built.findings, diff --git a/integrations/kilo/hooks/lib/metrics.mjs b/integrations/kilo/hooks/lib/metrics.mjs index 0bb08f1bc..09f822630 100644 --- a/integrations/kilo/hooks/lib/metrics.mjs +++ b/integrations/kilo/hooks/lib/metrics.mjs @@ -19,8 +19,15 @@ */ import { - appendFileSync, readFileSync, existsSync, mkdirSync, chmodSync, - statSync, openSync, readSync, closeSync, + appendFileSync, + readFileSync, + existsSync, + mkdirSync, + chmodSync, + statSync, + openSync, + readSync, + closeSync, } from 'node:fs'; import { join } from 'node:path'; import { createHash, randomBytes } from 'node:crypto'; @@ -86,6 +93,10 @@ export const BALANCE_KINDS = new Set([ 'keepwarm', ]); +/** Latest timestamp without passing an unbounded event log as function arguments. */ +export const latestEventTimestamp = (rows) => + rows.reduce((latest, event) => Math.max(latest, Number(event.at) || 0), 0); + /** * Causal records have their own bounded log. Tool outcomes are much rarer * than read/capture telemetry but more frequent than balance records; mixing @@ -130,7 +141,7 @@ export function inHoldout(anchorKey, now = Date.now()) { // other side than before. With nine holdout records in existence that costs // nothing, and stratification is a fresh draw either way. const digest = createHash('sha256').update(`${anchorKey}:${epoch}`).digest(); - return (digest[0] / 256) < fraction; + return digest[0] / 256 < fraction; } /** @@ -258,7 +269,8 @@ export function record(dir, event) { * It is also the right statistics. A saving measured months ago says little * about the code as it stands now. */ -const MAX_BYTES = Number(process.env.TOKEN_OPTIMIZER_METRICS_BYTES) || 2_000_000; +const MAX_BYTES = + Number(process.env.TOKEN_OPTIMIZER_METRICS_BYTES) || 2_000_000; const MAX_EVENTS = Number(process.env.TOKEN_OPTIMIZER_METRICS_WINDOW) || 5000; /** @@ -432,7 +444,10 @@ export function readBalance(dir) { // path for records written before ids existed; it is lossy by nature, which // is precisely why new records carry an id instead. const id = - e.id ?? [e.kind, e.at, e.anchor ?? '', e.sessionId ?? '', e.tokens ?? ''].join('|'); + e.id ?? + [e.kind, e.at, e.anchor ?? '', e.sessionId ?? '', e.tokens ?? ''].join( + '|' + ); if (seen.has(id)) continue; seen.add(id); out.push(e); @@ -493,16 +508,24 @@ export function recordToolOutcome(dir, outcome) { const evidence = readEvidence(dir); const anchor = String(outcome.anchor || '').slice(0, 120); const candidates = evidence - .filter((event) => event.kind === 'inject' && event.episodeId === outcome.episodeId) + .filter( + (event) => + event.kind === 'inject' && event.episodeId === outcome.episodeId + ) .filter((event) => { if (outcome.toolCallId && event.toolCallId) return String(event.toolCallId) === String(outcome.toolCallId); - return event.surface === outcome.surface && String(event.anchor || '').slice(0, 120) === anchor; + return ( + event.surface === outcome.surface && + String(event.anchor || '').slice(0, 120) === anchor + ); }) .sort((a, b) => (b.at || 0) - (a.at || 0)); const injection = candidates[0] || null; const joinMethod = injection - ? (outcome.toolCallId && injection.toolCallId ? 'tool-call-id' : 'episode-anchor') + ? outcome.toolCallId && injection.toolCallId + ? 'tool-call-id' + : 'episode-anchor' : 'none'; return record(dir, { @@ -542,12 +565,11 @@ export function isFixtureAnchor(anchor) { // code: it excluded ALL temp paths -- including a real project checked out // under /tmp -- and on macOS excluded nothing at all, because tmpdir() there // is /var/folders///T/ and did not match. - const underTemp = - new RegExp( - '[\\\\/](AppData[\\\\/]Local[\\\\/])?(Temp|tmp)[\\\\/]' + - '|[\\\\/]var[\\\\/]folders[\\\\/][^\\\\/]+[\\\\/][^\\\\/]+[\\\\/]T[\\\\/]', - 'i' - ).test(p); + const underTemp = new RegExp( + '[\\\\/](AppData[\\\\/]Local[\\\\/])?(Temp|tmp)[\\\\/]' + + '|[\\\\/]var[\\\\/]folders[\\\\/][^\\\\/]+[\\\\/][^\\\\/]+[\\\\/]T[\\\\/]', + 'i' + ).test(p); if (!underTemp) return false; // Only names this suite actually creates. A user's own scratch checkout under @@ -686,8 +708,12 @@ export function balanceSheet(dir) { // THE CONTROL ARM'S ACTUAL COST. `tokensFullFile` was recorded and read // by nothing, so the comparison the holdout exists for was not // computable from the report. - controlArmTokens: withheldSubs.reduce((sum, e) => sum + (e.tokensFullFile || 0), 0), - assumption: 'that the model would have read the file it explicitly requested', + controlArmTokens: withheldSubs.reduce( + (sum, e) => sum + (e.tokensFullFile || 0), + 0 + ), + assumption: + 'that the model would have read the file it explicitly requested', }, estimatedCausal: { what: 'findings injection, from the stratified holdout', @@ -702,7 +728,12 @@ export function balanceSheet(dir) { }; })(), }, - costs: { injection: injectCost, harvest: harvestCost, standing: standingCost, substitution: substitutionCost }, + costs: { + injection: injectCost, + harvest: harvestCost, + standing: standingCost, + substitution: substitutionCost, + }, waste, // Deliberately NOT a single number. The two benefit lines are known // differently; adding them would launder an assumption into a measurement. @@ -727,7 +758,9 @@ export function balanceSheet(dir) { /** Cheap path normalisation for grouping reads; not the identity canonicaliser. */ function canonicalKeyish(p) { - return String(p || '').replace(/\\/g, '/').toLowerCase(); + return String(p || '') + .replace(/\\/g, '/') + .toLowerCase(); } /** @@ -738,7 +771,43 @@ function canonicalKeyish(p) { * arms, multiplied by treated touches. Reporting it as a measured fact would be * the same overclaiming this project criticises competitors for. */ -function buildReport(events, balance) { +function buildReport(events, balance, sourceCoverage = {}) { + const substitutions = balance.filter( + (event) => event.kind === 'substitute' && !isFixtureAnchor(event.anchor) + ); + const servedSubstitutions = substitutions.filter((event) => !event.holdout); + const substitutionTokensSaved = servedSubstitutions.reduce( + (sum, event) => + sum + + (event.tokensNetAvoided ?? + Math.max( + 0, + Math.ceil((event.bytesAvoided || 0) / 4) - (event.tokens || 0) + )), + 0 + ); + const substitutionTokensReturned = servedSubstitutions.reduce( + (sum, event) => sum + (event.tokens || 0), + 0 + ); + const substitutionByClient = {}; + for (const event of servedSubstitutions) { + const name = event.client || 'Historical — client not recorded'; + const current = substitutionByClient[name] || { + substitutions: 0, + tokensReturned: 0, + tokensSaved: 0, + }; + current.substitutions += 1; + current.tokensReturned += event.tokens || 0; + current.tokensSaved += + event.tokensNetAvoided ?? + Math.max( + 0, + Math.ceil((event.bytesAvoided || 0) / 4) - (event.tokens || 0) + ); + substitutionByClient[name] = current; + } // COMMAND INJECTIONS ARE COUNTED SEPARATELY, not mixed into the balance. // @@ -762,13 +831,19 @@ function buildReport(events, balance) { // Report its cost independently so it cannot dilute the file-touch estimate. const isSessionStart = (e) => e.surface === 'session-start'; const sessionStartInjections = allInjections.filter(isSessionStart); - const commandInjections = allInjections.filter((e) => !isSessionStart(e) && isCommand(e)); - const injections = allInjections.filter((e) => !isSessionStart(e) && !isCommand(e)); + const commandInjections = allInjections.filter( + (e) => !isSessionStart(e) && isCommand(e) + ); + const injections = allInjections.filter( + (e) => !isSessionStart(e) && !isCommand(e) + ); const treated = injections.filter((e) => !e.holdout); const withheld = injections.filter((e) => e.holdout); const mean = (rows, field) => - rows.length ? rows.reduce((sum, r) => sum + (r[field] || 0), 0) / rows.length : 0; + rows.length + ? rows.reduce((sum, r) => sum + (r[field] || 0), 0) / rows.length + : 0; const injectedTokens = treated.reduce((sum, e) => sum + (e.tokens || 0), 0); const harvestTokens = balance @@ -819,7 +894,9 @@ function buildReport(events, balance) { const after = event.at ?? 0; const total = bucket.reduce( - (sum, r) => sum + ((r.at ?? 0) >= after ? (r.tokens || 0) : 0), 0); + (sum, r) => sum + ((r.at ?? 0) >= after ? r.tokens || 0 : 0), + 0 + ); // Split across the injections that share this key. Each touch is one // observation, so charging every one the full total would count the same @@ -828,52 +905,198 @@ function buildReport(events, balance) { }; const meanDownstream = (rows) => - rows.length ? rows.reduce((sum, r) => sum + downstreamOf(r), 0) / rows.length : 0; + rows.length + ? rows.reduce((sum, r) => sum + downstreamOf(r), 0) / rows.length + : 0; const treatedCost = meanDownstream(treated); const withheldCost = meanDownstream(withheld); const perTouchSaving = withheldCost - treatedCost; - const estimatedAvoided = Math.max(0, Math.round(perTouchSaving * treated.length)); + const estimatedAvoided = Math.max( + 0, + Math.round(perTouchSaving * treated.length) + ); + + const downstreamSamples = injections.filter((event) => { + if (event.downstream != null) return true; + const key = `${event.sessionId || ''}|${event.anchor}`; + return (reads.get(key) || []).some( + (read) => (read.at ?? 0) >= (event.at ?? 0) + ); + }).length; + const downstreamMeasured = downstreamSamples > 0; // Below this, arm means are noise and a ratio would be theatre. - const sufficient = treated.length >= 20 && withheld.length >= 5; + const sufficient = + downstreamMeasured && treated.length >= 20 && withheld.length >= 5; + + const coverageNumber = (value, fallback) => { + if (value === null || value === undefined || value === '') return fallback; + const number = Number(value); + return Number.isFinite(number) ? Math.max(0, number) : fallback; + }; + const projectCount = coverageNumber(sourceCoverage.projects, 1); + const telemetryProjects = coverageNumber( + sourceCoverage.projectsWithTelemetry, + 0 + ); + const balanceProjects = coverageNumber( + sourceCoverage.projectsWithBalanceEvents, + balance.length ? 1 : 0 + ); + const lastEventAt = Math.max( + latestEventTimestamp(events), + latestEventTimestamp(balance) + ); + const measured = + telemetryProjects > 0 || events.length > 0 || balance.length > 0; + const balanceMeasured = balanceProjects > 0 || balance.length > 0; + const metric = (status, source, samples, extra = {}) => ({ + status, + source, + samples, + ...extra, + }); return { + nativeOptimizer: { + substitutions: servedSubstitutions.length, + holdouts: substitutions.filter((event) => event.holdout).length, + tokensReturned: substitutionTokensReturned, + tokensSaved: substitutionTokensSaved, + byClient: substitutionByClient, + recent: servedSubstitutions + .slice() + .sort((a, b) => (b.at || 0) - (a.at || 0)) + .slice(0, 40) + .map((event) => { + const tokensSaved = + event.tokensNetAvoided ?? + Math.max( + 0, + Math.ceil((event.bytesAvoided || 0) / 4) - (event.tokens || 0) + ); + return { + name: 'live_graph_substitution', + client: event.client || 'Historical — client not recorded', + originalTokens: (event.tokens || 0) + tokensSaved, + optimizedTokens: event.tokens || 0, + tokensSaved, + savingsMeasured: false, + classification: 'modeled-counterfactual', + reportedTokensSaved: tokensSaved, + timestamp: event.at ? new Date(event.at).toISOString() : null, + }; + }), + source: + 'balance.jsonl: modeled full-file counterfactual minus annotated-skeleton tokens; excluded from verified MCP savings', + }, memoryDeliveries: allInjections.filter((event) => !event.holdout).length, memoryHoldouts: allInjections.filter((event) => event.holdout).length, deliveryTokens: allInjections .filter((event) => !event.holdout) .reduce( - (sum, event) => - sum + (event.deliveredTokens ?? event.tokens ?? 0), + (sum, event) => sum + (event.deliveredTokens ?? event.tokens ?? 0), 0 ), injections: treated.length, sessionStartInjections: sessionStartInjections.length, sessionStartInjectedTokens: sessionStartInjections.reduce( - (sum, event) => sum + (event.deliveredTokens ?? event.tokens ?? 0), 0 + (sum, event) => sum + (event.deliveredTokens ?? event.tokens ?? 0), + 0 ), commandInjections: commandInjections.length, commandHoldouts: commandInjections.filter((e) => e.holdout).length, holdouts: withheld.length, staleServed: injections.filter((e) => e.stale).length, - staleRate: injections.length ? injections.filter((e) => e.stale).length / injections.length : 0, + staleRate: injections.length + ? injections.filter((e) => e.stale).length / injections.length + : 0, injectedTokens, harvestTokens, estimatedTokensAvoided: sufficient ? estimatedAvoided : null, - netTokens: sufficient ? estimatedAvoided - injectedTokens - harvestTokens : null, + netTokens: sufficient + ? estimatedAvoided - injectedTokens - harvestTokens + : null, sufficientData: sufficient, verdict: !sufficient ? `insufficient data (${treated.length} treated, ${withheld.length} holdout; need 20 and 5)` : estimatedAvoided > injectedTokens + harvestTokens ? 'the graph is saving more than it costs' : 'the graph is NOT yet paying for itself', + measurement: { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + sourceCoverage: { + projects: projectCount, + projectsWithTelemetry: telemetryProjects || (measured ? 1 : 0), + projectsWithBalanceEvents: balanceProjects, + projectsWithoutTelemetry: Math.max( + 0, + projectCount - (telemetryProjects || (measured ? 1 : 0)) + ), + }, + freshness: { + lastEventAt: lastEventAt || null, + ageMs: lastEventAt ? Math.max(0, Date.now() - lastEventAt) : null, + status: !lastEventAt + ? 'not-measured' + : Date.now() - lastEventAt <= 7 * 86_400_000 + ? 'fresh' + : 'stale', + }, + metrics: { + nativeSubstitutions: metric( + balanceMeasured ? 'measured' : 'not-measured', + 'balance.jsonl: modeled full-file counterfactual minus annotated-skeleton tokens; not a materialized MCP before/after pair', + servedSubstitutions.length, + { classification: 'modeled-counterfactual' } + ), + memoryDeliveries: metric( + balanceMeasured ? 'measured' : 'not-measured', + 'balance.jsonl: inject events in the treated arm', + allInjections.filter((event) => !event.holdout).length + ), + memoryHoldouts: metric( + balanceMeasured ? 'measured' : 'not-measured', + 'balance.jsonl: inject events in the withheld arm', + allInjections.filter((event) => event.holdout).length + ), + rememberingCost: metric( + balanceMeasured ? 'measured' : 'not-measured', + 'balance.jsonl: delivered injection tokens plus semantic harvest tokens', + allInjections.length + + balance.filter((event) => event.kind === 'harvest').length + ), + readingAvoided: metric( + !downstreamMeasured + ? 'not-measured' + : sufficient + ? 'measured' + : 'collecting', + 'stratified file-touch holdout joined to downstream read events', + downstreamSamples, + { + treated: treated.length, + holdouts: withheld.length, + requiredTreated: 20, + requiredHoldouts: 5, + } + ), + }, + }, }; } export function report(dir) { - return buildReport(readAll(dir), readBalance(dir)); + const events = readAll(dir); + const balance = readBalance(dir); + return buildReport(events, balance, { + projects: 1, + projectsWithTelemetry: events.length || balance.length ? 1 : 0, + projectsWithBalanceEvents: balance.length ? 1 : 0, + }); } /** @@ -886,10 +1109,16 @@ export function report(dir) { */ export function reportMany(dirs) { const unique = [...new Set((dirs || []).map((dir) => String(dir)))]; - const report = buildReport( - unique.flatMap((dir) => readAll(dir)), - unique.flatMap((dir) => readBalance(dir)) - ); + const eventSources = unique.map((dir) => readAll(dir)); + const balanceSources = unique.map((dir) => readBalance(dir)); + const report = buildReport(eventSources.flat(), balanceSources.flat(), { + projects: unique.length, + projectsWithTelemetry: eventSources.filter( + (events, index) => events.length || balanceSources[index].length + ).length, + projectsWithBalanceEvents: balanceSources.filter((events) => events.length) + .length, + }); return { ...report, projects: unique.length }; } @@ -907,7 +1136,11 @@ export function reportMany(dirs) { * costs almost nothing. Without a control arm there is no way to tell those two * apart, which is why nobody else can do this. */ -export function substitutionBudget(dir, anchor, { floor = 300, base = 1200, ceiling = 3000 } = {}) { +export function substitutionBudget( + dir, + anchor, + { floor = 300, base = 1200, ceiling = 3000 } = {} +) { const events = readAll(dir); const mine = events.filter((e) => e.kind === 'inject' && e.anchor === anchor); @@ -932,13 +1165,17 @@ export function substitutionBudget(dir, anchor, { floor = 300, base = 1200, ceil for (const row of rows) { const bucket = reads.get(row.sessionId || '') || []; const after = row.at ?? 0; - total += bucket.reduce((sum, r) => sum + ((r.at ?? 0) >= after ? (r.tokens || 0) : 0), 0); + total += bucket.reduce( + (sum, r) => sum + ((r.at ?? 0) >= after ? r.tokens || 0 : 0), + 0 + ); } return total / rows.length; }; const saved = downstream(withheld) - downstream(treated); - const spent = treated.reduce((sum, e) => sum + (e.tokens || 0), 0) / treated.length; + const spent = + treated.reduce((sum, e) => sum + (e.tokens || 0), 0) / treated.length; // Ratio of what annotating this file avoided to what annotating it cost. // Above 1 it is paying for itself and earns room; below, it shrinks. @@ -959,7 +1196,10 @@ export function substitutionBudget(dir, anchor, { floor = 300, base = 1200, ceil * graph whose index leads to queries grows its allowance; a noisy one shrinks * back toward the floor. Nobody configures it, and it cannot run away. */ -export function indexBudget(dir, { floor = 150, base = 300, ceiling = 1200 } = {}) { +export function indexBudget( + dir, + { floor = 150, base = 300, ceiling = 1200 } = {} +) { const events = readAll(dir); const listed = events.filter((e) => e.kind === 'index').length; const queries = events.filter((e) => e.kind === 'query').length; @@ -968,7 +1208,9 @@ export function indexBudget(dir, { floor = 150, base = 300, ceiling = 1200 } = { const hitRate = queries / listed; // 0% hit rate falls to the floor; ~50% and above reaches the ceiling. - const scaled = Math.round(floor + (ceiling - floor) * Math.min(1, hitRate * 2)); + const scaled = Math.round( + floor + (ceiling - floor) * Math.min(1, hitRate * 2) + ); return Math.max(floor, Math.min(ceiling, scaled)); } @@ -1005,7 +1247,10 @@ function seededRandom(seedText) { } /** Percentile bootstrap interval for a mean, paired delta, or other scalar. */ -export function bootstrapMeanInterval(values, { samples = 1000, seed = 'token-optimizer' } = {}) { +export function bootstrapMeanInterval( + values, + { samples = 1000, seed = 'token-optimizer' } = {} +) { const finite = values.map(numeric).filter((value) => value !== null); if (!finite.length) return { mean: null, low: null, high: null, n: 0 }; if (finite.length === 1) { @@ -1037,24 +1282,32 @@ export function proportionInterval(successes, total, z = 1.96) { const denominator = 1 + (z * z) / total; const centre = (rate + (z * z) / (2 * total)) / denominator; const radius = - (z / denominator) * Math.sqrt((rate * (1 - rate)) / total + (z * z) / (4 * total * total)); - return { rate, low: Math.max(0, centre - radius), high: Math.min(1, centre + radius), n: total }; + (z / denominator) * + Math.sqrt((rate * (1 - rate)) / total + (z * z) / (4 * total * total)); + return { + rate, + low: Math.max(0, centre - radius), + high: Math.min(1, centre + radius), + n: total, + }; } -const cohortKey = (run) => [ - run.client || 'unknown', - run.clientVersion || 'unknown', - run.model || 'unknown', - run.modelVersion || 'unknown', - run.taskId || 'unknown', -].join('|'); +const cohortKey = (run) => + [ + run.client || 'unknown', + run.clientVersion || 'unknown', + run.model || 'unknown', + run.modelVersion || 'unknown', + run.taskId || 'unknown', + ].join('|'); function armMetrics(runs) { const correct = runs.filter((run) => run.correct === true).length; - const interval = (field) => bootstrapMeanInterval( - runs.map((run) => run[field]).filter((value) => numeric(value) !== null), - { seed: field } - ); + const interval = (field) => + bootstrapMeanInterval( + runs.map((run) => run[field]).filter((value) => numeric(value) !== null), + { seed: field } + ); return { runs: runs.length, correctness: proportionInterval(correct, runs.length), @@ -1068,29 +1321,40 @@ function armMetrics(runs) { latencyMs: interval('latencyMs'), costUsd: interval('costUsd'), injectedTokens: interval('injectedTokens'), - harmfulFindings: runs.reduce((sum, run) => sum + (numeric(run.harmfulFindings) || 0), 0), + harmfulFindings: runs.reduce( + (sum, run) => sum + (numeric(run.harmfulFindings) || 0), + 0 + ), }; } -function pairedEffects(runs, controlArm, treatmentArm, contrastType = 'incremental') { +function pairedEffects( + runs, + controlArm, + treatmentArm, + contrastType = 'incremental' +) { const pairs = new Map(); for (const run of runs) { if (!run.pairId || ![controlArm, treatmentArm].includes(run.arm)) continue; if (!pairs.has(run.pairId)) pairs.set(run.pairId, {}); pairs.get(run.pairId)[run.arm] = run; } - const complete = [...pairs.values()].filter((pair) => pair[controlArm] && pair[treatmentArm]); - const delta = (field, lowerIsBetter = true) => bootstrapMeanInterval( - complete - .map((pair) => { - const baseline = numeric(pair[controlArm][field]); - const treatment = numeric(pair[treatmentArm][field]); - if (baseline === null || treatment === null) return null; - return lowerIsBetter ? baseline - treatment : treatment - baseline; - }) - .filter((value) => value !== null), - { seed: `${controlArm}:${treatmentArm}:${field}` } + const complete = [...pairs.values()].filter( + (pair) => pair[controlArm] && pair[treatmentArm] ); + const delta = (field, lowerIsBetter = true) => + bootstrapMeanInterval( + complete + .map((pair) => { + const baseline = numeric(pair[controlArm][field]); + const treatment = numeric(pair[treatmentArm][field]); + if (baseline === null || treatment === null) return null; + return lowerIsBetter ? baseline - treatment : treatment - baseline; + }) + .filter((value) => value !== null), + { seed: `${controlArm}:${treatmentArm}:${field}` } + ); return { arm: treatmentArm, controlArm, @@ -1111,21 +1375,32 @@ function pairedEffects(runs, controlArm, treatmentArm, contrastType = 'increment } function matchesFilters(event, filters) { - for (const field of ['client', 'clientVersion', 'model', 'modelVersion', 'taskId', 'arm']) { + for (const field of [ + 'client', + 'clientVersion', + 'model', + 'modelVersion', + 'taskId', + 'arm', + ]) { if (!filters[field]) continue; let actual = event[field]; if (event.kind === 'handoff-run') { - actual = field === 'taskId' - ? event.scenarioId - : field === 'arm' - ? event.arm - : (event.consumer?.[field] ?? event.producer?.[field] ?? event[field]); + actual = + field === 'taskId' + ? event.scenarioId + : field === 'arm' + ? event.arm + : (event.consumer?.[field] ?? + event.producer?.[field] ?? + event[field]); } else if (event.kind === 'concurrency-run') { - actual = field === 'taskId' - ? 'concurrent-combined' - : field === 'arm' - ? event.arm - : (event.consumer?.[field] ?? event[field]); + actual = + field === 'taskId' + ? 'concurrent-combined' + : field === 'arm' + ? event.arm + : (event.consumer?.[field] ?? event[field]); } if (String(actual || '') !== String(filters[field])) return false; } @@ -1134,11 +1409,15 @@ function matchesFilters(event, filters) { function handoffArmMetrics(runs) { const consumers = runs.map((run) => run.consumer || {}); - const count = (field) => consumers.filter((consumer) => consumer[field] === true).length; - const interval = (field) => bootstrapMeanInterval( - consumers.map((consumer) => consumer[field]).filter((value) => numeric(value) !== null), - { seed: `handoff:${field}` } - ); + const count = (field) => + consumers.filter((consumer) => consumer[field] === true).length; + const interval = (field) => + bootstrapMeanInterval( + consumers + .map((consumer) => consumer[field]) + .filter((value) => numeric(value) !== null), + { seed: `handoff:${field}` } + ); return { runs: runs.length, delivery: proportionInterval( @@ -1147,7 +1426,10 @@ function handoffArmMetrics(runs) { ), correctness: proportionInterval(count('correct'), runs.length), firstPass: proportionInterval(count('firstPass'), runs.length), - mistakeAttempted: proportionInterval(count('mistakeAttempted'), runs.length), + mistakeAttempted: proportionInterval( + count('mistakeAttempted'), + runs.length + ), mistakeExecuted: proportionInterval(count('mistakeExecuted'), runs.length), totalTokens: interval('totalTokens'), toolCalls: interval('toolCalls'), @@ -1163,16 +1445,21 @@ function handoffPairedEffect(runs, controlArm, treatmentArm) { if (!pairs.has(run.pairId)) pairs.set(run.pairId, {}); pairs.get(run.pairId)[run.arm] = run; } - const complete = [...pairs.values()].filter((pair) => pair[controlArm] && pair[treatmentArm]); - const effect = (field, positiveWhenLower = true) => bootstrapMeanInterval( - complete.map((pair) => { - const control = numeric(pair[controlArm].consumer?.[field]); - const treatment = numeric(pair[treatmentArm].consumer?.[field]); - if (control === null || treatment === null) return null; - return positiveWhenLower ? control - treatment : treatment - control; - }).filter((value) => value !== null), - { seed: `handoff:${controlArm}:${treatmentArm}:${field}` } + const complete = [...pairs.values()].filter( + (pair) => pair[controlArm] && pair[treatmentArm] ); + const effect = (field, positiveWhenLower = true) => + bootstrapMeanInterval( + complete + .map((pair) => { + const control = numeric(pair[controlArm].consumer?.[field]); + const treatment = numeric(pair[treatmentArm].consumer?.[field]); + if (control === null || treatment === null) return null; + return positiveWhenLower ? control - treatment : treatment - control; + }) + .filter((value) => value !== null), + { seed: `handoff:${controlArm}:${treatmentArm}:${field}` } + ); return { comparison: `${treatmentArm} vs ${controlArm}`, pairs: complete.length, @@ -1187,13 +1474,22 @@ function handoffPairedEffect(runs, controlArm, treatmentArm) { }; } -const handoffKey = (run) => [ - run.producer?.client || 'unknown', run.producer?.model || 'unknown', - run.consumer?.client || 'unknown', run.consumer?.model || 'unknown', - run.scenarioId || 'unknown', -].join('|'); - -const HANDOFF_REPORT_ARMS = ['empty', 'natural', 'oracle', 'irrelevant', 'stale']; +const handoffKey = (run) => + [ + run.producer?.client || 'unknown', + run.producer?.model || 'unknown', + run.consumer?.client || 'unknown', + run.consumer?.model || 'unknown', + run.scenarioId || 'unknown', + ].join('|'); + +const HANDOFF_REPORT_ARMS = [ + 'empty', + 'natural', + 'oracle', + 'irrelevant', + 'stale', +]; const handoffMinimumPairs = () => Math.max(2, Number(process.env.TOKEN_OPTIMIZER_HANDOFF_MIN_PAIRS) || 10); @@ -1206,43 +1502,54 @@ function handoffCohorts(runs) { } const minimumPairs = handoffMinimumPairs(); return [...grouped.entries()].map(([key, rows]) => { - const arms = Object.fromEntries(HANDOFF_REPORT_ARMS.map((arm) => [ - arm, handoffArmMetrics(rows.filter((row) => row.arm === arm)), - ])); + const arms = Object.fromEntries( + HANDOFF_REPORT_ARMS.map((arm) => [ + arm, + handoffArmMetrics(rows.filter((row) => row.arm === arm)), + ]) + ); const naturalRows = rows.filter((row) => row.arm === 'natural'); const naturalVsEmpty = handoffPairedEffect(rows, 'empty', 'natural'); const naturalVsOracle = handoffPairedEffect(rows, 'oracle', 'natural'); const captureRate = naturalRows.length - ? naturalRows.filter((row) => row.producer?.captureSuccess).length / naturalRows.length + ? naturalRows.filter((row) => row.producer?.captureSuccess).length / + naturalRows.length : null; const preActionDeliveryRate = naturalRows.length - ? naturalRows.filter((row) => row.delivery?.beforeFirstExecutedMistake).length / naturalRows.length + ? naturalRows.filter((row) => row.delivery?.beforeFirstExecutedMistake) + .length / naturalRows.length : null; const emptyRecurrence = arms.empty.mistakeExecuted.rate; const naturalRecurrence = arms.natural.mistakeExecuted.rate; - const relativeRecurrenceReduction = emptyRecurrence !== null - && naturalRecurrence !== null - && emptyRecurrence !== 0 - ? (emptyRecurrence - naturalRecurrence) / emptyRecurrence - : null; - const controlsSafe = ['irrelevant', 'stale'].every((arm) => - arms[arm].correctness.rate !== null - && arms.empty.correctness.rate !== null - && arms[arm].correctness.rate >= arms.empty.correctness.rate - 0.1 + const relativeRecurrenceReduction = + emptyRecurrence !== null && + naturalRecurrence !== null && + emptyRecurrence !== 0 + ? (emptyRecurrence - naturalRecurrence) / emptyRecurrence + : null; + const controlsSafe = ['irrelevant', 'stale'].every( + (arm) => + arms[arm].correctness.rate !== null && + arms.empty.correctness.rate !== null && + arms[arm].correctness.rate >= arms.empty.correctness.rate - 0.1 ); - const irrelevantSuppressed = arms.irrelevant.delivery.rate !== null - && arms.irrelevant.delivery.rate === 0; + const irrelevantSuppressed = + arms.irrelevant.delivery.rate !== null && + arms.irrelevant.delivery.rate === 0; const gates = { minimumPairs: naturalVsEmpty.pairs >= minimumPairs, capture: captureRate !== null && captureRate >= 0.8, recurrenceMagnitude: - relativeRecurrenceReduction !== null && relativeRecurrenceReduction >= 0.5, - recurrenceInterval: (naturalVsEmpty.executedMistakesPrevented.low ?? -Infinity) > 0, + relativeRecurrenceReduction !== null && + relativeRecurrenceReduction >= 0.5, + recurrenceInterval: + (naturalVsEmpty.executedMistakesPrevented.low ?? -Infinity) > 0, correctness: - arms.natural.correctness.rate !== null - && arms.empty.correctness.rate !== null - && arms.natural.correctness.rate >= arms.empty.correctness.rate - 0.1, - preActionDelivery: preActionDeliveryRate !== null && preActionDeliveryRate >= 0.8, + arms.natural.correctness.rate !== null && + arms.empty.correctness.rate !== null && + arms.natural.correctness.rate >= arms.empty.correctness.rate - 0.1, + preActionDelivery: + preActionDeliveryRate !== null && preActionDeliveryRate >= 0.8, negativeControls: controlsSafe && irrelevantSuppressed, }; const claimReady = Object.values(gates).every(Boolean); @@ -1269,14 +1576,24 @@ function handoffCohorts(runs) { function concurrencySummary(runs) { const natural = runs.filter((run) => run.arm === 'natural'); const writers = natural.reduce((sum, run) => sum + (run.writerCount || 0), 0); - const captures = natural.reduce((sum, run) => sum + (run.captureSuccesses || 0), 0); - const integrityPasses = natural.filter((run) => - run.integrity?.zeroLoss - && run.integrity?.parseable - && run.integrity?.orphanedFindings === 0 + const captures = natural.reduce( + (sum, run) => sum + (run.captureSuccesses || 0), + 0 + ); + const integrityPasses = natural.filter( + (run) => + run.integrity?.zeroLoss && + run.integrity?.parseable && + run.integrity?.orphanedFindings === 0 ).length; - const delivered = natural.reduce((sum, run) => sum + (run.delivery?.delivered || 0), 0); - const expected = natural.reduce((sum, run) => sum + (run.delivery?.expected || 0), 0); + const delivered = natural.reduce( + (sum, run) => sum + (run.delivery?.delivered || 0), + 0 + ); + const expected = natural.reduce( + (sum, run) => sum + (run.delivery?.expected || 0), + 0 + ); const effect = handoffPairedEffect(runs, 'empty', 'natural'); return { runs: runs.length, @@ -1286,7 +1603,8 @@ function concurrencySummary(runs) { integrityPassRate: natural.length ? integrityPasses / natural.length : null, deliveryCoverage: expected ? delivered / expected : null, naturalCorrectness: proportionInterval( - natural.filter((run) => run.consumer?.correct).length, natural.length + natural.filter((run) => run.consumer?.correct).length, + natural.length ), effect, }; @@ -1297,17 +1615,26 @@ function concurrencySummary(runs) { * live hook traces, and randomized model evals remain visibly distinct: only * `eval-run` records with paired arms can produce a causal effect interval. */ -function buildEvidenceReport(evidence, { filters = {}, episodeLimit = 100 } = {}) { +function buildEvidenceReport( + evidence, + { filters = {}, episodeLimit = 100 } = {} +) { evidence = evidence.filter((event) => matchesFilters(event, filters)); const runs = evidence.filter((event) => event.kind === 'eval-run'); const handoffRuns = evidence.filter((event) => event.kind === 'handoff-run'); - const concurrencyRuns = evidence.filter((event) => event.kind === 'concurrency-run'); + const concurrencyRuns = evidence.filter( + (event) => event.kind === 'concurrency-run' + ); const injections = evidence.filter((event) => event.kind === 'inject'); const outcomes = evidence.filter((event) => event.kind === 'tool-outcome'); - const feedback = evidence.filter((event) => event.kind === 'finding-feedback'); + const feedback = evidence.filter( + (event) => event.kind === 'finding-feedback' + ); const byInjection = new Map( - outcomes.filter((event) => event.injectionId).map((event) => [event.injectionId, event]) + outcomes + .filter((event) => event.injectionId) + .map((event) => [event.injectionId, event]) ); const traced = injections .slice() @@ -1337,7 +1664,10 @@ function buildEvidenceReport(evidence, { filters = {}, episodeLimit = 100 } = {} grouped.get(key).push(run); } - const minimumPairs = Math.max(2, Number(process.env.TOKEN_OPTIMIZER_EVAL_MIN_PAIRS) || 5); + const minimumPairs = Math.max( + 2, + Number(process.env.TOKEN_OPTIMIZER_EVAL_MIN_PAIRS) || 5 + ); const cohorts = [...grouped.entries()].map(([key, cohortRuns]) => { const arms = Object.fromEntries( ['baseline', 'optimizer', 'retrieval', 'full'].map((arm) => [ @@ -1387,7 +1717,9 @@ function buildEvidenceReport(evidence, { filters = {}, episodeLimit = 100 } = {} cohorts: cohorts.length, harmfulFeedback: harmful, harmRate: feedback.length ? harmful / feedback.length : null, - evidenceStatus: cohorts.some((cohort) => cohort.evidenceStatus === 'causal estimate available') + evidenceStatus: cohorts.some( + (cohort) => cohort.evidenceStatus === 'causal estimate available' + ) ? 'causal estimates available' : 'insufficient randomized evidence', }, @@ -1396,7 +1728,8 @@ function buildEvidenceReport(evidence, { filters = {}, episodeLimit = 100 } = {} concurrency, episodes: traced, methodology: { - intervals: 'deterministic percentile bootstrap (95%); Wilson interval for correctness', + intervals: + 'deterministic percentile bootstrap (95%); Wilson interval for correctness', causalRule: 'matched pairs estimate optimizer vs baseline, retrieval vs optimizer, full vs retrieval, and full vs baseline', minimumPairs, diff --git a/integrations/kilo/hooks/lib/observability.mjs b/integrations/kilo/hooks/lib/observability.mjs index b9980076e..ef1c037af 100644 --- a/integrations/kilo/hooks/lib/observability.mjs +++ b/integrations/kilo/hooks/lib/observability.mjs @@ -26,7 +26,7 @@ import { createHash, randomUUID } from 'node:crypto'; import { homedir } from 'node:os'; import { join } from 'node:path'; -export const HOOK_LOG_SCHEMA_VERSION = 1; +export const HOOK_LOG_SCHEMA_VERSION = 2; const DEFAULT_MAX_BYTES = 5 * 1024 * 1024; const DEFAULT_RETENTION_DAYS = 14; const DEFAULT_MAX_FILES = 40; @@ -197,6 +197,8 @@ export function beginHookInvocation(client, event, options = {}) { turnIdHash: null, toolUseIdHash: null, toolName: null, + rawToolName: null, + codeModeEnvelope: false, clientVersion: null, model: null, cwdHash: null, @@ -208,12 +210,29 @@ export function beginHookInvocation(client, event, options = {}) { const spanId = hash(invocationId, 16); let traceId = hash(invocationId, 32); + // Write the start before reading stdin or importing project state. If the + // host kills the process, Node aborts, or a native call terminates without a + // JavaScript exception, diagnostics can now reconcile this record with the + // missing completion instead of reporting a false success rate. + writeHookEvent({ + level: 'info', + event: 'hook.started', + invocationId, + traceId, + spanId, + client, + hookEvent: event, + processId: process.pid, + }); + let deadline; const finish = (nextOutcome = outcome, nextReason = reason, nextError = error) => { if (ended) return; ended = true; if (deadline) clearTimeout(deadline); process.removeListener('exit', onExit); + process.removeListener('uncaughtException', onUncaughtException); + process.removeListener('unhandledRejection', onUnhandledRejection); writeHookEvent({ level: nextOutcome === 'failure' || nextOutcome === 'timeout' ? 'error' : 'info', event: 'hook.completed', @@ -231,7 +250,17 @@ export function beginHookInvocation(client, event, options = {}) { }); }; const onExit = () => finish(); + const onUncaughtException = (nextError) => { + finish('failure', 'uncaught_exception', nextError); + process.exit(0); + }; + const onUnhandledRejection = (nextError) => { + finish('failure', 'unhandled_rejection', nextError); + process.exit(0); + }; process.once('exit', onExit); + process.once('uncaughtException', onUncaughtException); + process.once('unhandledRejection', onUnhandledRejection); deadline = setTimeout(() => { outcome = 'timeout'; @@ -250,6 +279,11 @@ export function beginHookInvocation(client, event, options = {}) { dimensions.turnIdHash = hash(raw.turn_id ?? raw.turnId ?? null); dimensions.toolUseIdHash = hash(raw.tool_use_id ?? raw.toolUseId ?? null); dimensions.toolName = payload?.tool_name ?? raw.tool_name ?? raw.toolName ?? null; + dimensions.rawToolName = dimension( + raw.tool_name ?? raw.toolName ?? raw.tool ?? null + ); + dimensions.codeModeEnvelope = + payload?.tool_input?.code_mode_envelope === true; dimensions.clientVersion = dimension(raw.client_version ?? raw.clientVersion ?? null); dimensions.model = dimension(raw.model ?? raw.model_name ?? raw.modelName ?? null); dimensions.cwdHash = hash(raw.cwd ?? raw.working_directory ?? process.cwd()); @@ -291,6 +325,11 @@ export function beginHookInvocation(client, event, options = {}) { error = nextError; finish(outcome, reason, error); }, + block(nextReason = 'policy_denied') { + outcome = 'blocked'; + reason = nextReason; + finish(outcome, reason); + }, succeed(nextReason = 'completed') { outcome = 'success'; reason = nextReason; @@ -321,7 +360,11 @@ export function recordHookBootstrapFailure(client, event, error) { }); } -export function readHookEvents({ limit = 200, sinceMs = 24 * 60 * 60 * 1000 } = {}) { +export function readHookEvents({ + limit = 200, + sinceMs = 24 * 60 * 60 * 1000, + includeLifecycle = false, +} = {}) { try { const dir = hookLogDirectory(); if (!existsSync(dir)) return []; @@ -338,6 +381,7 @@ export function readHookEvents({ limit = 200, sinceMs = 24 * 60 * 60 * 1000 } = try { const event = JSON.parse(line); if (Date.parse(event.timestamp) < cutoff) continue; + if (!includeLifecycle && event.event === 'hook.started') continue; events.push(event); if (events.length >= limit) return events; } catch { @@ -353,38 +397,102 @@ export function readHookEvents({ limit = 200, sinceMs = 24 * 60 * 60 * 1000 } = export function hookHealthSummary(options = {}) { const sinceMs = options.sinceMs ?? 24 * 60 * 60 * 1000; - const events = readHookEvents({ limit: options.limit || 5000, sinceMs }); - const durations = events + const events = readHookEvents({ + limit: options.limit || 10000, + sinceMs, + includeLifecycle: true, + }); + const starts = events.filter((event) => event.event === 'hook.started'); + const completed = events.filter( + (event) => event.event === 'hook.completed' || !event.event + ); + const completedIds = new Set( + completed.map((event) => event.invocationId).filter(Boolean) + ); + const abandoned = starts + .filter( + (event) => + event.invocationId && + !completedIds.has(event.invocationId) && + Date.now() - Date.parse(event.timestamp) > 10_000 + ) + .map((event) => ({ + ...event, + event: 'hook.completed', + outcome: 'failure', + reason: 'process_terminated_before_completion', + durationMs: null, + })); + const runs = [...completed, ...abandoned]; + const durations = runs .map((event) => Number(event.durationMs)) .filter(Number.isFinite) .sort((a, b) => a - b); - const failures = events.filter((event) => event.outcome === 'failure'); - const timeouts = events.filter((event) => event.outcome === 'timeout'); - const skipped = events.filter((event) => event.outcome === 'skipped'); - const successes = events.filter((event) => event.outcome === 'success'); + const failures = runs.filter((event) => event.outcome === 'failure'); + const timeouts = runs.filter((event) => event.outcome === 'timeout'); + const skipped = runs.filter((event) => event.outcome === 'skipped'); + const blocked = runs.filter((event) => event.outcome === 'blocked'); + const successes = runs.filter((event) => event.outcome === 'success'); + const actions = runs.filter( + (event) => event.hookEvent === 'pre-tool' && event.toolName + ); + const byTool = {}; + for (const event of actions) { + const name = String(event.toolName || 'Unknown action'); + byTool[name] = (byTool[name] || 0) + 1; + } const byClient = {}; - for (const event of events) { + for (const event of runs) { const key = event.client || 'unknown'; - const current = byClient[key] || { total: 0, failures: 0, timeouts: 0, skipped: 0 }; + const current = byClient[key] || { + total: 0, + failures: 0, + timeouts: 0, + skipped: 0, + blocked: 0, + hookEvents: [], + }; current.total++; if (event.outcome === 'failure') current.failures++; if (event.outcome === 'timeout') current.timeouts++; if (event.outcome === 'skipped') current.skipped++; + if (event.outcome === 'blocked') current.blocked++; + if (event.hookEvent && !current.hookEvents.includes(event.hookEvent)) + current.hookEvents.push(event.hookEvent); byClient[key] = current; } + for (const current of Object.values(byClient)) current.hookEvents.sort(); const percentile = (p) => durations.length ? durations[Math.min(durations.length - 1, Math.floor(durations.length * p))] : null; const summary = { schemaVersion: HOOK_LOG_SCHEMA_VERSION, - available: events.length > 0, + available: runs.length > 0, windowHours: sinceMs / 3_600_000, - total: events.length, + total: runs.length, failures: failures.length, timeouts: timeouts.length, skipped: skipped.length, - successRate: events.length ? successes.length / events.length : null, + blocked: blocked.length, + abandoned: abandoned.length, + successes: successes.length, + actions: actions.length, + byTool, + // A skipped or policy-blocked hook completed its protocol successfully. + // Runtime health answers whether the hook process worked, not whether the + // optimizer chose to allow the host operation. + successRate: runs.length + ? (successes.length + skipped.length + blocked.length) / runs.length + : null, + healthStatus: + failures.length || timeouts.length + ? 'failing' + : skipped.length + ? 'degraded' + : runs.length + ? 'healthy' + : 'unavailable', p50DurationMs: percentile(0.5), p95DurationMs: percentile(0.95), byClient, diff --git a/integrations/kilo/hooks/lib/pricing.mjs b/integrations/kilo/hooks/lib/pricing.mjs index a4b09ebed..50b23b9cc 100644 --- a/integrations/kilo/hooks/lib/pricing.mjs +++ b/integrations/kilo/hooks/lib/pricing.mjs @@ -1,101 +1,72 @@ // GENERATED FILE -- do not edit. // Source of truth: hooks-core/pricing.mjs. Regenerate with `npm run sync:hooks`. /** - * Tokens in the unit people actually feel. + * Optional cost equivalents for token measurements. * - * The same data as everywhere else in this project, converted once, in one - * place, under three rules that keep a dollar figure honest: - * - * THE TABLE IS VISIBLE AND OVERRIDABLE. Prices change. A figure computed from - * rates buried in code goes stale silently and keeps looking authoritative, - * which is worse than no figure. The rates are printed next to the number and - * can be replaced without a release. - * - * DOLLARS BESIDE TOKENS, NOT INSTEAD OF THEM. The token count is exact and - * never goes out of date; the dollar figure is the token count times a rate - * that will. Showing both means the durable number survives the perishable - * one. - * - * NEVER A PRICE ON AN UNMEASURED SAVING. An invented number is worse in - * dollars than in tokens, because dollars get quoted to other people. A - * saving we cannot measure returns null and renders as "not yet measurable", - * never as "$0.00" -- which reads as "saved nothing" and is a different - * claim entirely. + * The runtime cannot infer a user's bill from a client name. A CLI may route + * through a subscription, direct API account, cloud marketplace, enterprise + * agreement, included credits, or a provider whose cache-read/write prices + * differ. Provider usage records are the source of truth when available. + * Everywhere else this module fails closed until the user supplies an + * effective blended input-token rate. */ -/** Cache multipliers, relative to an input token. */ -const CACHE_WRITE = 1.25; -const CACHE_READ = 0.1; +const RATE_ENV = 'TOKEN_OPTIMIZER_EFFECTIVE_INPUT_USD_PER_MILLION'; -/** - * Dollars per million tokens, by tier. - * - * Approximate published list rates as of 2026-07, carried with their date so a - * stale table is visible rather than silent. Override wholesale with - * TOKEN_OPTIMIZER_PRICES as JSON, e.g. - * {"asOf":"2026-09","opus":{"input":15,"output":75}}. - */ -const DEFAULT_PRICES = { - asOf: '2026-07', - haiku: { input: 1, output: 5 }, - sonnet: { input: 3, output: 15 }, - opus: { input: 15, output: 75 }, -}; - -export function prices() { - const raw = process.env.TOKEN_OPTIMIZER_PRICES; - if (raw) { - try { - const parsed = JSON.parse(raw); - if (parsed && typeof parsed === 'object') return { ...DEFAULT_PRICES, ...parsed, overridden: true }; - } catch { /* an unparseable override is ignored rather than fatal */ } - } - return DEFAULT_PRICES; +/** The configured effective input rate, or null when billing is unobservable. */ +export function effectiveRate() { + const rate = Number(process.env[RATE_ENV]); + return Number.isFinite(rate) && rate > 0 ? rate : null; } /** - * What a number of tokens costs. + * Dollars for a measured token quantity. * - * Returns null for a null input and says why: this is the single place the - * "unmeasured must not become $0.00" rule is enforced, so every caller inherits - * it rather than each remembering. + * `kind` is accepted for backwards compatibility but is not multiplied by a + * universal cache constant. The configured rate must already reflect the + * user's actual provider/model/cache/tier/credit mix. */ -export function dollars(tokens, { tier = 'opus', kind = 'input' } = {}) { - if (!Number.isFinite(tokens)) return null; - - const table = prices(); - const rate = table[tier]?.[kind === 'output' ? 'output' : 'input']; - if (!Number.isFinite(rate)) return null; - - const multiplier = kind === 'cacheWrite' ? CACHE_WRITE : kind === 'cacheRead' ? CACHE_READ : 1; - return (tokens / 1_000_000) * rate * multiplier; +export function dollars(tokens, { rate = effectiveRate() } = {}) { + if (!Number.isFinite(tokens) || !Number.isFinite(rate) || rate <= 0) { + return null; + } + return (tokens / 1_000_000) * rate; } /** A dollar figure, or an honest absence of one. */ -export function money(amount, { unmeasured = 'not yet measurable' } = {}) { +export function money(amount, { unmeasured = 'not priced' } = {}) { if (amount == null || !Number.isFinite(amount)) return unmeasured; if (amount === 0) return '$0.00'; if (Math.abs(amount) < 0.01) return '<$0.01'; return `$${amount.toFixed(2)}`; } -/** - * Per-session tokens expressed as a monthly cost. - * - * `sessionsPerMonth` is an assumption, so it is stated in the output rather - * than folded invisibly into the number. - */ -export function monthly(tokensPerSession, { tier = 'opus', sessionsPerMonth = 60 } = {}) { +/** Per-session tokens expressed with a stated monthly-frequency assumption. */ +export function monthly(tokensPerSession, { sessionsPerMonth = 60 } = {}) { if (!Number.isFinite(tokensPerSession)) return null; - const amount = dollars(tokensPerSession * sessionsPerMonth, { tier }); - return amount == null ? null : { amount, sessionsPerMonth, tier }; + const amount = dollars(tokensPerSession * sessionsPerMonth); + return amount == null + ? null + : { + amount, + sessionsPerMonth, + effectiveInputUsdPerMillion: effectiveRate(), + }; +} + +/** The pricing basis printed beside any cost equivalent. */ +export function priceNote() { + const rate = effectiveRate(); + return rate == null + ? `cost not priced: set ${RATE_ENV} to your effective blended input rate after provider, model, cache, tier, plan, and credits` + : `cost equivalent: configured effective input rate $${rate}/M via ${RATE_ENV}`; } -/** The rates behind the figures, for printing beside them. */ -export function priceNote(tier = 'opus') { - const table = prices(); - const rate = table[tier]; - if (!rate) return null; - return `prices: ${tier} $${rate.input}/$${rate.output} per Mtok (${table.asOf}` + - `${table.overridden ? ', overridden' : ''}); override with TOKEN_OPTIMIZER_PRICES`; +/** Compatibility surface: no built-in provider table is claimed. */ +export function prices() { + return { + source: + effectiveRate() == null ? 'unavailable' : 'configured-effective-rate', + effectiveInputUsdPerMillion: effectiveRate(), + }; } diff --git a/integrations/kilo/hooks/lib/routing.mjs b/integrations/kilo/hooks/lib/routing.mjs index 9b4642e0e..84ac866e4 100644 --- a/integrations/kilo/hooks/lib/routing.mjs +++ b/integrations/kilo/hooks/lib/routing.mjs @@ -65,7 +65,9 @@ export function modelCosts() { if (raw) { try { return JSON.parse(raw); - } catch { /* fall through to the defaults */ } + } catch { + /* fall through to the defaults */ + } } return { haiku: 1, sonnet: 3, opus: 15 }; } @@ -106,7 +108,12 @@ export function readEpisodes(path, { maxBytes = 6_000_000 } = {}) { let current = null; const close = () => { - if (current && current.turns > 0) episodes.push({ ...current, files: [...current.files], tools: [...current.tools] }); + if (current && current.turns > 0) + episodes.push({ + ...current, + files: [...current.files], + tools: [...current.tools], + }); current = null; }; @@ -123,7 +130,15 @@ export function readEpisodes(path, { maxBytes = 6_000_000 } = {}) { // a new episode. if (row.type === 'user' && typeof row.message?.content === 'string') { close(); - current = { at: Date.parse(row.timestamp) || null, models: new Set(), tools: new Set(), files: new Set(), turns: 0, errors: 0, results: 0 }; + current = { + at: Date.parse(row.timestamp) || null, + models: new Set(), + tools: new Set(), + files: new Set(), + turns: 0, + errors: 0, + results: 0, + }; continue; } if (!current) continue; @@ -157,8 +172,16 @@ export function readEpisodes(path, { maxBytes = 6_000_000 } = {}) { /* ---------------------------------------------------------------- SHAPES */ -const EDIT_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit', 'smart_edit', 'smart_write']); -const TEST_PATTERN = /\b(test|jest|pytest|vitest|dotnet test|npm test|go test)\b/i; +const EDIT_TOOLS = new Set([ + 'Edit', + 'MultiEdit', + 'Write', + 'NotebookEdit', + 'smart_edit', + 'smart_write', +]); +const TEST_PATTERN = + /\b(test|jest|pytest|vitest|dotnet test|npm test|go test)\b/i; /** * What kind of work this was. @@ -171,11 +194,19 @@ export function classifyShape(episode) { const tools = new Set(episode.tools || []); const edited = [...tools].some((t) => EDIT_TOOLS.has(t)); const files = (episode.files || []).length; - const ranTests = [...tools].some((t) => TEST_PATTERN.test(t)) || (episode.tools || []).includes('Bash'); + const ranTests = + [...tools].some((t) => TEST_PATTERN.test(t)) || + (episode.tools || []).includes('Bash'); if (edited && files >= 3) return 'multi-file-change'; if (edited) return 'single-file-change'; - if (files > 0 || tools.has('Grep') || tools.has('Glob') || tools.has('smart_grep')) return 'investigation'; + if ( + files > 0 || + tools.has('Grep') || + tools.has('Glob') || + tools.has('smart_grep') + ) + return 'investigation'; if (ranTests) return 'build-or-test'; return 'conversation'; } @@ -208,7 +239,13 @@ export function outcomeTable(episodes) { const tier = [...tiers][0]; table[shape] = table[shape] || {}; - const cell = table[shape][tier] = table[shape][tier] || { episodes: 0, turns: 0, errors: 0, results: 0, errored: 0 }; + const cell = (table[shape][tier] = table[shape][tier] || { + episodes: 0, + turns: 0, + errors: 0, + results: 0, + errored: 0, + }); cell.episodes += 1; cell.turns += episode.turns; cell.errors += episode.errors; @@ -287,7 +324,8 @@ export function route(shape, table = {}) { shape, recommend: heuristic, basis: 'heuristic', - reason: `every measured tier for ${shape} needed a retry in more than ` + + reason: + `every measured tier for ${shape} needed a retry in more than ` + `${Math.round(MAX_ERROR_RATE * 100)}% of episodes; falling back to the shipped default`, excluded: excluded.map((o) => ({ tier: o.tier, errorRate: o.errorRate })), options, @@ -306,17 +344,24 @@ export function route(shape, table = {}) { recommend: best.tier, basis: 'measured', // What each mistake would cost, from this project's own rates. - overpowered: strongest.tier === best.tier ? null : { - tier: strongest.tier, - wasted: Number((strongest.cost - best.cost).toFixed(2)), - }, - underpowered: cheapest.tier === best.tier ? null : { - tier: cheapest.tier, - expectedCost: Number(cheapest.cost.toFixed(2)), - errorRate: cheapest.errorRate, - excluded: cheapest.errorRate > MAX_ERROR_RATE, - }, - reason: `${shape}: ${best.tier} costs ${best.cost.toFixed(2)} in expectation over ${best.episodes} episodes ` + + overpowered: + strongest.tier === best.tier + ? null + : { + tier: strongest.tier, + wasted: Number((strongest.cost - best.cost).toFixed(2)), + }, + underpowered: + cheapest.tier === best.tier + ? null + : { + tier: cheapest.tier, + expectedCost: Number(cheapest.cost.toFixed(2)), + errorRate: cheapest.errorRate, + excluded: cheapest.errorRate > MAX_ERROR_RATE, + }, + reason: + `${shape}: ${best.tier} costs ${best.cost.toFixed(2)} in expectation over ${best.episodes} episodes ` + `(${Math.round(best.errorRate * 100)}% needed a retry)`, options, }; @@ -340,20 +385,31 @@ export function routingNote(shape, table, { currentModel, switchCost } = {}) { const lines = [decision.reason]; if (decision.underpowered) { - lines.push(` ${decision.underpowered.tier} looks cheaper per token, but needed a retry in ` + - `${Math.round(decision.underpowered.errorRate * 100)}% of episodes -- expected cost ${decision.underpowered.expectedCost}` + - `${decision.underpowered.excluded ? ', which is why it is not a candidate here at any price' : ''}.`); + lines.push( + ` ${decision.underpowered.tier} looks cheaper per token, but needed a retry in ` + + `${Math.round(decision.underpowered.errorRate * 100)}% of episodes -- expected cost ${decision.underpowered.expectedCost}` + + `${decision.underpowered.excluded ? ', which is why it is not a candidate here at any price' : ''}.` + ); } if (decision.overpowered) { - lines.push(` ${decision.overpowered.tier} would spend ${decision.overpowered.wasted} more than needed here.`); + lines.push( + ` ${decision.overpowered.tier} would spend ${decision.overpowered.wasted} more than needed here.` + ); } if (switchCost?.prefixTokens) { - lines.push(` Switching now also discards a ${switchCost.prefixTokens.toLocaleString()}-token warm prefix ` + - `(~${switchCost.rewriteCost.toLocaleString()} tokens to re-write); consider switching at the next break.`); + lines.push( + ` Switching now also discards a ${switchCost.prefixTokens.toLocaleString()}-token warm prefix ` + + `(~${switchCost.rewriteInputCostEquivalent.toLocaleString()} Anthropic uncached-input cost-equivalent tokens to re-write); consider switching at the next break.` + ); } - return { shape, recommend: decision.recommend, basis: decision.basis, text: lines.join('\n') }; + return { + shape, + recommend: decision.recommend, + basis: decision.basis, + text: lines.join('\n'), + }; } /** @@ -371,7 +427,9 @@ export function routingBriefing(table) { const decision = route(shape, table); if (decision.basis !== 'measured') continue; if (decision.recommend === HEURISTIC[shape]) continue; // nothing learned worth saying - lines.push(`${shape.replace(/-/g, ' ')} work goes better on ${decision.recommend} in this project.`); + lines.push( + `${shape.replace(/-/g, ' ')} work goes better on ${decision.recommend} in this project.` + ); } return lines.length ? lines.join('\n') : null; } @@ -389,7 +447,11 @@ export function routingBriefing(table) { * few more episodes accumulated would invalidate the cache behind it. Stability * is the feature; the memo is how it is bought. */ -export function cachedRoutingBriefing(dir, transcriptPath, { growthBytes = 4_000_000, maxAgeMs = 24 * 60 * 60 * 1000 } = {}) { +export function cachedRoutingBriefing( + dir, + transcriptPath, + { growthBytes = 4_000_000, maxAgeMs = 24 * 60 * 60 * 1000 } = {} +) { const memoPath = join(dir, 'routing-brief.json'); let stat; try { @@ -400,17 +462,31 @@ export function cachedRoutingBriefing(dir, transcriptPath, { growthBytes = 4_000 try { const memo = JSON.parse(readFileSync(memoPath, 'utf8')); - const fresh = memo.path === transcriptPath - && Math.abs(stat.size - memo.size) < growthBytes - && Date.now() - memo.at < maxAgeMs; + const fresh = + memo.path === transcriptPath && + Math.abs(stat.size - memo.size) < growthBytes && + Date.now() - memo.at < maxAgeMs; if (fresh) return memo.text || null; - } catch { /* no memo, or an unreadable one; recompute */ } + } catch { + /* no memo, or an unreadable one; recompute */ + } const text = routingBriefing(outcomeTable(readEpisodes(transcriptPath))); try { mkdirSync(dir, { recursive: true, mode: 0o700 }); - writeFileSync(memoPath, JSON.stringify({ path: transcriptPath, size: stat.size, at: Date.now(), text }), { mode: 0o600 }); - } catch { /* the memo is an optimisation; failing to write it costs latency, not correctness */ } + writeFileSync( + memoPath, + JSON.stringify({ + path: transcriptPath, + size: stat.size, + at: Date.now(), + text, + }), + { mode: 0o600 } + ); + } catch { + /* the memo is an optimisation; failing to write it costs latency, not correctness */ + } return text; } @@ -427,9 +503,11 @@ export function routingReport(table) { for (const tier of Object.keys(table[shape])) { const cell = table[shape][tier]; const cost = expectedCost(tier, cell); - lines.push(` ${tier.padEnd(7)} ${cell.episodes} episode(s), ` + - `${cell.meanTurns.toFixed(1)} turns avg, ${Math.round(cell.errorRate * 100)}% needed a retry, ` + - `expected cost ${cost.cost.toFixed(2)}${cell.measured ? '' : ' [too few episodes to trust]'}`); + lines.push( + ` ${tier.padEnd(7)} ${cell.episodes} episode(s), ` + + `${cell.meanTurns.toFixed(1)} turns avg, ${Math.round(cell.errorRate * 100)}% needed a retry, ` + + `expected cost ${cost.cost.toFixed(2)}${cell.measured ? '' : ' [too few episodes to trust]'}` + ); } } return lines.join('\n'); diff --git a/integrations/kilo/hooks/lib/symbols.mjs b/integrations/kilo/hooks/lib/symbols.mjs index 2a80e1e42..4d80666ac 100644 --- a/integrations/kilo/hooks/lib/symbols.mjs +++ b/integrations/kilo/hooks/lib/symbols.mjs @@ -50,7 +50,11 @@ const PATTERNS = { /^\s*(?:pub(?:\([^)]*\))?\s+)?(?:struct|enum|trait|impl)\s+([A-Za-z_]\w*)/, ], clike: [ - /^\s*(?:public|private|protected|internal|static|final|abstract|virtual|override|async|\s)*(?:[\w<>\[\],.?]+\s+)([A-Za-z_]\w*)\s*\([^;]*\)\s*\{/, + // Accept both K&R (`Run() {`) and Allman (`Run()` then `{`) declarations. + // Requiring the opening brace on the declaration line silently discarded + // nearly every method in idiomatic C# projects, leaving file nodes but no + // symbol nodes and making valid `file.cs#Method` anchors unresolved. + /^\s*(?:public|private|protected|internal|static|final|abstract|virtual|override|async|sealed|new|extern|unsafe|partial|\s)*(?:[\w<>\[\],.?]+\s+)([A-Za-z_]\w*)(?:<[^;()]+>)?\s*\([^;]*\)\s*(?:\{|=>|$)/, /^\s*(?:public|private|protected|internal|static|abstract|sealed|partial|\s)*(?:class|struct|interface|enum|record)\s+([A-Za-z_]\w*)/, ], ruby: [ diff --git a/integrations/opencode/hooks/lib/adapter.mjs b/integrations/opencode/hooks/lib/adapter.mjs index fee0102b9..9f76bbdea 100644 --- a/integrations/opencode/hooks/lib/adapter.mjs +++ b/integrations/opencode/hooks/lib/adapter.mjs @@ -188,8 +188,143 @@ function contextOutput(client, eventName, additionalContext) { }; } +/** Convert one static JavaScript string literal without evaluating code. */ +function codexStringLiteral(literal) { + if (typeof literal !== 'string' || literal.length < 2) return null; + const quote = literal[0]; + if (!['"', "'", '`'].includes(quote) || literal.at(-1) !== quote) return null; + + let decoded = ''; + for (let index = 1; index < literal.length - 1; index += 1) { + const character = literal[index]; + if (quote === '`' && character === '$' && literal[index + 1] === '{') + return null; + if (character !== '\\') { + decoded += character; + continue; + } + + index += 1; + if (index >= literal.length - 1) return null; + const escaped = literal[index]; + const simple = { + b: '\b', f: '\f', n: '\n', r: '\r', t: '\t', v: '\v', 0: '\0', + '\\': '\\', "'": "'", '"': '"', '`': '`', '$': '$', + }; + if (Object.hasOwn(simple, escaped)) { + decoded += simple[escaped]; + continue; + } + if (escaped === '\n') continue; + if (escaped === '\r') { + if (literal[index + 1] === '\n') index += 1; + continue; + } + if (escaped === 'x') { + const digits = literal.slice(index + 1, index + 3); + if (!/^[0-9a-f]{2}$/i.test(digits)) return null; + decoded += String.fromCodePoint(Number.parseInt(digits, 16)); + index += 2; + continue; + } + if (escaped === 'u') { + const braced = literal[index + 1] === '{'; + const close = braced ? literal.indexOf('}', index + 2) : index + 5; + const digits = braced + ? literal.slice(index + 2, close) + : literal.slice(index + 1, close); + if ( + close < 0 || + !(braced ? /^[0-9a-f]{1,6}$/i : /^[0-9a-f]{4}$/i).test(digits) + ) return null; + const codePoint = Number.parseInt(digits, 16); + if (codePoint > 0x10ffff) return null; + decoded += String.fromCodePoint(codePoint); + index = close; + continue; + } + // JavaScript identity escapes drop the slash (for example, '\q' is 'q'). + decoded += escaped; + } + return decoded; +} + /** Convert client-specific lifecycle envelopes into the common tool shape. */ +function codexStringBindings(source) { + const values = new Map(); + const declaration = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*((?:"(?:\\.|[^"\\])*")|(?:'(?:\\.|[^'\\])*')|(?:`(?:\\.|[^`\\])*`))/gs; + for (const match of String(source || '').matchAll(declaration)) { + try { + const value = codexStringLiteral(match[2]); + if (value !== null) values.set(match[1], value); + } catch { + // A computed or malformed JavaScript expression is not safe to execute + // merely to understand a hook envelope. Leave it opaque. + } + } + return values; +} + +function codexCallArguments(source, method) { + const calls = []; + const pattern = new RegExp(`(?:tools\\.)?${method}\\s*\\(\\s*([^),]+)`, 'gi'); + for (const match of String(source || '').matchAll(pattern)) + calls.push(match[1].trim()); + return calls; +} + +function codexLiteral(value, bindings) { + if (bindings.has(value)) return String(bindings.get(value)); + return codexStringLiteral(value); +} + +function codexExecCommands(source, bindings) { + const commands = []; + for (const call of String(source || '').matchAll( + /(?:tools\.)?exec_command\s*\(\s*\{([\s\S]*?)\}\s*\)/gi + )) { + const property = /\bcmd\s*:\s*((?:"(?:\\.|[^"\\])*")|(?:'(?:\\.|[^'\\])*')|(?:`(?:\\.|[^`\\])*`)|(?:[A-Za-z_$][\w$]*))/s.exec(call[1]); + if (!property) continue; + const command = codexLiteral(property[1], bindings); + if (command) commands.push(command); + } + return commands; +} + export function normalizeClientPayload(clientName, event, raw) { + if (clientName === 'codex') { + const outerName = String(raw.tool_name ?? raw.toolName ?? raw.tool ?? ''); + if (/^(?:functions\.)?exec$/i.test(outerName)) { + const envelope = raw.tool_input ?? raw.toolInput ?? raw.arguments ?? raw.args; + const source = + typeof envelope === 'string' + ? envelope + : envelope?.code ?? envelope?.input ?? envelope?.source ?? ''; + const bindings = codexStringBindings(source); + const patchCalls = codexCallArguments(source, 'apply_patch'); + const patch = patchCalls + .map((argument) => codexLiteral(argument, bindings)) + .find(Boolean); + const mutation = patchCalls.length > 0; + const commands = codexExecCommands(source, bindings); + + // `functions.exec` is also the orchestration envelope for web, image and + // other non-filesystem tools. Do not reinterpret those as shell commands: + // doing so produced irrelevant cross-project advice and fabricated graph + // activity for operations the filesystem hooks never observed. + if (!mutation && !commands.length) return raw; + return { + ...raw, + tool_name: mutation ? 'apply_patch' : 'run_command', + tool_input: { + ...(envelope && typeof envelope === 'object' ? envelope : {}), + command: String(patch || commands.join('\n') || source || ''), + code_mode_envelope: true, + }, + }; + } + } + if (clientName === 'cline') { const body = event === 'post-tool' ? raw.postToolUse : raw.preToolUse; if (!body) return raw; @@ -385,6 +520,22 @@ function projectBriefing() { function observeAndInject(payload, state, episode, features) { const touched = touchedFiles(payload); const dirFor = (path) => wikiDir(projectRootFor(path, payload.cwd)); + const registerRoot = (root) => { + const dir = wikiDir(root); + registerProject({ + root, + graphDir: dir, + client: episode.client || 'unknown', + }); + return dir; + }; + + // SessionStart is not delivered consistently by every host/version. The + // graph was still written in that case, but the dashboard registry never + // learned it existed. Register every repository observed by a real tool call + // as a second, idempotent production path. + for (const { path } of touched) + registerRoot(projectRootFor(path, payload.cwd)); const bytes = readCostBytes(payload); if (bytes && payload.tool_input.file_path) { @@ -425,7 +576,7 @@ function observeAndInject(payload, state, episode, features) { const command = payload.tool_input?.command; if (command) { const root = commandProjectRoot(payload, payload.cwd); - const dir = wikiDir(root); + const dir = registerRoot(root); const local = forCommand(dir, load(dir), command, { sessionId: payload.session_id, alreadyInjected, @@ -750,7 +901,14 @@ async function runHook(clientName, event, invocation) { client.canDeny && event === 'pre-tool' && !repeat && - mode() !== MODE_ADVISE + mode() !== MODE_ADVISE && + // Codex code mode presents an orchestration program as one outer + // `functions.exec` call. Refusing that envelope is rendered by the host + // as a failed script and can discard several otherwise-safe nested + // operations. Observe it, capture it and advise, but reserve a hard veto + // for legacy one-operation Codex tool calls where the denied operation is + // unambiguous. + !(clientName === 'codex' && payload.tool_input?.code_mode_envelope) ); if (canRefuse) { @@ -759,19 +917,23 @@ async function runHook(clientName, event, invocation) { // Every client's refusal carries the off switch, for the same reason Claude // Code's does: enforcement that hides its own disable is coercive. if (client.denyStyle === 'top-level') { + invocation.block('policy_denied'); emit({ decision: 'deny', reason: withEscape(verdict.reason) }); } else if (client.denyStyle === 'top-level-permission') { + invocation.block('policy_denied'); emit({ permissionDecision: 'deny', permissionDecisionReason: withEscape(verdict.reason), }); } else if (client.denyStyle === 'cline') { + invocation.block('policy_denied'); emit({ cancel: true, contextModification: '', errorMessage: withEscape(verdict.reason), }); } else if (client.denyStyle === 'cursor') { + invocation.block('policy_denied'); emit({ continue: true, permission: 'deny', @@ -780,9 +942,11 @@ async function runHook(clientName, event, invocation) { 'Token Optimizer redirected an expensive built-in operation.', }); } else if (client.denyStyle === 'exit-2') { + invocation.block('policy_denied'); process.stderr.write(withEscape(verdict.reason)); process.exit(2); } else { + invocation.block('policy_denied'); emit({ hookSpecificOutput: { hookEventName: eventName, diff --git a/integrations/opencode/hooks/lib/audit.mjs b/integrations/opencode/hooks/lib/audit.mjs index b82186586..df8a08b82 100644 --- a/integrations/opencode/hooks/lib/audit.mjs +++ b/integrations/opencode/hooks/lib/audit.mjs @@ -50,15 +50,17 @@ export function decline(dir, id) { export function declines(dir, { events = readMetrics(dir) } = {}) { const counts = new Map(); for (const event of events) { - if (event.kind !== 'advice' || event.action !== 'declined' || !event.id) continue; + if (event.kind !== 'advice' || event.action !== 'declined' || !event.id) + continue; counts.set(event.id, (counts.get(event.id) || 0) + 1); } return counts; } -const idOf = (finding) => finding.remedy - ? `${finding.remedy.type}:${finding.remedy.anchor || finding.remedy.file || (finding.remedy.anchors || []).join(',')}` - : `${finding.id}:${finding.anchor || finding.file || ''}`; +const idOf = (finding) => + finding.remedy + ? `${finding.remedy.type}:${finding.remedy.anchor || finding.remedy.file || (finding.remedy.anchors || []).join(',')}` + : `${finding.id}:${finding.anchor || finding.file || ''}`; /** * Whether a habit improved after we said something about it. @@ -68,12 +70,18 @@ const idOf = (finding) => finding.remedy * -- a trend computed from one session on each side is a coin flip with a * percentage sign. */ -export function habitTrend(dir, detector, { events = readMetrics(dir), minSessions = 2, anchors = null } = {}) { +export function habitTrend( + dir, + detector, + { events = readMetrics(dir), minSessions = 2, anchors = null } = {} +) { // SPLIT BY POSITION, NOT BY TIMESTAMP. The log is append-only, so its order is // authoritative -- while `at` has millisecond granularity, and a burst of // events inside one millisecond would land on the wrong side of the split or // collapse one side to nothing. Position is exact where the clock is not. - const raisedAt = events.findIndex((e) => e.kind === 'advice' && e.detector === detector); + const raisedAt = events.findIndex( + (e) => e.kind === 'advice' && e.detector === detector + ); if (raisedAt === -1) return null; // SCOPED TO THE FILES THIS DETECTOR NAMED. Counting every read in the window @@ -114,7 +122,12 @@ export function habitTrend(dir, detector, { events = readMetrics(dir), minSessio /** Marks a recommendation as having been raised, so its effect can be scored. */ export function raise(dir, finding) { - record(dir, { kind: 'advice', action: 'raised', id: idOf(finding), detector: finding.id }); + record(dir, { + kind: 'advice', + action: 'raised', + id: idOf(finding), + detector: finding.id, + }); } /** @@ -123,7 +136,11 @@ export function raise(dir, finding) { * `findings` comes from the detectors; `extra` lets the cache and routing * modules contribute without this file importing the world. */ -export function buildQueue(dir, findings = [], { events = readMetrics(dir) } = {}) { +export function buildQueue( + dir, + findings = [], + { events = readMetrics(dir) } = {} +) { const declined = declines(dir, { events }); const ledger = remedyLedger(dir); const applied = new Set(ledger.filter((r) => !r.revertedAt).map((r) => r.id)); @@ -146,7 +163,9 @@ export function buildQueue(dir, findings = [], { events = readMetrics(dir) } = { } return { - queue: queue.sort((a, b) => (b.costPerSession ?? 0) - (a.costPerSession ?? 0)), + queue: queue.sort( + (a, b) => (b.costPerSession ?? 0) - (a.costPerSession ?? 0) + ), suppressed, done: ledger.filter((r) => !r.revertedAt), }; @@ -161,7 +180,11 @@ export function buildQueue(dir, findings = [], { events = readMetrics(dir) } = { * many were withheld -- and what they are collectively worth -- keeps that from * being a silent cap. */ -export function renderAudit(dir, findings = [], { tier = 'opus', full = false, sessionsPerMonth = 60 } = {}) { +export function renderAudit( + dir, + findings = [], + { tier = 'opus', full = false, sessionsPerMonth = 60 } = {} +) { const { queue, suppressed, done } = buildQueue(dir, findings); const lines = []; @@ -175,22 +198,29 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s // read as "this costs nothing" beside a finding whose cost we simply do not // know -- the same unknown-becomes-zero error the rest of this project // corrects, in the one unit that gets quoted to other people. - const priced = item.costPerSession == null ? null : monthly(cost, { tier, sessionsPerMonth }); + const priced = + item.costPerSession == null + ? null + : monthly(cost, { tier, sessionsPerMonth }); // The id appears on EVERY actionable line, not just the appliable ones. It is the only // handle `decline` accepts and it cannot be guessed from the title, so omitting it made the // advertised decline path unreachable for precisely the findings most likely to be // unwanted -- model-routing findings carry no remedy, so they could never be suppressed no // matter how many times a user declined them. - const action = item.remedy?.kind === 'ours' ? `apply: waste_audit action="apply" id="${item.id}"` - : item.remedy?.kind === 'yours' ? `proposed edit -- needs your yes, nothing changed; decline: ${item.id}` - : `advice only -- no automatic fix; decline: ${item.id}`; + const action = + item.remedy?.kind === 'ours' + ? `apply: waste_audit action="apply" id="${item.id}"` + : item.remedy?.kind === 'yours' + ? `proposed edit -- needs your yes, nothing changed; decline: ${item.id}` + : `advice only -- no automatic fix; decline: ${item.id}`; // MEASURED ZERO IS NOT UNMEASURED. Testing `cost` for truthiness sent a real 0 down the // "not yet measurable" branch while `priced` above, which tests for null, still rendered // $0.00 beside it -- producing `cost not yet measurable (~$0.00/month)`, the exact string // the test suite declares must never appear. waste.mjs defaults every finding to 0 and // hard-sets it on the co-occurrence detector, so this is reachable, not theoretical. - const text = ` ${item.title}\n ${item.costPerSession != null ? `${cost.toLocaleString()} tokens/session` : 'cost not yet measurable'}` + + const text = + ` ${item.title}\n ${item.costPerSession != null ? `${cost.toLocaleString()} tokens/session` : 'cost not yet measurable'}` + `${priced ? ` (~${money(priced.amount)}/month)` : ''}; ${action}`; const printCost = estimate(text); @@ -211,14 +241,19 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s lines.push(text); } - const head = shown.length ? ['What to do next, most expensive first:', ...lines] : ['Nothing addressable found.']; + const head = shown.length + ? ['What to do next, most expensive first:', ...lines] + : ['Nothing addressable found.']; const body = [...head]; if (withheld.length) { const worth = withheld.reduce((sum, w) => sum + (w.costPerSession || 0), 0); - body.push('', ` ... ${withheld.length} more finding(s) worth ${worth.toLocaleString()} tokens/session in total, ` + - 'not shown -- printing them costs more than they are worth. Pass full=true to see all.'); + body.push( + '', + ` ... ${withheld.length} more finding(s) worth ${worth.toLocaleString()} tokens/session in total, ` + + 'not shown -- printing them costs more than they are worth. Pass full=true to see all.' + ); } // What we already told you, and what it bought. @@ -226,23 +261,35 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s body.push('', 'Already applied, and what it actually saved:'); for (const rule of done.slice(0, 6)) { const saved = rule.savedPerSession; - const priced = saved == null ? null : monthly(saved, { tier, sessionsPerMonth }); - body.push(` + ${rule.id} -- ${saved == null - ? 'not yet measurable (fewer than two sessions since)' - : `measured ${saved.toLocaleString()} tokens/session${priced ? `, ~${money(priced.amount)}/month` : ''}`}`); + const priced = + saved == null ? null : monthly(saved, { tier, sessionsPerMonth }); + body.push( + ` + ${rule.id} -- ${ + saved == null + ? 'not yet measurable (fewer than two sessions since)' + : `measured ${saved.toLocaleString()} tokens/session${priced ? `, ~${money(priced.amount)}/month` : ''}` + }` + ); } // Never a silent cap: the withheld block above states its remainder and so must this one. // Truncating to six with no disclosure showed a user six savings and let them conclude that // was everything the tool had bought them, which understates its own measured value. if (done.length > 6) { - body.push(` ... and ${done.length - 6} more applied fix(es), not shown.`); + body.push( + ` ... and ${done.length - 6} more applied fix(es), not shown.` + ); } } if (suppressed.length) { - body.push('', `Not repeating ${suppressed.length} recommendation(s) you have declined ` + - `${DECLINE_LIMIT}+ times. Ask for full=true to see them.`); - if (full) for (const item of suppressed) body.push(` - ${item.title} (declined ${item.times}x)`); + body.push( + '', + `Not repeating ${suppressed.length} recommendation(s) you have declined ` + + `${DECLINE_LIMIT}+ times. Ask for full=true to see them.` + ); + if (full) + for (const item of suppressed) + body.push(` - ${item.title} (declined ${item.times}x)`); } // Whether the advice changed anything. @@ -256,20 +303,30 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s for (const a of anchors) anchorsBy.get(f.id).add(a); } const trends = [...new Set(findings.map((f) => f.id))] - .map((detector) => habitTrend(dir, detector, { - anchors: anchorsBy.has(detector) ? [...anchorsBy.get(detector)] : null, - })) + .map((detector) => + habitTrend(dir, detector, { + anchors: anchorsBy.has(detector) ? [...anchorsBy.get(detector)] : null, + }) + ) .filter(Boolean); if (trends.length) { body.push('', 'Since the advice was given:'); for (const trend of trends) { - body.push(` ${trend.detector}: ${trend.before.toLocaleString()} -> ${trend.after.toLocaleString()} tokens/session ` + - `(${trend.improved ? 'improved' : 'worse'})`); + body.push( + ` ${trend.detector}: ${trend.before.toLocaleString()} -> ${trend.after.toLocaleString()} tokens/session ` + + `(${trend.improved ? 'improved' : 'worse'})` + ); } } - const addressable = queue.reduce((sum, item) => sum + (item.costPerSession || 0), 0); + const addressable = queue.reduce( + (sum, item) => sum + (item.costPerSession || 0), + 0 + ); + const addressableMonthly = addressable + ? monthly(addressable, { tier, sessionsPerMonth }) + : null; const note = priceNote(tier); if (note) body.push('', note); @@ -278,9 +335,10 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s // pushing it left the report's longest line out of its own reported cost, understating it by // 10-20% on a short audit. This figure exists to prove the audit is not a net loss, so // understating the cost side biases exactly the comparison it was written to be honest about. - const closing = (n) => `This report cost about ${n.toLocaleString()} tokens and names ` + + const closing = (n) => + `This report cost about ${n.toLocaleString()} tokens and names ` + `${addressable.toLocaleString()} tokens/session of addressable waste` + - `${addressable ? ` (~${money(monthly(addressable, { tier, sessionsPerMonth })?.amount)}/month across ${sessionsPerMonth} sessions)` : ''}.`; + `${addressableMonthly ? ` (~${money(addressableMonthly.amount)}/month across ${sessionsPerMonth} sessions)` : ''}.`; const selfCost = estimate([...body, '', closing(0)].join('\n')); body.push('', closing(selfCost)); diff --git a/integrations/opencode/hooks/lib/cache.mjs b/integrations/opencode/hooks/lib/cache.mjs index 9b11173ff..9782c3262 100644 --- a/integrations/opencode/hooks/lib/cache.mjs +++ b/integrations/opencode/hooks/lib/cache.mjs @@ -3,12 +3,12 @@ /** * Prompt-cache economics. * - * Cache economics is real money in a way most compression is not: a cache read - * costs a tenth of a fresh token and a cache WRITE costs a quarter more than - * one, so a prefix that keeps invalidating can cost more than every saving this - * product makes elsewhere. It is also the one area where we do not have to - * model anything -- the client's own transcript records cache_read_input_tokens - * and cache_creation_input_tokens per turn, which is ground truth. + * Anthropic prompt-cache usage, read from Claude Code transcripts. A cache read + * and a cache write are both billable, at different published multipliers from + * uncached input. Raw cache_read_input_tokens and + * cache_creation_input_tokens are observations. Any multiplier-based value is + * an Anthropic input-cost equivalent, not a universally comparable token count + * and not a provider invoice. * * MEASUREMENT ALONE IS NOT ACTIONABLE. A hit rate tells you the cache missed; * it cannot tell you why, and an unactionable number is the same dead thing as @@ -30,7 +30,15 @@ * cache-safety-by-construction half of this file. */ -import { readFileSync, readdirSync, statSync, existsSync, openSync, readSync, closeSync } from 'node:fs'; +import { + readFileSync, + readdirSync, + statSync, + existsSync, + openSync, + readSync, + closeSync, +} from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; @@ -51,7 +59,8 @@ export const READ_MULTIPLIER = 0.1; const SYNTHETIC_MODEL = ''; /** True for a turn naming a model an inference actually ran under. */ -const realModel = (turn) => Boolean(turn.model) && turn.model !== SYNTHETIC_MODEL; +const realModel = (turn) => + Boolean(turn.model) && turn.model !== SYNTHETIC_MODEL; /* -------------------------------------------------------------- MEASUREMENT */ @@ -72,7 +81,10 @@ export function transcriptFor(cwd = process.cwd()) { try { const files = readdirSync(dir) .filter((name) => name.endsWith('.jsonl')) - .map((name) => ({ path: join(dir, name), at: statSync(join(dir, name)).mtimeMs })) + .map((name) => ({ + path: join(dir, name), + at: statSync(join(dir, name)).mtimeMs, + })) .sort((a, b) => b.at - a.at); return files.length ? files[0].path : null; } catch { @@ -157,7 +169,8 @@ export function cacheHealth(turns) { read += turn.read; written += turn.written; if (!realModel(turn)) continue; - if (!models.has(turn.model)) models.set(turn.model, { read: 0, written: 0, turns: 0 }); + if (!models.has(turn.model)) + models.set(turn.model, { read: 0, written: 0, turns: 0 }); const entry = models.get(turn.model); entry.read += turn.read; entry.written += turn.written; @@ -171,20 +184,28 @@ export function cacheHealth(turns) { // attribution price, made keepWarm answer "unknown", and returned null from // modelSwitchCost under a guard its caller does not share, which crashed // cache_audit outright with "Cannot read properties of null". - const last = turns.reduce((best, turn) => (turn.read + turn.written > 0 ? turn : best), turns[turns.length - 1]); + const last = turns.reduce( + (best, turn) => (turn.read + turn.written > 0 ? turn : best), + turns[turns.length - 1] + ); const total = read + written; return { + provider: 'anthropic', + source: 'claude-code-transcript', turns: turns.length, read, written, hitRate: total ? read / total : null, prefixTokens: last.read + last.written, - // Priced in plain-token equivalents, which is what makes it comparable with - // every other saving this product reports. - writeCost: Math.round(written * WRITE_MULTIPLIER), - readCost: Math.round(read * READ_MULTIPLIER), - savedVersusNoCache: Math.round(read * (1 - READ_MULTIPLIER)), + // These are Anthropic uncached-input cost equivalents. They stay outside + // the verified MCP transport ledger and must not be relabeled as tokens + // saved across clients. + writeInputCostEquivalent: Math.round(written * WRITE_MULTIPLIER), + readInputCostEquivalent: Math.round(read * READ_MULTIPLIER), + inputCostEquivalentAvoidedVersusUncached: Math.round( + read * (1 - READ_MULTIPLIER) + ), models: Object.fromEntries(models), }; } @@ -200,13 +221,17 @@ export function modelSwitchCost(turns) { const health = cacheHealth(turns); if (!health?.prefixTokens) return null; - const switched = new Set(turns.filter(realModel).map((t) => t.model)).size > 1; + const switched = + new Set(turns.filter(realModel).map((t) => t.model)).size > 1; return { prefixTokens: health.prefixTokens, - rewriteCost: Math.round(health.prefixTokens * WRITE_MULTIPLIER), + rewriteInputCostEquivalent: Math.round( + health.prefixTokens * WRITE_MULTIPLIER + ), alreadySwitched: switched, - text: `Switching model now discards a ${health.prefixTokens.toLocaleString()}-token warm prefix; ` + - `re-writing it costs about ${Math.round(health.prefixTokens * WRITE_MULTIPLIER).toLocaleString()} tokens.`, + text: + `Switching model now discards a ${health.prefixTokens.toLocaleString()}-token warm prefix; ` + + `under Anthropic's current 5-minute write multiplier, re-writing it is about ${Math.round(health.prefixTokens * WRITE_MULTIPLIER).toLocaleString()} uncached-input cost-equivalent tokens.`, }; } @@ -221,17 +246,38 @@ export function modelSwitchCost(turns) { * time. That is why this class of bug survives -- nothing about it looks wrong. */ const VOLATILE = [ - { id: 'timestamp', re: /\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/, why: 'an embedded timestamp' }, - { id: 'date', re: /\b(?:today|current date)\b[^\n]{0,40}\d{4}-\d{2}-\d{2}/i, why: 'an embedded current date' }, + { + id: 'timestamp', + re: /\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/, + why: 'an embedded timestamp', + }, + { + id: 'date', + re: /\b(?:today|current date)\b[^\n]{0,40}\d{4}-\d{2}-\d{2}/i, + why: 'an embedded current date', + }, // A bare date is a SUSPICION, not a measurement: "- 2025-10-31: reworked the // loader" is byte-identical every session. Kept, because stableText fails // closed over our own output and dropping a line there costs little -- but // NOT priced, because in the blame report a false positive invents a // per-session cost and tells the user to delete their changelog. - { id: 'iso-date', re: /\b\d{4}-\d{2}-\d{2}\b/, why: 'an embedded date', priced: false }, - { id: 'session-id', re: /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i, why: 'a session id' }, + { + id: 'iso-date', + re: /\b\d{4}-\d{2}-\d{2}\b/, + why: 'an embedded date', + priced: false, + }, + { + id: 'session-id', + re: /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i, + why: 'a session id', + }, { id: 'git-sha', re: /\b[0-9a-f]{40}\b/, why: 'a git sha' }, - { id: 'counter', re: /\b(?:run|build|attempt|iteration)\s*#?\d+\b/i, why: 'a run counter' }, + { + id: 'counter', + re: /\b(?:run|build|attempt|iteration)\s*#?\d+\b/i, + why: 'a run counter', + }, { id: 'epoch', re: /\b1[6-9]\d{11}\b/, why: 'an epoch timestamp' }, ]; @@ -242,7 +288,13 @@ export function volatileLines(text) { for (let i = 0; i < lines.length; i++) { for (const rule of VOLATILE) { if (!rule.re.test(lines[i])) continue; - out.push({ line: i + 1, id: rule.id, why: rule.why, priced: rule.priced !== false, text: lines[i].trim().slice(0, 120) }); + out.push({ + line: i + 1, + id: rule.id, + why: rule.why, + priced: rule.priced !== false, + text: lines[i].trim().slice(0, 120), + }); break; // one cause per line is enough to act on } } @@ -264,7 +316,11 @@ const estimate = (text) => Math.ceil(String(text || '').length / 4); * unknown, and this reports the construct WITHOUT a price rather than inventing * one. */ -export function attributeInvalidation(cwd, prefixTokens = null, { files = null } = {}) { +export function attributeInvalidation( + cwd, + prefixTokens = null, + { files = null } = {} +) { // Ordered as the client assembles them: the client preamble and tool // definitions come first, then project instructions, then the conversation. const candidates = files || ['CLAUDE.md', 'AGENTS.md', '.claude/CLAUDE.md']; @@ -292,7 +348,8 @@ export function attributeInvalidation(cwd, prefixTokens = null, { files = null } // prefix -- 3.75x the entire prefix. Conservative still: the preamble // ahead of the file is not counted, so this remains a floor. const ahead = offset + estimate(lines.slice(0, hit.line - 1).join('\n')); - const downstream = prefixTokens != null ? Math.max(0, prefixTokens - ahead) : null; + const downstream = + prefixTokens != null ? Math.max(0, prefixTokens - ahead) : null; // A prefix cache invalidates ONCE, at the earliest difference. A later // volatile line in the same file costs nothing extra until the first one // is fixed, so it is reported for context but not billed again. @@ -304,7 +361,9 @@ export function attributeInvalidation(cwd, prefixTokens = null, { files = null } // into a reported zero. const firstPriced = found.find((h) => h.priced); const subsumedBy = - hit.priced && firstPriced && firstPriced !== hit ? `${relative}:${firstPriced.line}` : null; + hit.priced && firstPriced && firstPriced !== hit + ? `${relative}:${firstPriced.line}` + : null; out.push({ file: relative, line: hit.line, @@ -316,9 +375,12 @@ export function attributeInvalidation(cwd, prefixTokens = null, { files = null } // decline to price. 0 means measured and genuinely free, which is what a // subsumed hit is: real, but already billed by the earlier line. Folding // those two into one value is the same mistake this file warns about. - costPerSession: downstream == null || !hit.priced - ? null - : (subsumedBy ? 0 : Math.round(downstream * WRITE_MULTIPLIER)), + costPerSession: + downstream == null || !hit.priced + ? null + : subsumedBy + ? 0 + : Math.round(downstream * WRITE_MULTIPLIER), remedy: { kind: 'yours', type: 'edit', @@ -369,7 +431,7 @@ export function stableText(text) { */ export function cacheOrdered(items) { return [...items].sort((a, b) => { - const stability = (item) => (item.volatility ?? (item.fresh ? 1 : 0)); + const stability = (item) => item.volatility ?? (item.fresh ? 1 : 0); return stability(a) - stability(b); }); } diff --git a/integrations/opencode/hooks/lib/decide.mjs b/integrations/opencode/hooks/lib/decide.mjs index caae7de2f..a61dafdd5 100644 --- a/integrations/opencode/hooks/lib/decide.mjs +++ b/integrations/opencode/hooks/lib/decide.mjs @@ -392,6 +392,21 @@ export function touchedFiles(payload) { add(input.path); add(input.notebook_path); + // Codex code mode carries apply_patch as the nested program text rather than + // as a first-class file_path. Without reading these headers, successful + // code-mode edits incremented the semantic-harvest counter but produced no + // structural file/symbol nodes, which is why the live dashboard showed very + // few nodes after substantial work. PostToolUse can resolve Add/Update paths + // because the file now exists; deleted paths deliberately remain absent. + for (const match of command.matchAll( + /^\*\*\* (?:Add|Update|Delete) File:\s*(.+)$/gm + )) { + add(match[1].trim().replace(/^['"]|['"]$/g, '')); + } + for (const match of command.matchAll(/^\*\*\* Move to:\s*(.+)$/gm)) { + add(match[1].trim().replace(/^['"]|['"]$/g, '')); + } + // EVERY pipeline segment, not just the first. `fileOperands` looks only at // the head of the pipeline because that is where the COST is -- `git log | // head` must not be mistaken for a file dump. But observation is a different diff --git a/integrations/opencode/hooks/lib/experiment.mjs b/integrations/opencode/hooks/lib/experiment.mjs index 1f64713f1..c35c6cf5c 100644 --- a/integrations/opencode/hooks/lib/experiment.mjs +++ b/integrations/opencode/hooks/lib/experiment.mjs @@ -67,16 +67,71 @@ export function episodeMeta({ client, raw = {}, payload = {}, env = process.env } export function usageFrom(raw = {}) { - const usage = raw.usage || raw.token_usage || raw.tokenUsage || raw.metrics?.usage || {}; + const usage = raw.usage || raw.token_usage || raw.tokenUsage || raw.usageMetadata || + raw.usage_metadata || raw.metrics?.usage || raw.response?.usage || + raw.response?.usageMetadata || {}; const number = (...values) => { const value = first(...values); const parsed = Number(value); return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; }; + const cachedInputTokens = number( + usage.cached_input_tokens, usage.cachedInputTokens, + usage.cache_read_input_tokens, usage.cachedContentTokenCount + ); + const cacheCreation = usage.cache_creation || usage.cacheCreation || {}; + const cacheWrite5mInputTokens = number( + usage.cache_write_5m_input_tokens, usage.cacheWrite5mInputTokens, + cacheCreation.ephemeral_5m_input_tokens, cacheCreation.ephemeral5mInputTokens + ); + const cacheWrite1hInputTokens = number( + usage.cache_write_1h_input_tokens, usage.cacheWrite1hInputTokens, + cacheCreation.ephemeral_1h_input_tokens, cacheCreation.ephemeral1hInputTokens + ); + const detailedWrites = (cacheWrite5mInputTokens || 0) + (cacheWrite1hInputTokens || 0); + const reportedWrites = number( + usage.cache_write_input_tokens, usage.cacheWriteInputTokens, + usage.cache_creation_input_tokens, usage.cacheCreationInputTokens + ); + const cacheWriteInputTokens = reportedWrites == null + ? null + : Math.max(0, reportedWrites - detailedWrites); + const explicitUncached = number(usage.uncached_input_tokens, usage.uncachedInputTokens); + const promptInput = number( + usage.promptTokenCount, usage.prompt_token_count, + usage.input_tokens, usage.inputTokens + ); + const isAnthropic = usage.cache_creation_input_tokens !== undefined || + usage.cache_read_input_tokens !== undefined; + const isGemini = usage.promptTokenCount !== undefined || usage.cachedContentTokenCount !== undefined; + const uncachedInputTokens = explicitUncached != null + ? explicitUncached + : promptInput == null + ? null + : isAnthropic + ? promptInput + : isGemini || cachedInputTokens != null || reportedWrites != null + ? Math.max(0, promptInput - (cachedInputTokens || 0) - (reportedWrites || 0)) + : promptInput; + const candidates = number(usage.candidatesTokenCount, usage.candidates_token_count); + const thoughts = number(usage.thoughtsTokenCount, usage.thoughts_token_count); + const outputTokens = candidates != null || thoughts != null + ? (candidates || 0) + (thoughts || 0) + : number(usage.output_tokens, usage.outputTokens); return { - uncachedInputTokens: number(usage.uncached_input_tokens, usage.uncachedInputTokens), - cachedInputTokens: number(usage.cached_input_tokens, usage.cachedInputTokens, usage.cache_read_input_tokens), - outputTokens: number(usage.output_tokens, usage.outputTokens), - totalTokens: number(usage.total_tokens, usage.totalTokens, raw.tokens_used, raw.tokensUsed), + uncachedInputTokens, + cachedInputTokens, + cacheWrite5mInputTokens, + cacheWrite1hInputTokens, + cacheWriteInputTokens, + outputTokens, + totalTokens: number( + usage.total_tokens, usage.totalTokens, usage.totalTokenCount, + usage.total_token_count, raw.tokens_used, raw.tokensUsed + ), + usageMeasurementId: first( + raw.requestId, raw.request_id, raw.responseId, raw.response_id, + usage.responseId, usage.response_id + ) ?? null, }; } diff --git a/integrations/opencode/hooks/lib/harvest-write.mjs b/integrations/opencode/hooks/lib/harvest-write.mjs index 30433d370..db436c1dc 100644 --- a/integrations/opencode/hooks/lib/harvest-write.mjs +++ b/integrations/opencode/hooks/lib/harvest-write.mjs @@ -24,7 +24,7 @@ */ import { - putNodeWithEdges, load, nodeId, sharedDir, isSharedDir, putNode, + putNodeWithEdges, load, nodeId, sharedDir, isSharedDir, putNode, putEdge, } from './wiki.mjs'; import { indexFile } from './staleness.mjs'; import { symbolKey } from './symbols.mjs'; @@ -47,6 +47,37 @@ import { ORIGIN_HARVESTED, ORIGIN_AGENT, ORIGIN_HUMAN } from './curate.mjs'; */ export const SHAREABLE_TYPES = new Set(['command', 'failure', 'decision', 'feedback']); +/** + * Synthetic study traffic must never become advice for an unrelated live repo. + * Evaluation roots are intentionally realistic and may carry their own `.git` + * marker, so repository discovery alone cannot distinguish them. Publication + * remains useful inside the isolated study store; this guard protects only the + * default machine-wide store when a harness was accidentally run without an + * isolated TOKEN_OPTIMIZER_SHARED_DIR. + */ +export function isEphemeralProject(projectRoot) { + const root = canonicalPath(projectRoot || '').toLowerCase(); + if (!root) return true; + return ( + /\/appdata\/local\/temp\//.test(root) || + /(^|\/)tmp\//.test(root) || + /\/(artifacts|\.token-optimizer)\/(?:[^/]*\/)*(?:eval|scenario|study|fixture|debug)[^/]*\//.test(`${root}/`) + ); +} + +export function quarantineSharedSource(projectRoot) { + if (process.env.TOKEN_OPTIMIZER_ALLOW_EPHEMERAL_SHARED === '1') return false; + if (!isEphemeralProject(projectRoot)) return false; + // Isolated studies deliberately point the shared tier at scratch storage. + // Their findings may cross their own fixture projects, but cannot escape into + // the user's default machine-wide graph. Strict mode exists for a regression + // probe that verifies the guard without writing to a real user directory. + return ( + !process.env.TOKEN_OPTIMIZER_SHARED_DIR || + process.env.TOKEN_OPTIMIZER_STRICT_SHARED_PROVENANCE === '1' + ); +} + /** Claim text, normalised enough that the same lesson learned twice is one row. */ const claimFingerprint = (claim) => String(claim || '').toLowerCase().replace(/\s+/g, ' ').trim().slice(0, 300); @@ -72,6 +103,7 @@ function promoteToShared(finding, { projectRoot, sessionId, provenance, key }) { if (!projectRoot) return false; const root = canonicalPath(projectRoot); + if (quarantineSharedSource(root)) return false; const rootId = nodeId('file', root); // Same claim, already carried up from anywhere: keep the first. Two repos @@ -367,11 +399,21 @@ export function writeHarvested( && !node.retired && node.type === finding.type && claimFingerprint(node.claim) === fingerprint - && resolved.every((target) => currentGraph.edges.some((edge) => - edge.from === node.id && edge.edge === 'derived_from' && edge.to === target - )) ); if (duplicate) { + // Enrich, do not duplicate. A symbol extractor may learn how to resolve a + // previously file-only anchor (or a later session may add another real + // anchor) after the claim was first stored. Repeating the same semantic + // conclusion should connect that existing node to the newly available + // evidence, not create a second dashboard card with the same text. + for (const target of resolved) { + const alreadyLinked = currentGraph.edges.some((edge) => + edge.from === duplicate.id + && edge.edge === 'derived_from' + && edge.to === target + ); + if (!alreadyLinked) putEdge(dir, duplicate.id, 'derived_from', target); + } written.push(duplicate.key); continue; } @@ -437,7 +479,12 @@ export function writeHarvested( if ( id && !isSharedDir(dir) - && (finding.scope === undefined || finding.scope === 'organization' || finding.scope === 'global') + // Cross-project publication is explicit. `writeHarvested` stores an + // omitted scope as project-local above, so treating `undefined` as global + // here contradicted the stored record and promoted fixture/evaluation + // seeds into real user sessions. This was observed live as "learned in + // scenario" advice while working in AiDotNet. + && (finding.scope === 'organization' || finding.scope === 'global') ) { promoteToShared(finding, { projectRoot, sessionId, provenance, key }); } diff --git a/integrations/opencode/hooks/lib/inject.mjs b/integrations/opencode/hooks/lib/inject.mjs index b00494a01..c0aa2f1bf 100644 --- a/integrations/opencode/hooks/lib/inject.mjs +++ b/integrations/opencode/hooks/lib/inject.mjs @@ -31,6 +31,7 @@ import { canonicalPath, resolvableCandidates } from './paths.mjs'; import { annotatedSkeleton } from './skeleton.mjs'; import { substitutionBudget } from './metrics.mjs'; import { assessFindings } from './utility.mjs'; +import { quarantineSharedSource } from './harvest-write.mjs'; // Read per call for the same reason as the holdout fraction in metrics.mjs. const touchBudget = () => Number(process.env.TOKEN_OPTIMIZER_TOUCH_BUDGET) || 500; @@ -562,6 +563,7 @@ export function forRepeatedAct( const home = projectRoot ? canonicalPath(projectRoot) : null; for (const node of graph.nodes.values()) { if (node.kind !== 'finding' || node.retired || !node.claim) continue; + if (quarantineSharedSource(node.sourceProject)) continue; if (home && node.sourceProject && canonicalPath(node.sourceProject) === home) continue; const claimClasses = classesOf(node.claim, 'claim'); if (![...crossedClasses].some((c) => claimClasses.has(c))) continue; @@ -667,6 +669,7 @@ export function forSharedCommand( const candidates = []; for (const node of graph.nodes.values()) { if (node.kind !== 'finding' || node.retired) continue; + if (quarantineSharedSource(node.sourceProject)) continue; if (alreadyInjected.has(node.key)) continue; // ITS OWN LESSON IS NOT NEWS. A finding this project contributed is already // served by the local path; repeating it under a "from elsewhere" label @@ -1054,7 +1057,13 @@ export function standingRules(dir, graph, { budget = standingBudget(), episode = * informative response available -- more useful than the file, not a lossier * version of it. */ -export function substitutionFor(dir, graph, rawPath, source, { sessionId } = {}) { +export function substitutionFor( + dir, + graph, + rawPath, + source, + { sessionId, client = null, clientVersion = null, model = null, modelVersion = null } = {} +) { const filePath = canonicalPath(rawPath); const budget = substitutionBudget(dir, filePath); const built = annotatedSkeleton(graph, rawPath, source, { budget }); @@ -1089,6 +1098,10 @@ export function substitutionFor(dir, graph, rawPath, source, { sessionId } = {}) // on this machine turned out to be the enforcement suite's own fixture and // nothing distinguished them from real work. sessionId, + client, + clientVersion, + model, + modelVersion, holdout, tokens: holdout ? 0 : built.tokens, findings: built.findings, diff --git a/integrations/opencode/hooks/lib/metrics.mjs b/integrations/opencode/hooks/lib/metrics.mjs index 0bb08f1bc..09f822630 100644 --- a/integrations/opencode/hooks/lib/metrics.mjs +++ b/integrations/opencode/hooks/lib/metrics.mjs @@ -19,8 +19,15 @@ */ import { - appendFileSync, readFileSync, existsSync, mkdirSync, chmodSync, - statSync, openSync, readSync, closeSync, + appendFileSync, + readFileSync, + existsSync, + mkdirSync, + chmodSync, + statSync, + openSync, + readSync, + closeSync, } from 'node:fs'; import { join } from 'node:path'; import { createHash, randomBytes } from 'node:crypto'; @@ -86,6 +93,10 @@ export const BALANCE_KINDS = new Set([ 'keepwarm', ]); +/** Latest timestamp without passing an unbounded event log as function arguments. */ +export const latestEventTimestamp = (rows) => + rows.reduce((latest, event) => Math.max(latest, Number(event.at) || 0), 0); + /** * Causal records have their own bounded log. Tool outcomes are much rarer * than read/capture telemetry but more frequent than balance records; mixing @@ -130,7 +141,7 @@ export function inHoldout(anchorKey, now = Date.now()) { // other side than before. With nine holdout records in existence that costs // nothing, and stratification is a fresh draw either way. const digest = createHash('sha256').update(`${anchorKey}:${epoch}`).digest(); - return (digest[0] / 256) < fraction; + return digest[0] / 256 < fraction; } /** @@ -258,7 +269,8 @@ export function record(dir, event) { * It is also the right statistics. A saving measured months ago says little * about the code as it stands now. */ -const MAX_BYTES = Number(process.env.TOKEN_OPTIMIZER_METRICS_BYTES) || 2_000_000; +const MAX_BYTES = + Number(process.env.TOKEN_OPTIMIZER_METRICS_BYTES) || 2_000_000; const MAX_EVENTS = Number(process.env.TOKEN_OPTIMIZER_METRICS_WINDOW) || 5000; /** @@ -432,7 +444,10 @@ export function readBalance(dir) { // path for records written before ids existed; it is lossy by nature, which // is precisely why new records carry an id instead. const id = - e.id ?? [e.kind, e.at, e.anchor ?? '', e.sessionId ?? '', e.tokens ?? ''].join('|'); + e.id ?? + [e.kind, e.at, e.anchor ?? '', e.sessionId ?? '', e.tokens ?? ''].join( + '|' + ); if (seen.has(id)) continue; seen.add(id); out.push(e); @@ -493,16 +508,24 @@ export function recordToolOutcome(dir, outcome) { const evidence = readEvidence(dir); const anchor = String(outcome.anchor || '').slice(0, 120); const candidates = evidence - .filter((event) => event.kind === 'inject' && event.episodeId === outcome.episodeId) + .filter( + (event) => + event.kind === 'inject' && event.episodeId === outcome.episodeId + ) .filter((event) => { if (outcome.toolCallId && event.toolCallId) return String(event.toolCallId) === String(outcome.toolCallId); - return event.surface === outcome.surface && String(event.anchor || '').slice(0, 120) === anchor; + return ( + event.surface === outcome.surface && + String(event.anchor || '').slice(0, 120) === anchor + ); }) .sort((a, b) => (b.at || 0) - (a.at || 0)); const injection = candidates[0] || null; const joinMethod = injection - ? (outcome.toolCallId && injection.toolCallId ? 'tool-call-id' : 'episode-anchor') + ? outcome.toolCallId && injection.toolCallId + ? 'tool-call-id' + : 'episode-anchor' : 'none'; return record(dir, { @@ -542,12 +565,11 @@ export function isFixtureAnchor(anchor) { // code: it excluded ALL temp paths -- including a real project checked out // under /tmp -- and on macOS excluded nothing at all, because tmpdir() there // is /var/folders///T/ and did not match. - const underTemp = - new RegExp( - '[\\\\/](AppData[\\\\/]Local[\\\\/])?(Temp|tmp)[\\\\/]' + - '|[\\\\/]var[\\\\/]folders[\\\\/][^\\\\/]+[\\\\/][^\\\\/]+[\\\\/]T[\\\\/]', - 'i' - ).test(p); + const underTemp = new RegExp( + '[\\\\/](AppData[\\\\/]Local[\\\\/])?(Temp|tmp)[\\\\/]' + + '|[\\\\/]var[\\\\/]folders[\\\\/][^\\\\/]+[\\\\/][^\\\\/]+[\\\\/]T[\\\\/]', + 'i' + ).test(p); if (!underTemp) return false; // Only names this suite actually creates. A user's own scratch checkout under @@ -686,8 +708,12 @@ export function balanceSheet(dir) { // THE CONTROL ARM'S ACTUAL COST. `tokensFullFile` was recorded and read // by nothing, so the comparison the holdout exists for was not // computable from the report. - controlArmTokens: withheldSubs.reduce((sum, e) => sum + (e.tokensFullFile || 0), 0), - assumption: 'that the model would have read the file it explicitly requested', + controlArmTokens: withheldSubs.reduce( + (sum, e) => sum + (e.tokensFullFile || 0), + 0 + ), + assumption: + 'that the model would have read the file it explicitly requested', }, estimatedCausal: { what: 'findings injection, from the stratified holdout', @@ -702,7 +728,12 @@ export function balanceSheet(dir) { }; })(), }, - costs: { injection: injectCost, harvest: harvestCost, standing: standingCost, substitution: substitutionCost }, + costs: { + injection: injectCost, + harvest: harvestCost, + standing: standingCost, + substitution: substitutionCost, + }, waste, // Deliberately NOT a single number. The two benefit lines are known // differently; adding them would launder an assumption into a measurement. @@ -727,7 +758,9 @@ export function balanceSheet(dir) { /** Cheap path normalisation for grouping reads; not the identity canonicaliser. */ function canonicalKeyish(p) { - return String(p || '').replace(/\\/g, '/').toLowerCase(); + return String(p || '') + .replace(/\\/g, '/') + .toLowerCase(); } /** @@ -738,7 +771,43 @@ function canonicalKeyish(p) { * arms, multiplied by treated touches. Reporting it as a measured fact would be * the same overclaiming this project criticises competitors for. */ -function buildReport(events, balance) { +function buildReport(events, balance, sourceCoverage = {}) { + const substitutions = balance.filter( + (event) => event.kind === 'substitute' && !isFixtureAnchor(event.anchor) + ); + const servedSubstitutions = substitutions.filter((event) => !event.holdout); + const substitutionTokensSaved = servedSubstitutions.reduce( + (sum, event) => + sum + + (event.tokensNetAvoided ?? + Math.max( + 0, + Math.ceil((event.bytesAvoided || 0) / 4) - (event.tokens || 0) + )), + 0 + ); + const substitutionTokensReturned = servedSubstitutions.reduce( + (sum, event) => sum + (event.tokens || 0), + 0 + ); + const substitutionByClient = {}; + for (const event of servedSubstitutions) { + const name = event.client || 'Historical — client not recorded'; + const current = substitutionByClient[name] || { + substitutions: 0, + tokensReturned: 0, + tokensSaved: 0, + }; + current.substitutions += 1; + current.tokensReturned += event.tokens || 0; + current.tokensSaved += + event.tokensNetAvoided ?? + Math.max( + 0, + Math.ceil((event.bytesAvoided || 0) / 4) - (event.tokens || 0) + ); + substitutionByClient[name] = current; + } // COMMAND INJECTIONS ARE COUNTED SEPARATELY, not mixed into the balance. // @@ -762,13 +831,19 @@ function buildReport(events, balance) { // Report its cost independently so it cannot dilute the file-touch estimate. const isSessionStart = (e) => e.surface === 'session-start'; const sessionStartInjections = allInjections.filter(isSessionStart); - const commandInjections = allInjections.filter((e) => !isSessionStart(e) && isCommand(e)); - const injections = allInjections.filter((e) => !isSessionStart(e) && !isCommand(e)); + const commandInjections = allInjections.filter( + (e) => !isSessionStart(e) && isCommand(e) + ); + const injections = allInjections.filter( + (e) => !isSessionStart(e) && !isCommand(e) + ); const treated = injections.filter((e) => !e.holdout); const withheld = injections.filter((e) => e.holdout); const mean = (rows, field) => - rows.length ? rows.reduce((sum, r) => sum + (r[field] || 0), 0) / rows.length : 0; + rows.length + ? rows.reduce((sum, r) => sum + (r[field] || 0), 0) / rows.length + : 0; const injectedTokens = treated.reduce((sum, e) => sum + (e.tokens || 0), 0); const harvestTokens = balance @@ -819,7 +894,9 @@ function buildReport(events, balance) { const after = event.at ?? 0; const total = bucket.reduce( - (sum, r) => sum + ((r.at ?? 0) >= after ? (r.tokens || 0) : 0), 0); + (sum, r) => sum + ((r.at ?? 0) >= after ? r.tokens || 0 : 0), + 0 + ); // Split across the injections that share this key. Each touch is one // observation, so charging every one the full total would count the same @@ -828,52 +905,198 @@ function buildReport(events, balance) { }; const meanDownstream = (rows) => - rows.length ? rows.reduce((sum, r) => sum + downstreamOf(r), 0) / rows.length : 0; + rows.length + ? rows.reduce((sum, r) => sum + downstreamOf(r), 0) / rows.length + : 0; const treatedCost = meanDownstream(treated); const withheldCost = meanDownstream(withheld); const perTouchSaving = withheldCost - treatedCost; - const estimatedAvoided = Math.max(0, Math.round(perTouchSaving * treated.length)); + const estimatedAvoided = Math.max( + 0, + Math.round(perTouchSaving * treated.length) + ); + + const downstreamSamples = injections.filter((event) => { + if (event.downstream != null) return true; + const key = `${event.sessionId || ''}|${event.anchor}`; + return (reads.get(key) || []).some( + (read) => (read.at ?? 0) >= (event.at ?? 0) + ); + }).length; + const downstreamMeasured = downstreamSamples > 0; // Below this, arm means are noise and a ratio would be theatre. - const sufficient = treated.length >= 20 && withheld.length >= 5; + const sufficient = + downstreamMeasured && treated.length >= 20 && withheld.length >= 5; + + const coverageNumber = (value, fallback) => { + if (value === null || value === undefined || value === '') return fallback; + const number = Number(value); + return Number.isFinite(number) ? Math.max(0, number) : fallback; + }; + const projectCount = coverageNumber(sourceCoverage.projects, 1); + const telemetryProjects = coverageNumber( + sourceCoverage.projectsWithTelemetry, + 0 + ); + const balanceProjects = coverageNumber( + sourceCoverage.projectsWithBalanceEvents, + balance.length ? 1 : 0 + ); + const lastEventAt = Math.max( + latestEventTimestamp(events), + latestEventTimestamp(balance) + ); + const measured = + telemetryProjects > 0 || events.length > 0 || balance.length > 0; + const balanceMeasured = balanceProjects > 0 || balance.length > 0; + const metric = (status, source, samples, extra = {}) => ({ + status, + source, + samples, + ...extra, + }); return { + nativeOptimizer: { + substitutions: servedSubstitutions.length, + holdouts: substitutions.filter((event) => event.holdout).length, + tokensReturned: substitutionTokensReturned, + tokensSaved: substitutionTokensSaved, + byClient: substitutionByClient, + recent: servedSubstitutions + .slice() + .sort((a, b) => (b.at || 0) - (a.at || 0)) + .slice(0, 40) + .map((event) => { + const tokensSaved = + event.tokensNetAvoided ?? + Math.max( + 0, + Math.ceil((event.bytesAvoided || 0) / 4) - (event.tokens || 0) + ); + return { + name: 'live_graph_substitution', + client: event.client || 'Historical — client not recorded', + originalTokens: (event.tokens || 0) + tokensSaved, + optimizedTokens: event.tokens || 0, + tokensSaved, + savingsMeasured: false, + classification: 'modeled-counterfactual', + reportedTokensSaved: tokensSaved, + timestamp: event.at ? new Date(event.at).toISOString() : null, + }; + }), + source: + 'balance.jsonl: modeled full-file counterfactual minus annotated-skeleton tokens; excluded from verified MCP savings', + }, memoryDeliveries: allInjections.filter((event) => !event.holdout).length, memoryHoldouts: allInjections.filter((event) => event.holdout).length, deliveryTokens: allInjections .filter((event) => !event.holdout) .reduce( - (sum, event) => - sum + (event.deliveredTokens ?? event.tokens ?? 0), + (sum, event) => sum + (event.deliveredTokens ?? event.tokens ?? 0), 0 ), injections: treated.length, sessionStartInjections: sessionStartInjections.length, sessionStartInjectedTokens: sessionStartInjections.reduce( - (sum, event) => sum + (event.deliveredTokens ?? event.tokens ?? 0), 0 + (sum, event) => sum + (event.deliveredTokens ?? event.tokens ?? 0), + 0 ), commandInjections: commandInjections.length, commandHoldouts: commandInjections.filter((e) => e.holdout).length, holdouts: withheld.length, staleServed: injections.filter((e) => e.stale).length, - staleRate: injections.length ? injections.filter((e) => e.stale).length / injections.length : 0, + staleRate: injections.length + ? injections.filter((e) => e.stale).length / injections.length + : 0, injectedTokens, harvestTokens, estimatedTokensAvoided: sufficient ? estimatedAvoided : null, - netTokens: sufficient ? estimatedAvoided - injectedTokens - harvestTokens : null, + netTokens: sufficient + ? estimatedAvoided - injectedTokens - harvestTokens + : null, sufficientData: sufficient, verdict: !sufficient ? `insufficient data (${treated.length} treated, ${withheld.length} holdout; need 20 and 5)` : estimatedAvoided > injectedTokens + harvestTokens ? 'the graph is saving more than it costs' : 'the graph is NOT yet paying for itself', + measurement: { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + sourceCoverage: { + projects: projectCount, + projectsWithTelemetry: telemetryProjects || (measured ? 1 : 0), + projectsWithBalanceEvents: balanceProjects, + projectsWithoutTelemetry: Math.max( + 0, + projectCount - (telemetryProjects || (measured ? 1 : 0)) + ), + }, + freshness: { + lastEventAt: lastEventAt || null, + ageMs: lastEventAt ? Math.max(0, Date.now() - lastEventAt) : null, + status: !lastEventAt + ? 'not-measured' + : Date.now() - lastEventAt <= 7 * 86_400_000 + ? 'fresh' + : 'stale', + }, + metrics: { + nativeSubstitutions: metric( + balanceMeasured ? 'measured' : 'not-measured', + 'balance.jsonl: modeled full-file counterfactual minus annotated-skeleton tokens; not a materialized MCP before/after pair', + servedSubstitutions.length, + { classification: 'modeled-counterfactual' } + ), + memoryDeliveries: metric( + balanceMeasured ? 'measured' : 'not-measured', + 'balance.jsonl: inject events in the treated arm', + allInjections.filter((event) => !event.holdout).length + ), + memoryHoldouts: metric( + balanceMeasured ? 'measured' : 'not-measured', + 'balance.jsonl: inject events in the withheld arm', + allInjections.filter((event) => event.holdout).length + ), + rememberingCost: metric( + balanceMeasured ? 'measured' : 'not-measured', + 'balance.jsonl: delivered injection tokens plus semantic harvest tokens', + allInjections.length + + balance.filter((event) => event.kind === 'harvest').length + ), + readingAvoided: metric( + !downstreamMeasured + ? 'not-measured' + : sufficient + ? 'measured' + : 'collecting', + 'stratified file-touch holdout joined to downstream read events', + downstreamSamples, + { + treated: treated.length, + holdouts: withheld.length, + requiredTreated: 20, + requiredHoldouts: 5, + } + ), + }, + }, }; } export function report(dir) { - return buildReport(readAll(dir), readBalance(dir)); + const events = readAll(dir); + const balance = readBalance(dir); + return buildReport(events, balance, { + projects: 1, + projectsWithTelemetry: events.length || balance.length ? 1 : 0, + projectsWithBalanceEvents: balance.length ? 1 : 0, + }); } /** @@ -886,10 +1109,16 @@ export function report(dir) { */ export function reportMany(dirs) { const unique = [...new Set((dirs || []).map((dir) => String(dir)))]; - const report = buildReport( - unique.flatMap((dir) => readAll(dir)), - unique.flatMap((dir) => readBalance(dir)) - ); + const eventSources = unique.map((dir) => readAll(dir)); + const balanceSources = unique.map((dir) => readBalance(dir)); + const report = buildReport(eventSources.flat(), balanceSources.flat(), { + projects: unique.length, + projectsWithTelemetry: eventSources.filter( + (events, index) => events.length || balanceSources[index].length + ).length, + projectsWithBalanceEvents: balanceSources.filter((events) => events.length) + .length, + }); return { ...report, projects: unique.length }; } @@ -907,7 +1136,11 @@ export function reportMany(dirs) { * costs almost nothing. Without a control arm there is no way to tell those two * apart, which is why nobody else can do this. */ -export function substitutionBudget(dir, anchor, { floor = 300, base = 1200, ceiling = 3000 } = {}) { +export function substitutionBudget( + dir, + anchor, + { floor = 300, base = 1200, ceiling = 3000 } = {} +) { const events = readAll(dir); const mine = events.filter((e) => e.kind === 'inject' && e.anchor === anchor); @@ -932,13 +1165,17 @@ export function substitutionBudget(dir, anchor, { floor = 300, base = 1200, ceil for (const row of rows) { const bucket = reads.get(row.sessionId || '') || []; const after = row.at ?? 0; - total += bucket.reduce((sum, r) => sum + ((r.at ?? 0) >= after ? (r.tokens || 0) : 0), 0); + total += bucket.reduce( + (sum, r) => sum + ((r.at ?? 0) >= after ? r.tokens || 0 : 0), + 0 + ); } return total / rows.length; }; const saved = downstream(withheld) - downstream(treated); - const spent = treated.reduce((sum, e) => sum + (e.tokens || 0), 0) / treated.length; + const spent = + treated.reduce((sum, e) => sum + (e.tokens || 0), 0) / treated.length; // Ratio of what annotating this file avoided to what annotating it cost. // Above 1 it is paying for itself and earns room; below, it shrinks. @@ -959,7 +1196,10 @@ export function substitutionBudget(dir, anchor, { floor = 300, base = 1200, ceil * graph whose index leads to queries grows its allowance; a noisy one shrinks * back toward the floor. Nobody configures it, and it cannot run away. */ -export function indexBudget(dir, { floor = 150, base = 300, ceiling = 1200 } = {}) { +export function indexBudget( + dir, + { floor = 150, base = 300, ceiling = 1200 } = {} +) { const events = readAll(dir); const listed = events.filter((e) => e.kind === 'index').length; const queries = events.filter((e) => e.kind === 'query').length; @@ -968,7 +1208,9 @@ export function indexBudget(dir, { floor = 150, base = 300, ceiling = 1200 } = { const hitRate = queries / listed; // 0% hit rate falls to the floor; ~50% and above reaches the ceiling. - const scaled = Math.round(floor + (ceiling - floor) * Math.min(1, hitRate * 2)); + const scaled = Math.round( + floor + (ceiling - floor) * Math.min(1, hitRate * 2) + ); return Math.max(floor, Math.min(ceiling, scaled)); } @@ -1005,7 +1247,10 @@ function seededRandom(seedText) { } /** Percentile bootstrap interval for a mean, paired delta, or other scalar. */ -export function bootstrapMeanInterval(values, { samples = 1000, seed = 'token-optimizer' } = {}) { +export function bootstrapMeanInterval( + values, + { samples = 1000, seed = 'token-optimizer' } = {} +) { const finite = values.map(numeric).filter((value) => value !== null); if (!finite.length) return { mean: null, low: null, high: null, n: 0 }; if (finite.length === 1) { @@ -1037,24 +1282,32 @@ export function proportionInterval(successes, total, z = 1.96) { const denominator = 1 + (z * z) / total; const centre = (rate + (z * z) / (2 * total)) / denominator; const radius = - (z / denominator) * Math.sqrt((rate * (1 - rate)) / total + (z * z) / (4 * total * total)); - return { rate, low: Math.max(0, centre - radius), high: Math.min(1, centre + radius), n: total }; + (z / denominator) * + Math.sqrt((rate * (1 - rate)) / total + (z * z) / (4 * total * total)); + return { + rate, + low: Math.max(0, centre - radius), + high: Math.min(1, centre + radius), + n: total, + }; } -const cohortKey = (run) => [ - run.client || 'unknown', - run.clientVersion || 'unknown', - run.model || 'unknown', - run.modelVersion || 'unknown', - run.taskId || 'unknown', -].join('|'); +const cohortKey = (run) => + [ + run.client || 'unknown', + run.clientVersion || 'unknown', + run.model || 'unknown', + run.modelVersion || 'unknown', + run.taskId || 'unknown', + ].join('|'); function armMetrics(runs) { const correct = runs.filter((run) => run.correct === true).length; - const interval = (field) => bootstrapMeanInterval( - runs.map((run) => run[field]).filter((value) => numeric(value) !== null), - { seed: field } - ); + const interval = (field) => + bootstrapMeanInterval( + runs.map((run) => run[field]).filter((value) => numeric(value) !== null), + { seed: field } + ); return { runs: runs.length, correctness: proportionInterval(correct, runs.length), @@ -1068,29 +1321,40 @@ function armMetrics(runs) { latencyMs: interval('latencyMs'), costUsd: interval('costUsd'), injectedTokens: interval('injectedTokens'), - harmfulFindings: runs.reduce((sum, run) => sum + (numeric(run.harmfulFindings) || 0), 0), + harmfulFindings: runs.reduce( + (sum, run) => sum + (numeric(run.harmfulFindings) || 0), + 0 + ), }; } -function pairedEffects(runs, controlArm, treatmentArm, contrastType = 'incremental') { +function pairedEffects( + runs, + controlArm, + treatmentArm, + contrastType = 'incremental' +) { const pairs = new Map(); for (const run of runs) { if (!run.pairId || ![controlArm, treatmentArm].includes(run.arm)) continue; if (!pairs.has(run.pairId)) pairs.set(run.pairId, {}); pairs.get(run.pairId)[run.arm] = run; } - const complete = [...pairs.values()].filter((pair) => pair[controlArm] && pair[treatmentArm]); - const delta = (field, lowerIsBetter = true) => bootstrapMeanInterval( - complete - .map((pair) => { - const baseline = numeric(pair[controlArm][field]); - const treatment = numeric(pair[treatmentArm][field]); - if (baseline === null || treatment === null) return null; - return lowerIsBetter ? baseline - treatment : treatment - baseline; - }) - .filter((value) => value !== null), - { seed: `${controlArm}:${treatmentArm}:${field}` } + const complete = [...pairs.values()].filter( + (pair) => pair[controlArm] && pair[treatmentArm] ); + const delta = (field, lowerIsBetter = true) => + bootstrapMeanInterval( + complete + .map((pair) => { + const baseline = numeric(pair[controlArm][field]); + const treatment = numeric(pair[treatmentArm][field]); + if (baseline === null || treatment === null) return null; + return lowerIsBetter ? baseline - treatment : treatment - baseline; + }) + .filter((value) => value !== null), + { seed: `${controlArm}:${treatmentArm}:${field}` } + ); return { arm: treatmentArm, controlArm, @@ -1111,21 +1375,32 @@ function pairedEffects(runs, controlArm, treatmentArm, contrastType = 'increment } function matchesFilters(event, filters) { - for (const field of ['client', 'clientVersion', 'model', 'modelVersion', 'taskId', 'arm']) { + for (const field of [ + 'client', + 'clientVersion', + 'model', + 'modelVersion', + 'taskId', + 'arm', + ]) { if (!filters[field]) continue; let actual = event[field]; if (event.kind === 'handoff-run') { - actual = field === 'taskId' - ? event.scenarioId - : field === 'arm' - ? event.arm - : (event.consumer?.[field] ?? event.producer?.[field] ?? event[field]); + actual = + field === 'taskId' + ? event.scenarioId + : field === 'arm' + ? event.arm + : (event.consumer?.[field] ?? + event.producer?.[field] ?? + event[field]); } else if (event.kind === 'concurrency-run') { - actual = field === 'taskId' - ? 'concurrent-combined' - : field === 'arm' - ? event.arm - : (event.consumer?.[field] ?? event[field]); + actual = + field === 'taskId' + ? 'concurrent-combined' + : field === 'arm' + ? event.arm + : (event.consumer?.[field] ?? event[field]); } if (String(actual || '') !== String(filters[field])) return false; } @@ -1134,11 +1409,15 @@ function matchesFilters(event, filters) { function handoffArmMetrics(runs) { const consumers = runs.map((run) => run.consumer || {}); - const count = (field) => consumers.filter((consumer) => consumer[field] === true).length; - const interval = (field) => bootstrapMeanInterval( - consumers.map((consumer) => consumer[field]).filter((value) => numeric(value) !== null), - { seed: `handoff:${field}` } - ); + const count = (field) => + consumers.filter((consumer) => consumer[field] === true).length; + const interval = (field) => + bootstrapMeanInterval( + consumers + .map((consumer) => consumer[field]) + .filter((value) => numeric(value) !== null), + { seed: `handoff:${field}` } + ); return { runs: runs.length, delivery: proportionInterval( @@ -1147,7 +1426,10 @@ function handoffArmMetrics(runs) { ), correctness: proportionInterval(count('correct'), runs.length), firstPass: proportionInterval(count('firstPass'), runs.length), - mistakeAttempted: proportionInterval(count('mistakeAttempted'), runs.length), + mistakeAttempted: proportionInterval( + count('mistakeAttempted'), + runs.length + ), mistakeExecuted: proportionInterval(count('mistakeExecuted'), runs.length), totalTokens: interval('totalTokens'), toolCalls: interval('toolCalls'), @@ -1163,16 +1445,21 @@ function handoffPairedEffect(runs, controlArm, treatmentArm) { if (!pairs.has(run.pairId)) pairs.set(run.pairId, {}); pairs.get(run.pairId)[run.arm] = run; } - const complete = [...pairs.values()].filter((pair) => pair[controlArm] && pair[treatmentArm]); - const effect = (field, positiveWhenLower = true) => bootstrapMeanInterval( - complete.map((pair) => { - const control = numeric(pair[controlArm].consumer?.[field]); - const treatment = numeric(pair[treatmentArm].consumer?.[field]); - if (control === null || treatment === null) return null; - return positiveWhenLower ? control - treatment : treatment - control; - }).filter((value) => value !== null), - { seed: `handoff:${controlArm}:${treatmentArm}:${field}` } + const complete = [...pairs.values()].filter( + (pair) => pair[controlArm] && pair[treatmentArm] ); + const effect = (field, positiveWhenLower = true) => + bootstrapMeanInterval( + complete + .map((pair) => { + const control = numeric(pair[controlArm].consumer?.[field]); + const treatment = numeric(pair[treatmentArm].consumer?.[field]); + if (control === null || treatment === null) return null; + return positiveWhenLower ? control - treatment : treatment - control; + }) + .filter((value) => value !== null), + { seed: `handoff:${controlArm}:${treatmentArm}:${field}` } + ); return { comparison: `${treatmentArm} vs ${controlArm}`, pairs: complete.length, @@ -1187,13 +1474,22 @@ function handoffPairedEffect(runs, controlArm, treatmentArm) { }; } -const handoffKey = (run) => [ - run.producer?.client || 'unknown', run.producer?.model || 'unknown', - run.consumer?.client || 'unknown', run.consumer?.model || 'unknown', - run.scenarioId || 'unknown', -].join('|'); - -const HANDOFF_REPORT_ARMS = ['empty', 'natural', 'oracle', 'irrelevant', 'stale']; +const handoffKey = (run) => + [ + run.producer?.client || 'unknown', + run.producer?.model || 'unknown', + run.consumer?.client || 'unknown', + run.consumer?.model || 'unknown', + run.scenarioId || 'unknown', + ].join('|'); + +const HANDOFF_REPORT_ARMS = [ + 'empty', + 'natural', + 'oracle', + 'irrelevant', + 'stale', +]; const handoffMinimumPairs = () => Math.max(2, Number(process.env.TOKEN_OPTIMIZER_HANDOFF_MIN_PAIRS) || 10); @@ -1206,43 +1502,54 @@ function handoffCohorts(runs) { } const minimumPairs = handoffMinimumPairs(); return [...grouped.entries()].map(([key, rows]) => { - const arms = Object.fromEntries(HANDOFF_REPORT_ARMS.map((arm) => [ - arm, handoffArmMetrics(rows.filter((row) => row.arm === arm)), - ])); + const arms = Object.fromEntries( + HANDOFF_REPORT_ARMS.map((arm) => [ + arm, + handoffArmMetrics(rows.filter((row) => row.arm === arm)), + ]) + ); const naturalRows = rows.filter((row) => row.arm === 'natural'); const naturalVsEmpty = handoffPairedEffect(rows, 'empty', 'natural'); const naturalVsOracle = handoffPairedEffect(rows, 'oracle', 'natural'); const captureRate = naturalRows.length - ? naturalRows.filter((row) => row.producer?.captureSuccess).length / naturalRows.length + ? naturalRows.filter((row) => row.producer?.captureSuccess).length / + naturalRows.length : null; const preActionDeliveryRate = naturalRows.length - ? naturalRows.filter((row) => row.delivery?.beforeFirstExecutedMistake).length / naturalRows.length + ? naturalRows.filter((row) => row.delivery?.beforeFirstExecutedMistake) + .length / naturalRows.length : null; const emptyRecurrence = arms.empty.mistakeExecuted.rate; const naturalRecurrence = arms.natural.mistakeExecuted.rate; - const relativeRecurrenceReduction = emptyRecurrence !== null - && naturalRecurrence !== null - && emptyRecurrence !== 0 - ? (emptyRecurrence - naturalRecurrence) / emptyRecurrence - : null; - const controlsSafe = ['irrelevant', 'stale'].every((arm) => - arms[arm].correctness.rate !== null - && arms.empty.correctness.rate !== null - && arms[arm].correctness.rate >= arms.empty.correctness.rate - 0.1 + const relativeRecurrenceReduction = + emptyRecurrence !== null && + naturalRecurrence !== null && + emptyRecurrence !== 0 + ? (emptyRecurrence - naturalRecurrence) / emptyRecurrence + : null; + const controlsSafe = ['irrelevant', 'stale'].every( + (arm) => + arms[arm].correctness.rate !== null && + arms.empty.correctness.rate !== null && + arms[arm].correctness.rate >= arms.empty.correctness.rate - 0.1 ); - const irrelevantSuppressed = arms.irrelevant.delivery.rate !== null - && arms.irrelevant.delivery.rate === 0; + const irrelevantSuppressed = + arms.irrelevant.delivery.rate !== null && + arms.irrelevant.delivery.rate === 0; const gates = { minimumPairs: naturalVsEmpty.pairs >= minimumPairs, capture: captureRate !== null && captureRate >= 0.8, recurrenceMagnitude: - relativeRecurrenceReduction !== null && relativeRecurrenceReduction >= 0.5, - recurrenceInterval: (naturalVsEmpty.executedMistakesPrevented.low ?? -Infinity) > 0, + relativeRecurrenceReduction !== null && + relativeRecurrenceReduction >= 0.5, + recurrenceInterval: + (naturalVsEmpty.executedMistakesPrevented.low ?? -Infinity) > 0, correctness: - arms.natural.correctness.rate !== null - && arms.empty.correctness.rate !== null - && arms.natural.correctness.rate >= arms.empty.correctness.rate - 0.1, - preActionDelivery: preActionDeliveryRate !== null && preActionDeliveryRate >= 0.8, + arms.natural.correctness.rate !== null && + arms.empty.correctness.rate !== null && + arms.natural.correctness.rate >= arms.empty.correctness.rate - 0.1, + preActionDelivery: + preActionDeliveryRate !== null && preActionDeliveryRate >= 0.8, negativeControls: controlsSafe && irrelevantSuppressed, }; const claimReady = Object.values(gates).every(Boolean); @@ -1269,14 +1576,24 @@ function handoffCohorts(runs) { function concurrencySummary(runs) { const natural = runs.filter((run) => run.arm === 'natural'); const writers = natural.reduce((sum, run) => sum + (run.writerCount || 0), 0); - const captures = natural.reduce((sum, run) => sum + (run.captureSuccesses || 0), 0); - const integrityPasses = natural.filter((run) => - run.integrity?.zeroLoss - && run.integrity?.parseable - && run.integrity?.orphanedFindings === 0 + const captures = natural.reduce( + (sum, run) => sum + (run.captureSuccesses || 0), + 0 + ); + const integrityPasses = natural.filter( + (run) => + run.integrity?.zeroLoss && + run.integrity?.parseable && + run.integrity?.orphanedFindings === 0 ).length; - const delivered = natural.reduce((sum, run) => sum + (run.delivery?.delivered || 0), 0); - const expected = natural.reduce((sum, run) => sum + (run.delivery?.expected || 0), 0); + const delivered = natural.reduce( + (sum, run) => sum + (run.delivery?.delivered || 0), + 0 + ); + const expected = natural.reduce( + (sum, run) => sum + (run.delivery?.expected || 0), + 0 + ); const effect = handoffPairedEffect(runs, 'empty', 'natural'); return { runs: runs.length, @@ -1286,7 +1603,8 @@ function concurrencySummary(runs) { integrityPassRate: natural.length ? integrityPasses / natural.length : null, deliveryCoverage: expected ? delivered / expected : null, naturalCorrectness: proportionInterval( - natural.filter((run) => run.consumer?.correct).length, natural.length + natural.filter((run) => run.consumer?.correct).length, + natural.length ), effect, }; @@ -1297,17 +1615,26 @@ function concurrencySummary(runs) { * live hook traces, and randomized model evals remain visibly distinct: only * `eval-run` records with paired arms can produce a causal effect interval. */ -function buildEvidenceReport(evidence, { filters = {}, episodeLimit = 100 } = {}) { +function buildEvidenceReport( + evidence, + { filters = {}, episodeLimit = 100 } = {} +) { evidence = evidence.filter((event) => matchesFilters(event, filters)); const runs = evidence.filter((event) => event.kind === 'eval-run'); const handoffRuns = evidence.filter((event) => event.kind === 'handoff-run'); - const concurrencyRuns = evidence.filter((event) => event.kind === 'concurrency-run'); + const concurrencyRuns = evidence.filter( + (event) => event.kind === 'concurrency-run' + ); const injections = evidence.filter((event) => event.kind === 'inject'); const outcomes = evidence.filter((event) => event.kind === 'tool-outcome'); - const feedback = evidence.filter((event) => event.kind === 'finding-feedback'); + const feedback = evidence.filter( + (event) => event.kind === 'finding-feedback' + ); const byInjection = new Map( - outcomes.filter((event) => event.injectionId).map((event) => [event.injectionId, event]) + outcomes + .filter((event) => event.injectionId) + .map((event) => [event.injectionId, event]) ); const traced = injections .slice() @@ -1337,7 +1664,10 @@ function buildEvidenceReport(evidence, { filters = {}, episodeLimit = 100 } = {} grouped.get(key).push(run); } - const minimumPairs = Math.max(2, Number(process.env.TOKEN_OPTIMIZER_EVAL_MIN_PAIRS) || 5); + const minimumPairs = Math.max( + 2, + Number(process.env.TOKEN_OPTIMIZER_EVAL_MIN_PAIRS) || 5 + ); const cohorts = [...grouped.entries()].map(([key, cohortRuns]) => { const arms = Object.fromEntries( ['baseline', 'optimizer', 'retrieval', 'full'].map((arm) => [ @@ -1387,7 +1717,9 @@ function buildEvidenceReport(evidence, { filters = {}, episodeLimit = 100 } = {} cohorts: cohorts.length, harmfulFeedback: harmful, harmRate: feedback.length ? harmful / feedback.length : null, - evidenceStatus: cohorts.some((cohort) => cohort.evidenceStatus === 'causal estimate available') + evidenceStatus: cohorts.some( + (cohort) => cohort.evidenceStatus === 'causal estimate available' + ) ? 'causal estimates available' : 'insufficient randomized evidence', }, @@ -1396,7 +1728,8 @@ function buildEvidenceReport(evidence, { filters = {}, episodeLimit = 100 } = {} concurrency, episodes: traced, methodology: { - intervals: 'deterministic percentile bootstrap (95%); Wilson interval for correctness', + intervals: + 'deterministic percentile bootstrap (95%); Wilson interval for correctness', causalRule: 'matched pairs estimate optimizer vs baseline, retrieval vs optimizer, full vs retrieval, and full vs baseline', minimumPairs, diff --git a/integrations/opencode/hooks/lib/observability.mjs b/integrations/opencode/hooks/lib/observability.mjs index b9980076e..ef1c037af 100644 --- a/integrations/opencode/hooks/lib/observability.mjs +++ b/integrations/opencode/hooks/lib/observability.mjs @@ -26,7 +26,7 @@ import { createHash, randomUUID } from 'node:crypto'; import { homedir } from 'node:os'; import { join } from 'node:path'; -export const HOOK_LOG_SCHEMA_VERSION = 1; +export const HOOK_LOG_SCHEMA_VERSION = 2; const DEFAULT_MAX_BYTES = 5 * 1024 * 1024; const DEFAULT_RETENTION_DAYS = 14; const DEFAULT_MAX_FILES = 40; @@ -197,6 +197,8 @@ export function beginHookInvocation(client, event, options = {}) { turnIdHash: null, toolUseIdHash: null, toolName: null, + rawToolName: null, + codeModeEnvelope: false, clientVersion: null, model: null, cwdHash: null, @@ -208,12 +210,29 @@ export function beginHookInvocation(client, event, options = {}) { const spanId = hash(invocationId, 16); let traceId = hash(invocationId, 32); + // Write the start before reading stdin or importing project state. If the + // host kills the process, Node aborts, or a native call terminates without a + // JavaScript exception, diagnostics can now reconcile this record with the + // missing completion instead of reporting a false success rate. + writeHookEvent({ + level: 'info', + event: 'hook.started', + invocationId, + traceId, + spanId, + client, + hookEvent: event, + processId: process.pid, + }); + let deadline; const finish = (nextOutcome = outcome, nextReason = reason, nextError = error) => { if (ended) return; ended = true; if (deadline) clearTimeout(deadline); process.removeListener('exit', onExit); + process.removeListener('uncaughtException', onUncaughtException); + process.removeListener('unhandledRejection', onUnhandledRejection); writeHookEvent({ level: nextOutcome === 'failure' || nextOutcome === 'timeout' ? 'error' : 'info', event: 'hook.completed', @@ -231,7 +250,17 @@ export function beginHookInvocation(client, event, options = {}) { }); }; const onExit = () => finish(); + const onUncaughtException = (nextError) => { + finish('failure', 'uncaught_exception', nextError); + process.exit(0); + }; + const onUnhandledRejection = (nextError) => { + finish('failure', 'unhandled_rejection', nextError); + process.exit(0); + }; process.once('exit', onExit); + process.once('uncaughtException', onUncaughtException); + process.once('unhandledRejection', onUnhandledRejection); deadline = setTimeout(() => { outcome = 'timeout'; @@ -250,6 +279,11 @@ export function beginHookInvocation(client, event, options = {}) { dimensions.turnIdHash = hash(raw.turn_id ?? raw.turnId ?? null); dimensions.toolUseIdHash = hash(raw.tool_use_id ?? raw.toolUseId ?? null); dimensions.toolName = payload?.tool_name ?? raw.tool_name ?? raw.toolName ?? null; + dimensions.rawToolName = dimension( + raw.tool_name ?? raw.toolName ?? raw.tool ?? null + ); + dimensions.codeModeEnvelope = + payload?.tool_input?.code_mode_envelope === true; dimensions.clientVersion = dimension(raw.client_version ?? raw.clientVersion ?? null); dimensions.model = dimension(raw.model ?? raw.model_name ?? raw.modelName ?? null); dimensions.cwdHash = hash(raw.cwd ?? raw.working_directory ?? process.cwd()); @@ -291,6 +325,11 @@ export function beginHookInvocation(client, event, options = {}) { error = nextError; finish(outcome, reason, error); }, + block(nextReason = 'policy_denied') { + outcome = 'blocked'; + reason = nextReason; + finish(outcome, reason); + }, succeed(nextReason = 'completed') { outcome = 'success'; reason = nextReason; @@ -321,7 +360,11 @@ export function recordHookBootstrapFailure(client, event, error) { }); } -export function readHookEvents({ limit = 200, sinceMs = 24 * 60 * 60 * 1000 } = {}) { +export function readHookEvents({ + limit = 200, + sinceMs = 24 * 60 * 60 * 1000, + includeLifecycle = false, +} = {}) { try { const dir = hookLogDirectory(); if (!existsSync(dir)) return []; @@ -338,6 +381,7 @@ export function readHookEvents({ limit = 200, sinceMs = 24 * 60 * 60 * 1000 } = try { const event = JSON.parse(line); if (Date.parse(event.timestamp) < cutoff) continue; + if (!includeLifecycle && event.event === 'hook.started') continue; events.push(event); if (events.length >= limit) return events; } catch { @@ -353,38 +397,102 @@ export function readHookEvents({ limit = 200, sinceMs = 24 * 60 * 60 * 1000 } = export function hookHealthSummary(options = {}) { const sinceMs = options.sinceMs ?? 24 * 60 * 60 * 1000; - const events = readHookEvents({ limit: options.limit || 5000, sinceMs }); - const durations = events + const events = readHookEvents({ + limit: options.limit || 10000, + sinceMs, + includeLifecycle: true, + }); + const starts = events.filter((event) => event.event === 'hook.started'); + const completed = events.filter( + (event) => event.event === 'hook.completed' || !event.event + ); + const completedIds = new Set( + completed.map((event) => event.invocationId).filter(Boolean) + ); + const abandoned = starts + .filter( + (event) => + event.invocationId && + !completedIds.has(event.invocationId) && + Date.now() - Date.parse(event.timestamp) > 10_000 + ) + .map((event) => ({ + ...event, + event: 'hook.completed', + outcome: 'failure', + reason: 'process_terminated_before_completion', + durationMs: null, + })); + const runs = [...completed, ...abandoned]; + const durations = runs .map((event) => Number(event.durationMs)) .filter(Number.isFinite) .sort((a, b) => a - b); - const failures = events.filter((event) => event.outcome === 'failure'); - const timeouts = events.filter((event) => event.outcome === 'timeout'); - const skipped = events.filter((event) => event.outcome === 'skipped'); - const successes = events.filter((event) => event.outcome === 'success'); + const failures = runs.filter((event) => event.outcome === 'failure'); + const timeouts = runs.filter((event) => event.outcome === 'timeout'); + const skipped = runs.filter((event) => event.outcome === 'skipped'); + const blocked = runs.filter((event) => event.outcome === 'blocked'); + const successes = runs.filter((event) => event.outcome === 'success'); + const actions = runs.filter( + (event) => event.hookEvent === 'pre-tool' && event.toolName + ); + const byTool = {}; + for (const event of actions) { + const name = String(event.toolName || 'Unknown action'); + byTool[name] = (byTool[name] || 0) + 1; + } const byClient = {}; - for (const event of events) { + for (const event of runs) { const key = event.client || 'unknown'; - const current = byClient[key] || { total: 0, failures: 0, timeouts: 0, skipped: 0 }; + const current = byClient[key] || { + total: 0, + failures: 0, + timeouts: 0, + skipped: 0, + blocked: 0, + hookEvents: [], + }; current.total++; if (event.outcome === 'failure') current.failures++; if (event.outcome === 'timeout') current.timeouts++; if (event.outcome === 'skipped') current.skipped++; + if (event.outcome === 'blocked') current.blocked++; + if (event.hookEvent && !current.hookEvents.includes(event.hookEvent)) + current.hookEvents.push(event.hookEvent); byClient[key] = current; } + for (const current of Object.values(byClient)) current.hookEvents.sort(); const percentile = (p) => durations.length ? durations[Math.min(durations.length - 1, Math.floor(durations.length * p))] : null; const summary = { schemaVersion: HOOK_LOG_SCHEMA_VERSION, - available: events.length > 0, + available: runs.length > 0, windowHours: sinceMs / 3_600_000, - total: events.length, + total: runs.length, failures: failures.length, timeouts: timeouts.length, skipped: skipped.length, - successRate: events.length ? successes.length / events.length : null, + blocked: blocked.length, + abandoned: abandoned.length, + successes: successes.length, + actions: actions.length, + byTool, + // A skipped or policy-blocked hook completed its protocol successfully. + // Runtime health answers whether the hook process worked, not whether the + // optimizer chose to allow the host operation. + successRate: runs.length + ? (successes.length + skipped.length + blocked.length) / runs.length + : null, + healthStatus: + failures.length || timeouts.length + ? 'failing' + : skipped.length + ? 'degraded' + : runs.length + ? 'healthy' + : 'unavailable', p50DurationMs: percentile(0.5), p95DurationMs: percentile(0.95), byClient, diff --git a/integrations/opencode/hooks/lib/pricing.mjs b/integrations/opencode/hooks/lib/pricing.mjs index a4b09ebed..50b23b9cc 100644 --- a/integrations/opencode/hooks/lib/pricing.mjs +++ b/integrations/opencode/hooks/lib/pricing.mjs @@ -1,101 +1,72 @@ // GENERATED FILE -- do not edit. // Source of truth: hooks-core/pricing.mjs. Regenerate with `npm run sync:hooks`. /** - * Tokens in the unit people actually feel. + * Optional cost equivalents for token measurements. * - * The same data as everywhere else in this project, converted once, in one - * place, under three rules that keep a dollar figure honest: - * - * THE TABLE IS VISIBLE AND OVERRIDABLE. Prices change. A figure computed from - * rates buried in code goes stale silently and keeps looking authoritative, - * which is worse than no figure. The rates are printed next to the number and - * can be replaced without a release. - * - * DOLLARS BESIDE TOKENS, NOT INSTEAD OF THEM. The token count is exact and - * never goes out of date; the dollar figure is the token count times a rate - * that will. Showing both means the durable number survives the perishable - * one. - * - * NEVER A PRICE ON AN UNMEASURED SAVING. An invented number is worse in - * dollars than in tokens, because dollars get quoted to other people. A - * saving we cannot measure returns null and renders as "not yet measurable", - * never as "$0.00" -- which reads as "saved nothing" and is a different - * claim entirely. + * The runtime cannot infer a user's bill from a client name. A CLI may route + * through a subscription, direct API account, cloud marketplace, enterprise + * agreement, included credits, or a provider whose cache-read/write prices + * differ. Provider usage records are the source of truth when available. + * Everywhere else this module fails closed until the user supplies an + * effective blended input-token rate. */ -/** Cache multipliers, relative to an input token. */ -const CACHE_WRITE = 1.25; -const CACHE_READ = 0.1; +const RATE_ENV = 'TOKEN_OPTIMIZER_EFFECTIVE_INPUT_USD_PER_MILLION'; -/** - * Dollars per million tokens, by tier. - * - * Approximate published list rates as of 2026-07, carried with their date so a - * stale table is visible rather than silent. Override wholesale with - * TOKEN_OPTIMIZER_PRICES as JSON, e.g. - * {"asOf":"2026-09","opus":{"input":15,"output":75}}. - */ -const DEFAULT_PRICES = { - asOf: '2026-07', - haiku: { input: 1, output: 5 }, - sonnet: { input: 3, output: 15 }, - opus: { input: 15, output: 75 }, -}; - -export function prices() { - const raw = process.env.TOKEN_OPTIMIZER_PRICES; - if (raw) { - try { - const parsed = JSON.parse(raw); - if (parsed && typeof parsed === 'object') return { ...DEFAULT_PRICES, ...parsed, overridden: true }; - } catch { /* an unparseable override is ignored rather than fatal */ } - } - return DEFAULT_PRICES; +/** The configured effective input rate, or null when billing is unobservable. */ +export function effectiveRate() { + const rate = Number(process.env[RATE_ENV]); + return Number.isFinite(rate) && rate > 0 ? rate : null; } /** - * What a number of tokens costs. + * Dollars for a measured token quantity. * - * Returns null for a null input and says why: this is the single place the - * "unmeasured must not become $0.00" rule is enforced, so every caller inherits - * it rather than each remembering. + * `kind` is accepted for backwards compatibility but is not multiplied by a + * universal cache constant. The configured rate must already reflect the + * user's actual provider/model/cache/tier/credit mix. */ -export function dollars(tokens, { tier = 'opus', kind = 'input' } = {}) { - if (!Number.isFinite(tokens)) return null; - - const table = prices(); - const rate = table[tier]?.[kind === 'output' ? 'output' : 'input']; - if (!Number.isFinite(rate)) return null; - - const multiplier = kind === 'cacheWrite' ? CACHE_WRITE : kind === 'cacheRead' ? CACHE_READ : 1; - return (tokens / 1_000_000) * rate * multiplier; +export function dollars(tokens, { rate = effectiveRate() } = {}) { + if (!Number.isFinite(tokens) || !Number.isFinite(rate) || rate <= 0) { + return null; + } + return (tokens / 1_000_000) * rate; } /** A dollar figure, or an honest absence of one. */ -export function money(amount, { unmeasured = 'not yet measurable' } = {}) { +export function money(amount, { unmeasured = 'not priced' } = {}) { if (amount == null || !Number.isFinite(amount)) return unmeasured; if (amount === 0) return '$0.00'; if (Math.abs(amount) < 0.01) return '<$0.01'; return `$${amount.toFixed(2)}`; } -/** - * Per-session tokens expressed as a monthly cost. - * - * `sessionsPerMonth` is an assumption, so it is stated in the output rather - * than folded invisibly into the number. - */ -export function monthly(tokensPerSession, { tier = 'opus', sessionsPerMonth = 60 } = {}) { +/** Per-session tokens expressed with a stated monthly-frequency assumption. */ +export function monthly(tokensPerSession, { sessionsPerMonth = 60 } = {}) { if (!Number.isFinite(tokensPerSession)) return null; - const amount = dollars(tokensPerSession * sessionsPerMonth, { tier }); - return amount == null ? null : { amount, sessionsPerMonth, tier }; + const amount = dollars(tokensPerSession * sessionsPerMonth); + return amount == null + ? null + : { + amount, + sessionsPerMonth, + effectiveInputUsdPerMillion: effectiveRate(), + }; +} + +/** The pricing basis printed beside any cost equivalent. */ +export function priceNote() { + const rate = effectiveRate(); + return rate == null + ? `cost not priced: set ${RATE_ENV} to your effective blended input rate after provider, model, cache, tier, plan, and credits` + : `cost equivalent: configured effective input rate $${rate}/M via ${RATE_ENV}`; } -/** The rates behind the figures, for printing beside them. */ -export function priceNote(tier = 'opus') { - const table = prices(); - const rate = table[tier]; - if (!rate) return null; - return `prices: ${tier} $${rate.input}/$${rate.output} per Mtok (${table.asOf}` + - `${table.overridden ? ', overridden' : ''}); override with TOKEN_OPTIMIZER_PRICES`; +/** Compatibility surface: no built-in provider table is claimed. */ +export function prices() { + return { + source: + effectiveRate() == null ? 'unavailable' : 'configured-effective-rate', + effectiveInputUsdPerMillion: effectiveRate(), + }; } diff --git a/integrations/opencode/hooks/lib/routing.mjs b/integrations/opencode/hooks/lib/routing.mjs index 9b4642e0e..84ac866e4 100644 --- a/integrations/opencode/hooks/lib/routing.mjs +++ b/integrations/opencode/hooks/lib/routing.mjs @@ -65,7 +65,9 @@ export function modelCosts() { if (raw) { try { return JSON.parse(raw); - } catch { /* fall through to the defaults */ } + } catch { + /* fall through to the defaults */ + } } return { haiku: 1, sonnet: 3, opus: 15 }; } @@ -106,7 +108,12 @@ export function readEpisodes(path, { maxBytes = 6_000_000 } = {}) { let current = null; const close = () => { - if (current && current.turns > 0) episodes.push({ ...current, files: [...current.files], tools: [...current.tools] }); + if (current && current.turns > 0) + episodes.push({ + ...current, + files: [...current.files], + tools: [...current.tools], + }); current = null; }; @@ -123,7 +130,15 @@ export function readEpisodes(path, { maxBytes = 6_000_000 } = {}) { // a new episode. if (row.type === 'user' && typeof row.message?.content === 'string') { close(); - current = { at: Date.parse(row.timestamp) || null, models: new Set(), tools: new Set(), files: new Set(), turns: 0, errors: 0, results: 0 }; + current = { + at: Date.parse(row.timestamp) || null, + models: new Set(), + tools: new Set(), + files: new Set(), + turns: 0, + errors: 0, + results: 0, + }; continue; } if (!current) continue; @@ -157,8 +172,16 @@ export function readEpisodes(path, { maxBytes = 6_000_000 } = {}) { /* ---------------------------------------------------------------- SHAPES */ -const EDIT_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit', 'smart_edit', 'smart_write']); -const TEST_PATTERN = /\b(test|jest|pytest|vitest|dotnet test|npm test|go test)\b/i; +const EDIT_TOOLS = new Set([ + 'Edit', + 'MultiEdit', + 'Write', + 'NotebookEdit', + 'smart_edit', + 'smart_write', +]); +const TEST_PATTERN = + /\b(test|jest|pytest|vitest|dotnet test|npm test|go test)\b/i; /** * What kind of work this was. @@ -171,11 +194,19 @@ export function classifyShape(episode) { const tools = new Set(episode.tools || []); const edited = [...tools].some((t) => EDIT_TOOLS.has(t)); const files = (episode.files || []).length; - const ranTests = [...tools].some((t) => TEST_PATTERN.test(t)) || (episode.tools || []).includes('Bash'); + const ranTests = + [...tools].some((t) => TEST_PATTERN.test(t)) || + (episode.tools || []).includes('Bash'); if (edited && files >= 3) return 'multi-file-change'; if (edited) return 'single-file-change'; - if (files > 0 || tools.has('Grep') || tools.has('Glob') || tools.has('smart_grep')) return 'investigation'; + if ( + files > 0 || + tools.has('Grep') || + tools.has('Glob') || + tools.has('smart_grep') + ) + return 'investigation'; if (ranTests) return 'build-or-test'; return 'conversation'; } @@ -208,7 +239,13 @@ export function outcomeTable(episodes) { const tier = [...tiers][0]; table[shape] = table[shape] || {}; - const cell = table[shape][tier] = table[shape][tier] || { episodes: 0, turns: 0, errors: 0, results: 0, errored: 0 }; + const cell = (table[shape][tier] = table[shape][tier] || { + episodes: 0, + turns: 0, + errors: 0, + results: 0, + errored: 0, + }); cell.episodes += 1; cell.turns += episode.turns; cell.errors += episode.errors; @@ -287,7 +324,8 @@ export function route(shape, table = {}) { shape, recommend: heuristic, basis: 'heuristic', - reason: `every measured tier for ${shape} needed a retry in more than ` + + reason: + `every measured tier for ${shape} needed a retry in more than ` + `${Math.round(MAX_ERROR_RATE * 100)}% of episodes; falling back to the shipped default`, excluded: excluded.map((o) => ({ tier: o.tier, errorRate: o.errorRate })), options, @@ -306,17 +344,24 @@ export function route(shape, table = {}) { recommend: best.tier, basis: 'measured', // What each mistake would cost, from this project's own rates. - overpowered: strongest.tier === best.tier ? null : { - tier: strongest.tier, - wasted: Number((strongest.cost - best.cost).toFixed(2)), - }, - underpowered: cheapest.tier === best.tier ? null : { - tier: cheapest.tier, - expectedCost: Number(cheapest.cost.toFixed(2)), - errorRate: cheapest.errorRate, - excluded: cheapest.errorRate > MAX_ERROR_RATE, - }, - reason: `${shape}: ${best.tier} costs ${best.cost.toFixed(2)} in expectation over ${best.episodes} episodes ` + + overpowered: + strongest.tier === best.tier + ? null + : { + tier: strongest.tier, + wasted: Number((strongest.cost - best.cost).toFixed(2)), + }, + underpowered: + cheapest.tier === best.tier + ? null + : { + tier: cheapest.tier, + expectedCost: Number(cheapest.cost.toFixed(2)), + errorRate: cheapest.errorRate, + excluded: cheapest.errorRate > MAX_ERROR_RATE, + }, + reason: + `${shape}: ${best.tier} costs ${best.cost.toFixed(2)} in expectation over ${best.episodes} episodes ` + `(${Math.round(best.errorRate * 100)}% needed a retry)`, options, }; @@ -340,20 +385,31 @@ export function routingNote(shape, table, { currentModel, switchCost } = {}) { const lines = [decision.reason]; if (decision.underpowered) { - lines.push(` ${decision.underpowered.tier} looks cheaper per token, but needed a retry in ` + - `${Math.round(decision.underpowered.errorRate * 100)}% of episodes -- expected cost ${decision.underpowered.expectedCost}` + - `${decision.underpowered.excluded ? ', which is why it is not a candidate here at any price' : ''}.`); + lines.push( + ` ${decision.underpowered.tier} looks cheaper per token, but needed a retry in ` + + `${Math.round(decision.underpowered.errorRate * 100)}% of episodes -- expected cost ${decision.underpowered.expectedCost}` + + `${decision.underpowered.excluded ? ', which is why it is not a candidate here at any price' : ''}.` + ); } if (decision.overpowered) { - lines.push(` ${decision.overpowered.tier} would spend ${decision.overpowered.wasted} more than needed here.`); + lines.push( + ` ${decision.overpowered.tier} would spend ${decision.overpowered.wasted} more than needed here.` + ); } if (switchCost?.prefixTokens) { - lines.push(` Switching now also discards a ${switchCost.prefixTokens.toLocaleString()}-token warm prefix ` + - `(~${switchCost.rewriteCost.toLocaleString()} tokens to re-write); consider switching at the next break.`); + lines.push( + ` Switching now also discards a ${switchCost.prefixTokens.toLocaleString()}-token warm prefix ` + + `(~${switchCost.rewriteInputCostEquivalent.toLocaleString()} Anthropic uncached-input cost-equivalent tokens to re-write); consider switching at the next break.` + ); } - return { shape, recommend: decision.recommend, basis: decision.basis, text: lines.join('\n') }; + return { + shape, + recommend: decision.recommend, + basis: decision.basis, + text: lines.join('\n'), + }; } /** @@ -371,7 +427,9 @@ export function routingBriefing(table) { const decision = route(shape, table); if (decision.basis !== 'measured') continue; if (decision.recommend === HEURISTIC[shape]) continue; // nothing learned worth saying - lines.push(`${shape.replace(/-/g, ' ')} work goes better on ${decision.recommend} in this project.`); + lines.push( + `${shape.replace(/-/g, ' ')} work goes better on ${decision.recommend} in this project.` + ); } return lines.length ? lines.join('\n') : null; } @@ -389,7 +447,11 @@ export function routingBriefing(table) { * few more episodes accumulated would invalidate the cache behind it. Stability * is the feature; the memo is how it is bought. */ -export function cachedRoutingBriefing(dir, transcriptPath, { growthBytes = 4_000_000, maxAgeMs = 24 * 60 * 60 * 1000 } = {}) { +export function cachedRoutingBriefing( + dir, + transcriptPath, + { growthBytes = 4_000_000, maxAgeMs = 24 * 60 * 60 * 1000 } = {} +) { const memoPath = join(dir, 'routing-brief.json'); let stat; try { @@ -400,17 +462,31 @@ export function cachedRoutingBriefing(dir, transcriptPath, { growthBytes = 4_000 try { const memo = JSON.parse(readFileSync(memoPath, 'utf8')); - const fresh = memo.path === transcriptPath - && Math.abs(stat.size - memo.size) < growthBytes - && Date.now() - memo.at < maxAgeMs; + const fresh = + memo.path === transcriptPath && + Math.abs(stat.size - memo.size) < growthBytes && + Date.now() - memo.at < maxAgeMs; if (fresh) return memo.text || null; - } catch { /* no memo, or an unreadable one; recompute */ } + } catch { + /* no memo, or an unreadable one; recompute */ + } const text = routingBriefing(outcomeTable(readEpisodes(transcriptPath))); try { mkdirSync(dir, { recursive: true, mode: 0o700 }); - writeFileSync(memoPath, JSON.stringify({ path: transcriptPath, size: stat.size, at: Date.now(), text }), { mode: 0o600 }); - } catch { /* the memo is an optimisation; failing to write it costs latency, not correctness */ } + writeFileSync( + memoPath, + JSON.stringify({ + path: transcriptPath, + size: stat.size, + at: Date.now(), + text, + }), + { mode: 0o600 } + ); + } catch { + /* the memo is an optimisation; failing to write it costs latency, not correctness */ + } return text; } @@ -427,9 +503,11 @@ export function routingReport(table) { for (const tier of Object.keys(table[shape])) { const cell = table[shape][tier]; const cost = expectedCost(tier, cell); - lines.push(` ${tier.padEnd(7)} ${cell.episodes} episode(s), ` + - `${cell.meanTurns.toFixed(1)} turns avg, ${Math.round(cell.errorRate * 100)}% needed a retry, ` + - `expected cost ${cost.cost.toFixed(2)}${cell.measured ? '' : ' [too few episodes to trust]'}`); + lines.push( + ` ${tier.padEnd(7)} ${cell.episodes} episode(s), ` + + `${cell.meanTurns.toFixed(1)} turns avg, ${Math.round(cell.errorRate * 100)}% needed a retry, ` + + `expected cost ${cost.cost.toFixed(2)}${cell.measured ? '' : ' [too few episodes to trust]'}` + ); } } return lines.join('\n'); diff --git a/integrations/opencode/hooks/lib/symbols.mjs b/integrations/opencode/hooks/lib/symbols.mjs index 2a80e1e42..4d80666ac 100644 --- a/integrations/opencode/hooks/lib/symbols.mjs +++ b/integrations/opencode/hooks/lib/symbols.mjs @@ -50,7 +50,11 @@ const PATTERNS = { /^\s*(?:pub(?:\([^)]*\))?\s+)?(?:struct|enum|trait|impl)\s+([A-Za-z_]\w*)/, ], clike: [ - /^\s*(?:public|private|protected|internal|static|final|abstract|virtual|override|async|\s)*(?:[\w<>\[\],.?]+\s+)([A-Za-z_]\w*)\s*\([^;]*\)\s*\{/, + // Accept both K&R (`Run() {`) and Allman (`Run()` then `{`) declarations. + // Requiring the opening brace on the declaration line silently discarded + // nearly every method in idiomatic C# projects, leaving file nodes but no + // symbol nodes and making valid `file.cs#Method` anchors unresolved. + /^\s*(?:public|private|protected|internal|static|final|abstract|virtual|override|async|sealed|new|extern|unsafe|partial|\s)*(?:[\w<>\[\],.?]+\s+)([A-Za-z_]\w*)(?:<[^;()]+>)?\s*\([^;]*\)\s*(?:\{|=>|$)/, /^\s*(?:public|private|protected|internal|static|abstract|sealed|partial|\s)*(?:class|struct|interface|enum|record)\s+([A-Za-z_]\w*)/, ], ruby: [ diff --git a/integrations/qwen/hooks/lib/adapter.mjs b/integrations/qwen/hooks/lib/adapter.mjs index fee0102b9..9f76bbdea 100644 --- a/integrations/qwen/hooks/lib/adapter.mjs +++ b/integrations/qwen/hooks/lib/adapter.mjs @@ -188,8 +188,143 @@ function contextOutput(client, eventName, additionalContext) { }; } +/** Convert one static JavaScript string literal without evaluating code. */ +function codexStringLiteral(literal) { + if (typeof literal !== 'string' || literal.length < 2) return null; + const quote = literal[0]; + if (!['"', "'", '`'].includes(quote) || literal.at(-1) !== quote) return null; + + let decoded = ''; + for (let index = 1; index < literal.length - 1; index += 1) { + const character = literal[index]; + if (quote === '`' && character === '$' && literal[index + 1] === '{') + return null; + if (character !== '\\') { + decoded += character; + continue; + } + + index += 1; + if (index >= literal.length - 1) return null; + const escaped = literal[index]; + const simple = { + b: '\b', f: '\f', n: '\n', r: '\r', t: '\t', v: '\v', 0: '\0', + '\\': '\\', "'": "'", '"': '"', '`': '`', '$': '$', + }; + if (Object.hasOwn(simple, escaped)) { + decoded += simple[escaped]; + continue; + } + if (escaped === '\n') continue; + if (escaped === '\r') { + if (literal[index + 1] === '\n') index += 1; + continue; + } + if (escaped === 'x') { + const digits = literal.slice(index + 1, index + 3); + if (!/^[0-9a-f]{2}$/i.test(digits)) return null; + decoded += String.fromCodePoint(Number.parseInt(digits, 16)); + index += 2; + continue; + } + if (escaped === 'u') { + const braced = literal[index + 1] === '{'; + const close = braced ? literal.indexOf('}', index + 2) : index + 5; + const digits = braced + ? literal.slice(index + 2, close) + : literal.slice(index + 1, close); + if ( + close < 0 || + !(braced ? /^[0-9a-f]{1,6}$/i : /^[0-9a-f]{4}$/i).test(digits) + ) return null; + const codePoint = Number.parseInt(digits, 16); + if (codePoint > 0x10ffff) return null; + decoded += String.fromCodePoint(codePoint); + index = close; + continue; + } + // JavaScript identity escapes drop the slash (for example, '\q' is 'q'). + decoded += escaped; + } + return decoded; +} + /** Convert client-specific lifecycle envelopes into the common tool shape. */ +function codexStringBindings(source) { + const values = new Map(); + const declaration = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*((?:"(?:\\.|[^"\\])*")|(?:'(?:\\.|[^'\\])*')|(?:`(?:\\.|[^`\\])*`))/gs; + for (const match of String(source || '').matchAll(declaration)) { + try { + const value = codexStringLiteral(match[2]); + if (value !== null) values.set(match[1], value); + } catch { + // A computed or malformed JavaScript expression is not safe to execute + // merely to understand a hook envelope. Leave it opaque. + } + } + return values; +} + +function codexCallArguments(source, method) { + const calls = []; + const pattern = new RegExp(`(?:tools\\.)?${method}\\s*\\(\\s*([^),]+)`, 'gi'); + for (const match of String(source || '').matchAll(pattern)) + calls.push(match[1].trim()); + return calls; +} + +function codexLiteral(value, bindings) { + if (bindings.has(value)) return String(bindings.get(value)); + return codexStringLiteral(value); +} + +function codexExecCommands(source, bindings) { + const commands = []; + for (const call of String(source || '').matchAll( + /(?:tools\.)?exec_command\s*\(\s*\{([\s\S]*?)\}\s*\)/gi + )) { + const property = /\bcmd\s*:\s*((?:"(?:\\.|[^"\\])*")|(?:'(?:\\.|[^'\\])*')|(?:`(?:\\.|[^`\\])*`)|(?:[A-Za-z_$][\w$]*))/s.exec(call[1]); + if (!property) continue; + const command = codexLiteral(property[1], bindings); + if (command) commands.push(command); + } + return commands; +} + export function normalizeClientPayload(clientName, event, raw) { + if (clientName === 'codex') { + const outerName = String(raw.tool_name ?? raw.toolName ?? raw.tool ?? ''); + if (/^(?:functions\.)?exec$/i.test(outerName)) { + const envelope = raw.tool_input ?? raw.toolInput ?? raw.arguments ?? raw.args; + const source = + typeof envelope === 'string' + ? envelope + : envelope?.code ?? envelope?.input ?? envelope?.source ?? ''; + const bindings = codexStringBindings(source); + const patchCalls = codexCallArguments(source, 'apply_patch'); + const patch = patchCalls + .map((argument) => codexLiteral(argument, bindings)) + .find(Boolean); + const mutation = patchCalls.length > 0; + const commands = codexExecCommands(source, bindings); + + // `functions.exec` is also the orchestration envelope for web, image and + // other non-filesystem tools. Do not reinterpret those as shell commands: + // doing so produced irrelevant cross-project advice and fabricated graph + // activity for operations the filesystem hooks never observed. + if (!mutation && !commands.length) return raw; + return { + ...raw, + tool_name: mutation ? 'apply_patch' : 'run_command', + tool_input: { + ...(envelope && typeof envelope === 'object' ? envelope : {}), + command: String(patch || commands.join('\n') || source || ''), + code_mode_envelope: true, + }, + }; + } + } + if (clientName === 'cline') { const body = event === 'post-tool' ? raw.postToolUse : raw.preToolUse; if (!body) return raw; @@ -385,6 +520,22 @@ function projectBriefing() { function observeAndInject(payload, state, episode, features) { const touched = touchedFiles(payload); const dirFor = (path) => wikiDir(projectRootFor(path, payload.cwd)); + const registerRoot = (root) => { + const dir = wikiDir(root); + registerProject({ + root, + graphDir: dir, + client: episode.client || 'unknown', + }); + return dir; + }; + + // SessionStart is not delivered consistently by every host/version. The + // graph was still written in that case, but the dashboard registry never + // learned it existed. Register every repository observed by a real tool call + // as a second, idempotent production path. + for (const { path } of touched) + registerRoot(projectRootFor(path, payload.cwd)); const bytes = readCostBytes(payload); if (bytes && payload.tool_input.file_path) { @@ -425,7 +576,7 @@ function observeAndInject(payload, state, episode, features) { const command = payload.tool_input?.command; if (command) { const root = commandProjectRoot(payload, payload.cwd); - const dir = wikiDir(root); + const dir = registerRoot(root); const local = forCommand(dir, load(dir), command, { sessionId: payload.session_id, alreadyInjected, @@ -750,7 +901,14 @@ async function runHook(clientName, event, invocation) { client.canDeny && event === 'pre-tool' && !repeat && - mode() !== MODE_ADVISE + mode() !== MODE_ADVISE && + // Codex code mode presents an orchestration program as one outer + // `functions.exec` call. Refusing that envelope is rendered by the host + // as a failed script and can discard several otherwise-safe nested + // operations. Observe it, capture it and advise, but reserve a hard veto + // for legacy one-operation Codex tool calls where the denied operation is + // unambiguous. + !(clientName === 'codex' && payload.tool_input?.code_mode_envelope) ); if (canRefuse) { @@ -759,19 +917,23 @@ async function runHook(clientName, event, invocation) { // Every client's refusal carries the off switch, for the same reason Claude // Code's does: enforcement that hides its own disable is coercive. if (client.denyStyle === 'top-level') { + invocation.block('policy_denied'); emit({ decision: 'deny', reason: withEscape(verdict.reason) }); } else if (client.denyStyle === 'top-level-permission') { + invocation.block('policy_denied'); emit({ permissionDecision: 'deny', permissionDecisionReason: withEscape(verdict.reason), }); } else if (client.denyStyle === 'cline') { + invocation.block('policy_denied'); emit({ cancel: true, contextModification: '', errorMessage: withEscape(verdict.reason), }); } else if (client.denyStyle === 'cursor') { + invocation.block('policy_denied'); emit({ continue: true, permission: 'deny', @@ -780,9 +942,11 @@ async function runHook(clientName, event, invocation) { 'Token Optimizer redirected an expensive built-in operation.', }); } else if (client.denyStyle === 'exit-2') { + invocation.block('policy_denied'); process.stderr.write(withEscape(verdict.reason)); process.exit(2); } else { + invocation.block('policy_denied'); emit({ hookSpecificOutput: { hookEventName: eventName, diff --git a/integrations/qwen/hooks/lib/audit.mjs b/integrations/qwen/hooks/lib/audit.mjs index b82186586..df8a08b82 100644 --- a/integrations/qwen/hooks/lib/audit.mjs +++ b/integrations/qwen/hooks/lib/audit.mjs @@ -50,15 +50,17 @@ export function decline(dir, id) { export function declines(dir, { events = readMetrics(dir) } = {}) { const counts = new Map(); for (const event of events) { - if (event.kind !== 'advice' || event.action !== 'declined' || !event.id) continue; + if (event.kind !== 'advice' || event.action !== 'declined' || !event.id) + continue; counts.set(event.id, (counts.get(event.id) || 0) + 1); } return counts; } -const idOf = (finding) => finding.remedy - ? `${finding.remedy.type}:${finding.remedy.anchor || finding.remedy.file || (finding.remedy.anchors || []).join(',')}` - : `${finding.id}:${finding.anchor || finding.file || ''}`; +const idOf = (finding) => + finding.remedy + ? `${finding.remedy.type}:${finding.remedy.anchor || finding.remedy.file || (finding.remedy.anchors || []).join(',')}` + : `${finding.id}:${finding.anchor || finding.file || ''}`; /** * Whether a habit improved after we said something about it. @@ -68,12 +70,18 @@ const idOf = (finding) => finding.remedy * -- a trend computed from one session on each side is a coin flip with a * percentage sign. */ -export function habitTrend(dir, detector, { events = readMetrics(dir), minSessions = 2, anchors = null } = {}) { +export function habitTrend( + dir, + detector, + { events = readMetrics(dir), minSessions = 2, anchors = null } = {} +) { // SPLIT BY POSITION, NOT BY TIMESTAMP. The log is append-only, so its order is // authoritative -- while `at` has millisecond granularity, and a burst of // events inside one millisecond would land on the wrong side of the split or // collapse one side to nothing. Position is exact where the clock is not. - const raisedAt = events.findIndex((e) => e.kind === 'advice' && e.detector === detector); + const raisedAt = events.findIndex( + (e) => e.kind === 'advice' && e.detector === detector + ); if (raisedAt === -1) return null; // SCOPED TO THE FILES THIS DETECTOR NAMED. Counting every read in the window @@ -114,7 +122,12 @@ export function habitTrend(dir, detector, { events = readMetrics(dir), minSessio /** Marks a recommendation as having been raised, so its effect can be scored. */ export function raise(dir, finding) { - record(dir, { kind: 'advice', action: 'raised', id: idOf(finding), detector: finding.id }); + record(dir, { + kind: 'advice', + action: 'raised', + id: idOf(finding), + detector: finding.id, + }); } /** @@ -123,7 +136,11 @@ export function raise(dir, finding) { * `findings` comes from the detectors; `extra` lets the cache and routing * modules contribute without this file importing the world. */ -export function buildQueue(dir, findings = [], { events = readMetrics(dir) } = {}) { +export function buildQueue( + dir, + findings = [], + { events = readMetrics(dir) } = {} +) { const declined = declines(dir, { events }); const ledger = remedyLedger(dir); const applied = new Set(ledger.filter((r) => !r.revertedAt).map((r) => r.id)); @@ -146,7 +163,9 @@ export function buildQueue(dir, findings = [], { events = readMetrics(dir) } = { } return { - queue: queue.sort((a, b) => (b.costPerSession ?? 0) - (a.costPerSession ?? 0)), + queue: queue.sort( + (a, b) => (b.costPerSession ?? 0) - (a.costPerSession ?? 0) + ), suppressed, done: ledger.filter((r) => !r.revertedAt), }; @@ -161,7 +180,11 @@ export function buildQueue(dir, findings = [], { events = readMetrics(dir) } = { * many were withheld -- and what they are collectively worth -- keeps that from * being a silent cap. */ -export function renderAudit(dir, findings = [], { tier = 'opus', full = false, sessionsPerMonth = 60 } = {}) { +export function renderAudit( + dir, + findings = [], + { tier = 'opus', full = false, sessionsPerMonth = 60 } = {} +) { const { queue, suppressed, done } = buildQueue(dir, findings); const lines = []; @@ -175,22 +198,29 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s // read as "this costs nothing" beside a finding whose cost we simply do not // know -- the same unknown-becomes-zero error the rest of this project // corrects, in the one unit that gets quoted to other people. - const priced = item.costPerSession == null ? null : monthly(cost, { tier, sessionsPerMonth }); + const priced = + item.costPerSession == null + ? null + : monthly(cost, { tier, sessionsPerMonth }); // The id appears on EVERY actionable line, not just the appliable ones. It is the only // handle `decline` accepts and it cannot be guessed from the title, so omitting it made the // advertised decline path unreachable for precisely the findings most likely to be // unwanted -- model-routing findings carry no remedy, so they could never be suppressed no // matter how many times a user declined them. - const action = item.remedy?.kind === 'ours' ? `apply: waste_audit action="apply" id="${item.id}"` - : item.remedy?.kind === 'yours' ? `proposed edit -- needs your yes, nothing changed; decline: ${item.id}` - : `advice only -- no automatic fix; decline: ${item.id}`; + const action = + item.remedy?.kind === 'ours' + ? `apply: waste_audit action="apply" id="${item.id}"` + : item.remedy?.kind === 'yours' + ? `proposed edit -- needs your yes, nothing changed; decline: ${item.id}` + : `advice only -- no automatic fix; decline: ${item.id}`; // MEASURED ZERO IS NOT UNMEASURED. Testing `cost` for truthiness sent a real 0 down the // "not yet measurable" branch while `priced` above, which tests for null, still rendered // $0.00 beside it -- producing `cost not yet measurable (~$0.00/month)`, the exact string // the test suite declares must never appear. waste.mjs defaults every finding to 0 and // hard-sets it on the co-occurrence detector, so this is reachable, not theoretical. - const text = ` ${item.title}\n ${item.costPerSession != null ? `${cost.toLocaleString()} tokens/session` : 'cost not yet measurable'}` + + const text = + ` ${item.title}\n ${item.costPerSession != null ? `${cost.toLocaleString()} tokens/session` : 'cost not yet measurable'}` + `${priced ? ` (~${money(priced.amount)}/month)` : ''}; ${action}`; const printCost = estimate(text); @@ -211,14 +241,19 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s lines.push(text); } - const head = shown.length ? ['What to do next, most expensive first:', ...lines] : ['Nothing addressable found.']; + const head = shown.length + ? ['What to do next, most expensive first:', ...lines] + : ['Nothing addressable found.']; const body = [...head]; if (withheld.length) { const worth = withheld.reduce((sum, w) => sum + (w.costPerSession || 0), 0); - body.push('', ` ... ${withheld.length} more finding(s) worth ${worth.toLocaleString()} tokens/session in total, ` + - 'not shown -- printing them costs more than they are worth. Pass full=true to see all.'); + body.push( + '', + ` ... ${withheld.length} more finding(s) worth ${worth.toLocaleString()} tokens/session in total, ` + + 'not shown -- printing them costs more than they are worth. Pass full=true to see all.' + ); } // What we already told you, and what it bought. @@ -226,23 +261,35 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s body.push('', 'Already applied, and what it actually saved:'); for (const rule of done.slice(0, 6)) { const saved = rule.savedPerSession; - const priced = saved == null ? null : monthly(saved, { tier, sessionsPerMonth }); - body.push(` + ${rule.id} -- ${saved == null - ? 'not yet measurable (fewer than two sessions since)' - : `measured ${saved.toLocaleString()} tokens/session${priced ? `, ~${money(priced.amount)}/month` : ''}`}`); + const priced = + saved == null ? null : monthly(saved, { tier, sessionsPerMonth }); + body.push( + ` + ${rule.id} -- ${ + saved == null + ? 'not yet measurable (fewer than two sessions since)' + : `measured ${saved.toLocaleString()} tokens/session${priced ? `, ~${money(priced.amount)}/month` : ''}` + }` + ); } // Never a silent cap: the withheld block above states its remainder and so must this one. // Truncating to six with no disclosure showed a user six savings and let them conclude that // was everything the tool had bought them, which understates its own measured value. if (done.length > 6) { - body.push(` ... and ${done.length - 6} more applied fix(es), not shown.`); + body.push( + ` ... and ${done.length - 6} more applied fix(es), not shown.` + ); } } if (suppressed.length) { - body.push('', `Not repeating ${suppressed.length} recommendation(s) you have declined ` + - `${DECLINE_LIMIT}+ times. Ask for full=true to see them.`); - if (full) for (const item of suppressed) body.push(` - ${item.title} (declined ${item.times}x)`); + body.push( + '', + `Not repeating ${suppressed.length} recommendation(s) you have declined ` + + `${DECLINE_LIMIT}+ times. Ask for full=true to see them.` + ); + if (full) + for (const item of suppressed) + body.push(` - ${item.title} (declined ${item.times}x)`); } // Whether the advice changed anything. @@ -256,20 +303,30 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s for (const a of anchors) anchorsBy.get(f.id).add(a); } const trends = [...new Set(findings.map((f) => f.id))] - .map((detector) => habitTrend(dir, detector, { - anchors: anchorsBy.has(detector) ? [...anchorsBy.get(detector)] : null, - })) + .map((detector) => + habitTrend(dir, detector, { + anchors: anchorsBy.has(detector) ? [...anchorsBy.get(detector)] : null, + }) + ) .filter(Boolean); if (trends.length) { body.push('', 'Since the advice was given:'); for (const trend of trends) { - body.push(` ${trend.detector}: ${trend.before.toLocaleString()} -> ${trend.after.toLocaleString()} tokens/session ` + - `(${trend.improved ? 'improved' : 'worse'})`); + body.push( + ` ${trend.detector}: ${trend.before.toLocaleString()} -> ${trend.after.toLocaleString()} tokens/session ` + + `(${trend.improved ? 'improved' : 'worse'})` + ); } } - const addressable = queue.reduce((sum, item) => sum + (item.costPerSession || 0), 0); + const addressable = queue.reduce( + (sum, item) => sum + (item.costPerSession || 0), + 0 + ); + const addressableMonthly = addressable + ? monthly(addressable, { tier, sessionsPerMonth }) + : null; const note = priceNote(tier); if (note) body.push('', note); @@ -278,9 +335,10 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s // pushing it left the report's longest line out of its own reported cost, understating it by // 10-20% on a short audit. This figure exists to prove the audit is not a net loss, so // understating the cost side biases exactly the comparison it was written to be honest about. - const closing = (n) => `This report cost about ${n.toLocaleString()} tokens and names ` + + const closing = (n) => + `This report cost about ${n.toLocaleString()} tokens and names ` + `${addressable.toLocaleString()} tokens/session of addressable waste` + - `${addressable ? ` (~${money(monthly(addressable, { tier, sessionsPerMonth })?.amount)}/month across ${sessionsPerMonth} sessions)` : ''}.`; + `${addressableMonthly ? ` (~${money(addressableMonthly.amount)}/month across ${sessionsPerMonth} sessions)` : ''}.`; const selfCost = estimate([...body, '', closing(0)].join('\n')); body.push('', closing(selfCost)); diff --git a/integrations/qwen/hooks/lib/cache.mjs b/integrations/qwen/hooks/lib/cache.mjs index 9b11173ff..9782c3262 100644 --- a/integrations/qwen/hooks/lib/cache.mjs +++ b/integrations/qwen/hooks/lib/cache.mjs @@ -3,12 +3,12 @@ /** * Prompt-cache economics. * - * Cache economics is real money in a way most compression is not: a cache read - * costs a tenth of a fresh token and a cache WRITE costs a quarter more than - * one, so a prefix that keeps invalidating can cost more than every saving this - * product makes elsewhere. It is also the one area where we do not have to - * model anything -- the client's own transcript records cache_read_input_tokens - * and cache_creation_input_tokens per turn, which is ground truth. + * Anthropic prompt-cache usage, read from Claude Code transcripts. A cache read + * and a cache write are both billable, at different published multipliers from + * uncached input. Raw cache_read_input_tokens and + * cache_creation_input_tokens are observations. Any multiplier-based value is + * an Anthropic input-cost equivalent, not a universally comparable token count + * and not a provider invoice. * * MEASUREMENT ALONE IS NOT ACTIONABLE. A hit rate tells you the cache missed; * it cannot tell you why, and an unactionable number is the same dead thing as @@ -30,7 +30,15 @@ * cache-safety-by-construction half of this file. */ -import { readFileSync, readdirSync, statSync, existsSync, openSync, readSync, closeSync } from 'node:fs'; +import { + readFileSync, + readdirSync, + statSync, + existsSync, + openSync, + readSync, + closeSync, +} from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; @@ -51,7 +59,8 @@ export const READ_MULTIPLIER = 0.1; const SYNTHETIC_MODEL = ''; /** True for a turn naming a model an inference actually ran under. */ -const realModel = (turn) => Boolean(turn.model) && turn.model !== SYNTHETIC_MODEL; +const realModel = (turn) => + Boolean(turn.model) && turn.model !== SYNTHETIC_MODEL; /* -------------------------------------------------------------- MEASUREMENT */ @@ -72,7 +81,10 @@ export function transcriptFor(cwd = process.cwd()) { try { const files = readdirSync(dir) .filter((name) => name.endsWith('.jsonl')) - .map((name) => ({ path: join(dir, name), at: statSync(join(dir, name)).mtimeMs })) + .map((name) => ({ + path: join(dir, name), + at: statSync(join(dir, name)).mtimeMs, + })) .sort((a, b) => b.at - a.at); return files.length ? files[0].path : null; } catch { @@ -157,7 +169,8 @@ export function cacheHealth(turns) { read += turn.read; written += turn.written; if (!realModel(turn)) continue; - if (!models.has(turn.model)) models.set(turn.model, { read: 0, written: 0, turns: 0 }); + if (!models.has(turn.model)) + models.set(turn.model, { read: 0, written: 0, turns: 0 }); const entry = models.get(turn.model); entry.read += turn.read; entry.written += turn.written; @@ -171,20 +184,28 @@ export function cacheHealth(turns) { // attribution price, made keepWarm answer "unknown", and returned null from // modelSwitchCost under a guard its caller does not share, which crashed // cache_audit outright with "Cannot read properties of null". - const last = turns.reduce((best, turn) => (turn.read + turn.written > 0 ? turn : best), turns[turns.length - 1]); + const last = turns.reduce( + (best, turn) => (turn.read + turn.written > 0 ? turn : best), + turns[turns.length - 1] + ); const total = read + written; return { + provider: 'anthropic', + source: 'claude-code-transcript', turns: turns.length, read, written, hitRate: total ? read / total : null, prefixTokens: last.read + last.written, - // Priced in plain-token equivalents, which is what makes it comparable with - // every other saving this product reports. - writeCost: Math.round(written * WRITE_MULTIPLIER), - readCost: Math.round(read * READ_MULTIPLIER), - savedVersusNoCache: Math.round(read * (1 - READ_MULTIPLIER)), + // These are Anthropic uncached-input cost equivalents. They stay outside + // the verified MCP transport ledger and must not be relabeled as tokens + // saved across clients. + writeInputCostEquivalent: Math.round(written * WRITE_MULTIPLIER), + readInputCostEquivalent: Math.round(read * READ_MULTIPLIER), + inputCostEquivalentAvoidedVersusUncached: Math.round( + read * (1 - READ_MULTIPLIER) + ), models: Object.fromEntries(models), }; } @@ -200,13 +221,17 @@ export function modelSwitchCost(turns) { const health = cacheHealth(turns); if (!health?.prefixTokens) return null; - const switched = new Set(turns.filter(realModel).map((t) => t.model)).size > 1; + const switched = + new Set(turns.filter(realModel).map((t) => t.model)).size > 1; return { prefixTokens: health.prefixTokens, - rewriteCost: Math.round(health.prefixTokens * WRITE_MULTIPLIER), + rewriteInputCostEquivalent: Math.round( + health.prefixTokens * WRITE_MULTIPLIER + ), alreadySwitched: switched, - text: `Switching model now discards a ${health.prefixTokens.toLocaleString()}-token warm prefix; ` + - `re-writing it costs about ${Math.round(health.prefixTokens * WRITE_MULTIPLIER).toLocaleString()} tokens.`, + text: + `Switching model now discards a ${health.prefixTokens.toLocaleString()}-token warm prefix; ` + + `under Anthropic's current 5-minute write multiplier, re-writing it is about ${Math.round(health.prefixTokens * WRITE_MULTIPLIER).toLocaleString()} uncached-input cost-equivalent tokens.`, }; } @@ -221,17 +246,38 @@ export function modelSwitchCost(turns) { * time. That is why this class of bug survives -- nothing about it looks wrong. */ const VOLATILE = [ - { id: 'timestamp', re: /\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/, why: 'an embedded timestamp' }, - { id: 'date', re: /\b(?:today|current date)\b[^\n]{0,40}\d{4}-\d{2}-\d{2}/i, why: 'an embedded current date' }, + { + id: 'timestamp', + re: /\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/, + why: 'an embedded timestamp', + }, + { + id: 'date', + re: /\b(?:today|current date)\b[^\n]{0,40}\d{4}-\d{2}-\d{2}/i, + why: 'an embedded current date', + }, // A bare date is a SUSPICION, not a measurement: "- 2025-10-31: reworked the // loader" is byte-identical every session. Kept, because stableText fails // closed over our own output and dropping a line there costs little -- but // NOT priced, because in the blame report a false positive invents a // per-session cost and tells the user to delete their changelog. - { id: 'iso-date', re: /\b\d{4}-\d{2}-\d{2}\b/, why: 'an embedded date', priced: false }, - { id: 'session-id', re: /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i, why: 'a session id' }, + { + id: 'iso-date', + re: /\b\d{4}-\d{2}-\d{2}\b/, + why: 'an embedded date', + priced: false, + }, + { + id: 'session-id', + re: /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i, + why: 'a session id', + }, { id: 'git-sha', re: /\b[0-9a-f]{40}\b/, why: 'a git sha' }, - { id: 'counter', re: /\b(?:run|build|attempt|iteration)\s*#?\d+\b/i, why: 'a run counter' }, + { + id: 'counter', + re: /\b(?:run|build|attempt|iteration)\s*#?\d+\b/i, + why: 'a run counter', + }, { id: 'epoch', re: /\b1[6-9]\d{11}\b/, why: 'an epoch timestamp' }, ]; @@ -242,7 +288,13 @@ export function volatileLines(text) { for (let i = 0; i < lines.length; i++) { for (const rule of VOLATILE) { if (!rule.re.test(lines[i])) continue; - out.push({ line: i + 1, id: rule.id, why: rule.why, priced: rule.priced !== false, text: lines[i].trim().slice(0, 120) }); + out.push({ + line: i + 1, + id: rule.id, + why: rule.why, + priced: rule.priced !== false, + text: lines[i].trim().slice(0, 120), + }); break; // one cause per line is enough to act on } } @@ -264,7 +316,11 @@ const estimate = (text) => Math.ceil(String(text || '').length / 4); * unknown, and this reports the construct WITHOUT a price rather than inventing * one. */ -export function attributeInvalidation(cwd, prefixTokens = null, { files = null } = {}) { +export function attributeInvalidation( + cwd, + prefixTokens = null, + { files = null } = {} +) { // Ordered as the client assembles them: the client preamble and tool // definitions come first, then project instructions, then the conversation. const candidates = files || ['CLAUDE.md', 'AGENTS.md', '.claude/CLAUDE.md']; @@ -292,7 +348,8 @@ export function attributeInvalidation(cwd, prefixTokens = null, { files = null } // prefix -- 3.75x the entire prefix. Conservative still: the preamble // ahead of the file is not counted, so this remains a floor. const ahead = offset + estimate(lines.slice(0, hit.line - 1).join('\n')); - const downstream = prefixTokens != null ? Math.max(0, prefixTokens - ahead) : null; + const downstream = + prefixTokens != null ? Math.max(0, prefixTokens - ahead) : null; // A prefix cache invalidates ONCE, at the earliest difference. A later // volatile line in the same file costs nothing extra until the first one // is fixed, so it is reported for context but not billed again. @@ -304,7 +361,9 @@ export function attributeInvalidation(cwd, prefixTokens = null, { files = null } // into a reported zero. const firstPriced = found.find((h) => h.priced); const subsumedBy = - hit.priced && firstPriced && firstPriced !== hit ? `${relative}:${firstPriced.line}` : null; + hit.priced && firstPriced && firstPriced !== hit + ? `${relative}:${firstPriced.line}` + : null; out.push({ file: relative, line: hit.line, @@ -316,9 +375,12 @@ export function attributeInvalidation(cwd, prefixTokens = null, { files = null } // decline to price. 0 means measured and genuinely free, which is what a // subsumed hit is: real, but already billed by the earlier line. Folding // those two into one value is the same mistake this file warns about. - costPerSession: downstream == null || !hit.priced - ? null - : (subsumedBy ? 0 : Math.round(downstream * WRITE_MULTIPLIER)), + costPerSession: + downstream == null || !hit.priced + ? null + : subsumedBy + ? 0 + : Math.round(downstream * WRITE_MULTIPLIER), remedy: { kind: 'yours', type: 'edit', @@ -369,7 +431,7 @@ export function stableText(text) { */ export function cacheOrdered(items) { return [...items].sort((a, b) => { - const stability = (item) => (item.volatility ?? (item.fresh ? 1 : 0)); + const stability = (item) => item.volatility ?? (item.fresh ? 1 : 0); return stability(a) - stability(b); }); } diff --git a/integrations/qwen/hooks/lib/decide.mjs b/integrations/qwen/hooks/lib/decide.mjs index caae7de2f..a61dafdd5 100644 --- a/integrations/qwen/hooks/lib/decide.mjs +++ b/integrations/qwen/hooks/lib/decide.mjs @@ -392,6 +392,21 @@ export function touchedFiles(payload) { add(input.path); add(input.notebook_path); + // Codex code mode carries apply_patch as the nested program text rather than + // as a first-class file_path. Without reading these headers, successful + // code-mode edits incremented the semantic-harvest counter but produced no + // structural file/symbol nodes, which is why the live dashboard showed very + // few nodes after substantial work. PostToolUse can resolve Add/Update paths + // because the file now exists; deleted paths deliberately remain absent. + for (const match of command.matchAll( + /^\*\*\* (?:Add|Update|Delete) File:\s*(.+)$/gm + )) { + add(match[1].trim().replace(/^['"]|['"]$/g, '')); + } + for (const match of command.matchAll(/^\*\*\* Move to:\s*(.+)$/gm)) { + add(match[1].trim().replace(/^['"]|['"]$/g, '')); + } + // EVERY pipeline segment, not just the first. `fileOperands` looks only at // the head of the pipeline because that is where the COST is -- `git log | // head` must not be mistaken for a file dump. But observation is a different diff --git a/integrations/qwen/hooks/lib/experiment.mjs b/integrations/qwen/hooks/lib/experiment.mjs index 1f64713f1..c35c6cf5c 100644 --- a/integrations/qwen/hooks/lib/experiment.mjs +++ b/integrations/qwen/hooks/lib/experiment.mjs @@ -67,16 +67,71 @@ export function episodeMeta({ client, raw = {}, payload = {}, env = process.env } export function usageFrom(raw = {}) { - const usage = raw.usage || raw.token_usage || raw.tokenUsage || raw.metrics?.usage || {}; + const usage = raw.usage || raw.token_usage || raw.tokenUsage || raw.usageMetadata || + raw.usage_metadata || raw.metrics?.usage || raw.response?.usage || + raw.response?.usageMetadata || {}; const number = (...values) => { const value = first(...values); const parsed = Number(value); return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; }; + const cachedInputTokens = number( + usage.cached_input_tokens, usage.cachedInputTokens, + usage.cache_read_input_tokens, usage.cachedContentTokenCount + ); + const cacheCreation = usage.cache_creation || usage.cacheCreation || {}; + const cacheWrite5mInputTokens = number( + usage.cache_write_5m_input_tokens, usage.cacheWrite5mInputTokens, + cacheCreation.ephemeral_5m_input_tokens, cacheCreation.ephemeral5mInputTokens + ); + const cacheWrite1hInputTokens = number( + usage.cache_write_1h_input_tokens, usage.cacheWrite1hInputTokens, + cacheCreation.ephemeral_1h_input_tokens, cacheCreation.ephemeral1hInputTokens + ); + const detailedWrites = (cacheWrite5mInputTokens || 0) + (cacheWrite1hInputTokens || 0); + const reportedWrites = number( + usage.cache_write_input_tokens, usage.cacheWriteInputTokens, + usage.cache_creation_input_tokens, usage.cacheCreationInputTokens + ); + const cacheWriteInputTokens = reportedWrites == null + ? null + : Math.max(0, reportedWrites - detailedWrites); + const explicitUncached = number(usage.uncached_input_tokens, usage.uncachedInputTokens); + const promptInput = number( + usage.promptTokenCount, usage.prompt_token_count, + usage.input_tokens, usage.inputTokens + ); + const isAnthropic = usage.cache_creation_input_tokens !== undefined || + usage.cache_read_input_tokens !== undefined; + const isGemini = usage.promptTokenCount !== undefined || usage.cachedContentTokenCount !== undefined; + const uncachedInputTokens = explicitUncached != null + ? explicitUncached + : promptInput == null + ? null + : isAnthropic + ? promptInput + : isGemini || cachedInputTokens != null || reportedWrites != null + ? Math.max(0, promptInput - (cachedInputTokens || 0) - (reportedWrites || 0)) + : promptInput; + const candidates = number(usage.candidatesTokenCount, usage.candidates_token_count); + const thoughts = number(usage.thoughtsTokenCount, usage.thoughts_token_count); + const outputTokens = candidates != null || thoughts != null + ? (candidates || 0) + (thoughts || 0) + : number(usage.output_tokens, usage.outputTokens); return { - uncachedInputTokens: number(usage.uncached_input_tokens, usage.uncachedInputTokens), - cachedInputTokens: number(usage.cached_input_tokens, usage.cachedInputTokens, usage.cache_read_input_tokens), - outputTokens: number(usage.output_tokens, usage.outputTokens), - totalTokens: number(usage.total_tokens, usage.totalTokens, raw.tokens_used, raw.tokensUsed), + uncachedInputTokens, + cachedInputTokens, + cacheWrite5mInputTokens, + cacheWrite1hInputTokens, + cacheWriteInputTokens, + outputTokens, + totalTokens: number( + usage.total_tokens, usage.totalTokens, usage.totalTokenCount, + usage.total_token_count, raw.tokens_used, raw.tokensUsed + ), + usageMeasurementId: first( + raw.requestId, raw.request_id, raw.responseId, raw.response_id, + usage.responseId, usage.response_id + ) ?? null, }; } diff --git a/integrations/qwen/hooks/lib/harvest-write.mjs b/integrations/qwen/hooks/lib/harvest-write.mjs index 30433d370..db436c1dc 100644 --- a/integrations/qwen/hooks/lib/harvest-write.mjs +++ b/integrations/qwen/hooks/lib/harvest-write.mjs @@ -24,7 +24,7 @@ */ import { - putNodeWithEdges, load, nodeId, sharedDir, isSharedDir, putNode, + putNodeWithEdges, load, nodeId, sharedDir, isSharedDir, putNode, putEdge, } from './wiki.mjs'; import { indexFile } from './staleness.mjs'; import { symbolKey } from './symbols.mjs'; @@ -47,6 +47,37 @@ import { ORIGIN_HARVESTED, ORIGIN_AGENT, ORIGIN_HUMAN } from './curate.mjs'; */ export const SHAREABLE_TYPES = new Set(['command', 'failure', 'decision', 'feedback']); +/** + * Synthetic study traffic must never become advice for an unrelated live repo. + * Evaluation roots are intentionally realistic and may carry their own `.git` + * marker, so repository discovery alone cannot distinguish them. Publication + * remains useful inside the isolated study store; this guard protects only the + * default machine-wide store when a harness was accidentally run without an + * isolated TOKEN_OPTIMIZER_SHARED_DIR. + */ +export function isEphemeralProject(projectRoot) { + const root = canonicalPath(projectRoot || '').toLowerCase(); + if (!root) return true; + return ( + /\/appdata\/local\/temp\//.test(root) || + /(^|\/)tmp\//.test(root) || + /\/(artifacts|\.token-optimizer)\/(?:[^/]*\/)*(?:eval|scenario|study|fixture|debug)[^/]*\//.test(`${root}/`) + ); +} + +export function quarantineSharedSource(projectRoot) { + if (process.env.TOKEN_OPTIMIZER_ALLOW_EPHEMERAL_SHARED === '1') return false; + if (!isEphemeralProject(projectRoot)) return false; + // Isolated studies deliberately point the shared tier at scratch storage. + // Their findings may cross their own fixture projects, but cannot escape into + // the user's default machine-wide graph. Strict mode exists for a regression + // probe that verifies the guard without writing to a real user directory. + return ( + !process.env.TOKEN_OPTIMIZER_SHARED_DIR || + process.env.TOKEN_OPTIMIZER_STRICT_SHARED_PROVENANCE === '1' + ); +} + /** Claim text, normalised enough that the same lesson learned twice is one row. */ const claimFingerprint = (claim) => String(claim || '').toLowerCase().replace(/\s+/g, ' ').trim().slice(0, 300); @@ -72,6 +103,7 @@ function promoteToShared(finding, { projectRoot, sessionId, provenance, key }) { if (!projectRoot) return false; const root = canonicalPath(projectRoot); + if (quarantineSharedSource(root)) return false; const rootId = nodeId('file', root); // Same claim, already carried up from anywhere: keep the first. Two repos @@ -367,11 +399,21 @@ export function writeHarvested( && !node.retired && node.type === finding.type && claimFingerprint(node.claim) === fingerprint - && resolved.every((target) => currentGraph.edges.some((edge) => - edge.from === node.id && edge.edge === 'derived_from' && edge.to === target - )) ); if (duplicate) { + // Enrich, do not duplicate. A symbol extractor may learn how to resolve a + // previously file-only anchor (or a later session may add another real + // anchor) after the claim was first stored. Repeating the same semantic + // conclusion should connect that existing node to the newly available + // evidence, not create a second dashboard card with the same text. + for (const target of resolved) { + const alreadyLinked = currentGraph.edges.some((edge) => + edge.from === duplicate.id + && edge.edge === 'derived_from' + && edge.to === target + ); + if (!alreadyLinked) putEdge(dir, duplicate.id, 'derived_from', target); + } written.push(duplicate.key); continue; } @@ -437,7 +479,12 @@ export function writeHarvested( if ( id && !isSharedDir(dir) - && (finding.scope === undefined || finding.scope === 'organization' || finding.scope === 'global') + // Cross-project publication is explicit. `writeHarvested` stores an + // omitted scope as project-local above, so treating `undefined` as global + // here contradicted the stored record and promoted fixture/evaluation + // seeds into real user sessions. This was observed live as "learned in + // scenario" advice while working in AiDotNet. + && (finding.scope === 'organization' || finding.scope === 'global') ) { promoteToShared(finding, { projectRoot, sessionId, provenance, key }); } diff --git a/integrations/qwen/hooks/lib/inject.mjs b/integrations/qwen/hooks/lib/inject.mjs index b00494a01..c0aa2f1bf 100644 --- a/integrations/qwen/hooks/lib/inject.mjs +++ b/integrations/qwen/hooks/lib/inject.mjs @@ -31,6 +31,7 @@ import { canonicalPath, resolvableCandidates } from './paths.mjs'; import { annotatedSkeleton } from './skeleton.mjs'; import { substitutionBudget } from './metrics.mjs'; import { assessFindings } from './utility.mjs'; +import { quarantineSharedSource } from './harvest-write.mjs'; // Read per call for the same reason as the holdout fraction in metrics.mjs. const touchBudget = () => Number(process.env.TOKEN_OPTIMIZER_TOUCH_BUDGET) || 500; @@ -562,6 +563,7 @@ export function forRepeatedAct( const home = projectRoot ? canonicalPath(projectRoot) : null; for (const node of graph.nodes.values()) { if (node.kind !== 'finding' || node.retired || !node.claim) continue; + if (quarantineSharedSource(node.sourceProject)) continue; if (home && node.sourceProject && canonicalPath(node.sourceProject) === home) continue; const claimClasses = classesOf(node.claim, 'claim'); if (![...crossedClasses].some((c) => claimClasses.has(c))) continue; @@ -667,6 +669,7 @@ export function forSharedCommand( const candidates = []; for (const node of graph.nodes.values()) { if (node.kind !== 'finding' || node.retired) continue; + if (quarantineSharedSource(node.sourceProject)) continue; if (alreadyInjected.has(node.key)) continue; // ITS OWN LESSON IS NOT NEWS. A finding this project contributed is already // served by the local path; repeating it under a "from elsewhere" label @@ -1054,7 +1057,13 @@ export function standingRules(dir, graph, { budget = standingBudget(), episode = * informative response available -- more useful than the file, not a lossier * version of it. */ -export function substitutionFor(dir, graph, rawPath, source, { sessionId } = {}) { +export function substitutionFor( + dir, + graph, + rawPath, + source, + { sessionId, client = null, clientVersion = null, model = null, modelVersion = null } = {} +) { const filePath = canonicalPath(rawPath); const budget = substitutionBudget(dir, filePath); const built = annotatedSkeleton(graph, rawPath, source, { budget }); @@ -1089,6 +1098,10 @@ export function substitutionFor(dir, graph, rawPath, source, { sessionId } = {}) // on this machine turned out to be the enforcement suite's own fixture and // nothing distinguished them from real work. sessionId, + client, + clientVersion, + model, + modelVersion, holdout, tokens: holdout ? 0 : built.tokens, findings: built.findings, diff --git a/integrations/qwen/hooks/lib/metrics.mjs b/integrations/qwen/hooks/lib/metrics.mjs index 0bb08f1bc..09f822630 100644 --- a/integrations/qwen/hooks/lib/metrics.mjs +++ b/integrations/qwen/hooks/lib/metrics.mjs @@ -19,8 +19,15 @@ */ import { - appendFileSync, readFileSync, existsSync, mkdirSync, chmodSync, - statSync, openSync, readSync, closeSync, + appendFileSync, + readFileSync, + existsSync, + mkdirSync, + chmodSync, + statSync, + openSync, + readSync, + closeSync, } from 'node:fs'; import { join } from 'node:path'; import { createHash, randomBytes } from 'node:crypto'; @@ -86,6 +93,10 @@ export const BALANCE_KINDS = new Set([ 'keepwarm', ]); +/** Latest timestamp without passing an unbounded event log as function arguments. */ +export const latestEventTimestamp = (rows) => + rows.reduce((latest, event) => Math.max(latest, Number(event.at) || 0), 0); + /** * Causal records have their own bounded log. Tool outcomes are much rarer * than read/capture telemetry but more frequent than balance records; mixing @@ -130,7 +141,7 @@ export function inHoldout(anchorKey, now = Date.now()) { // other side than before. With nine holdout records in existence that costs // nothing, and stratification is a fresh draw either way. const digest = createHash('sha256').update(`${anchorKey}:${epoch}`).digest(); - return (digest[0] / 256) < fraction; + return digest[0] / 256 < fraction; } /** @@ -258,7 +269,8 @@ export function record(dir, event) { * It is also the right statistics. A saving measured months ago says little * about the code as it stands now. */ -const MAX_BYTES = Number(process.env.TOKEN_OPTIMIZER_METRICS_BYTES) || 2_000_000; +const MAX_BYTES = + Number(process.env.TOKEN_OPTIMIZER_METRICS_BYTES) || 2_000_000; const MAX_EVENTS = Number(process.env.TOKEN_OPTIMIZER_METRICS_WINDOW) || 5000; /** @@ -432,7 +444,10 @@ export function readBalance(dir) { // path for records written before ids existed; it is lossy by nature, which // is precisely why new records carry an id instead. const id = - e.id ?? [e.kind, e.at, e.anchor ?? '', e.sessionId ?? '', e.tokens ?? ''].join('|'); + e.id ?? + [e.kind, e.at, e.anchor ?? '', e.sessionId ?? '', e.tokens ?? ''].join( + '|' + ); if (seen.has(id)) continue; seen.add(id); out.push(e); @@ -493,16 +508,24 @@ export function recordToolOutcome(dir, outcome) { const evidence = readEvidence(dir); const anchor = String(outcome.anchor || '').slice(0, 120); const candidates = evidence - .filter((event) => event.kind === 'inject' && event.episodeId === outcome.episodeId) + .filter( + (event) => + event.kind === 'inject' && event.episodeId === outcome.episodeId + ) .filter((event) => { if (outcome.toolCallId && event.toolCallId) return String(event.toolCallId) === String(outcome.toolCallId); - return event.surface === outcome.surface && String(event.anchor || '').slice(0, 120) === anchor; + return ( + event.surface === outcome.surface && + String(event.anchor || '').slice(0, 120) === anchor + ); }) .sort((a, b) => (b.at || 0) - (a.at || 0)); const injection = candidates[0] || null; const joinMethod = injection - ? (outcome.toolCallId && injection.toolCallId ? 'tool-call-id' : 'episode-anchor') + ? outcome.toolCallId && injection.toolCallId + ? 'tool-call-id' + : 'episode-anchor' : 'none'; return record(dir, { @@ -542,12 +565,11 @@ export function isFixtureAnchor(anchor) { // code: it excluded ALL temp paths -- including a real project checked out // under /tmp -- and on macOS excluded nothing at all, because tmpdir() there // is /var/folders///T/ and did not match. - const underTemp = - new RegExp( - '[\\\\/](AppData[\\\\/]Local[\\\\/])?(Temp|tmp)[\\\\/]' + - '|[\\\\/]var[\\\\/]folders[\\\\/][^\\\\/]+[\\\\/][^\\\\/]+[\\\\/]T[\\\\/]', - 'i' - ).test(p); + const underTemp = new RegExp( + '[\\\\/](AppData[\\\\/]Local[\\\\/])?(Temp|tmp)[\\\\/]' + + '|[\\\\/]var[\\\\/]folders[\\\\/][^\\\\/]+[\\\\/][^\\\\/]+[\\\\/]T[\\\\/]', + 'i' + ).test(p); if (!underTemp) return false; // Only names this suite actually creates. A user's own scratch checkout under @@ -686,8 +708,12 @@ export function balanceSheet(dir) { // THE CONTROL ARM'S ACTUAL COST. `tokensFullFile` was recorded and read // by nothing, so the comparison the holdout exists for was not // computable from the report. - controlArmTokens: withheldSubs.reduce((sum, e) => sum + (e.tokensFullFile || 0), 0), - assumption: 'that the model would have read the file it explicitly requested', + controlArmTokens: withheldSubs.reduce( + (sum, e) => sum + (e.tokensFullFile || 0), + 0 + ), + assumption: + 'that the model would have read the file it explicitly requested', }, estimatedCausal: { what: 'findings injection, from the stratified holdout', @@ -702,7 +728,12 @@ export function balanceSheet(dir) { }; })(), }, - costs: { injection: injectCost, harvest: harvestCost, standing: standingCost, substitution: substitutionCost }, + costs: { + injection: injectCost, + harvest: harvestCost, + standing: standingCost, + substitution: substitutionCost, + }, waste, // Deliberately NOT a single number. The two benefit lines are known // differently; adding them would launder an assumption into a measurement. @@ -727,7 +758,9 @@ export function balanceSheet(dir) { /** Cheap path normalisation for grouping reads; not the identity canonicaliser. */ function canonicalKeyish(p) { - return String(p || '').replace(/\\/g, '/').toLowerCase(); + return String(p || '') + .replace(/\\/g, '/') + .toLowerCase(); } /** @@ -738,7 +771,43 @@ function canonicalKeyish(p) { * arms, multiplied by treated touches. Reporting it as a measured fact would be * the same overclaiming this project criticises competitors for. */ -function buildReport(events, balance) { +function buildReport(events, balance, sourceCoverage = {}) { + const substitutions = balance.filter( + (event) => event.kind === 'substitute' && !isFixtureAnchor(event.anchor) + ); + const servedSubstitutions = substitutions.filter((event) => !event.holdout); + const substitutionTokensSaved = servedSubstitutions.reduce( + (sum, event) => + sum + + (event.tokensNetAvoided ?? + Math.max( + 0, + Math.ceil((event.bytesAvoided || 0) / 4) - (event.tokens || 0) + )), + 0 + ); + const substitutionTokensReturned = servedSubstitutions.reduce( + (sum, event) => sum + (event.tokens || 0), + 0 + ); + const substitutionByClient = {}; + for (const event of servedSubstitutions) { + const name = event.client || 'Historical — client not recorded'; + const current = substitutionByClient[name] || { + substitutions: 0, + tokensReturned: 0, + tokensSaved: 0, + }; + current.substitutions += 1; + current.tokensReturned += event.tokens || 0; + current.tokensSaved += + event.tokensNetAvoided ?? + Math.max( + 0, + Math.ceil((event.bytesAvoided || 0) / 4) - (event.tokens || 0) + ); + substitutionByClient[name] = current; + } // COMMAND INJECTIONS ARE COUNTED SEPARATELY, not mixed into the balance. // @@ -762,13 +831,19 @@ function buildReport(events, balance) { // Report its cost independently so it cannot dilute the file-touch estimate. const isSessionStart = (e) => e.surface === 'session-start'; const sessionStartInjections = allInjections.filter(isSessionStart); - const commandInjections = allInjections.filter((e) => !isSessionStart(e) && isCommand(e)); - const injections = allInjections.filter((e) => !isSessionStart(e) && !isCommand(e)); + const commandInjections = allInjections.filter( + (e) => !isSessionStart(e) && isCommand(e) + ); + const injections = allInjections.filter( + (e) => !isSessionStart(e) && !isCommand(e) + ); const treated = injections.filter((e) => !e.holdout); const withheld = injections.filter((e) => e.holdout); const mean = (rows, field) => - rows.length ? rows.reduce((sum, r) => sum + (r[field] || 0), 0) / rows.length : 0; + rows.length + ? rows.reduce((sum, r) => sum + (r[field] || 0), 0) / rows.length + : 0; const injectedTokens = treated.reduce((sum, e) => sum + (e.tokens || 0), 0); const harvestTokens = balance @@ -819,7 +894,9 @@ function buildReport(events, balance) { const after = event.at ?? 0; const total = bucket.reduce( - (sum, r) => sum + ((r.at ?? 0) >= after ? (r.tokens || 0) : 0), 0); + (sum, r) => sum + ((r.at ?? 0) >= after ? r.tokens || 0 : 0), + 0 + ); // Split across the injections that share this key. Each touch is one // observation, so charging every one the full total would count the same @@ -828,52 +905,198 @@ function buildReport(events, balance) { }; const meanDownstream = (rows) => - rows.length ? rows.reduce((sum, r) => sum + downstreamOf(r), 0) / rows.length : 0; + rows.length + ? rows.reduce((sum, r) => sum + downstreamOf(r), 0) / rows.length + : 0; const treatedCost = meanDownstream(treated); const withheldCost = meanDownstream(withheld); const perTouchSaving = withheldCost - treatedCost; - const estimatedAvoided = Math.max(0, Math.round(perTouchSaving * treated.length)); + const estimatedAvoided = Math.max( + 0, + Math.round(perTouchSaving * treated.length) + ); + + const downstreamSamples = injections.filter((event) => { + if (event.downstream != null) return true; + const key = `${event.sessionId || ''}|${event.anchor}`; + return (reads.get(key) || []).some( + (read) => (read.at ?? 0) >= (event.at ?? 0) + ); + }).length; + const downstreamMeasured = downstreamSamples > 0; // Below this, arm means are noise and a ratio would be theatre. - const sufficient = treated.length >= 20 && withheld.length >= 5; + const sufficient = + downstreamMeasured && treated.length >= 20 && withheld.length >= 5; + + const coverageNumber = (value, fallback) => { + if (value === null || value === undefined || value === '') return fallback; + const number = Number(value); + return Number.isFinite(number) ? Math.max(0, number) : fallback; + }; + const projectCount = coverageNumber(sourceCoverage.projects, 1); + const telemetryProjects = coverageNumber( + sourceCoverage.projectsWithTelemetry, + 0 + ); + const balanceProjects = coverageNumber( + sourceCoverage.projectsWithBalanceEvents, + balance.length ? 1 : 0 + ); + const lastEventAt = Math.max( + latestEventTimestamp(events), + latestEventTimestamp(balance) + ); + const measured = + telemetryProjects > 0 || events.length > 0 || balance.length > 0; + const balanceMeasured = balanceProjects > 0 || balance.length > 0; + const metric = (status, source, samples, extra = {}) => ({ + status, + source, + samples, + ...extra, + }); return { + nativeOptimizer: { + substitutions: servedSubstitutions.length, + holdouts: substitutions.filter((event) => event.holdout).length, + tokensReturned: substitutionTokensReturned, + tokensSaved: substitutionTokensSaved, + byClient: substitutionByClient, + recent: servedSubstitutions + .slice() + .sort((a, b) => (b.at || 0) - (a.at || 0)) + .slice(0, 40) + .map((event) => { + const tokensSaved = + event.tokensNetAvoided ?? + Math.max( + 0, + Math.ceil((event.bytesAvoided || 0) / 4) - (event.tokens || 0) + ); + return { + name: 'live_graph_substitution', + client: event.client || 'Historical — client not recorded', + originalTokens: (event.tokens || 0) + tokensSaved, + optimizedTokens: event.tokens || 0, + tokensSaved, + savingsMeasured: false, + classification: 'modeled-counterfactual', + reportedTokensSaved: tokensSaved, + timestamp: event.at ? new Date(event.at).toISOString() : null, + }; + }), + source: + 'balance.jsonl: modeled full-file counterfactual minus annotated-skeleton tokens; excluded from verified MCP savings', + }, memoryDeliveries: allInjections.filter((event) => !event.holdout).length, memoryHoldouts: allInjections.filter((event) => event.holdout).length, deliveryTokens: allInjections .filter((event) => !event.holdout) .reduce( - (sum, event) => - sum + (event.deliveredTokens ?? event.tokens ?? 0), + (sum, event) => sum + (event.deliveredTokens ?? event.tokens ?? 0), 0 ), injections: treated.length, sessionStartInjections: sessionStartInjections.length, sessionStartInjectedTokens: sessionStartInjections.reduce( - (sum, event) => sum + (event.deliveredTokens ?? event.tokens ?? 0), 0 + (sum, event) => sum + (event.deliveredTokens ?? event.tokens ?? 0), + 0 ), commandInjections: commandInjections.length, commandHoldouts: commandInjections.filter((e) => e.holdout).length, holdouts: withheld.length, staleServed: injections.filter((e) => e.stale).length, - staleRate: injections.length ? injections.filter((e) => e.stale).length / injections.length : 0, + staleRate: injections.length + ? injections.filter((e) => e.stale).length / injections.length + : 0, injectedTokens, harvestTokens, estimatedTokensAvoided: sufficient ? estimatedAvoided : null, - netTokens: sufficient ? estimatedAvoided - injectedTokens - harvestTokens : null, + netTokens: sufficient + ? estimatedAvoided - injectedTokens - harvestTokens + : null, sufficientData: sufficient, verdict: !sufficient ? `insufficient data (${treated.length} treated, ${withheld.length} holdout; need 20 and 5)` : estimatedAvoided > injectedTokens + harvestTokens ? 'the graph is saving more than it costs' : 'the graph is NOT yet paying for itself', + measurement: { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + sourceCoverage: { + projects: projectCount, + projectsWithTelemetry: telemetryProjects || (measured ? 1 : 0), + projectsWithBalanceEvents: balanceProjects, + projectsWithoutTelemetry: Math.max( + 0, + projectCount - (telemetryProjects || (measured ? 1 : 0)) + ), + }, + freshness: { + lastEventAt: lastEventAt || null, + ageMs: lastEventAt ? Math.max(0, Date.now() - lastEventAt) : null, + status: !lastEventAt + ? 'not-measured' + : Date.now() - lastEventAt <= 7 * 86_400_000 + ? 'fresh' + : 'stale', + }, + metrics: { + nativeSubstitutions: metric( + balanceMeasured ? 'measured' : 'not-measured', + 'balance.jsonl: modeled full-file counterfactual minus annotated-skeleton tokens; not a materialized MCP before/after pair', + servedSubstitutions.length, + { classification: 'modeled-counterfactual' } + ), + memoryDeliveries: metric( + balanceMeasured ? 'measured' : 'not-measured', + 'balance.jsonl: inject events in the treated arm', + allInjections.filter((event) => !event.holdout).length + ), + memoryHoldouts: metric( + balanceMeasured ? 'measured' : 'not-measured', + 'balance.jsonl: inject events in the withheld arm', + allInjections.filter((event) => event.holdout).length + ), + rememberingCost: metric( + balanceMeasured ? 'measured' : 'not-measured', + 'balance.jsonl: delivered injection tokens plus semantic harvest tokens', + allInjections.length + + balance.filter((event) => event.kind === 'harvest').length + ), + readingAvoided: metric( + !downstreamMeasured + ? 'not-measured' + : sufficient + ? 'measured' + : 'collecting', + 'stratified file-touch holdout joined to downstream read events', + downstreamSamples, + { + treated: treated.length, + holdouts: withheld.length, + requiredTreated: 20, + requiredHoldouts: 5, + } + ), + }, + }, }; } export function report(dir) { - return buildReport(readAll(dir), readBalance(dir)); + const events = readAll(dir); + const balance = readBalance(dir); + return buildReport(events, balance, { + projects: 1, + projectsWithTelemetry: events.length || balance.length ? 1 : 0, + projectsWithBalanceEvents: balance.length ? 1 : 0, + }); } /** @@ -886,10 +1109,16 @@ export function report(dir) { */ export function reportMany(dirs) { const unique = [...new Set((dirs || []).map((dir) => String(dir)))]; - const report = buildReport( - unique.flatMap((dir) => readAll(dir)), - unique.flatMap((dir) => readBalance(dir)) - ); + const eventSources = unique.map((dir) => readAll(dir)); + const balanceSources = unique.map((dir) => readBalance(dir)); + const report = buildReport(eventSources.flat(), balanceSources.flat(), { + projects: unique.length, + projectsWithTelemetry: eventSources.filter( + (events, index) => events.length || balanceSources[index].length + ).length, + projectsWithBalanceEvents: balanceSources.filter((events) => events.length) + .length, + }); return { ...report, projects: unique.length }; } @@ -907,7 +1136,11 @@ export function reportMany(dirs) { * costs almost nothing. Without a control arm there is no way to tell those two * apart, which is why nobody else can do this. */ -export function substitutionBudget(dir, anchor, { floor = 300, base = 1200, ceiling = 3000 } = {}) { +export function substitutionBudget( + dir, + anchor, + { floor = 300, base = 1200, ceiling = 3000 } = {} +) { const events = readAll(dir); const mine = events.filter((e) => e.kind === 'inject' && e.anchor === anchor); @@ -932,13 +1165,17 @@ export function substitutionBudget(dir, anchor, { floor = 300, base = 1200, ceil for (const row of rows) { const bucket = reads.get(row.sessionId || '') || []; const after = row.at ?? 0; - total += bucket.reduce((sum, r) => sum + ((r.at ?? 0) >= after ? (r.tokens || 0) : 0), 0); + total += bucket.reduce( + (sum, r) => sum + ((r.at ?? 0) >= after ? r.tokens || 0 : 0), + 0 + ); } return total / rows.length; }; const saved = downstream(withheld) - downstream(treated); - const spent = treated.reduce((sum, e) => sum + (e.tokens || 0), 0) / treated.length; + const spent = + treated.reduce((sum, e) => sum + (e.tokens || 0), 0) / treated.length; // Ratio of what annotating this file avoided to what annotating it cost. // Above 1 it is paying for itself and earns room; below, it shrinks. @@ -959,7 +1196,10 @@ export function substitutionBudget(dir, anchor, { floor = 300, base = 1200, ceil * graph whose index leads to queries grows its allowance; a noisy one shrinks * back toward the floor. Nobody configures it, and it cannot run away. */ -export function indexBudget(dir, { floor = 150, base = 300, ceiling = 1200 } = {}) { +export function indexBudget( + dir, + { floor = 150, base = 300, ceiling = 1200 } = {} +) { const events = readAll(dir); const listed = events.filter((e) => e.kind === 'index').length; const queries = events.filter((e) => e.kind === 'query').length; @@ -968,7 +1208,9 @@ export function indexBudget(dir, { floor = 150, base = 300, ceiling = 1200 } = { const hitRate = queries / listed; // 0% hit rate falls to the floor; ~50% and above reaches the ceiling. - const scaled = Math.round(floor + (ceiling - floor) * Math.min(1, hitRate * 2)); + const scaled = Math.round( + floor + (ceiling - floor) * Math.min(1, hitRate * 2) + ); return Math.max(floor, Math.min(ceiling, scaled)); } @@ -1005,7 +1247,10 @@ function seededRandom(seedText) { } /** Percentile bootstrap interval for a mean, paired delta, or other scalar. */ -export function bootstrapMeanInterval(values, { samples = 1000, seed = 'token-optimizer' } = {}) { +export function bootstrapMeanInterval( + values, + { samples = 1000, seed = 'token-optimizer' } = {} +) { const finite = values.map(numeric).filter((value) => value !== null); if (!finite.length) return { mean: null, low: null, high: null, n: 0 }; if (finite.length === 1) { @@ -1037,24 +1282,32 @@ export function proportionInterval(successes, total, z = 1.96) { const denominator = 1 + (z * z) / total; const centre = (rate + (z * z) / (2 * total)) / denominator; const radius = - (z / denominator) * Math.sqrt((rate * (1 - rate)) / total + (z * z) / (4 * total * total)); - return { rate, low: Math.max(0, centre - radius), high: Math.min(1, centre + radius), n: total }; + (z / denominator) * + Math.sqrt((rate * (1 - rate)) / total + (z * z) / (4 * total * total)); + return { + rate, + low: Math.max(0, centre - radius), + high: Math.min(1, centre + radius), + n: total, + }; } -const cohortKey = (run) => [ - run.client || 'unknown', - run.clientVersion || 'unknown', - run.model || 'unknown', - run.modelVersion || 'unknown', - run.taskId || 'unknown', -].join('|'); +const cohortKey = (run) => + [ + run.client || 'unknown', + run.clientVersion || 'unknown', + run.model || 'unknown', + run.modelVersion || 'unknown', + run.taskId || 'unknown', + ].join('|'); function armMetrics(runs) { const correct = runs.filter((run) => run.correct === true).length; - const interval = (field) => bootstrapMeanInterval( - runs.map((run) => run[field]).filter((value) => numeric(value) !== null), - { seed: field } - ); + const interval = (field) => + bootstrapMeanInterval( + runs.map((run) => run[field]).filter((value) => numeric(value) !== null), + { seed: field } + ); return { runs: runs.length, correctness: proportionInterval(correct, runs.length), @@ -1068,29 +1321,40 @@ function armMetrics(runs) { latencyMs: interval('latencyMs'), costUsd: interval('costUsd'), injectedTokens: interval('injectedTokens'), - harmfulFindings: runs.reduce((sum, run) => sum + (numeric(run.harmfulFindings) || 0), 0), + harmfulFindings: runs.reduce( + (sum, run) => sum + (numeric(run.harmfulFindings) || 0), + 0 + ), }; } -function pairedEffects(runs, controlArm, treatmentArm, contrastType = 'incremental') { +function pairedEffects( + runs, + controlArm, + treatmentArm, + contrastType = 'incremental' +) { const pairs = new Map(); for (const run of runs) { if (!run.pairId || ![controlArm, treatmentArm].includes(run.arm)) continue; if (!pairs.has(run.pairId)) pairs.set(run.pairId, {}); pairs.get(run.pairId)[run.arm] = run; } - const complete = [...pairs.values()].filter((pair) => pair[controlArm] && pair[treatmentArm]); - const delta = (field, lowerIsBetter = true) => bootstrapMeanInterval( - complete - .map((pair) => { - const baseline = numeric(pair[controlArm][field]); - const treatment = numeric(pair[treatmentArm][field]); - if (baseline === null || treatment === null) return null; - return lowerIsBetter ? baseline - treatment : treatment - baseline; - }) - .filter((value) => value !== null), - { seed: `${controlArm}:${treatmentArm}:${field}` } + const complete = [...pairs.values()].filter( + (pair) => pair[controlArm] && pair[treatmentArm] ); + const delta = (field, lowerIsBetter = true) => + bootstrapMeanInterval( + complete + .map((pair) => { + const baseline = numeric(pair[controlArm][field]); + const treatment = numeric(pair[treatmentArm][field]); + if (baseline === null || treatment === null) return null; + return lowerIsBetter ? baseline - treatment : treatment - baseline; + }) + .filter((value) => value !== null), + { seed: `${controlArm}:${treatmentArm}:${field}` } + ); return { arm: treatmentArm, controlArm, @@ -1111,21 +1375,32 @@ function pairedEffects(runs, controlArm, treatmentArm, contrastType = 'increment } function matchesFilters(event, filters) { - for (const field of ['client', 'clientVersion', 'model', 'modelVersion', 'taskId', 'arm']) { + for (const field of [ + 'client', + 'clientVersion', + 'model', + 'modelVersion', + 'taskId', + 'arm', + ]) { if (!filters[field]) continue; let actual = event[field]; if (event.kind === 'handoff-run') { - actual = field === 'taskId' - ? event.scenarioId - : field === 'arm' - ? event.arm - : (event.consumer?.[field] ?? event.producer?.[field] ?? event[field]); + actual = + field === 'taskId' + ? event.scenarioId + : field === 'arm' + ? event.arm + : (event.consumer?.[field] ?? + event.producer?.[field] ?? + event[field]); } else if (event.kind === 'concurrency-run') { - actual = field === 'taskId' - ? 'concurrent-combined' - : field === 'arm' - ? event.arm - : (event.consumer?.[field] ?? event[field]); + actual = + field === 'taskId' + ? 'concurrent-combined' + : field === 'arm' + ? event.arm + : (event.consumer?.[field] ?? event[field]); } if (String(actual || '') !== String(filters[field])) return false; } @@ -1134,11 +1409,15 @@ function matchesFilters(event, filters) { function handoffArmMetrics(runs) { const consumers = runs.map((run) => run.consumer || {}); - const count = (field) => consumers.filter((consumer) => consumer[field] === true).length; - const interval = (field) => bootstrapMeanInterval( - consumers.map((consumer) => consumer[field]).filter((value) => numeric(value) !== null), - { seed: `handoff:${field}` } - ); + const count = (field) => + consumers.filter((consumer) => consumer[field] === true).length; + const interval = (field) => + bootstrapMeanInterval( + consumers + .map((consumer) => consumer[field]) + .filter((value) => numeric(value) !== null), + { seed: `handoff:${field}` } + ); return { runs: runs.length, delivery: proportionInterval( @@ -1147,7 +1426,10 @@ function handoffArmMetrics(runs) { ), correctness: proportionInterval(count('correct'), runs.length), firstPass: proportionInterval(count('firstPass'), runs.length), - mistakeAttempted: proportionInterval(count('mistakeAttempted'), runs.length), + mistakeAttempted: proportionInterval( + count('mistakeAttempted'), + runs.length + ), mistakeExecuted: proportionInterval(count('mistakeExecuted'), runs.length), totalTokens: interval('totalTokens'), toolCalls: interval('toolCalls'), @@ -1163,16 +1445,21 @@ function handoffPairedEffect(runs, controlArm, treatmentArm) { if (!pairs.has(run.pairId)) pairs.set(run.pairId, {}); pairs.get(run.pairId)[run.arm] = run; } - const complete = [...pairs.values()].filter((pair) => pair[controlArm] && pair[treatmentArm]); - const effect = (field, positiveWhenLower = true) => bootstrapMeanInterval( - complete.map((pair) => { - const control = numeric(pair[controlArm].consumer?.[field]); - const treatment = numeric(pair[treatmentArm].consumer?.[field]); - if (control === null || treatment === null) return null; - return positiveWhenLower ? control - treatment : treatment - control; - }).filter((value) => value !== null), - { seed: `handoff:${controlArm}:${treatmentArm}:${field}` } + const complete = [...pairs.values()].filter( + (pair) => pair[controlArm] && pair[treatmentArm] ); + const effect = (field, positiveWhenLower = true) => + bootstrapMeanInterval( + complete + .map((pair) => { + const control = numeric(pair[controlArm].consumer?.[field]); + const treatment = numeric(pair[treatmentArm].consumer?.[field]); + if (control === null || treatment === null) return null; + return positiveWhenLower ? control - treatment : treatment - control; + }) + .filter((value) => value !== null), + { seed: `handoff:${controlArm}:${treatmentArm}:${field}` } + ); return { comparison: `${treatmentArm} vs ${controlArm}`, pairs: complete.length, @@ -1187,13 +1474,22 @@ function handoffPairedEffect(runs, controlArm, treatmentArm) { }; } -const handoffKey = (run) => [ - run.producer?.client || 'unknown', run.producer?.model || 'unknown', - run.consumer?.client || 'unknown', run.consumer?.model || 'unknown', - run.scenarioId || 'unknown', -].join('|'); - -const HANDOFF_REPORT_ARMS = ['empty', 'natural', 'oracle', 'irrelevant', 'stale']; +const handoffKey = (run) => + [ + run.producer?.client || 'unknown', + run.producer?.model || 'unknown', + run.consumer?.client || 'unknown', + run.consumer?.model || 'unknown', + run.scenarioId || 'unknown', + ].join('|'); + +const HANDOFF_REPORT_ARMS = [ + 'empty', + 'natural', + 'oracle', + 'irrelevant', + 'stale', +]; const handoffMinimumPairs = () => Math.max(2, Number(process.env.TOKEN_OPTIMIZER_HANDOFF_MIN_PAIRS) || 10); @@ -1206,43 +1502,54 @@ function handoffCohorts(runs) { } const minimumPairs = handoffMinimumPairs(); return [...grouped.entries()].map(([key, rows]) => { - const arms = Object.fromEntries(HANDOFF_REPORT_ARMS.map((arm) => [ - arm, handoffArmMetrics(rows.filter((row) => row.arm === arm)), - ])); + const arms = Object.fromEntries( + HANDOFF_REPORT_ARMS.map((arm) => [ + arm, + handoffArmMetrics(rows.filter((row) => row.arm === arm)), + ]) + ); const naturalRows = rows.filter((row) => row.arm === 'natural'); const naturalVsEmpty = handoffPairedEffect(rows, 'empty', 'natural'); const naturalVsOracle = handoffPairedEffect(rows, 'oracle', 'natural'); const captureRate = naturalRows.length - ? naturalRows.filter((row) => row.producer?.captureSuccess).length / naturalRows.length + ? naturalRows.filter((row) => row.producer?.captureSuccess).length / + naturalRows.length : null; const preActionDeliveryRate = naturalRows.length - ? naturalRows.filter((row) => row.delivery?.beforeFirstExecutedMistake).length / naturalRows.length + ? naturalRows.filter((row) => row.delivery?.beforeFirstExecutedMistake) + .length / naturalRows.length : null; const emptyRecurrence = arms.empty.mistakeExecuted.rate; const naturalRecurrence = arms.natural.mistakeExecuted.rate; - const relativeRecurrenceReduction = emptyRecurrence !== null - && naturalRecurrence !== null - && emptyRecurrence !== 0 - ? (emptyRecurrence - naturalRecurrence) / emptyRecurrence - : null; - const controlsSafe = ['irrelevant', 'stale'].every((arm) => - arms[arm].correctness.rate !== null - && arms.empty.correctness.rate !== null - && arms[arm].correctness.rate >= arms.empty.correctness.rate - 0.1 + const relativeRecurrenceReduction = + emptyRecurrence !== null && + naturalRecurrence !== null && + emptyRecurrence !== 0 + ? (emptyRecurrence - naturalRecurrence) / emptyRecurrence + : null; + const controlsSafe = ['irrelevant', 'stale'].every( + (arm) => + arms[arm].correctness.rate !== null && + arms.empty.correctness.rate !== null && + arms[arm].correctness.rate >= arms.empty.correctness.rate - 0.1 ); - const irrelevantSuppressed = arms.irrelevant.delivery.rate !== null - && arms.irrelevant.delivery.rate === 0; + const irrelevantSuppressed = + arms.irrelevant.delivery.rate !== null && + arms.irrelevant.delivery.rate === 0; const gates = { minimumPairs: naturalVsEmpty.pairs >= minimumPairs, capture: captureRate !== null && captureRate >= 0.8, recurrenceMagnitude: - relativeRecurrenceReduction !== null && relativeRecurrenceReduction >= 0.5, - recurrenceInterval: (naturalVsEmpty.executedMistakesPrevented.low ?? -Infinity) > 0, + relativeRecurrenceReduction !== null && + relativeRecurrenceReduction >= 0.5, + recurrenceInterval: + (naturalVsEmpty.executedMistakesPrevented.low ?? -Infinity) > 0, correctness: - arms.natural.correctness.rate !== null - && arms.empty.correctness.rate !== null - && arms.natural.correctness.rate >= arms.empty.correctness.rate - 0.1, - preActionDelivery: preActionDeliveryRate !== null && preActionDeliveryRate >= 0.8, + arms.natural.correctness.rate !== null && + arms.empty.correctness.rate !== null && + arms.natural.correctness.rate >= arms.empty.correctness.rate - 0.1, + preActionDelivery: + preActionDeliveryRate !== null && preActionDeliveryRate >= 0.8, negativeControls: controlsSafe && irrelevantSuppressed, }; const claimReady = Object.values(gates).every(Boolean); @@ -1269,14 +1576,24 @@ function handoffCohorts(runs) { function concurrencySummary(runs) { const natural = runs.filter((run) => run.arm === 'natural'); const writers = natural.reduce((sum, run) => sum + (run.writerCount || 0), 0); - const captures = natural.reduce((sum, run) => sum + (run.captureSuccesses || 0), 0); - const integrityPasses = natural.filter((run) => - run.integrity?.zeroLoss - && run.integrity?.parseable - && run.integrity?.orphanedFindings === 0 + const captures = natural.reduce( + (sum, run) => sum + (run.captureSuccesses || 0), + 0 + ); + const integrityPasses = natural.filter( + (run) => + run.integrity?.zeroLoss && + run.integrity?.parseable && + run.integrity?.orphanedFindings === 0 ).length; - const delivered = natural.reduce((sum, run) => sum + (run.delivery?.delivered || 0), 0); - const expected = natural.reduce((sum, run) => sum + (run.delivery?.expected || 0), 0); + const delivered = natural.reduce( + (sum, run) => sum + (run.delivery?.delivered || 0), + 0 + ); + const expected = natural.reduce( + (sum, run) => sum + (run.delivery?.expected || 0), + 0 + ); const effect = handoffPairedEffect(runs, 'empty', 'natural'); return { runs: runs.length, @@ -1286,7 +1603,8 @@ function concurrencySummary(runs) { integrityPassRate: natural.length ? integrityPasses / natural.length : null, deliveryCoverage: expected ? delivered / expected : null, naturalCorrectness: proportionInterval( - natural.filter((run) => run.consumer?.correct).length, natural.length + natural.filter((run) => run.consumer?.correct).length, + natural.length ), effect, }; @@ -1297,17 +1615,26 @@ function concurrencySummary(runs) { * live hook traces, and randomized model evals remain visibly distinct: only * `eval-run` records with paired arms can produce a causal effect interval. */ -function buildEvidenceReport(evidence, { filters = {}, episodeLimit = 100 } = {}) { +function buildEvidenceReport( + evidence, + { filters = {}, episodeLimit = 100 } = {} +) { evidence = evidence.filter((event) => matchesFilters(event, filters)); const runs = evidence.filter((event) => event.kind === 'eval-run'); const handoffRuns = evidence.filter((event) => event.kind === 'handoff-run'); - const concurrencyRuns = evidence.filter((event) => event.kind === 'concurrency-run'); + const concurrencyRuns = evidence.filter( + (event) => event.kind === 'concurrency-run' + ); const injections = evidence.filter((event) => event.kind === 'inject'); const outcomes = evidence.filter((event) => event.kind === 'tool-outcome'); - const feedback = evidence.filter((event) => event.kind === 'finding-feedback'); + const feedback = evidence.filter( + (event) => event.kind === 'finding-feedback' + ); const byInjection = new Map( - outcomes.filter((event) => event.injectionId).map((event) => [event.injectionId, event]) + outcomes + .filter((event) => event.injectionId) + .map((event) => [event.injectionId, event]) ); const traced = injections .slice() @@ -1337,7 +1664,10 @@ function buildEvidenceReport(evidence, { filters = {}, episodeLimit = 100 } = {} grouped.get(key).push(run); } - const minimumPairs = Math.max(2, Number(process.env.TOKEN_OPTIMIZER_EVAL_MIN_PAIRS) || 5); + const minimumPairs = Math.max( + 2, + Number(process.env.TOKEN_OPTIMIZER_EVAL_MIN_PAIRS) || 5 + ); const cohorts = [...grouped.entries()].map(([key, cohortRuns]) => { const arms = Object.fromEntries( ['baseline', 'optimizer', 'retrieval', 'full'].map((arm) => [ @@ -1387,7 +1717,9 @@ function buildEvidenceReport(evidence, { filters = {}, episodeLimit = 100 } = {} cohorts: cohorts.length, harmfulFeedback: harmful, harmRate: feedback.length ? harmful / feedback.length : null, - evidenceStatus: cohorts.some((cohort) => cohort.evidenceStatus === 'causal estimate available') + evidenceStatus: cohorts.some( + (cohort) => cohort.evidenceStatus === 'causal estimate available' + ) ? 'causal estimates available' : 'insufficient randomized evidence', }, @@ -1396,7 +1728,8 @@ function buildEvidenceReport(evidence, { filters = {}, episodeLimit = 100 } = {} concurrency, episodes: traced, methodology: { - intervals: 'deterministic percentile bootstrap (95%); Wilson interval for correctness', + intervals: + 'deterministic percentile bootstrap (95%); Wilson interval for correctness', causalRule: 'matched pairs estimate optimizer vs baseline, retrieval vs optimizer, full vs retrieval, and full vs baseline', minimumPairs, diff --git a/integrations/qwen/hooks/lib/observability.mjs b/integrations/qwen/hooks/lib/observability.mjs index b9980076e..ef1c037af 100644 --- a/integrations/qwen/hooks/lib/observability.mjs +++ b/integrations/qwen/hooks/lib/observability.mjs @@ -26,7 +26,7 @@ import { createHash, randomUUID } from 'node:crypto'; import { homedir } from 'node:os'; import { join } from 'node:path'; -export const HOOK_LOG_SCHEMA_VERSION = 1; +export const HOOK_LOG_SCHEMA_VERSION = 2; const DEFAULT_MAX_BYTES = 5 * 1024 * 1024; const DEFAULT_RETENTION_DAYS = 14; const DEFAULT_MAX_FILES = 40; @@ -197,6 +197,8 @@ export function beginHookInvocation(client, event, options = {}) { turnIdHash: null, toolUseIdHash: null, toolName: null, + rawToolName: null, + codeModeEnvelope: false, clientVersion: null, model: null, cwdHash: null, @@ -208,12 +210,29 @@ export function beginHookInvocation(client, event, options = {}) { const spanId = hash(invocationId, 16); let traceId = hash(invocationId, 32); + // Write the start before reading stdin or importing project state. If the + // host kills the process, Node aborts, or a native call terminates without a + // JavaScript exception, diagnostics can now reconcile this record with the + // missing completion instead of reporting a false success rate. + writeHookEvent({ + level: 'info', + event: 'hook.started', + invocationId, + traceId, + spanId, + client, + hookEvent: event, + processId: process.pid, + }); + let deadline; const finish = (nextOutcome = outcome, nextReason = reason, nextError = error) => { if (ended) return; ended = true; if (deadline) clearTimeout(deadline); process.removeListener('exit', onExit); + process.removeListener('uncaughtException', onUncaughtException); + process.removeListener('unhandledRejection', onUnhandledRejection); writeHookEvent({ level: nextOutcome === 'failure' || nextOutcome === 'timeout' ? 'error' : 'info', event: 'hook.completed', @@ -231,7 +250,17 @@ export function beginHookInvocation(client, event, options = {}) { }); }; const onExit = () => finish(); + const onUncaughtException = (nextError) => { + finish('failure', 'uncaught_exception', nextError); + process.exit(0); + }; + const onUnhandledRejection = (nextError) => { + finish('failure', 'unhandled_rejection', nextError); + process.exit(0); + }; process.once('exit', onExit); + process.once('uncaughtException', onUncaughtException); + process.once('unhandledRejection', onUnhandledRejection); deadline = setTimeout(() => { outcome = 'timeout'; @@ -250,6 +279,11 @@ export function beginHookInvocation(client, event, options = {}) { dimensions.turnIdHash = hash(raw.turn_id ?? raw.turnId ?? null); dimensions.toolUseIdHash = hash(raw.tool_use_id ?? raw.toolUseId ?? null); dimensions.toolName = payload?.tool_name ?? raw.tool_name ?? raw.toolName ?? null; + dimensions.rawToolName = dimension( + raw.tool_name ?? raw.toolName ?? raw.tool ?? null + ); + dimensions.codeModeEnvelope = + payload?.tool_input?.code_mode_envelope === true; dimensions.clientVersion = dimension(raw.client_version ?? raw.clientVersion ?? null); dimensions.model = dimension(raw.model ?? raw.model_name ?? raw.modelName ?? null); dimensions.cwdHash = hash(raw.cwd ?? raw.working_directory ?? process.cwd()); @@ -291,6 +325,11 @@ export function beginHookInvocation(client, event, options = {}) { error = nextError; finish(outcome, reason, error); }, + block(nextReason = 'policy_denied') { + outcome = 'blocked'; + reason = nextReason; + finish(outcome, reason); + }, succeed(nextReason = 'completed') { outcome = 'success'; reason = nextReason; @@ -321,7 +360,11 @@ export function recordHookBootstrapFailure(client, event, error) { }); } -export function readHookEvents({ limit = 200, sinceMs = 24 * 60 * 60 * 1000 } = {}) { +export function readHookEvents({ + limit = 200, + sinceMs = 24 * 60 * 60 * 1000, + includeLifecycle = false, +} = {}) { try { const dir = hookLogDirectory(); if (!existsSync(dir)) return []; @@ -338,6 +381,7 @@ export function readHookEvents({ limit = 200, sinceMs = 24 * 60 * 60 * 1000 } = try { const event = JSON.parse(line); if (Date.parse(event.timestamp) < cutoff) continue; + if (!includeLifecycle && event.event === 'hook.started') continue; events.push(event); if (events.length >= limit) return events; } catch { @@ -353,38 +397,102 @@ export function readHookEvents({ limit = 200, sinceMs = 24 * 60 * 60 * 1000 } = export function hookHealthSummary(options = {}) { const sinceMs = options.sinceMs ?? 24 * 60 * 60 * 1000; - const events = readHookEvents({ limit: options.limit || 5000, sinceMs }); - const durations = events + const events = readHookEvents({ + limit: options.limit || 10000, + sinceMs, + includeLifecycle: true, + }); + const starts = events.filter((event) => event.event === 'hook.started'); + const completed = events.filter( + (event) => event.event === 'hook.completed' || !event.event + ); + const completedIds = new Set( + completed.map((event) => event.invocationId).filter(Boolean) + ); + const abandoned = starts + .filter( + (event) => + event.invocationId && + !completedIds.has(event.invocationId) && + Date.now() - Date.parse(event.timestamp) > 10_000 + ) + .map((event) => ({ + ...event, + event: 'hook.completed', + outcome: 'failure', + reason: 'process_terminated_before_completion', + durationMs: null, + })); + const runs = [...completed, ...abandoned]; + const durations = runs .map((event) => Number(event.durationMs)) .filter(Number.isFinite) .sort((a, b) => a - b); - const failures = events.filter((event) => event.outcome === 'failure'); - const timeouts = events.filter((event) => event.outcome === 'timeout'); - const skipped = events.filter((event) => event.outcome === 'skipped'); - const successes = events.filter((event) => event.outcome === 'success'); + const failures = runs.filter((event) => event.outcome === 'failure'); + const timeouts = runs.filter((event) => event.outcome === 'timeout'); + const skipped = runs.filter((event) => event.outcome === 'skipped'); + const blocked = runs.filter((event) => event.outcome === 'blocked'); + const successes = runs.filter((event) => event.outcome === 'success'); + const actions = runs.filter( + (event) => event.hookEvent === 'pre-tool' && event.toolName + ); + const byTool = {}; + for (const event of actions) { + const name = String(event.toolName || 'Unknown action'); + byTool[name] = (byTool[name] || 0) + 1; + } const byClient = {}; - for (const event of events) { + for (const event of runs) { const key = event.client || 'unknown'; - const current = byClient[key] || { total: 0, failures: 0, timeouts: 0, skipped: 0 }; + const current = byClient[key] || { + total: 0, + failures: 0, + timeouts: 0, + skipped: 0, + blocked: 0, + hookEvents: [], + }; current.total++; if (event.outcome === 'failure') current.failures++; if (event.outcome === 'timeout') current.timeouts++; if (event.outcome === 'skipped') current.skipped++; + if (event.outcome === 'blocked') current.blocked++; + if (event.hookEvent && !current.hookEvents.includes(event.hookEvent)) + current.hookEvents.push(event.hookEvent); byClient[key] = current; } + for (const current of Object.values(byClient)) current.hookEvents.sort(); const percentile = (p) => durations.length ? durations[Math.min(durations.length - 1, Math.floor(durations.length * p))] : null; const summary = { schemaVersion: HOOK_LOG_SCHEMA_VERSION, - available: events.length > 0, + available: runs.length > 0, windowHours: sinceMs / 3_600_000, - total: events.length, + total: runs.length, failures: failures.length, timeouts: timeouts.length, skipped: skipped.length, - successRate: events.length ? successes.length / events.length : null, + blocked: blocked.length, + abandoned: abandoned.length, + successes: successes.length, + actions: actions.length, + byTool, + // A skipped or policy-blocked hook completed its protocol successfully. + // Runtime health answers whether the hook process worked, not whether the + // optimizer chose to allow the host operation. + successRate: runs.length + ? (successes.length + skipped.length + blocked.length) / runs.length + : null, + healthStatus: + failures.length || timeouts.length + ? 'failing' + : skipped.length + ? 'degraded' + : runs.length + ? 'healthy' + : 'unavailable', p50DurationMs: percentile(0.5), p95DurationMs: percentile(0.95), byClient, diff --git a/integrations/qwen/hooks/lib/pricing.mjs b/integrations/qwen/hooks/lib/pricing.mjs index a4b09ebed..50b23b9cc 100644 --- a/integrations/qwen/hooks/lib/pricing.mjs +++ b/integrations/qwen/hooks/lib/pricing.mjs @@ -1,101 +1,72 @@ // GENERATED FILE -- do not edit. // Source of truth: hooks-core/pricing.mjs. Regenerate with `npm run sync:hooks`. /** - * Tokens in the unit people actually feel. + * Optional cost equivalents for token measurements. * - * The same data as everywhere else in this project, converted once, in one - * place, under three rules that keep a dollar figure honest: - * - * THE TABLE IS VISIBLE AND OVERRIDABLE. Prices change. A figure computed from - * rates buried in code goes stale silently and keeps looking authoritative, - * which is worse than no figure. The rates are printed next to the number and - * can be replaced without a release. - * - * DOLLARS BESIDE TOKENS, NOT INSTEAD OF THEM. The token count is exact and - * never goes out of date; the dollar figure is the token count times a rate - * that will. Showing both means the durable number survives the perishable - * one. - * - * NEVER A PRICE ON AN UNMEASURED SAVING. An invented number is worse in - * dollars than in tokens, because dollars get quoted to other people. A - * saving we cannot measure returns null and renders as "not yet measurable", - * never as "$0.00" -- which reads as "saved nothing" and is a different - * claim entirely. + * The runtime cannot infer a user's bill from a client name. A CLI may route + * through a subscription, direct API account, cloud marketplace, enterprise + * agreement, included credits, or a provider whose cache-read/write prices + * differ. Provider usage records are the source of truth when available. + * Everywhere else this module fails closed until the user supplies an + * effective blended input-token rate. */ -/** Cache multipliers, relative to an input token. */ -const CACHE_WRITE = 1.25; -const CACHE_READ = 0.1; +const RATE_ENV = 'TOKEN_OPTIMIZER_EFFECTIVE_INPUT_USD_PER_MILLION'; -/** - * Dollars per million tokens, by tier. - * - * Approximate published list rates as of 2026-07, carried with their date so a - * stale table is visible rather than silent. Override wholesale with - * TOKEN_OPTIMIZER_PRICES as JSON, e.g. - * {"asOf":"2026-09","opus":{"input":15,"output":75}}. - */ -const DEFAULT_PRICES = { - asOf: '2026-07', - haiku: { input: 1, output: 5 }, - sonnet: { input: 3, output: 15 }, - opus: { input: 15, output: 75 }, -}; - -export function prices() { - const raw = process.env.TOKEN_OPTIMIZER_PRICES; - if (raw) { - try { - const parsed = JSON.parse(raw); - if (parsed && typeof parsed === 'object') return { ...DEFAULT_PRICES, ...parsed, overridden: true }; - } catch { /* an unparseable override is ignored rather than fatal */ } - } - return DEFAULT_PRICES; +/** The configured effective input rate, or null when billing is unobservable. */ +export function effectiveRate() { + const rate = Number(process.env[RATE_ENV]); + return Number.isFinite(rate) && rate > 0 ? rate : null; } /** - * What a number of tokens costs. + * Dollars for a measured token quantity. * - * Returns null for a null input and says why: this is the single place the - * "unmeasured must not become $0.00" rule is enforced, so every caller inherits - * it rather than each remembering. + * `kind` is accepted for backwards compatibility but is not multiplied by a + * universal cache constant. The configured rate must already reflect the + * user's actual provider/model/cache/tier/credit mix. */ -export function dollars(tokens, { tier = 'opus', kind = 'input' } = {}) { - if (!Number.isFinite(tokens)) return null; - - const table = prices(); - const rate = table[tier]?.[kind === 'output' ? 'output' : 'input']; - if (!Number.isFinite(rate)) return null; - - const multiplier = kind === 'cacheWrite' ? CACHE_WRITE : kind === 'cacheRead' ? CACHE_READ : 1; - return (tokens / 1_000_000) * rate * multiplier; +export function dollars(tokens, { rate = effectiveRate() } = {}) { + if (!Number.isFinite(tokens) || !Number.isFinite(rate) || rate <= 0) { + return null; + } + return (tokens / 1_000_000) * rate; } /** A dollar figure, or an honest absence of one. */ -export function money(amount, { unmeasured = 'not yet measurable' } = {}) { +export function money(amount, { unmeasured = 'not priced' } = {}) { if (amount == null || !Number.isFinite(amount)) return unmeasured; if (amount === 0) return '$0.00'; if (Math.abs(amount) < 0.01) return '<$0.01'; return `$${amount.toFixed(2)}`; } -/** - * Per-session tokens expressed as a monthly cost. - * - * `sessionsPerMonth` is an assumption, so it is stated in the output rather - * than folded invisibly into the number. - */ -export function monthly(tokensPerSession, { tier = 'opus', sessionsPerMonth = 60 } = {}) { +/** Per-session tokens expressed with a stated monthly-frequency assumption. */ +export function monthly(tokensPerSession, { sessionsPerMonth = 60 } = {}) { if (!Number.isFinite(tokensPerSession)) return null; - const amount = dollars(tokensPerSession * sessionsPerMonth, { tier }); - return amount == null ? null : { amount, sessionsPerMonth, tier }; + const amount = dollars(tokensPerSession * sessionsPerMonth); + return amount == null + ? null + : { + amount, + sessionsPerMonth, + effectiveInputUsdPerMillion: effectiveRate(), + }; +} + +/** The pricing basis printed beside any cost equivalent. */ +export function priceNote() { + const rate = effectiveRate(); + return rate == null + ? `cost not priced: set ${RATE_ENV} to your effective blended input rate after provider, model, cache, tier, plan, and credits` + : `cost equivalent: configured effective input rate $${rate}/M via ${RATE_ENV}`; } -/** The rates behind the figures, for printing beside them. */ -export function priceNote(tier = 'opus') { - const table = prices(); - const rate = table[tier]; - if (!rate) return null; - return `prices: ${tier} $${rate.input}/$${rate.output} per Mtok (${table.asOf}` + - `${table.overridden ? ', overridden' : ''}); override with TOKEN_OPTIMIZER_PRICES`; +/** Compatibility surface: no built-in provider table is claimed. */ +export function prices() { + return { + source: + effectiveRate() == null ? 'unavailable' : 'configured-effective-rate', + effectiveInputUsdPerMillion: effectiveRate(), + }; } diff --git a/integrations/qwen/hooks/lib/routing.mjs b/integrations/qwen/hooks/lib/routing.mjs index 9b4642e0e..84ac866e4 100644 --- a/integrations/qwen/hooks/lib/routing.mjs +++ b/integrations/qwen/hooks/lib/routing.mjs @@ -65,7 +65,9 @@ export function modelCosts() { if (raw) { try { return JSON.parse(raw); - } catch { /* fall through to the defaults */ } + } catch { + /* fall through to the defaults */ + } } return { haiku: 1, sonnet: 3, opus: 15 }; } @@ -106,7 +108,12 @@ export function readEpisodes(path, { maxBytes = 6_000_000 } = {}) { let current = null; const close = () => { - if (current && current.turns > 0) episodes.push({ ...current, files: [...current.files], tools: [...current.tools] }); + if (current && current.turns > 0) + episodes.push({ + ...current, + files: [...current.files], + tools: [...current.tools], + }); current = null; }; @@ -123,7 +130,15 @@ export function readEpisodes(path, { maxBytes = 6_000_000 } = {}) { // a new episode. if (row.type === 'user' && typeof row.message?.content === 'string') { close(); - current = { at: Date.parse(row.timestamp) || null, models: new Set(), tools: new Set(), files: new Set(), turns: 0, errors: 0, results: 0 }; + current = { + at: Date.parse(row.timestamp) || null, + models: new Set(), + tools: new Set(), + files: new Set(), + turns: 0, + errors: 0, + results: 0, + }; continue; } if (!current) continue; @@ -157,8 +172,16 @@ export function readEpisodes(path, { maxBytes = 6_000_000 } = {}) { /* ---------------------------------------------------------------- SHAPES */ -const EDIT_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit', 'smart_edit', 'smart_write']); -const TEST_PATTERN = /\b(test|jest|pytest|vitest|dotnet test|npm test|go test)\b/i; +const EDIT_TOOLS = new Set([ + 'Edit', + 'MultiEdit', + 'Write', + 'NotebookEdit', + 'smart_edit', + 'smart_write', +]); +const TEST_PATTERN = + /\b(test|jest|pytest|vitest|dotnet test|npm test|go test)\b/i; /** * What kind of work this was. @@ -171,11 +194,19 @@ export function classifyShape(episode) { const tools = new Set(episode.tools || []); const edited = [...tools].some((t) => EDIT_TOOLS.has(t)); const files = (episode.files || []).length; - const ranTests = [...tools].some((t) => TEST_PATTERN.test(t)) || (episode.tools || []).includes('Bash'); + const ranTests = + [...tools].some((t) => TEST_PATTERN.test(t)) || + (episode.tools || []).includes('Bash'); if (edited && files >= 3) return 'multi-file-change'; if (edited) return 'single-file-change'; - if (files > 0 || tools.has('Grep') || tools.has('Glob') || tools.has('smart_grep')) return 'investigation'; + if ( + files > 0 || + tools.has('Grep') || + tools.has('Glob') || + tools.has('smart_grep') + ) + return 'investigation'; if (ranTests) return 'build-or-test'; return 'conversation'; } @@ -208,7 +239,13 @@ export function outcomeTable(episodes) { const tier = [...tiers][0]; table[shape] = table[shape] || {}; - const cell = table[shape][tier] = table[shape][tier] || { episodes: 0, turns: 0, errors: 0, results: 0, errored: 0 }; + const cell = (table[shape][tier] = table[shape][tier] || { + episodes: 0, + turns: 0, + errors: 0, + results: 0, + errored: 0, + }); cell.episodes += 1; cell.turns += episode.turns; cell.errors += episode.errors; @@ -287,7 +324,8 @@ export function route(shape, table = {}) { shape, recommend: heuristic, basis: 'heuristic', - reason: `every measured tier for ${shape} needed a retry in more than ` + + reason: + `every measured tier for ${shape} needed a retry in more than ` + `${Math.round(MAX_ERROR_RATE * 100)}% of episodes; falling back to the shipped default`, excluded: excluded.map((o) => ({ tier: o.tier, errorRate: o.errorRate })), options, @@ -306,17 +344,24 @@ export function route(shape, table = {}) { recommend: best.tier, basis: 'measured', // What each mistake would cost, from this project's own rates. - overpowered: strongest.tier === best.tier ? null : { - tier: strongest.tier, - wasted: Number((strongest.cost - best.cost).toFixed(2)), - }, - underpowered: cheapest.tier === best.tier ? null : { - tier: cheapest.tier, - expectedCost: Number(cheapest.cost.toFixed(2)), - errorRate: cheapest.errorRate, - excluded: cheapest.errorRate > MAX_ERROR_RATE, - }, - reason: `${shape}: ${best.tier} costs ${best.cost.toFixed(2)} in expectation over ${best.episodes} episodes ` + + overpowered: + strongest.tier === best.tier + ? null + : { + tier: strongest.tier, + wasted: Number((strongest.cost - best.cost).toFixed(2)), + }, + underpowered: + cheapest.tier === best.tier + ? null + : { + tier: cheapest.tier, + expectedCost: Number(cheapest.cost.toFixed(2)), + errorRate: cheapest.errorRate, + excluded: cheapest.errorRate > MAX_ERROR_RATE, + }, + reason: + `${shape}: ${best.tier} costs ${best.cost.toFixed(2)} in expectation over ${best.episodes} episodes ` + `(${Math.round(best.errorRate * 100)}% needed a retry)`, options, }; @@ -340,20 +385,31 @@ export function routingNote(shape, table, { currentModel, switchCost } = {}) { const lines = [decision.reason]; if (decision.underpowered) { - lines.push(` ${decision.underpowered.tier} looks cheaper per token, but needed a retry in ` + - `${Math.round(decision.underpowered.errorRate * 100)}% of episodes -- expected cost ${decision.underpowered.expectedCost}` + - `${decision.underpowered.excluded ? ', which is why it is not a candidate here at any price' : ''}.`); + lines.push( + ` ${decision.underpowered.tier} looks cheaper per token, but needed a retry in ` + + `${Math.round(decision.underpowered.errorRate * 100)}% of episodes -- expected cost ${decision.underpowered.expectedCost}` + + `${decision.underpowered.excluded ? ', which is why it is not a candidate here at any price' : ''}.` + ); } if (decision.overpowered) { - lines.push(` ${decision.overpowered.tier} would spend ${decision.overpowered.wasted} more than needed here.`); + lines.push( + ` ${decision.overpowered.tier} would spend ${decision.overpowered.wasted} more than needed here.` + ); } if (switchCost?.prefixTokens) { - lines.push(` Switching now also discards a ${switchCost.prefixTokens.toLocaleString()}-token warm prefix ` + - `(~${switchCost.rewriteCost.toLocaleString()} tokens to re-write); consider switching at the next break.`); + lines.push( + ` Switching now also discards a ${switchCost.prefixTokens.toLocaleString()}-token warm prefix ` + + `(~${switchCost.rewriteInputCostEquivalent.toLocaleString()} Anthropic uncached-input cost-equivalent tokens to re-write); consider switching at the next break.` + ); } - return { shape, recommend: decision.recommend, basis: decision.basis, text: lines.join('\n') }; + return { + shape, + recommend: decision.recommend, + basis: decision.basis, + text: lines.join('\n'), + }; } /** @@ -371,7 +427,9 @@ export function routingBriefing(table) { const decision = route(shape, table); if (decision.basis !== 'measured') continue; if (decision.recommend === HEURISTIC[shape]) continue; // nothing learned worth saying - lines.push(`${shape.replace(/-/g, ' ')} work goes better on ${decision.recommend} in this project.`); + lines.push( + `${shape.replace(/-/g, ' ')} work goes better on ${decision.recommend} in this project.` + ); } return lines.length ? lines.join('\n') : null; } @@ -389,7 +447,11 @@ export function routingBriefing(table) { * few more episodes accumulated would invalidate the cache behind it. Stability * is the feature; the memo is how it is bought. */ -export function cachedRoutingBriefing(dir, transcriptPath, { growthBytes = 4_000_000, maxAgeMs = 24 * 60 * 60 * 1000 } = {}) { +export function cachedRoutingBriefing( + dir, + transcriptPath, + { growthBytes = 4_000_000, maxAgeMs = 24 * 60 * 60 * 1000 } = {} +) { const memoPath = join(dir, 'routing-brief.json'); let stat; try { @@ -400,17 +462,31 @@ export function cachedRoutingBriefing(dir, transcriptPath, { growthBytes = 4_000 try { const memo = JSON.parse(readFileSync(memoPath, 'utf8')); - const fresh = memo.path === transcriptPath - && Math.abs(stat.size - memo.size) < growthBytes - && Date.now() - memo.at < maxAgeMs; + const fresh = + memo.path === transcriptPath && + Math.abs(stat.size - memo.size) < growthBytes && + Date.now() - memo.at < maxAgeMs; if (fresh) return memo.text || null; - } catch { /* no memo, or an unreadable one; recompute */ } + } catch { + /* no memo, or an unreadable one; recompute */ + } const text = routingBriefing(outcomeTable(readEpisodes(transcriptPath))); try { mkdirSync(dir, { recursive: true, mode: 0o700 }); - writeFileSync(memoPath, JSON.stringify({ path: transcriptPath, size: stat.size, at: Date.now(), text }), { mode: 0o600 }); - } catch { /* the memo is an optimisation; failing to write it costs latency, not correctness */ } + writeFileSync( + memoPath, + JSON.stringify({ + path: transcriptPath, + size: stat.size, + at: Date.now(), + text, + }), + { mode: 0o600 } + ); + } catch { + /* the memo is an optimisation; failing to write it costs latency, not correctness */ + } return text; } @@ -427,9 +503,11 @@ export function routingReport(table) { for (const tier of Object.keys(table[shape])) { const cell = table[shape][tier]; const cost = expectedCost(tier, cell); - lines.push(` ${tier.padEnd(7)} ${cell.episodes} episode(s), ` + - `${cell.meanTurns.toFixed(1)} turns avg, ${Math.round(cell.errorRate * 100)}% needed a retry, ` + - `expected cost ${cost.cost.toFixed(2)}${cell.measured ? '' : ' [too few episodes to trust]'}`); + lines.push( + ` ${tier.padEnd(7)} ${cell.episodes} episode(s), ` + + `${cell.meanTurns.toFixed(1)} turns avg, ${Math.round(cell.errorRate * 100)}% needed a retry, ` + + `expected cost ${cost.cost.toFixed(2)}${cell.measured ? '' : ' [too few episodes to trust]'}` + ); } } return lines.join('\n'); diff --git a/integrations/qwen/hooks/lib/symbols.mjs b/integrations/qwen/hooks/lib/symbols.mjs index 2a80e1e42..4d80666ac 100644 --- a/integrations/qwen/hooks/lib/symbols.mjs +++ b/integrations/qwen/hooks/lib/symbols.mjs @@ -50,7 +50,11 @@ const PATTERNS = { /^\s*(?:pub(?:\([^)]*\))?\s+)?(?:struct|enum|trait|impl)\s+([A-Za-z_]\w*)/, ], clike: [ - /^\s*(?:public|private|protected|internal|static|final|abstract|virtual|override|async|\s)*(?:[\w<>\[\],.?]+\s+)([A-Za-z_]\w*)\s*\([^;]*\)\s*\{/, + // Accept both K&R (`Run() {`) and Allman (`Run()` then `{`) declarations. + // Requiring the opening brace on the declaration line silently discarded + // nearly every method in idiomatic C# projects, leaving file nodes but no + // symbol nodes and making valid `file.cs#Method` anchors unresolved. + /^\s*(?:public|private|protected|internal|static|final|abstract|virtual|override|async|sealed|new|extern|unsafe|partial|\s)*(?:[\w<>\[\],.?]+\s+)([A-Za-z_]\w*)(?:<[^;()]+>)?\s*\([^;]*\)\s*(?:\{|=>|$)/, /^\s*(?:public|private|protected|internal|static|abstract|sealed|partial|\s)*(?:class|struct|interface|enum|record)\s+([A-Za-z_]\w*)/, ], ruby: [ diff --git a/integrations/windsurf/hooks/lib/adapter.mjs b/integrations/windsurf/hooks/lib/adapter.mjs index fee0102b9..9f76bbdea 100644 --- a/integrations/windsurf/hooks/lib/adapter.mjs +++ b/integrations/windsurf/hooks/lib/adapter.mjs @@ -188,8 +188,143 @@ function contextOutput(client, eventName, additionalContext) { }; } +/** Convert one static JavaScript string literal without evaluating code. */ +function codexStringLiteral(literal) { + if (typeof literal !== 'string' || literal.length < 2) return null; + const quote = literal[0]; + if (!['"', "'", '`'].includes(quote) || literal.at(-1) !== quote) return null; + + let decoded = ''; + for (let index = 1; index < literal.length - 1; index += 1) { + const character = literal[index]; + if (quote === '`' && character === '$' && literal[index + 1] === '{') + return null; + if (character !== '\\') { + decoded += character; + continue; + } + + index += 1; + if (index >= literal.length - 1) return null; + const escaped = literal[index]; + const simple = { + b: '\b', f: '\f', n: '\n', r: '\r', t: '\t', v: '\v', 0: '\0', + '\\': '\\', "'": "'", '"': '"', '`': '`', '$': '$', + }; + if (Object.hasOwn(simple, escaped)) { + decoded += simple[escaped]; + continue; + } + if (escaped === '\n') continue; + if (escaped === '\r') { + if (literal[index + 1] === '\n') index += 1; + continue; + } + if (escaped === 'x') { + const digits = literal.slice(index + 1, index + 3); + if (!/^[0-9a-f]{2}$/i.test(digits)) return null; + decoded += String.fromCodePoint(Number.parseInt(digits, 16)); + index += 2; + continue; + } + if (escaped === 'u') { + const braced = literal[index + 1] === '{'; + const close = braced ? literal.indexOf('}', index + 2) : index + 5; + const digits = braced + ? literal.slice(index + 2, close) + : literal.slice(index + 1, close); + if ( + close < 0 || + !(braced ? /^[0-9a-f]{1,6}$/i : /^[0-9a-f]{4}$/i).test(digits) + ) return null; + const codePoint = Number.parseInt(digits, 16); + if (codePoint > 0x10ffff) return null; + decoded += String.fromCodePoint(codePoint); + index = close; + continue; + } + // JavaScript identity escapes drop the slash (for example, '\q' is 'q'). + decoded += escaped; + } + return decoded; +} + /** Convert client-specific lifecycle envelopes into the common tool shape. */ +function codexStringBindings(source) { + const values = new Map(); + const declaration = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*((?:"(?:\\.|[^"\\])*")|(?:'(?:\\.|[^'\\])*')|(?:`(?:\\.|[^`\\])*`))/gs; + for (const match of String(source || '').matchAll(declaration)) { + try { + const value = codexStringLiteral(match[2]); + if (value !== null) values.set(match[1], value); + } catch { + // A computed or malformed JavaScript expression is not safe to execute + // merely to understand a hook envelope. Leave it opaque. + } + } + return values; +} + +function codexCallArguments(source, method) { + const calls = []; + const pattern = new RegExp(`(?:tools\\.)?${method}\\s*\\(\\s*([^),]+)`, 'gi'); + for (const match of String(source || '').matchAll(pattern)) + calls.push(match[1].trim()); + return calls; +} + +function codexLiteral(value, bindings) { + if (bindings.has(value)) return String(bindings.get(value)); + return codexStringLiteral(value); +} + +function codexExecCommands(source, bindings) { + const commands = []; + for (const call of String(source || '').matchAll( + /(?:tools\.)?exec_command\s*\(\s*\{([\s\S]*?)\}\s*\)/gi + )) { + const property = /\bcmd\s*:\s*((?:"(?:\\.|[^"\\])*")|(?:'(?:\\.|[^'\\])*')|(?:`(?:\\.|[^`\\])*`)|(?:[A-Za-z_$][\w$]*))/s.exec(call[1]); + if (!property) continue; + const command = codexLiteral(property[1], bindings); + if (command) commands.push(command); + } + return commands; +} + export function normalizeClientPayload(clientName, event, raw) { + if (clientName === 'codex') { + const outerName = String(raw.tool_name ?? raw.toolName ?? raw.tool ?? ''); + if (/^(?:functions\.)?exec$/i.test(outerName)) { + const envelope = raw.tool_input ?? raw.toolInput ?? raw.arguments ?? raw.args; + const source = + typeof envelope === 'string' + ? envelope + : envelope?.code ?? envelope?.input ?? envelope?.source ?? ''; + const bindings = codexStringBindings(source); + const patchCalls = codexCallArguments(source, 'apply_patch'); + const patch = patchCalls + .map((argument) => codexLiteral(argument, bindings)) + .find(Boolean); + const mutation = patchCalls.length > 0; + const commands = codexExecCommands(source, bindings); + + // `functions.exec` is also the orchestration envelope for web, image and + // other non-filesystem tools. Do not reinterpret those as shell commands: + // doing so produced irrelevant cross-project advice and fabricated graph + // activity for operations the filesystem hooks never observed. + if (!mutation && !commands.length) return raw; + return { + ...raw, + tool_name: mutation ? 'apply_patch' : 'run_command', + tool_input: { + ...(envelope && typeof envelope === 'object' ? envelope : {}), + command: String(patch || commands.join('\n') || source || ''), + code_mode_envelope: true, + }, + }; + } + } + if (clientName === 'cline') { const body = event === 'post-tool' ? raw.postToolUse : raw.preToolUse; if (!body) return raw; @@ -385,6 +520,22 @@ function projectBriefing() { function observeAndInject(payload, state, episode, features) { const touched = touchedFiles(payload); const dirFor = (path) => wikiDir(projectRootFor(path, payload.cwd)); + const registerRoot = (root) => { + const dir = wikiDir(root); + registerProject({ + root, + graphDir: dir, + client: episode.client || 'unknown', + }); + return dir; + }; + + // SessionStart is not delivered consistently by every host/version. The + // graph was still written in that case, but the dashboard registry never + // learned it existed. Register every repository observed by a real tool call + // as a second, idempotent production path. + for (const { path } of touched) + registerRoot(projectRootFor(path, payload.cwd)); const bytes = readCostBytes(payload); if (bytes && payload.tool_input.file_path) { @@ -425,7 +576,7 @@ function observeAndInject(payload, state, episode, features) { const command = payload.tool_input?.command; if (command) { const root = commandProjectRoot(payload, payload.cwd); - const dir = wikiDir(root); + const dir = registerRoot(root); const local = forCommand(dir, load(dir), command, { sessionId: payload.session_id, alreadyInjected, @@ -750,7 +901,14 @@ async function runHook(clientName, event, invocation) { client.canDeny && event === 'pre-tool' && !repeat && - mode() !== MODE_ADVISE + mode() !== MODE_ADVISE && + // Codex code mode presents an orchestration program as one outer + // `functions.exec` call. Refusing that envelope is rendered by the host + // as a failed script and can discard several otherwise-safe nested + // operations. Observe it, capture it and advise, but reserve a hard veto + // for legacy one-operation Codex tool calls where the denied operation is + // unambiguous. + !(clientName === 'codex' && payload.tool_input?.code_mode_envelope) ); if (canRefuse) { @@ -759,19 +917,23 @@ async function runHook(clientName, event, invocation) { // Every client's refusal carries the off switch, for the same reason Claude // Code's does: enforcement that hides its own disable is coercive. if (client.denyStyle === 'top-level') { + invocation.block('policy_denied'); emit({ decision: 'deny', reason: withEscape(verdict.reason) }); } else if (client.denyStyle === 'top-level-permission') { + invocation.block('policy_denied'); emit({ permissionDecision: 'deny', permissionDecisionReason: withEscape(verdict.reason), }); } else if (client.denyStyle === 'cline') { + invocation.block('policy_denied'); emit({ cancel: true, contextModification: '', errorMessage: withEscape(verdict.reason), }); } else if (client.denyStyle === 'cursor') { + invocation.block('policy_denied'); emit({ continue: true, permission: 'deny', @@ -780,9 +942,11 @@ async function runHook(clientName, event, invocation) { 'Token Optimizer redirected an expensive built-in operation.', }); } else if (client.denyStyle === 'exit-2') { + invocation.block('policy_denied'); process.stderr.write(withEscape(verdict.reason)); process.exit(2); } else { + invocation.block('policy_denied'); emit({ hookSpecificOutput: { hookEventName: eventName, diff --git a/integrations/windsurf/hooks/lib/audit.mjs b/integrations/windsurf/hooks/lib/audit.mjs index b82186586..df8a08b82 100644 --- a/integrations/windsurf/hooks/lib/audit.mjs +++ b/integrations/windsurf/hooks/lib/audit.mjs @@ -50,15 +50,17 @@ export function decline(dir, id) { export function declines(dir, { events = readMetrics(dir) } = {}) { const counts = new Map(); for (const event of events) { - if (event.kind !== 'advice' || event.action !== 'declined' || !event.id) continue; + if (event.kind !== 'advice' || event.action !== 'declined' || !event.id) + continue; counts.set(event.id, (counts.get(event.id) || 0) + 1); } return counts; } -const idOf = (finding) => finding.remedy - ? `${finding.remedy.type}:${finding.remedy.anchor || finding.remedy.file || (finding.remedy.anchors || []).join(',')}` - : `${finding.id}:${finding.anchor || finding.file || ''}`; +const idOf = (finding) => + finding.remedy + ? `${finding.remedy.type}:${finding.remedy.anchor || finding.remedy.file || (finding.remedy.anchors || []).join(',')}` + : `${finding.id}:${finding.anchor || finding.file || ''}`; /** * Whether a habit improved after we said something about it. @@ -68,12 +70,18 @@ const idOf = (finding) => finding.remedy * -- a trend computed from one session on each side is a coin flip with a * percentage sign. */ -export function habitTrend(dir, detector, { events = readMetrics(dir), minSessions = 2, anchors = null } = {}) { +export function habitTrend( + dir, + detector, + { events = readMetrics(dir), minSessions = 2, anchors = null } = {} +) { // SPLIT BY POSITION, NOT BY TIMESTAMP. The log is append-only, so its order is // authoritative -- while `at` has millisecond granularity, and a burst of // events inside one millisecond would land on the wrong side of the split or // collapse one side to nothing. Position is exact where the clock is not. - const raisedAt = events.findIndex((e) => e.kind === 'advice' && e.detector === detector); + const raisedAt = events.findIndex( + (e) => e.kind === 'advice' && e.detector === detector + ); if (raisedAt === -1) return null; // SCOPED TO THE FILES THIS DETECTOR NAMED. Counting every read in the window @@ -114,7 +122,12 @@ export function habitTrend(dir, detector, { events = readMetrics(dir), minSessio /** Marks a recommendation as having been raised, so its effect can be scored. */ export function raise(dir, finding) { - record(dir, { kind: 'advice', action: 'raised', id: idOf(finding), detector: finding.id }); + record(dir, { + kind: 'advice', + action: 'raised', + id: idOf(finding), + detector: finding.id, + }); } /** @@ -123,7 +136,11 @@ export function raise(dir, finding) { * `findings` comes from the detectors; `extra` lets the cache and routing * modules contribute without this file importing the world. */ -export function buildQueue(dir, findings = [], { events = readMetrics(dir) } = {}) { +export function buildQueue( + dir, + findings = [], + { events = readMetrics(dir) } = {} +) { const declined = declines(dir, { events }); const ledger = remedyLedger(dir); const applied = new Set(ledger.filter((r) => !r.revertedAt).map((r) => r.id)); @@ -146,7 +163,9 @@ export function buildQueue(dir, findings = [], { events = readMetrics(dir) } = { } return { - queue: queue.sort((a, b) => (b.costPerSession ?? 0) - (a.costPerSession ?? 0)), + queue: queue.sort( + (a, b) => (b.costPerSession ?? 0) - (a.costPerSession ?? 0) + ), suppressed, done: ledger.filter((r) => !r.revertedAt), }; @@ -161,7 +180,11 @@ export function buildQueue(dir, findings = [], { events = readMetrics(dir) } = { * many were withheld -- and what they are collectively worth -- keeps that from * being a silent cap. */ -export function renderAudit(dir, findings = [], { tier = 'opus', full = false, sessionsPerMonth = 60 } = {}) { +export function renderAudit( + dir, + findings = [], + { tier = 'opus', full = false, sessionsPerMonth = 60 } = {} +) { const { queue, suppressed, done } = buildQueue(dir, findings); const lines = []; @@ -175,22 +198,29 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s // read as "this costs nothing" beside a finding whose cost we simply do not // know -- the same unknown-becomes-zero error the rest of this project // corrects, in the one unit that gets quoted to other people. - const priced = item.costPerSession == null ? null : monthly(cost, { tier, sessionsPerMonth }); + const priced = + item.costPerSession == null + ? null + : monthly(cost, { tier, sessionsPerMonth }); // The id appears on EVERY actionable line, not just the appliable ones. It is the only // handle `decline` accepts and it cannot be guessed from the title, so omitting it made the // advertised decline path unreachable for precisely the findings most likely to be // unwanted -- model-routing findings carry no remedy, so they could never be suppressed no // matter how many times a user declined them. - const action = item.remedy?.kind === 'ours' ? `apply: waste_audit action="apply" id="${item.id}"` - : item.remedy?.kind === 'yours' ? `proposed edit -- needs your yes, nothing changed; decline: ${item.id}` - : `advice only -- no automatic fix; decline: ${item.id}`; + const action = + item.remedy?.kind === 'ours' + ? `apply: waste_audit action="apply" id="${item.id}"` + : item.remedy?.kind === 'yours' + ? `proposed edit -- needs your yes, nothing changed; decline: ${item.id}` + : `advice only -- no automatic fix; decline: ${item.id}`; // MEASURED ZERO IS NOT UNMEASURED. Testing `cost` for truthiness sent a real 0 down the // "not yet measurable" branch while `priced` above, which tests for null, still rendered // $0.00 beside it -- producing `cost not yet measurable (~$0.00/month)`, the exact string // the test suite declares must never appear. waste.mjs defaults every finding to 0 and // hard-sets it on the co-occurrence detector, so this is reachable, not theoretical. - const text = ` ${item.title}\n ${item.costPerSession != null ? `${cost.toLocaleString()} tokens/session` : 'cost not yet measurable'}` + + const text = + ` ${item.title}\n ${item.costPerSession != null ? `${cost.toLocaleString()} tokens/session` : 'cost not yet measurable'}` + `${priced ? ` (~${money(priced.amount)}/month)` : ''}; ${action}`; const printCost = estimate(text); @@ -211,14 +241,19 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s lines.push(text); } - const head = shown.length ? ['What to do next, most expensive first:', ...lines] : ['Nothing addressable found.']; + const head = shown.length + ? ['What to do next, most expensive first:', ...lines] + : ['Nothing addressable found.']; const body = [...head]; if (withheld.length) { const worth = withheld.reduce((sum, w) => sum + (w.costPerSession || 0), 0); - body.push('', ` ... ${withheld.length} more finding(s) worth ${worth.toLocaleString()} tokens/session in total, ` + - 'not shown -- printing them costs more than they are worth. Pass full=true to see all.'); + body.push( + '', + ` ... ${withheld.length} more finding(s) worth ${worth.toLocaleString()} tokens/session in total, ` + + 'not shown -- printing them costs more than they are worth. Pass full=true to see all.' + ); } // What we already told you, and what it bought. @@ -226,23 +261,35 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s body.push('', 'Already applied, and what it actually saved:'); for (const rule of done.slice(0, 6)) { const saved = rule.savedPerSession; - const priced = saved == null ? null : monthly(saved, { tier, sessionsPerMonth }); - body.push(` + ${rule.id} -- ${saved == null - ? 'not yet measurable (fewer than two sessions since)' - : `measured ${saved.toLocaleString()} tokens/session${priced ? `, ~${money(priced.amount)}/month` : ''}`}`); + const priced = + saved == null ? null : monthly(saved, { tier, sessionsPerMonth }); + body.push( + ` + ${rule.id} -- ${ + saved == null + ? 'not yet measurable (fewer than two sessions since)' + : `measured ${saved.toLocaleString()} tokens/session${priced ? `, ~${money(priced.amount)}/month` : ''}` + }` + ); } // Never a silent cap: the withheld block above states its remainder and so must this one. // Truncating to six with no disclosure showed a user six savings and let them conclude that // was everything the tool had bought them, which understates its own measured value. if (done.length > 6) { - body.push(` ... and ${done.length - 6} more applied fix(es), not shown.`); + body.push( + ` ... and ${done.length - 6} more applied fix(es), not shown.` + ); } } if (suppressed.length) { - body.push('', `Not repeating ${suppressed.length} recommendation(s) you have declined ` + - `${DECLINE_LIMIT}+ times. Ask for full=true to see them.`); - if (full) for (const item of suppressed) body.push(` - ${item.title} (declined ${item.times}x)`); + body.push( + '', + `Not repeating ${suppressed.length} recommendation(s) you have declined ` + + `${DECLINE_LIMIT}+ times. Ask for full=true to see them.` + ); + if (full) + for (const item of suppressed) + body.push(` - ${item.title} (declined ${item.times}x)`); } // Whether the advice changed anything. @@ -256,20 +303,30 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s for (const a of anchors) anchorsBy.get(f.id).add(a); } const trends = [...new Set(findings.map((f) => f.id))] - .map((detector) => habitTrend(dir, detector, { - anchors: anchorsBy.has(detector) ? [...anchorsBy.get(detector)] : null, - })) + .map((detector) => + habitTrend(dir, detector, { + anchors: anchorsBy.has(detector) ? [...anchorsBy.get(detector)] : null, + }) + ) .filter(Boolean); if (trends.length) { body.push('', 'Since the advice was given:'); for (const trend of trends) { - body.push(` ${trend.detector}: ${trend.before.toLocaleString()} -> ${trend.after.toLocaleString()} tokens/session ` + - `(${trend.improved ? 'improved' : 'worse'})`); + body.push( + ` ${trend.detector}: ${trend.before.toLocaleString()} -> ${trend.after.toLocaleString()} tokens/session ` + + `(${trend.improved ? 'improved' : 'worse'})` + ); } } - const addressable = queue.reduce((sum, item) => sum + (item.costPerSession || 0), 0); + const addressable = queue.reduce( + (sum, item) => sum + (item.costPerSession || 0), + 0 + ); + const addressableMonthly = addressable + ? monthly(addressable, { tier, sessionsPerMonth }) + : null; const note = priceNote(tier); if (note) body.push('', note); @@ -278,9 +335,10 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s // pushing it left the report's longest line out of its own reported cost, understating it by // 10-20% on a short audit. This figure exists to prove the audit is not a net loss, so // understating the cost side biases exactly the comparison it was written to be honest about. - const closing = (n) => `This report cost about ${n.toLocaleString()} tokens and names ` + + const closing = (n) => + `This report cost about ${n.toLocaleString()} tokens and names ` + `${addressable.toLocaleString()} tokens/session of addressable waste` + - `${addressable ? ` (~${money(monthly(addressable, { tier, sessionsPerMonth })?.amount)}/month across ${sessionsPerMonth} sessions)` : ''}.`; + `${addressableMonthly ? ` (~${money(addressableMonthly.amount)}/month across ${sessionsPerMonth} sessions)` : ''}.`; const selfCost = estimate([...body, '', closing(0)].join('\n')); body.push('', closing(selfCost)); diff --git a/integrations/windsurf/hooks/lib/cache.mjs b/integrations/windsurf/hooks/lib/cache.mjs index 9b11173ff..9782c3262 100644 --- a/integrations/windsurf/hooks/lib/cache.mjs +++ b/integrations/windsurf/hooks/lib/cache.mjs @@ -3,12 +3,12 @@ /** * Prompt-cache economics. * - * Cache economics is real money in a way most compression is not: a cache read - * costs a tenth of a fresh token and a cache WRITE costs a quarter more than - * one, so a prefix that keeps invalidating can cost more than every saving this - * product makes elsewhere. It is also the one area where we do not have to - * model anything -- the client's own transcript records cache_read_input_tokens - * and cache_creation_input_tokens per turn, which is ground truth. + * Anthropic prompt-cache usage, read from Claude Code transcripts. A cache read + * and a cache write are both billable, at different published multipliers from + * uncached input. Raw cache_read_input_tokens and + * cache_creation_input_tokens are observations. Any multiplier-based value is + * an Anthropic input-cost equivalent, not a universally comparable token count + * and not a provider invoice. * * MEASUREMENT ALONE IS NOT ACTIONABLE. A hit rate tells you the cache missed; * it cannot tell you why, and an unactionable number is the same dead thing as @@ -30,7 +30,15 @@ * cache-safety-by-construction half of this file. */ -import { readFileSync, readdirSync, statSync, existsSync, openSync, readSync, closeSync } from 'node:fs'; +import { + readFileSync, + readdirSync, + statSync, + existsSync, + openSync, + readSync, + closeSync, +} from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; @@ -51,7 +59,8 @@ export const READ_MULTIPLIER = 0.1; const SYNTHETIC_MODEL = ''; /** True for a turn naming a model an inference actually ran under. */ -const realModel = (turn) => Boolean(turn.model) && turn.model !== SYNTHETIC_MODEL; +const realModel = (turn) => + Boolean(turn.model) && turn.model !== SYNTHETIC_MODEL; /* -------------------------------------------------------------- MEASUREMENT */ @@ -72,7 +81,10 @@ export function transcriptFor(cwd = process.cwd()) { try { const files = readdirSync(dir) .filter((name) => name.endsWith('.jsonl')) - .map((name) => ({ path: join(dir, name), at: statSync(join(dir, name)).mtimeMs })) + .map((name) => ({ + path: join(dir, name), + at: statSync(join(dir, name)).mtimeMs, + })) .sort((a, b) => b.at - a.at); return files.length ? files[0].path : null; } catch { @@ -157,7 +169,8 @@ export function cacheHealth(turns) { read += turn.read; written += turn.written; if (!realModel(turn)) continue; - if (!models.has(turn.model)) models.set(turn.model, { read: 0, written: 0, turns: 0 }); + if (!models.has(turn.model)) + models.set(turn.model, { read: 0, written: 0, turns: 0 }); const entry = models.get(turn.model); entry.read += turn.read; entry.written += turn.written; @@ -171,20 +184,28 @@ export function cacheHealth(turns) { // attribution price, made keepWarm answer "unknown", and returned null from // modelSwitchCost under a guard its caller does not share, which crashed // cache_audit outright with "Cannot read properties of null". - const last = turns.reduce((best, turn) => (turn.read + turn.written > 0 ? turn : best), turns[turns.length - 1]); + const last = turns.reduce( + (best, turn) => (turn.read + turn.written > 0 ? turn : best), + turns[turns.length - 1] + ); const total = read + written; return { + provider: 'anthropic', + source: 'claude-code-transcript', turns: turns.length, read, written, hitRate: total ? read / total : null, prefixTokens: last.read + last.written, - // Priced in plain-token equivalents, which is what makes it comparable with - // every other saving this product reports. - writeCost: Math.round(written * WRITE_MULTIPLIER), - readCost: Math.round(read * READ_MULTIPLIER), - savedVersusNoCache: Math.round(read * (1 - READ_MULTIPLIER)), + // These are Anthropic uncached-input cost equivalents. They stay outside + // the verified MCP transport ledger and must not be relabeled as tokens + // saved across clients. + writeInputCostEquivalent: Math.round(written * WRITE_MULTIPLIER), + readInputCostEquivalent: Math.round(read * READ_MULTIPLIER), + inputCostEquivalentAvoidedVersusUncached: Math.round( + read * (1 - READ_MULTIPLIER) + ), models: Object.fromEntries(models), }; } @@ -200,13 +221,17 @@ export function modelSwitchCost(turns) { const health = cacheHealth(turns); if (!health?.prefixTokens) return null; - const switched = new Set(turns.filter(realModel).map((t) => t.model)).size > 1; + const switched = + new Set(turns.filter(realModel).map((t) => t.model)).size > 1; return { prefixTokens: health.prefixTokens, - rewriteCost: Math.round(health.prefixTokens * WRITE_MULTIPLIER), + rewriteInputCostEquivalent: Math.round( + health.prefixTokens * WRITE_MULTIPLIER + ), alreadySwitched: switched, - text: `Switching model now discards a ${health.prefixTokens.toLocaleString()}-token warm prefix; ` + - `re-writing it costs about ${Math.round(health.prefixTokens * WRITE_MULTIPLIER).toLocaleString()} tokens.`, + text: + `Switching model now discards a ${health.prefixTokens.toLocaleString()}-token warm prefix; ` + + `under Anthropic's current 5-minute write multiplier, re-writing it is about ${Math.round(health.prefixTokens * WRITE_MULTIPLIER).toLocaleString()} uncached-input cost-equivalent tokens.`, }; } @@ -221,17 +246,38 @@ export function modelSwitchCost(turns) { * time. That is why this class of bug survives -- nothing about it looks wrong. */ const VOLATILE = [ - { id: 'timestamp', re: /\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/, why: 'an embedded timestamp' }, - { id: 'date', re: /\b(?:today|current date)\b[^\n]{0,40}\d{4}-\d{2}-\d{2}/i, why: 'an embedded current date' }, + { + id: 'timestamp', + re: /\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/, + why: 'an embedded timestamp', + }, + { + id: 'date', + re: /\b(?:today|current date)\b[^\n]{0,40}\d{4}-\d{2}-\d{2}/i, + why: 'an embedded current date', + }, // A bare date is a SUSPICION, not a measurement: "- 2025-10-31: reworked the // loader" is byte-identical every session. Kept, because stableText fails // closed over our own output and dropping a line there costs little -- but // NOT priced, because in the blame report a false positive invents a // per-session cost and tells the user to delete their changelog. - { id: 'iso-date', re: /\b\d{4}-\d{2}-\d{2}\b/, why: 'an embedded date', priced: false }, - { id: 'session-id', re: /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i, why: 'a session id' }, + { + id: 'iso-date', + re: /\b\d{4}-\d{2}-\d{2}\b/, + why: 'an embedded date', + priced: false, + }, + { + id: 'session-id', + re: /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i, + why: 'a session id', + }, { id: 'git-sha', re: /\b[0-9a-f]{40}\b/, why: 'a git sha' }, - { id: 'counter', re: /\b(?:run|build|attempt|iteration)\s*#?\d+\b/i, why: 'a run counter' }, + { + id: 'counter', + re: /\b(?:run|build|attempt|iteration)\s*#?\d+\b/i, + why: 'a run counter', + }, { id: 'epoch', re: /\b1[6-9]\d{11}\b/, why: 'an epoch timestamp' }, ]; @@ -242,7 +288,13 @@ export function volatileLines(text) { for (let i = 0; i < lines.length; i++) { for (const rule of VOLATILE) { if (!rule.re.test(lines[i])) continue; - out.push({ line: i + 1, id: rule.id, why: rule.why, priced: rule.priced !== false, text: lines[i].trim().slice(0, 120) }); + out.push({ + line: i + 1, + id: rule.id, + why: rule.why, + priced: rule.priced !== false, + text: lines[i].trim().slice(0, 120), + }); break; // one cause per line is enough to act on } } @@ -264,7 +316,11 @@ const estimate = (text) => Math.ceil(String(text || '').length / 4); * unknown, and this reports the construct WITHOUT a price rather than inventing * one. */ -export function attributeInvalidation(cwd, prefixTokens = null, { files = null } = {}) { +export function attributeInvalidation( + cwd, + prefixTokens = null, + { files = null } = {} +) { // Ordered as the client assembles them: the client preamble and tool // definitions come first, then project instructions, then the conversation. const candidates = files || ['CLAUDE.md', 'AGENTS.md', '.claude/CLAUDE.md']; @@ -292,7 +348,8 @@ export function attributeInvalidation(cwd, prefixTokens = null, { files = null } // prefix -- 3.75x the entire prefix. Conservative still: the preamble // ahead of the file is not counted, so this remains a floor. const ahead = offset + estimate(lines.slice(0, hit.line - 1).join('\n')); - const downstream = prefixTokens != null ? Math.max(0, prefixTokens - ahead) : null; + const downstream = + prefixTokens != null ? Math.max(0, prefixTokens - ahead) : null; // A prefix cache invalidates ONCE, at the earliest difference. A later // volatile line in the same file costs nothing extra until the first one // is fixed, so it is reported for context but not billed again. @@ -304,7 +361,9 @@ export function attributeInvalidation(cwd, prefixTokens = null, { files = null } // into a reported zero. const firstPriced = found.find((h) => h.priced); const subsumedBy = - hit.priced && firstPriced && firstPriced !== hit ? `${relative}:${firstPriced.line}` : null; + hit.priced && firstPriced && firstPriced !== hit + ? `${relative}:${firstPriced.line}` + : null; out.push({ file: relative, line: hit.line, @@ -316,9 +375,12 @@ export function attributeInvalidation(cwd, prefixTokens = null, { files = null } // decline to price. 0 means measured and genuinely free, which is what a // subsumed hit is: real, but already billed by the earlier line. Folding // those two into one value is the same mistake this file warns about. - costPerSession: downstream == null || !hit.priced - ? null - : (subsumedBy ? 0 : Math.round(downstream * WRITE_MULTIPLIER)), + costPerSession: + downstream == null || !hit.priced + ? null + : subsumedBy + ? 0 + : Math.round(downstream * WRITE_MULTIPLIER), remedy: { kind: 'yours', type: 'edit', @@ -369,7 +431,7 @@ export function stableText(text) { */ export function cacheOrdered(items) { return [...items].sort((a, b) => { - const stability = (item) => (item.volatility ?? (item.fresh ? 1 : 0)); + const stability = (item) => item.volatility ?? (item.fresh ? 1 : 0); return stability(a) - stability(b); }); } diff --git a/integrations/windsurf/hooks/lib/decide.mjs b/integrations/windsurf/hooks/lib/decide.mjs index caae7de2f..a61dafdd5 100644 --- a/integrations/windsurf/hooks/lib/decide.mjs +++ b/integrations/windsurf/hooks/lib/decide.mjs @@ -392,6 +392,21 @@ export function touchedFiles(payload) { add(input.path); add(input.notebook_path); + // Codex code mode carries apply_patch as the nested program text rather than + // as a first-class file_path. Without reading these headers, successful + // code-mode edits incremented the semantic-harvest counter but produced no + // structural file/symbol nodes, which is why the live dashboard showed very + // few nodes after substantial work. PostToolUse can resolve Add/Update paths + // because the file now exists; deleted paths deliberately remain absent. + for (const match of command.matchAll( + /^\*\*\* (?:Add|Update|Delete) File:\s*(.+)$/gm + )) { + add(match[1].trim().replace(/^['"]|['"]$/g, '')); + } + for (const match of command.matchAll(/^\*\*\* Move to:\s*(.+)$/gm)) { + add(match[1].trim().replace(/^['"]|['"]$/g, '')); + } + // EVERY pipeline segment, not just the first. `fileOperands` looks only at // the head of the pipeline because that is where the COST is -- `git log | // head` must not be mistaken for a file dump. But observation is a different diff --git a/integrations/windsurf/hooks/lib/experiment.mjs b/integrations/windsurf/hooks/lib/experiment.mjs index 1f64713f1..c35c6cf5c 100644 --- a/integrations/windsurf/hooks/lib/experiment.mjs +++ b/integrations/windsurf/hooks/lib/experiment.mjs @@ -67,16 +67,71 @@ export function episodeMeta({ client, raw = {}, payload = {}, env = process.env } export function usageFrom(raw = {}) { - const usage = raw.usage || raw.token_usage || raw.tokenUsage || raw.metrics?.usage || {}; + const usage = raw.usage || raw.token_usage || raw.tokenUsage || raw.usageMetadata || + raw.usage_metadata || raw.metrics?.usage || raw.response?.usage || + raw.response?.usageMetadata || {}; const number = (...values) => { const value = first(...values); const parsed = Number(value); return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; }; + const cachedInputTokens = number( + usage.cached_input_tokens, usage.cachedInputTokens, + usage.cache_read_input_tokens, usage.cachedContentTokenCount + ); + const cacheCreation = usage.cache_creation || usage.cacheCreation || {}; + const cacheWrite5mInputTokens = number( + usage.cache_write_5m_input_tokens, usage.cacheWrite5mInputTokens, + cacheCreation.ephemeral_5m_input_tokens, cacheCreation.ephemeral5mInputTokens + ); + const cacheWrite1hInputTokens = number( + usage.cache_write_1h_input_tokens, usage.cacheWrite1hInputTokens, + cacheCreation.ephemeral_1h_input_tokens, cacheCreation.ephemeral1hInputTokens + ); + const detailedWrites = (cacheWrite5mInputTokens || 0) + (cacheWrite1hInputTokens || 0); + const reportedWrites = number( + usage.cache_write_input_tokens, usage.cacheWriteInputTokens, + usage.cache_creation_input_tokens, usage.cacheCreationInputTokens + ); + const cacheWriteInputTokens = reportedWrites == null + ? null + : Math.max(0, reportedWrites - detailedWrites); + const explicitUncached = number(usage.uncached_input_tokens, usage.uncachedInputTokens); + const promptInput = number( + usage.promptTokenCount, usage.prompt_token_count, + usage.input_tokens, usage.inputTokens + ); + const isAnthropic = usage.cache_creation_input_tokens !== undefined || + usage.cache_read_input_tokens !== undefined; + const isGemini = usage.promptTokenCount !== undefined || usage.cachedContentTokenCount !== undefined; + const uncachedInputTokens = explicitUncached != null + ? explicitUncached + : promptInput == null + ? null + : isAnthropic + ? promptInput + : isGemini || cachedInputTokens != null || reportedWrites != null + ? Math.max(0, promptInput - (cachedInputTokens || 0) - (reportedWrites || 0)) + : promptInput; + const candidates = number(usage.candidatesTokenCount, usage.candidates_token_count); + const thoughts = number(usage.thoughtsTokenCount, usage.thoughts_token_count); + const outputTokens = candidates != null || thoughts != null + ? (candidates || 0) + (thoughts || 0) + : number(usage.output_tokens, usage.outputTokens); return { - uncachedInputTokens: number(usage.uncached_input_tokens, usage.uncachedInputTokens), - cachedInputTokens: number(usage.cached_input_tokens, usage.cachedInputTokens, usage.cache_read_input_tokens), - outputTokens: number(usage.output_tokens, usage.outputTokens), - totalTokens: number(usage.total_tokens, usage.totalTokens, raw.tokens_used, raw.tokensUsed), + uncachedInputTokens, + cachedInputTokens, + cacheWrite5mInputTokens, + cacheWrite1hInputTokens, + cacheWriteInputTokens, + outputTokens, + totalTokens: number( + usage.total_tokens, usage.totalTokens, usage.totalTokenCount, + usage.total_token_count, raw.tokens_used, raw.tokensUsed + ), + usageMeasurementId: first( + raw.requestId, raw.request_id, raw.responseId, raw.response_id, + usage.responseId, usage.response_id + ) ?? null, }; } diff --git a/integrations/windsurf/hooks/lib/harvest-write.mjs b/integrations/windsurf/hooks/lib/harvest-write.mjs index 30433d370..db436c1dc 100644 --- a/integrations/windsurf/hooks/lib/harvest-write.mjs +++ b/integrations/windsurf/hooks/lib/harvest-write.mjs @@ -24,7 +24,7 @@ */ import { - putNodeWithEdges, load, nodeId, sharedDir, isSharedDir, putNode, + putNodeWithEdges, load, nodeId, sharedDir, isSharedDir, putNode, putEdge, } from './wiki.mjs'; import { indexFile } from './staleness.mjs'; import { symbolKey } from './symbols.mjs'; @@ -47,6 +47,37 @@ import { ORIGIN_HARVESTED, ORIGIN_AGENT, ORIGIN_HUMAN } from './curate.mjs'; */ export const SHAREABLE_TYPES = new Set(['command', 'failure', 'decision', 'feedback']); +/** + * Synthetic study traffic must never become advice for an unrelated live repo. + * Evaluation roots are intentionally realistic and may carry their own `.git` + * marker, so repository discovery alone cannot distinguish them. Publication + * remains useful inside the isolated study store; this guard protects only the + * default machine-wide store when a harness was accidentally run without an + * isolated TOKEN_OPTIMIZER_SHARED_DIR. + */ +export function isEphemeralProject(projectRoot) { + const root = canonicalPath(projectRoot || '').toLowerCase(); + if (!root) return true; + return ( + /\/appdata\/local\/temp\//.test(root) || + /(^|\/)tmp\//.test(root) || + /\/(artifacts|\.token-optimizer)\/(?:[^/]*\/)*(?:eval|scenario|study|fixture|debug)[^/]*\//.test(`${root}/`) + ); +} + +export function quarantineSharedSource(projectRoot) { + if (process.env.TOKEN_OPTIMIZER_ALLOW_EPHEMERAL_SHARED === '1') return false; + if (!isEphemeralProject(projectRoot)) return false; + // Isolated studies deliberately point the shared tier at scratch storage. + // Their findings may cross their own fixture projects, but cannot escape into + // the user's default machine-wide graph. Strict mode exists for a regression + // probe that verifies the guard without writing to a real user directory. + return ( + !process.env.TOKEN_OPTIMIZER_SHARED_DIR || + process.env.TOKEN_OPTIMIZER_STRICT_SHARED_PROVENANCE === '1' + ); +} + /** Claim text, normalised enough that the same lesson learned twice is one row. */ const claimFingerprint = (claim) => String(claim || '').toLowerCase().replace(/\s+/g, ' ').trim().slice(0, 300); @@ -72,6 +103,7 @@ function promoteToShared(finding, { projectRoot, sessionId, provenance, key }) { if (!projectRoot) return false; const root = canonicalPath(projectRoot); + if (quarantineSharedSource(root)) return false; const rootId = nodeId('file', root); // Same claim, already carried up from anywhere: keep the first. Two repos @@ -367,11 +399,21 @@ export function writeHarvested( && !node.retired && node.type === finding.type && claimFingerprint(node.claim) === fingerprint - && resolved.every((target) => currentGraph.edges.some((edge) => - edge.from === node.id && edge.edge === 'derived_from' && edge.to === target - )) ); if (duplicate) { + // Enrich, do not duplicate. A symbol extractor may learn how to resolve a + // previously file-only anchor (or a later session may add another real + // anchor) after the claim was first stored. Repeating the same semantic + // conclusion should connect that existing node to the newly available + // evidence, not create a second dashboard card with the same text. + for (const target of resolved) { + const alreadyLinked = currentGraph.edges.some((edge) => + edge.from === duplicate.id + && edge.edge === 'derived_from' + && edge.to === target + ); + if (!alreadyLinked) putEdge(dir, duplicate.id, 'derived_from', target); + } written.push(duplicate.key); continue; } @@ -437,7 +479,12 @@ export function writeHarvested( if ( id && !isSharedDir(dir) - && (finding.scope === undefined || finding.scope === 'organization' || finding.scope === 'global') + // Cross-project publication is explicit. `writeHarvested` stores an + // omitted scope as project-local above, so treating `undefined` as global + // here contradicted the stored record and promoted fixture/evaluation + // seeds into real user sessions. This was observed live as "learned in + // scenario" advice while working in AiDotNet. + && (finding.scope === 'organization' || finding.scope === 'global') ) { promoteToShared(finding, { projectRoot, sessionId, provenance, key }); } diff --git a/integrations/windsurf/hooks/lib/inject.mjs b/integrations/windsurf/hooks/lib/inject.mjs index b00494a01..c0aa2f1bf 100644 --- a/integrations/windsurf/hooks/lib/inject.mjs +++ b/integrations/windsurf/hooks/lib/inject.mjs @@ -31,6 +31,7 @@ import { canonicalPath, resolvableCandidates } from './paths.mjs'; import { annotatedSkeleton } from './skeleton.mjs'; import { substitutionBudget } from './metrics.mjs'; import { assessFindings } from './utility.mjs'; +import { quarantineSharedSource } from './harvest-write.mjs'; // Read per call for the same reason as the holdout fraction in metrics.mjs. const touchBudget = () => Number(process.env.TOKEN_OPTIMIZER_TOUCH_BUDGET) || 500; @@ -562,6 +563,7 @@ export function forRepeatedAct( const home = projectRoot ? canonicalPath(projectRoot) : null; for (const node of graph.nodes.values()) { if (node.kind !== 'finding' || node.retired || !node.claim) continue; + if (quarantineSharedSource(node.sourceProject)) continue; if (home && node.sourceProject && canonicalPath(node.sourceProject) === home) continue; const claimClasses = classesOf(node.claim, 'claim'); if (![...crossedClasses].some((c) => claimClasses.has(c))) continue; @@ -667,6 +669,7 @@ export function forSharedCommand( const candidates = []; for (const node of graph.nodes.values()) { if (node.kind !== 'finding' || node.retired) continue; + if (quarantineSharedSource(node.sourceProject)) continue; if (alreadyInjected.has(node.key)) continue; // ITS OWN LESSON IS NOT NEWS. A finding this project contributed is already // served by the local path; repeating it under a "from elsewhere" label @@ -1054,7 +1057,13 @@ export function standingRules(dir, graph, { budget = standingBudget(), episode = * informative response available -- more useful than the file, not a lossier * version of it. */ -export function substitutionFor(dir, graph, rawPath, source, { sessionId } = {}) { +export function substitutionFor( + dir, + graph, + rawPath, + source, + { sessionId, client = null, clientVersion = null, model = null, modelVersion = null } = {} +) { const filePath = canonicalPath(rawPath); const budget = substitutionBudget(dir, filePath); const built = annotatedSkeleton(graph, rawPath, source, { budget }); @@ -1089,6 +1098,10 @@ export function substitutionFor(dir, graph, rawPath, source, { sessionId } = {}) // on this machine turned out to be the enforcement suite's own fixture and // nothing distinguished them from real work. sessionId, + client, + clientVersion, + model, + modelVersion, holdout, tokens: holdout ? 0 : built.tokens, findings: built.findings, diff --git a/integrations/windsurf/hooks/lib/metrics.mjs b/integrations/windsurf/hooks/lib/metrics.mjs index 0bb08f1bc..09f822630 100644 --- a/integrations/windsurf/hooks/lib/metrics.mjs +++ b/integrations/windsurf/hooks/lib/metrics.mjs @@ -19,8 +19,15 @@ */ import { - appendFileSync, readFileSync, existsSync, mkdirSync, chmodSync, - statSync, openSync, readSync, closeSync, + appendFileSync, + readFileSync, + existsSync, + mkdirSync, + chmodSync, + statSync, + openSync, + readSync, + closeSync, } from 'node:fs'; import { join } from 'node:path'; import { createHash, randomBytes } from 'node:crypto'; @@ -86,6 +93,10 @@ export const BALANCE_KINDS = new Set([ 'keepwarm', ]); +/** Latest timestamp without passing an unbounded event log as function arguments. */ +export const latestEventTimestamp = (rows) => + rows.reduce((latest, event) => Math.max(latest, Number(event.at) || 0), 0); + /** * Causal records have their own bounded log. Tool outcomes are much rarer * than read/capture telemetry but more frequent than balance records; mixing @@ -130,7 +141,7 @@ export function inHoldout(anchorKey, now = Date.now()) { // other side than before. With nine holdout records in existence that costs // nothing, and stratification is a fresh draw either way. const digest = createHash('sha256').update(`${anchorKey}:${epoch}`).digest(); - return (digest[0] / 256) < fraction; + return digest[0] / 256 < fraction; } /** @@ -258,7 +269,8 @@ export function record(dir, event) { * It is also the right statistics. A saving measured months ago says little * about the code as it stands now. */ -const MAX_BYTES = Number(process.env.TOKEN_OPTIMIZER_METRICS_BYTES) || 2_000_000; +const MAX_BYTES = + Number(process.env.TOKEN_OPTIMIZER_METRICS_BYTES) || 2_000_000; const MAX_EVENTS = Number(process.env.TOKEN_OPTIMIZER_METRICS_WINDOW) || 5000; /** @@ -432,7 +444,10 @@ export function readBalance(dir) { // path for records written before ids existed; it is lossy by nature, which // is precisely why new records carry an id instead. const id = - e.id ?? [e.kind, e.at, e.anchor ?? '', e.sessionId ?? '', e.tokens ?? ''].join('|'); + e.id ?? + [e.kind, e.at, e.anchor ?? '', e.sessionId ?? '', e.tokens ?? ''].join( + '|' + ); if (seen.has(id)) continue; seen.add(id); out.push(e); @@ -493,16 +508,24 @@ export function recordToolOutcome(dir, outcome) { const evidence = readEvidence(dir); const anchor = String(outcome.anchor || '').slice(0, 120); const candidates = evidence - .filter((event) => event.kind === 'inject' && event.episodeId === outcome.episodeId) + .filter( + (event) => + event.kind === 'inject' && event.episodeId === outcome.episodeId + ) .filter((event) => { if (outcome.toolCallId && event.toolCallId) return String(event.toolCallId) === String(outcome.toolCallId); - return event.surface === outcome.surface && String(event.anchor || '').slice(0, 120) === anchor; + return ( + event.surface === outcome.surface && + String(event.anchor || '').slice(0, 120) === anchor + ); }) .sort((a, b) => (b.at || 0) - (a.at || 0)); const injection = candidates[0] || null; const joinMethod = injection - ? (outcome.toolCallId && injection.toolCallId ? 'tool-call-id' : 'episode-anchor') + ? outcome.toolCallId && injection.toolCallId + ? 'tool-call-id' + : 'episode-anchor' : 'none'; return record(dir, { @@ -542,12 +565,11 @@ export function isFixtureAnchor(anchor) { // code: it excluded ALL temp paths -- including a real project checked out // under /tmp -- and on macOS excluded nothing at all, because tmpdir() there // is /var/folders///T/ and did not match. - const underTemp = - new RegExp( - '[\\\\/](AppData[\\\\/]Local[\\\\/])?(Temp|tmp)[\\\\/]' + - '|[\\\\/]var[\\\\/]folders[\\\\/][^\\\\/]+[\\\\/][^\\\\/]+[\\\\/]T[\\\\/]', - 'i' - ).test(p); + const underTemp = new RegExp( + '[\\\\/](AppData[\\\\/]Local[\\\\/])?(Temp|tmp)[\\\\/]' + + '|[\\\\/]var[\\\\/]folders[\\\\/][^\\\\/]+[\\\\/][^\\\\/]+[\\\\/]T[\\\\/]', + 'i' + ).test(p); if (!underTemp) return false; // Only names this suite actually creates. A user's own scratch checkout under @@ -686,8 +708,12 @@ export function balanceSheet(dir) { // THE CONTROL ARM'S ACTUAL COST. `tokensFullFile` was recorded and read // by nothing, so the comparison the holdout exists for was not // computable from the report. - controlArmTokens: withheldSubs.reduce((sum, e) => sum + (e.tokensFullFile || 0), 0), - assumption: 'that the model would have read the file it explicitly requested', + controlArmTokens: withheldSubs.reduce( + (sum, e) => sum + (e.tokensFullFile || 0), + 0 + ), + assumption: + 'that the model would have read the file it explicitly requested', }, estimatedCausal: { what: 'findings injection, from the stratified holdout', @@ -702,7 +728,12 @@ export function balanceSheet(dir) { }; })(), }, - costs: { injection: injectCost, harvest: harvestCost, standing: standingCost, substitution: substitutionCost }, + costs: { + injection: injectCost, + harvest: harvestCost, + standing: standingCost, + substitution: substitutionCost, + }, waste, // Deliberately NOT a single number. The two benefit lines are known // differently; adding them would launder an assumption into a measurement. @@ -727,7 +758,9 @@ export function balanceSheet(dir) { /** Cheap path normalisation for grouping reads; not the identity canonicaliser. */ function canonicalKeyish(p) { - return String(p || '').replace(/\\/g, '/').toLowerCase(); + return String(p || '') + .replace(/\\/g, '/') + .toLowerCase(); } /** @@ -738,7 +771,43 @@ function canonicalKeyish(p) { * arms, multiplied by treated touches. Reporting it as a measured fact would be * the same overclaiming this project criticises competitors for. */ -function buildReport(events, balance) { +function buildReport(events, balance, sourceCoverage = {}) { + const substitutions = balance.filter( + (event) => event.kind === 'substitute' && !isFixtureAnchor(event.anchor) + ); + const servedSubstitutions = substitutions.filter((event) => !event.holdout); + const substitutionTokensSaved = servedSubstitutions.reduce( + (sum, event) => + sum + + (event.tokensNetAvoided ?? + Math.max( + 0, + Math.ceil((event.bytesAvoided || 0) / 4) - (event.tokens || 0) + )), + 0 + ); + const substitutionTokensReturned = servedSubstitutions.reduce( + (sum, event) => sum + (event.tokens || 0), + 0 + ); + const substitutionByClient = {}; + for (const event of servedSubstitutions) { + const name = event.client || 'Historical — client not recorded'; + const current = substitutionByClient[name] || { + substitutions: 0, + tokensReturned: 0, + tokensSaved: 0, + }; + current.substitutions += 1; + current.tokensReturned += event.tokens || 0; + current.tokensSaved += + event.tokensNetAvoided ?? + Math.max( + 0, + Math.ceil((event.bytesAvoided || 0) / 4) - (event.tokens || 0) + ); + substitutionByClient[name] = current; + } // COMMAND INJECTIONS ARE COUNTED SEPARATELY, not mixed into the balance. // @@ -762,13 +831,19 @@ function buildReport(events, balance) { // Report its cost independently so it cannot dilute the file-touch estimate. const isSessionStart = (e) => e.surface === 'session-start'; const sessionStartInjections = allInjections.filter(isSessionStart); - const commandInjections = allInjections.filter((e) => !isSessionStart(e) && isCommand(e)); - const injections = allInjections.filter((e) => !isSessionStart(e) && !isCommand(e)); + const commandInjections = allInjections.filter( + (e) => !isSessionStart(e) && isCommand(e) + ); + const injections = allInjections.filter( + (e) => !isSessionStart(e) && !isCommand(e) + ); const treated = injections.filter((e) => !e.holdout); const withheld = injections.filter((e) => e.holdout); const mean = (rows, field) => - rows.length ? rows.reduce((sum, r) => sum + (r[field] || 0), 0) / rows.length : 0; + rows.length + ? rows.reduce((sum, r) => sum + (r[field] || 0), 0) / rows.length + : 0; const injectedTokens = treated.reduce((sum, e) => sum + (e.tokens || 0), 0); const harvestTokens = balance @@ -819,7 +894,9 @@ function buildReport(events, balance) { const after = event.at ?? 0; const total = bucket.reduce( - (sum, r) => sum + ((r.at ?? 0) >= after ? (r.tokens || 0) : 0), 0); + (sum, r) => sum + ((r.at ?? 0) >= after ? r.tokens || 0 : 0), + 0 + ); // Split across the injections that share this key. Each touch is one // observation, so charging every one the full total would count the same @@ -828,52 +905,198 @@ function buildReport(events, balance) { }; const meanDownstream = (rows) => - rows.length ? rows.reduce((sum, r) => sum + downstreamOf(r), 0) / rows.length : 0; + rows.length + ? rows.reduce((sum, r) => sum + downstreamOf(r), 0) / rows.length + : 0; const treatedCost = meanDownstream(treated); const withheldCost = meanDownstream(withheld); const perTouchSaving = withheldCost - treatedCost; - const estimatedAvoided = Math.max(0, Math.round(perTouchSaving * treated.length)); + const estimatedAvoided = Math.max( + 0, + Math.round(perTouchSaving * treated.length) + ); + + const downstreamSamples = injections.filter((event) => { + if (event.downstream != null) return true; + const key = `${event.sessionId || ''}|${event.anchor}`; + return (reads.get(key) || []).some( + (read) => (read.at ?? 0) >= (event.at ?? 0) + ); + }).length; + const downstreamMeasured = downstreamSamples > 0; // Below this, arm means are noise and a ratio would be theatre. - const sufficient = treated.length >= 20 && withheld.length >= 5; + const sufficient = + downstreamMeasured && treated.length >= 20 && withheld.length >= 5; + + const coverageNumber = (value, fallback) => { + if (value === null || value === undefined || value === '') return fallback; + const number = Number(value); + return Number.isFinite(number) ? Math.max(0, number) : fallback; + }; + const projectCount = coverageNumber(sourceCoverage.projects, 1); + const telemetryProjects = coverageNumber( + sourceCoverage.projectsWithTelemetry, + 0 + ); + const balanceProjects = coverageNumber( + sourceCoverage.projectsWithBalanceEvents, + balance.length ? 1 : 0 + ); + const lastEventAt = Math.max( + latestEventTimestamp(events), + latestEventTimestamp(balance) + ); + const measured = + telemetryProjects > 0 || events.length > 0 || balance.length > 0; + const balanceMeasured = balanceProjects > 0 || balance.length > 0; + const metric = (status, source, samples, extra = {}) => ({ + status, + source, + samples, + ...extra, + }); return { + nativeOptimizer: { + substitutions: servedSubstitutions.length, + holdouts: substitutions.filter((event) => event.holdout).length, + tokensReturned: substitutionTokensReturned, + tokensSaved: substitutionTokensSaved, + byClient: substitutionByClient, + recent: servedSubstitutions + .slice() + .sort((a, b) => (b.at || 0) - (a.at || 0)) + .slice(0, 40) + .map((event) => { + const tokensSaved = + event.tokensNetAvoided ?? + Math.max( + 0, + Math.ceil((event.bytesAvoided || 0) / 4) - (event.tokens || 0) + ); + return { + name: 'live_graph_substitution', + client: event.client || 'Historical — client not recorded', + originalTokens: (event.tokens || 0) + tokensSaved, + optimizedTokens: event.tokens || 0, + tokensSaved, + savingsMeasured: false, + classification: 'modeled-counterfactual', + reportedTokensSaved: tokensSaved, + timestamp: event.at ? new Date(event.at).toISOString() : null, + }; + }), + source: + 'balance.jsonl: modeled full-file counterfactual minus annotated-skeleton tokens; excluded from verified MCP savings', + }, memoryDeliveries: allInjections.filter((event) => !event.holdout).length, memoryHoldouts: allInjections.filter((event) => event.holdout).length, deliveryTokens: allInjections .filter((event) => !event.holdout) .reduce( - (sum, event) => - sum + (event.deliveredTokens ?? event.tokens ?? 0), + (sum, event) => sum + (event.deliveredTokens ?? event.tokens ?? 0), 0 ), injections: treated.length, sessionStartInjections: sessionStartInjections.length, sessionStartInjectedTokens: sessionStartInjections.reduce( - (sum, event) => sum + (event.deliveredTokens ?? event.tokens ?? 0), 0 + (sum, event) => sum + (event.deliveredTokens ?? event.tokens ?? 0), + 0 ), commandInjections: commandInjections.length, commandHoldouts: commandInjections.filter((e) => e.holdout).length, holdouts: withheld.length, staleServed: injections.filter((e) => e.stale).length, - staleRate: injections.length ? injections.filter((e) => e.stale).length / injections.length : 0, + staleRate: injections.length + ? injections.filter((e) => e.stale).length / injections.length + : 0, injectedTokens, harvestTokens, estimatedTokensAvoided: sufficient ? estimatedAvoided : null, - netTokens: sufficient ? estimatedAvoided - injectedTokens - harvestTokens : null, + netTokens: sufficient + ? estimatedAvoided - injectedTokens - harvestTokens + : null, sufficientData: sufficient, verdict: !sufficient ? `insufficient data (${treated.length} treated, ${withheld.length} holdout; need 20 and 5)` : estimatedAvoided > injectedTokens + harvestTokens ? 'the graph is saving more than it costs' : 'the graph is NOT yet paying for itself', + measurement: { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + sourceCoverage: { + projects: projectCount, + projectsWithTelemetry: telemetryProjects || (measured ? 1 : 0), + projectsWithBalanceEvents: balanceProjects, + projectsWithoutTelemetry: Math.max( + 0, + projectCount - (telemetryProjects || (measured ? 1 : 0)) + ), + }, + freshness: { + lastEventAt: lastEventAt || null, + ageMs: lastEventAt ? Math.max(0, Date.now() - lastEventAt) : null, + status: !lastEventAt + ? 'not-measured' + : Date.now() - lastEventAt <= 7 * 86_400_000 + ? 'fresh' + : 'stale', + }, + metrics: { + nativeSubstitutions: metric( + balanceMeasured ? 'measured' : 'not-measured', + 'balance.jsonl: modeled full-file counterfactual minus annotated-skeleton tokens; not a materialized MCP before/after pair', + servedSubstitutions.length, + { classification: 'modeled-counterfactual' } + ), + memoryDeliveries: metric( + balanceMeasured ? 'measured' : 'not-measured', + 'balance.jsonl: inject events in the treated arm', + allInjections.filter((event) => !event.holdout).length + ), + memoryHoldouts: metric( + balanceMeasured ? 'measured' : 'not-measured', + 'balance.jsonl: inject events in the withheld arm', + allInjections.filter((event) => event.holdout).length + ), + rememberingCost: metric( + balanceMeasured ? 'measured' : 'not-measured', + 'balance.jsonl: delivered injection tokens plus semantic harvest tokens', + allInjections.length + + balance.filter((event) => event.kind === 'harvest').length + ), + readingAvoided: metric( + !downstreamMeasured + ? 'not-measured' + : sufficient + ? 'measured' + : 'collecting', + 'stratified file-touch holdout joined to downstream read events', + downstreamSamples, + { + treated: treated.length, + holdouts: withheld.length, + requiredTreated: 20, + requiredHoldouts: 5, + } + ), + }, + }, }; } export function report(dir) { - return buildReport(readAll(dir), readBalance(dir)); + const events = readAll(dir); + const balance = readBalance(dir); + return buildReport(events, balance, { + projects: 1, + projectsWithTelemetry: events.length || balance.length ? 1 : 0, + projectsWithBalanceEvents: balance.length ? 1 : 0, + }); } /** @@ -886,10 +1109,16 @@ export function report(dir) { */ export function reportMany(dirs) { const unique = [...new Set((dirs || []).map((dir) => String(dir)))]; - const report = buildReport( - unique.flatMap((dir) => readAll(dir)), - unique.flatMap((dir) => readBalance(dir)) - ); + const eventSources = unique.map((dir) => readAll(dir)); + const balanceSources = unique.map((dir) => readBalance(dir)); + const report = buildReport(eventSources.flat(), balanceSources.flat(), { + projects: unique.length, + projectsWithTelemetry: eventSources.filter( + (events, index) => events.length || balanceSources[index].length + ).length, + projectsWithBalanceEvents: balanceSources.filter((events) => events.length) + .length, + }); return { ...report, projects: unique.length }; } @@ -907,7 +1136,11 @@ export function reportMany(dirs) { * costs almost nothing. Without a control arm there is no way to tell those two * apart, which is why nobody else can do this. */ -export function substitutionBudget(dir, anchor, { floor = 300, base = 1200, ceiling = 3000 } = {}) { +export function substitutionBudget( + dir, + anchor, + { floor = 300, base = 1200, ceiling = 3000 } = {} +) { const events = readAll(dir); const mine = events.filter((e) => e.kind === 'inject' && e.anchor === anchor); @@ -932,13 +1165,17 @@ export function substitutionBudget(dir, anchor, { floor = 300, base = 1200, ceil for (const row of rows) { const bucket = reads.get(row.sessionId || '') || []; const after = row.at ?? 0; - total += bucket.reduce((sum, r) => sum + ((r.at ?? 0) >= after ? (r.tokens || 0) : 0), 0); + total += bucket.reduce( + (sum, r) => sum + ((r.at ?? 0) >= after ? r.tokens || 0 : 0), + 0 + ); } return total / rows.length; }; const saved = downstream(withheld) - downstream(treated); - const spent = treated.reduce((sum, e) => sum + (e.tokens || 0), 0) / treated.length; + const spent = + treated.reduce((sum, e) => sum + (e.tokens || 0), 0) / treated.length; // Ratio of what annotating this file avoided to what annotating it cost. // Above 1 it is paying for itself and earns room; below, it shrinks. @@ -959,7 +1196,10 @@ export function substitutionBudget(dir, anchor, { floor = 300, base = 1200, ceil * graph whose index leads to queries grows its allowance; a noisy one shrinks * back toward the floor. Nobody configures it, and it cannot run away. */ -export function indexBudget(dir, { floor = 150, base = 300, ceiling = 1200 } = {}) { +export function indexBudget( + dir, + { floor = 150, base = 300, ceiling = 1200 } = {} +) { const events = readAll(dir); const listed = events.filter((e) => e.kind === 'index').length; const queries = events.filter((e) => e.kind === 'query').length; @@ -968,7 +1208,9 @@ export function indexBudget(dir, { floor = 150, base = 300, ceiling = 1200 } = { const hitRate = queries / listed; // 0% hit rate falls to the floor; ~50% and above reaches the ceiling. - const scaled = Math.round(floor + (ceiling - floor) * Math.min(1, hitRate * 2)); + const scaled = Math.round( + floor + (ceiling - floor) * Math.min(1, hitRate * 2) + ); return Math.max(floor, Math.min(ceiling, scaled)); } @@ -1005,7 +1247,10 @@ function seededRandom(seedText) { } /** Percentile bootstrap interval for a mean, paired delta, or other scalar. */ -export function bootstrapMeanInterval(values, { samples = 1000, seed = 'token-optimizer' } = {}) { +export function bootstrapMeanInterval( + values, + { samples = 1000, seed = 'token-optimizer' } = {} +) { const finite = values.map(numeric).filter((value) => value !== null); if (!finite.length) return { mean: null, low: null, high: null, n: 0 }; if (finite.length === 1) { @@ -1037,24 +1282,32 @@ export function proportionInterval(successes, total, z = 1.96) { const denominator = 1 + (z * z) / total; const centre = (rate + (z * z) / (2 * total)) / denominator; const radius = - (z / denominator) * Math.sqrt((rate * (1 - rate)) / total + (z * z) / (4 * total * total)); - return { rate, low: Math.max(0, centre - radius), high: Math.min(1, centre + radius), n: total }; + (z / denominator) * + Math.sqrt((rate * (1 - rate)) / total + (z * z) / (4 * total * total)); + return { + rate, + low: Math.max(0, centre - radius), + high: Math.min(1, centre + radius), + n: total, + }; } -const cohortKey = (run) => [ - run.client || 'unknown', - run.clientVersion || 'unknown', - run.model || 'unknown', - run.modelVersion || 'unknown', - run.taskId || 'unknown', -].join('|'); +const cohortKey = (run) => + [ + run.client || 'unknown', + run.clientVersion || 'unknown', + run.model || 'unknown', + run.modelVersion || 'unknown', + run.taskId || 'unknown', + ].join('|'); function armMetrics(runs) { const correct = runs.filter((run) => run.correct === true).length; - const interval = (field) => bootstrapMeanInterval( - runs.map((run) => run[field]).filter((value) => numeric(value) !== null), - { seed: field } - ); + const interval = (field) => + bootstrapMeanInterval( + runs.map((run) => run[field]).filter((value) => numeric(value) !== null), + { seed: field } + ); return { runs: runs.length, correctness: proportionInterval(correct, runs.length), @@ -1068,29 +1321,40 @@ function armMetrics(runs) { latencyMs: interval('latencyMs'), costUsd: interval('costUsd'), injectedTokens: interval('injectedTokens'), - harmfulFindings: runs.reduce((sum, run) => sum + (numeric(run.harmfulFindings) || 0), 0), + harmfulFindings: runs.reduce( + (sum, run) => sum + (numeric(run.harmfulFindings) || 0), + 0 + ), }; } -function pairedEffects(runs, controlArm, treatmentArm, contrastType = 'incremental') { +function pairedEffects( + runs, + controlArm, + treatmentArm, + contrastType = 'incremental' +) { const pairs = new Map(); for (const run of runs) { if (!run.pairId || ![controlArm, treatmentArm].includes(run.arm)) continue; if (!pairs.has(run.pairId)) pairs.set(run.pairId, {}); pairs.get(run.pairId)[run.arm] = run; } - const complete = [...pairs.values()].filter((pair) => pair[controlArm] && pair[treatmentArm]); - const delta = (field, lowerIsBetter = true) => bootstrapMeanInterval( - complete - .map((pair) => { - const baseline = numeric(pair[controlArm][field]); - const treatment = numeric(pair[treatmentArm][field]); - if (baseline === null || treatment === null) return null; - return lowerIsBetter ? baseline - treatment : treatment - baseline; - }) - .filter((value) => value !== null), - { seed: `${controlArm}:${treatmentArm}:${field}` } + const complete = [...pairs.values()].filter( + (pair) => pair[controlArm] && pair[treatmentArm] ); + const delta = (field, lowerIsBetter = true) => + bootstrapMeanInterval( + complete + .map((pair) => { + const baseline = numeric(pair[controlArm][field]); + const treatment = numeric(pair[treatmentArm][field]); + if (baseline === null || treatment === null) return null; + return lowerIsBetter ? baseline - treatment : treatment - baseline; + }) + .filter((value) => value !== null), + { seed: `${controlArm}:${treatmentArm}:${field}` } + ); return { arm: treatmentArm, controlArm, @@ -1111,21 +1375,32 @@ function pairedEffects(runs, controlArm, treatmentArm, contrastType = 'increment } function matchesFilters(event, filters) { - for (const field of ['client', 'clientVersion', 'model', 'modelVersion', 'taskId', 'arm']) { + for (const field of [ + 'client', + 'clientVersion', + 'model', + 'modelVersion', + 'taskId', + 'arm', + ]) { if (!filters[field]) continue; let actual = event[field]; if (event.kind === 'handoff-run') { - actual = field === 'taskId' - ? event.scenarioId - : field === 'arm' - ? event.arm - : (event.consumer?.[field] ?? event.producer?.[field] ?? event[field]); + actual = + field === 'taskId' + ? event.scenarioId + : field === 'arm' + ? event.arm + : (event.consumer?.[field] ?? + event.producer?.[field] ?? + event[field]); } else if (event.kind === 'concurrency-run') { - actual = field === 'taskId' - ? 'concurrent-combined' - : field === 'arm' - ? event.arm - : (event.consumer?.[field] ?? event[field]); + actual = + field === 'taskId' + ? 'concurrent-combined' + : field === 'arm' + ? event.arm + : (event.consumer?.[field] ?? event[field]); } if (String(actual || '') !== String(filters[field])) return false; } @@ -1134,11 +1409,15 @@ function matchesFilters(event, filters) { function handoffArmMetrics(runs) { const consumers = runs.map((run) => run.consumer || {}); - const count = (field) => consumers.filter((consumer) => consumer[field] === true).length; - const interval = (field) => bootstrapMeanInterval( - consumers.map((consumer) => consumer[field]).filter((value) => numeric(value) !== null), - { seed: `handoff:${field}` } - ); + const count = (field) => + consumers.filter((consumer) => consumer[field] === true).length; + const interval = (field) => + bootstrapMeanInterval( + consumers + .map((consumer) => consumer[field]) + .filter((value) => numeric(value) !== null), + { seed: `handoff:${field}` } + ); return { runs: runs.length, delivery: proportionInterval( @@ -1147,7 +1426,10 @@ function handoffArmMetrics(runs) { ), correctness: proportionInterval(count('correct'), runs.length), firstPass: proportionInterval(count('firstPass'), runs.length), - mistakeAttempted: proportionInterval(count('mistakeAttempted'), runs.length), + mistakeAttempted: proportionInterval( + count('mistakeAttempted'), + runs.length + ), mistakeExecuted: proportionInterval(count('mistakeExecuted'), runs.length), totalTokens: interval('totalTokens'), toolCalls: interval('toolCalls'), @@ -1163,16 +1445,21 @@ function handoffPairedEffect(runs, controlArm, treatmentArm) { if (!pairs.has(run.pairId)) pairs.set(run.pairId, {}); pairs.get(run.pairId)[run.arm] = run; } - const complete = [...pairs.values()].filter((pair) => pair[controlArm] && pair[treatmentArm]); - const effect = (field, positiveWhenLower = true) => bootstrapMeanInterval( - complete.map((pair) => { - const control = numeric(pair[controlArm].consumer?.[field]); - const treatment = numeric(pair[treatmentArm].consumer?.[field]); - if (control === null || treatment === null) return null; - return positiveWhenLower ? control - treatment : treatment - control; - }).filter((value) => value !== null), - { seed: `handoff:${controlArm}:${treatmentArm}:${field}` } + const complete = [...pairs.values()].filter( + (pair) => pair[controlArm] && pair[treatmentArm] ); + const effect = (field, positiveWhenLower = true) => + bootstrapMeanInterval( + complete + .map((pair) => { + const control = numeric(pair[controlArm].consumer?.[field]); + const treatment = numeric(pair[treatmentArm].consumer?.[field]); + if (control === null || treatment === null) return null; + return positiveWhenLower ? control - treatment : treatment - control; + }) + .filter((value) => value !== null), + { seed: `handoff:${controlArm}:${treatmentArm}:${field}` } + ); return { comparison: `${treatmentArm} vs ${controlArm}`, pairs: complete.length, @@ -1187,13 +1474,22 @@ function handoffPairedEffect(runs, controlArm, treatmentArm) { }; } -const handoffKey = (run) => [ - run.producer?.client || 'unknown', run.producer?.model || 'unknown', - run.consumer?.client || 'unknown', run.consumer?.model || 'unknown', - run.scenarioId || 'unknown', -].join('|'); - -const HANDOFF_REPORT_ARMS = ['empty', 'natural', 'oracle', 'irrelevant', 'stale']; +const handoffKey = (run) => + [ + run.producer?.client || 'unknown', + run.producer?.model || 'unknown', + run.consumer?.client || 'unknown', + run.consumer?.model || 'unknown', + run.scenarioId || 'unknown', + ].join('|'); + +const HANDOFF_REPORT_ARMS = [ + 'empty', + 'natural', + 'oracle', + 'irrelevant', + 'stale', +]; const handoffMinimumPairs = () => Math.max(2, Number(process.env.TOKEN_OPTIMIZER_HANDOFF_MIN_PAIRS) || 10); @@ -1206,43 +1502,54 @@ function handoffCohorts(runs) { } const minimumPairs = handoffMinimumPairs(); return [...grouped.entries()].map(([key, rows]) => { - const arms = Object.fromEntries(HANDOFF_REPORT_ARMS.map((arm) => [ - arm, handoffArmMetrics(rows.filter((row) => row.arm === arm)), - ])); + const arms = Object.fromEntries( + HANDOFF_REPORT_ARMS.map((arm) => [ + arm, + handoffArmMetrics(rows.filter((row) => row.arm === arm)), + ]) + ); const naturalRows = rows.filter((row) => row.arm === 'natural'); const naturalVsEmpty = handoffPairedEffect(rows, 'empty', 'natural'); const naturalVsOracle = handoffPairedEffect(rows, 'oracle', 'natural'); const captureRate = naturalRows.length - ? naturalRows.filter((row) => row.producer?.captureSuccess).length / naturalRows.length + ? naturalRows.filter((row) => row.producer?.captureSuccess).length / + naturalRows.length : null; const preActionDeliveryRate = naturalRows.length - ? naturalRows.filter((row) => row.delivery?.beforeFirstExecutedMistake).length / naturalRows.length + ? naturalRows.filter((row) => row.delivery?.beforeFirstExecutedMistake) + .length / naturalRows.length : null; const emptyRecurrence = arms.empty.mistakeExecuted.rate; const naturalRecurrence = arms.natural.mistakeExecuted.rate; - const relativeRecurrenceReduction = emptyRecurrence !== null - && naturalRecurrence !== null - && emptyRecurrence !== 0 - ? (emptyRecurrence - naturalRecurrence) / emptyRecurrence - : null; - const controlsSafe = ['irrelevant', 'stale'].every((arm) => - arms[arm].correctness.rate !== null - && arms.empty.correctness.rate !== null - && arms[arm].correctness.rate >= arms.empty.correctness.rate - 0.1 + const relativeRecurrenceReduction = + emptyRecurrence !== null && + naturalRecurrence !== null && + emptyRecurrence !== 0 + ? (emptyRecurrence - naturalRecurrence) / emptyRecurrence + : null; + const controlsSafe = ['irrelevant', 'stale'].every( + (arm) => + arms[arm].correctness.rate !== null && + arms.empty.correctness.rate !== null && + arms[arm].correctness.rate >= arms.empty.correctness.rate - 0.1 ); - const irrelevantSuppressed = arms.irrelevant.delivery.rate !== null - && arms.irrelevant.delivery.rate === 0; + const irrelevantSuppressed = + arms.irrelevant.delivery.rate !== null && + arms.irrelevant.delivery.rate === 0; const gates = { minimumPairs: naturalVsEmpty.pairs >= minimumPairs, capture: captureRate !== null && captureRate >= 0.8, recurrenceMagnitude: - relativeRecurrenceReduction !== null && relativeRecurrenceReduction >= 0.5, - recurrenceInterval: (naturalVsEmpty.executedMistakesPrevented.low ?? -Infinity) > 0, + relativeRecurrenceReduction !== null && + relativeRecurrenceReduction >= 0.5, + recurrenceInterval: + (naturalVsEmpty.executedMistakesPrevented.low ?? -Infinity) > 0, correctness: - arms.natural.correctness.rate !== null - && arms.empty.correctness.rate !== null - && arms.natural.correctness.rate >= arms.empty.correctness.rate - 0.1, - preActionDelivery: preActionDeliveryRate !== null && preActionDeliveryRate >= 0.8, + arms.natural.correctness.rate !== null && + arms.empty.correctness.rate !== null && + arms.natural.correctness.rate >= arms.empty.correctness.rate - 0.1, + preActionDelivery: + preActionDeliveryRate !== null && preActionDeliveryRate >= 0.8, negativeControls: controlsSafe && irrelevantSuppressed, }; const claimReady = Object.values(gates).every(Boolean); @@ -1269,14 +1576,24 @@ function handoffCohorts(runs) { function concurrencySummary(runs) { const natural = runs.filter((run) => run.arm === 'natural'); const writers = natural.reduce((sum, run) => sum + (run.writerCount || 0), 0); - const captures = natural.reduce((sum, run) => sum + (run.captureSuccesses || 0), 0); - const integrityPasses = natural.filter((run) => - run.integrity?.zeroLoss - && run.integrity?.parseable - && run.integrity?.orphanedFindings === 0 + const captures = natural.reduce( + (sum, run) => sum + (run.captureSuccesses || 0), + 0 + ); + const integrityPasses = natural.filter( + (run) => + run.integrity?.zeroLoss && + run.integrity?.parseable && + run.integrity?.orphanedFindings === 0 ).length; - const delivered = natural.reduce((sum, run) => sum + (run.delivery?.delivered || 0), 0); - const expected = natural.reduce((sum, run) => sum + (run.delivery?.expected || 0), 0); + const delivered = natural.reduce( + (sum, run) => sum + (run.delivery?.delivered || 0), + 0 + ); + const expected = natural.reduce( + (sum, run) => sum + (run.delivery?.expected || 0), + 0 + ); const effect = handoffPairedEffect(runs, 'empty', 'natural'); return { runs: runs.length, @@ -1286,7 +1603,8 @@ function concurrencySummary(runs) { integrityPassRate: natural.length ? integrityPasses / natural.length : null, deliveryCoverage: expected ? delivered / expected : null, naturalCorrectness: proportionInterval( - natural.filter((run) => run.consumer?.correct).length, natural.length + natural.filter((run) => run.consumer?.correct).length, + natural.length ), effect, }; @@ -1297,17 +1615,26 @@ function concurrencySummary(runs) { * live hook traces, and randomized model evals remain visibly distinct: only * `eval-run` records with paired arms can produce a causal effect interval. */ -function buildEvidenceReport(evidence, { filters = {}, episodeLimit = 100 } = {}) { +function buildEvidenceReport( + evidence, + { filters = {}, episodeLimit = 100 } = {} +) { evidence = evidence.filter((event) => matchesFilters(event, filters)); const runs = evidence.filter((event) => event.kind === 'eval-run'); const handoffRuns = evidence.filter((event) => event.kind === 'handoff-run'); - const concurrencyRuns = evidence.filter((event) => event.kind === 'concurrency-run'); + const concurrencyRuns = evidence.filter( + (event) => event.kind === 'concurrency-run' + ); const injections = evidence.filter((event) => event.kind === 'inject'); const outcomes = evidence.filter((event) => event.kind === 'tool-outcome'); - const feedback = evidence.filter((event) => event.kind === 'finding-feedback'); + const feedback = evidence.filter( + (event) => event.kind === 'finding-feedback' + ); const byInjection = new Map( - outcomes.filter((event) => event.injectionId).map((event) => [event.injectionId, event]) + outcomes + .filter((event) => event.injectionId) + .map((event) => [event.injectionId, event]) ); const traced = injections .slice() @@ -1337,7 +1664,10 @@ function buildEvidenceReport(evidence, { filters = {}, episodeLimit = 100 } = {} grouped.get(key).push(run); } - const minimumPairs = Math.max(2, Number(process.env.TOKEN_OPTIMIZER_EVAL_MIN_PAIRS) || 5); + const minimumPairs = Math.max( + 2, + Number(process.env.TOKEN_OPTIMIZER_EVAL_MIN_PAIRS) || 5 + ); const cohorts = [...grouped.entries()].map(([key, cohortRuns]) => { const arms = Object.fromEntries( ['baseline', 'optimizer', 'retrieval', 'full'].map((arm) => [ @@ -1387,7 +1717,9 @@ function buildEvidenceReport(evidence, { filters = {}, episodeLimit = 100 } = {} cohorts: cohorts.length, harmfulFeedback: harmful, harmRate: feedback.length ? harmful / feedback.length : null, - evidenceStatus: cohorts.some((cohort) => cohort.evidenceStatus === 'causal estimate available') + evidenceStatus: cohorts.some( + (cohort) => cohort.evidenceStatus === 'causal estimate available' + ) ? 'causal estimates available' : 'insufficient randomized evidence', }, @@ -1396,7 +1728,8 @@ function buildEvidenceReport(evidence, { filters = {}, episodeLimit = 100 } = {} concurrency, episodes: traced, methodology: { - intervals: 'deterministic percentile bootstrap (95%); Wilson interval for correctness', + intervals: + 'deterministic percentile bootstrap (95%); Wilson interval for correctness', causalRule: 'matched pairs estimate optimizer vs baseline, retrieval vs optimizer, full vs retrieval, and full vs baseline', minimumPairs, diff --git a/integrations/windsurf/hooks/lib/observability.mjs b/integrations/windsurf/hooks/lib/observability.mjs index b9980076e..ef1c037af 100644 --- a/integrations/windsurf/hooks/lib/observability.mjs +++ b/integrations/windsurf/hooks/lib/observability.mjs @@ -26,7 +26,7 @@ import { createHash, randomUUID } from 'node:crypto'; import { homedir } from 'node:os'; import { join } from 'node:path'; -export const HOOK_LOG_SCHEMA_VERSION = 1; +export const HOOK_LOG_SCHEMA_VERSION = 2; const DEFAULT_MAX_BYTES = 5 * 1024 * 1024; const DEFAULT_RETENTION_DAYS = 14; const DEFAULT_MAX_FILES = 40; @@ -197,6 +197,8 @@ export function beginHookInvocation(client, event, options = {}) { turnIdHash: null, toolUseIdHash: null, toolName: null, + rawToolName: null, + codeModeEnvelope: false, clientVersion: null, model: null, cwdHash: null, @@ -208,12 +210,29 @@ export function beginHookInvocation(client, event, options = {}) { const spanId = hash(invocationId, 16); let traceId = hash(invocationId, 32); + // Write the start before reading stdin or importing project state. If the + // host kills the process, Node aborts, or a native call terminates without a + // JavaScript exception, diagnostics can now reconcile this record with the + // missing completion instead of reporting a false success rate. + writeHookEvent({ + level: 'info', + event: 'hook.started', + invocationId, + traceId, + spanId, + client, + hookEvent: event, + processId: process.pid, + }); + let deadline; const finish = (nextOutcome = outcome, nextReason = reason, nextError = error) => { if (ended) return; ended = true; if (deadline) clearTimeout(deadline); process.removeListener('exit', onExit); + process.removeListener('uncaughtException', onUncaughtException); + process.removeListener('unhandledRejection', onUnhandledRejection); writeHookEvent({ level: nextOutcome === 'failure' || nextOutcome === 'timeout' ? 'error' : 'info', event: 'hook.completed', @@ -231,7 +250,17 @@ export function beginHookInvocation(client, event, options = {}) { }); }; const onExit = () => finish(); + const onUncaughtException = (nextError) => { + finish('failure', 'uncaught_exception', nextError); + process.exit(0); + }; + const onUnhandledRejection = (nextError) => { + finish('failure', 'unhandled_rejection', nextError); + process.exit(0); + }; process.once('exit', onExit); + process.once('uncaughtException', onUncaughtException); + process.once('unhandledRejection', onUnhandledRejection); deadline = setTimeout(() => { outcome = 'timeout'; @@ -250,6 +279,11 @@ export function beginHookInvocation(client, event, options = {}) { dimensions.turnIdHash = hash(raw.turn_id ?? raw.turnId ?? null); dimensions.toolUseIdHash = hash(raw.tool_use_id ?? raw.toolUseId ?? null); dimensions.toolName = payload?.tool_name ?? raw.tool_name ?? raw.toolName ?? null; + dimensions.rawToolName = dimension( + raw.tool_name ?? raw.toolName ?? raw.tool ?? null + ); + dimensions.codeModeEnvelope = + payload?.tool_input?.code_mode_envelope === true; dimensions.clientVersion = dimension(raw.client_version ?? raw.clientVersion ?? null); dimensions.model = dimension(raw.model ?? raw.model_name ?? raw.modelName ?? null); dimensions.cwdHash = hash(raw.cwd ?? raw.working_directory ?? process.cwd()); @@ -291,6 +325,11 @@ export function beginHookInvocation(client, event, options = {}) { error = nextError; finish(outcome, reason, error); }, + block(nextReason = 'policy_denied') { + outcome = 'blocked'; + reason = nextReason; + finish(outcome, reason); + }, succeed(nextReason = 'completed') { outcome = 'success'; reason = nextReason; @@ -321,7 +360,11 @@ export function recordHookBootstrapFailure(client, event, error) { }); } -export function readHookEvents({ limit = 200, sinceMs = 24 * 60 * 60 * 1000 } = {}) { +export function readHookEvents({ + limit = 200, + sinceMs = 24 * 60 * 60 * 1000, + includeLifecycle = false, +} = {}) { try { const dir = hookLogDirectory(); if (!existsSync(dir)) return []; @@ -338,6 +381,7 @@ export function readHookEvents({ limit = 200, sinceMs = 24 * 60 * 60 * 1000 } = try { const event = JSON.parse(line); if (Date.parse(event.timestamp) < cutoff) continue; + if (!includeLifecycle && event.event === 'hook.started') continue; events.push(event); if (events.length >= limit) return events; } catch { @@ -353,38 +397,102 @@ export function readHookEvents({ limit = 200, sinceMs = 24 * 60 * 60 * 1000 } = export function hookHealthSummary(options = {}) { const sinceMs = options.sinceMs ?? 24 * 60 * 60 * 1000; - const events = readHookEvents({ limit: options.limit || 5000, sinceMs }); - const durations = events + const events = readHookEvents({ + limit: options.limit || 10000, + sinceMs, + includeLifecycle: true, + }); + const starts = events.filter((event) => event.event === 'hook.started'); + const completed = events.filter( + (event) => event.event === 'hook.completed' || !event.event + ); + const completedIds = new Set( + completed.map((event) => event.invocationId).filter(Boolean) + ); + const abandoned = starts + .filter( + (event) => + event.invocationId && + !completedIds.has(event.invocationId) && + Date.now() - Date.parse(event.timestamp) > 10_000 + ) + .map((event) => ({ + ...event, + event: 'hook.completed', + outcome: 'failure', + reason: 'process_terminated_before_completion', + durationMs: null, + })); + const runs = [...completed, ...abandoned]; + const durations = runs .map((event) => Number(event.durationMs)) .filter(Number.isFinite) .sort((a, b) => a - b); - const failures = events.filter((event) => event.outcome === 'failure'); - const timeouts = events.filter((event) => event.outcome === 'timeout'); - const skipped = events.filter((event) => event.outcome === 'skipped'); - const successes = events.filter((event) => event.outcome === 'success'); + const failures = runs.filter((event) => event.outcome === 'failure'); + const timeouts = runs.filter((event) => event.outcome === 'timeout'); + const skipped = runs.filter((event) => event.outcome === 'skipped'); + const blocked = runs.filter((event) => event.outcome === 'blocked'); + const successes = runs.filter((event) => event.outcome === 'success'); + const actions = runs.filter( + (event) => event.hookEvent === 'pre-tool' && event.toolName + ); + const byTool = {}; + for (const event of actions) { + const name = String(event.toolName || 'Unknown action'); + byTool[name] = (byTool[name] || 0) + 1; + } const byClient = {}; - for (const event of events) { + for (const event of runs) { const key = event.client || 'unknown'; - const current = byClient[key] || { total: 0, failures: 0, timeouts: 0, skipped: 0 }; + const current = byClient[key] || { + total: 0, + failures: 0, + timeouts: 0, + skipped: 0, + blocked: 0, + hookEvents: [], + }; current.total++; if (event.outcome === 'failure') current.failures++; if (event.outcome === 'timeout') current.timeouts++; if (event.outcome === 'skipped') current.skipped++; + if (event.outcome === 'blocked') current.blocked++; + if (event.hookEvent && !current.hookEvents.includes(event.hookEvent)) + current.hookEvents.push(event.hookEvent); byClient[key] = current; } + for (const current of Object.values(byClient)) current.hookEvents.sort(); const percentile = (p) => durations.length ? durations[Math.min(durations.length - 1, Math.floor(durations.length * p))] : null; const summary = { schemaVersion: HOOK_LOG_SCHEMA_VERSION, - available: events.length > 0, + available: runs.length > 0, windowHours: sinceMs / 3_600_000, - total: events.length, + total: runs.length, failures: failures.length, timeouts: timeouts.length, skipped: skipped.length, - successRate: events.length ? successes.length / events.length : null, + blocked: blocked.length, + abandoned: abandoned.length, + successes: successes.length, + actions: actions.length, + byTool, + // A skipped or policy-blocked hook completed its protocol successfully. + // Runtime health answers whether the hook process worked, not whether the + // optimizer chose to allow the host operation. + successRate: runs.length + ? (successes.length + skipped.length + blocked.length) / runs.length + : null, + healthStatus: + failures.length || timeouts.length + ? 'failing' + : skipped.length + ? 'degraded' + : runs.length + ? 'healthy' + : 'unavailable', p50DurationMs: percentile(0.5), p95DurationMs: percentile(0.95), byClient, diff --git a/integrations/windsurf/hooks/lib/pricing.mjs b/integrations/windsurf/hooks/lib/pricing.mjs index a4b09ebed..50b23b9cc 100644 --- a/integrations/windsurf/hooks/lib/pricing.mjs +++ b/integrations/windsurf/hooks/lib/pricing.mjs @@ -1,101 +1,72 @@ // GENERATED FILE -- do not edit. // Source of truth: hooks-core/pricing.mjs. Regenerate with `npm run sync:hooks`. /** - * Tokens in the unit people actually feel. + * Optional cost equivalents for token measurements. * - * The same data as everywhere else in this project, converted once, in one - * place, under three rules that keep a dollar figure honest: - * - * THE TABLE IS VISIBLE AND OVERRIDABLE. Prices change. A figure computed from - * rates buried in code goes stale silently and keeps looking authoritative, - * which is worse than no figure. The rates are printed next to the number and - * can be replaced without a release. - * - * DOLLARS BESIDE TOKENS, NOT INSTEAD OF THEM. The token count is exact and - * never goes out of date; the dollar figure is the token count times a rate - * that will. Showing both means the durable number survives the perishable - * one. - * - * NEVER A PRICE ON AN UNMEASURED SAVING. An invented number is worse in - * dollars than in tokens, because dollars get quoted to other people. A - * saving we cannot measure returns null and renders as "not yet measurable", - * never as "$0.00" -- which reads as "saved nothing" and is a different - * claim entirely. + * The runtime cannot infer a user's bill from a client name. A CLI may route + * through a subscription, direct API account, cloud marketplace, enterprise + * agreement, included credits, or a provider whose cache-read/write prices + * differ. Provider usage records are the source of truth when available. + * Everywhere else this module fails closed until the user supplies an + * effective blended input-token rate. */ -/** Cache multipliers, relative to an input token. */ -const CACHE_WRITE = 1.25; -const CACHE_READ = 0.1; +const RATE_ENV = 'TOKEN_OPTIMIZER_EFFECTIVE_INPUT_USD_PER_MILLION'; -/** - * Dollars per million tokens, by tier. - * - * Approximate published list rates as of 2026-07, carried with their date so a - * stale table is visible rather than silent. Override wholesale with - * TOKEN_OPTIMIZER_PRICES as JSON, e.g. - * {"asOf":"2026-09","opus":{"input":15,"output":75}}. - */ -const DEFAULT_PRICES = { - asOf: '2026-07', - haiku: { input: 1, output: 5 }, - sonnet: { input: 3, output: 15 }, - opus: { input: 15, output: 75 }, -}; - -export function prices() { - const raw = process.env.TOKEN_OPTIMIZER_PRICES; - if (raw) { - try { - const parsed = JSON.parse(raw); - if (parsed && typeof parsed === 'object') return { ...DEFAULT_PRICES, ...parsed, overridden: true }; - } catch { /* an unparseable override is ignored rather than fatal */ } - } - return DEFAULT_PRICES; +/** The configured effective input rate, or null when billing is unobservable. */ +export function effectiveRate() { + const rate = Number(process.env[RATE_ENV]); + return Number.isFinite(rate) && rate > 0 ? rate : null; } /** - * What a number of tokens costs. + * Dollars for a measured token quantity. * - * Returns null for a null input and says why: this is the single place the - * "unmeasured must not become $0.00" rule is enforced, so every caller inherits - * it rather than each remembering. + * `kind` is accepted for backwards compatibility but is not multiplied by a + * universal cache constant. The configured rate must already reflect the + * user's actual provider/model/cache/tier/credit mix. */ -export function dollars(tokens, { tier = 'opus', kind = 'input' } = {}) { - if (!Number.isFinite(tokens)) return null; - - const table = prices(); - const rate = table[tier]?.[kind === 'output' ? 'output' : 'input']; - if (!Number.isFinite(rate)) return null; - - const multiplier = kind === 'cacheWrite' ? CACHE_WRITE : kind === 'cacheRead' ? CACHE_READ : 1; - return (tokens / 1_000_000) * rate * multiplier; +export function dollars(tokens, { rate = effectiveRate() } = {}) { + if (!Number.isFinite(tokens) || !Number.isFinite(rate) || rate <= 0) { + return null; + } + return (tokens / 1_000_000) * rate; } /** A dollar figure, or an honest absence of one. */ -export function money(amount, { unmeasured = 'not yet measurable' } = {}) { +export function money(amount, { unmeasured = 'not priced' } = {}) { if (amount == null || !Number.isFinite(amount)) return unmeasured; if (amount === 0) return '$0.00'; if (Math.abs(amount) < 0.01) return '<$0.01'; return `$${amount.toFixed(2)}`; } -/** - * Per-session tokens expressed as a monthly cost. - * - * `sessionsPerMonth` is an assumption, so it is stated in the output rather - * than folded invisibly into the number. - */ -export function monthly(tokensPerSession, { tier = 'opus', sessionsPerMonth = 60 } = {}) { +/** Per-session tokens expressed with a stated monthly-frequency assumption. */ +export function monthly(tokensPerSession, { sessionsPerMonth = 60 } = {}) { if (!Number.isFinite(tokensPerSession)) return null; - const amount = dollars(tokensPerSession * sessionsPerMonth, { tier }); - return amount == null ? null : { amount, sessionsPerMonth, tier }; + const amount = dollars(tokensPerSession * sessionsPerMonth); + return amount == null + ? null + : { + amount, + sessionsPerMonth, + effectiveInputUsdPerMillion: effectiveRate(), + }; +} + +/** The pricing basis printed beside any cost equivalent. */ +export function priceNote() { + const rate = effectiveRate(); + return rate == null + ? `cost not priced: set ${RATE_ENV} to your effective blended input rate after provider, model, cache, tier, plan, and credits` + : `cost equivalent: configured effective input rate $${rate}/M via ${RATE_ENV}`; } -/** The rates behind the figures, for printing beside them. */ -export function priceNote(tier = 'opus') { - const table = prices(); - const rate = table[tier]; - if (!rate) return null; - return `prices: ${tier} $${rate.input}/$${rate.output} per Mtok (${table.asOf}` + - `${table.overridden ? ', overridden' : ''}); override with TOKEN_OPTIMIZER_PRICES`; +/** Compatibility surface: no built-in provider table is claimed. */ +export function prices() { + return { + source: + effectiveRate() == null ? 'unavailable' : 'configured-effective-rate', + effectiveInputUsdPerMillion: effectiveRate(), + }; } diff --git a/integrations/windsurf/hooks/lib/routing.mjs b/integrations/windsurf/hooks/lib/routing.mjs index 9b4642e0e..84ac866e4 100644 --- a/integrations/windsurf/hooks/lib/routing.mjs +++ b/integrations/windsurf/hooks/lib/routing.mjs @@ -65,7 +65,9 @@ export function modelCosts() { if (raw) { try { return JSON.parse(raw); - } catch { /* fall through to the defaults */ } + } catch { + /* fall through to the defaults */ + } } return { haiku: 1, sonnet: 3, opus: 15 }; } @@ -106,7 +108,12 @@ export function readEpisodes(path, { maxBytes = 6_000_000 } = {}) { let current = null; const close = () => { - if (current && current.turns > 0) episodes.push({ ...current, files: [...current.files], tools: [...current.tools] }); + if (current && current.turns > 0) + episodes.push({ + ...current, + files: [...current.files], + tools: [...current.tools], + }); current = null; }; @@ -123,7 +130,15 @@ export function readEpisodes(path, { maxBytes = 6_000_000 } = {}) { // a new episode. if (row.type === 'user' && typeof row.message?.content === 'string') { close(); - current = { at: Date.parse(row.timestamp) || null, models: new Set(), tools: new Set(), files: new Set(), turns: 0, errors: 0, results: 0 }; + current = { + at: Date.parse(row.timestamp) || null, + models: new Set(), + tools: new Set(), + files: new Set(), + turns: 0, + errors: 0, + results: 0, + }; continue; } if (!current) continue; @@ -157,8 +172,16 @@ export function readEpisodes(path, { maxBytes = 6_000_000 } = {}) { /* ---------------------------------------------------------------- SHAPES */ -const EDIT_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit', 'smart_edit', 'smart_write']); -const TEST_PATTERN = /\b(test|jest|pytest|vitest|dotnet test|npm test|go test)\b/i; +const EDIT_TOOLS = new Set([ + 'Edit', + 'MultiEdit', + 'Write', + 'NotebookEdit', + 'smart_edit', + 'smart_write', +]); +const TEST_PATTERN = + /\b(test|jest|pytest|vitest|dotnet test|npm test|go test)\b/i; /** * What kind of work this was. @@ -171,11 +194,19 @@ export function classifyShape(episode) { const tools = new Set(episode.tools || []); const edited = [...tools].some((t) => EDIT_TOOLS.has(t)); const files = (episode.files || []).length; - const ranTests = [...tools].some((t) => TEST_PATTERN.test(t)) || (episode.tools || []).includes('Bash'); + const ranTests = + [...tools].some((t) => TEST_PATTERN.test(t)) || + (episode.tools || []).includes('Bash'); if (edited && files >= 3) return 'multi-file-change'; if (edited) return 'single-file-change'; - if (files > 0 || tools.has('Grep') || tools.has('Glob') || tools.has('smart_grep')) return 'investigation'; + if ( + files > 0 || + tools.has('Grep') || + tools.has('Glob') || + tools.has('smart_grep') + ) + return 'investigation'; if (ranTests) return 'build-or-test'; return 'conversation'; } @@ -208,7 +239,13 @@ export function outcomeTable(episodes) { const tier = [...tiers][0]; table[shape] = table[shape] || {}; - const cell = table[shape][tier] = table[shape][tier] || { episodes: 0, turns: 0, errors: 0, results: 0, errored: 0 }; + const cell = (table[shape][tier] = table[shape][tier] || { + episodes: 0, + turns: 0, + errors: 0, + results: 0, + errored: 0, + }); cell.episodes += 1; cell.turns += episode.turns; cell.errors += episode.errors; @@ -287,7 +324,8 @@ export function route(shape, table = {}) { shape, recommend: heuristic, basis: 'heuristic', - reason: `every measured tier for ${shape} needed a retry in more than ` + + reason: + `every measured tier for ${shape} needed a retry in more than ` + `${Math.round(MAX_ERROR_RATE * 100)}% of episodes; falling back to the shipped default`, excluded: excluded.map((o) => ({ tier: o.tier, errorRate: o.errorRate })), options, @@ -306,17 +344,24 @@ export function route(shape, table = {}) { recommend: best.tier, basis: 'measured', // What each mistake would cost, from this project's own rates. - overpowered: strongest.tier === best.tier ? null : { - tier: strongest.tier, - wasted: Number((strongest.cost - best.cost).toFixed(2)), - }, - underpowered: cheapest.tier === best.tier ? null : { - tier: cheapest.tier, - expectedCost: Number(cheapest.cost.toFixed(2)), - errorRate: cheapest.errorRate, - excluded: cheapest.errorRate > MAX_ERROR_RATE, - }, - reason: `${shape}: ${best.tier} costs ${best.cost.toFixed(2)} in expectation over ${best.episodes} episodes ` + + overpowered: + strongest.tier === best.tier + ? null + : { + tier: strongest.tier, + wasted: Number((strongest.cost - best.cost).toFixed(2)), + }, + underpowered: + cheapest.tier === best.tier + ? null + : { + tier: cheapest.tier, + expectedCost: Number(cheapest.cost.toFixed(2)), + errorRate: cheapest.errorRate, + excluded: cheapest.errorRate > MAX_ERROR_RATE, + }, + reason: + `${shape}: ${best.tier} costs ${best.cost.toFixed(2)} in expectation over ${best.episodes} episodes ` + `(${Math.round(best.errorRate * 100)}% needed a retry)`, options, }; @@ -340,20 +385,31 @@ export function routingNote(shape, table, { currentModel, switchCost } = {}) { const lines = [decision.reason]; if (decision.underpowered) { - lines.push(` ${decision.underpowered.tier} looks cheaper per token, but needed a retry in ` + - `${Math.round(decision.underpowered.errorRate * 100)}% of episodes -- expected cost ${decision.underpowered.expectedCost}` + - `${decision.underpowered.excluded ? ', which is why it is not a candidate here at any price' : ''}.`); + lines.push( + ` ${decision.underpowered.tier} looks cheaper per token, but needed a retry in ` + + `${Math.round(decision.underpowered.errorRate * 100)}% of episodes -- expected cost ${decision.underpowered.expectedCost}` + + `${decision.underpowered.excluded ? ', which is why it is not a candidate here at any price' : ''}.` + ); } if (decision.overpowered) { - lines.push(` ${decision.overpowered.tier} would spend ${decision.overpowered.wasted} more than needed here.`); + lines.push( + ` ${decision.overpowered.tier} would spend ${decision.overpowered.wasted} more than needed here.` + ); } if (switchCost?.prefixTokens) { - lines.push(` Switching now also discards a ${switchCost.prefixTokens.toLocaleString()}-token warm prefix ` + - `(~${switchCost.rewriteCost.toLocaleString()} tokens to re-write); consider switching at the next break.`); + lines.push( + ` Switching now also discards a ${switchCost.prefixTokens.toLocaleString()}-token warm prefix ` + + `(~${switchCost.rewriteInputCostEquivalent.toLocaleString()} Anthropic uncached-input cost-equivalent tokens to re-write); consider switching at the next break.` + ); } - return { shape, recommend: decision.recommend, basis: decision.basis, text: lines.join('\n') }; + return { + shape, + recommend: decision.recommend, + basis: decision.basis, + text: lines.join('\n'), + }; } /** @@ -371,7 +427,9 @@ export function routingBriefing(table) { const decision = route(shape, table); if (decision.basis !== 'measured') continue; if (decision.recommend === HEURISTIC[shape]) continue; // nothing learned worth saying - lines.push(`${shape.replace(/-/g, ' ')} work goes better on ${decision.recommend} in this project.`); + lines.push( + `${shape.replace(/-/g, ' ')} work goes better on ${decision.recommend} in this project.` + ); } return lines.length ? lines.join('\n') : null; } @@ -389,7 +447,11 @@ export function routingBriefing(table) { * few more episodes accumulated would invalidate the cache behind it. Stability * is the feature; the memo is how it is bought. */ -export function cachedRoutingBriefing(dir, transcriptPath, { growthBytes = 4_000_000, maxAgeMs = 24 * 60 * 60 * 1000 } = {}) { +export function cachedRoutingBriefing( + dir, + transcriptPath, + { growthBytes = 4_000_000, maxAgeMs = 24 * 60 * 60 * 1000 } = {} +) { const memoPath = join(dir, 'routing-brief.json'); let stat; try { @@ -400,17 +462,31 @@ export function cachedRoutingBriefing(dir, transcriptPath, { growthBytes = 4_000 try { const memo = JSON.parse(readFileSync(memoPath, 'utf8')); - const fresh = memo.path === transcriptPath - && Math.abs(stat.size - memo.size) < growthBytes - && Date.now() - memo.at < maxAgeMs; + const fresh = + memo.path === transcriptPath && + Math.abs(stat.size - memo.size) < growthBytes && + Date.now() - memo.at < maxAgeMs; if (fresh) return memo.text || null; - } catch { /* no memo, or an unreadable one; recompute */ } + } catch { + /* no memo, or an unreadable one; recompute */ + } const text = routingBriefing(outcomeTable(readEpisodes(transcriptPath))); try { mkdirSync(dir, { recursive: true, mode: 0o700 }); - writeFileSync(memoPath, JSON.stringify({ path: transcriptPath, size: stat.size, at: Date.now(), text }), { mode: 0o600 }); - } catch { /* the memo is an optimisation; failing to write it costs latency, not correctness */ } + writeFileSync( + memoPath, + JSON.stringify({ + path: transcriptPath, + size: stat.size, + at: Date.now(), + text, + }), + { mode: 0o600 } + ); + } catch { + /* the memo is an optimisation; failing to write it costs latency, not correctness */ + } return text; } @@ -427,9 +503,11 @@ export function routingReport(table) { for (const tier of Object.keys(table[shape])) { const cell = table[shape][tier]; const cost = expectedCost(tier, cell); - lines.push(` ${tier.padEnd(7)} ${cell.episodes} episode(s), ` + - `${cell.meanTurns.toFixed(1)} turns avg, ${Math.round(cell.errorRate * 100)}% needed a retry, ` + - `expected cost ${cost.cost.toFixed(2)}${cell.measured ? '' : ' [too few episodes to trust]'}`); + lines.push( + ` ${tier.padEnd(7)} ${cell.episodes} episode(s), ` + + `${cell.meanTurns.toFixed(1)} turns avg, ${Math.round(cell.errorRate * 100)}% needed a retry, ` + + `expected cost ${cost.cost.toFixed(2)}${cell.measured ? '' : ' [too few episodes to trust]'}` + ); } } return lines.join('\n'); diff --git a/integrations/windsurf/hooks/lib/symbols.mjs b/integrations/windsurf/hooks/lib/symbols.mjs index 2a80e1e42..4d80666ac 100644 --- a/integrations/windsurf/hooks/lib/symbols.mjs +++ b/integrations/windsurf/hooks/lib/symbols.mjs @@ -50,7 +50,11 @@ const PATTERNS = { /^\s*(?:pub(?:\([^)]*\))?\s+)?(?:struct|enum|trait|impl)\s+([A-Za-z_]\w*)/, ], clike: [ - /^\s*(?:public|private|protected|internal|static|final|abstract|virtual|override|async|\s)*(?:[\w<>\[\],.?]+\s+)([A-Za-z_]\w*)\s*\([^;]*\)\s*\{/, + // Accept both K&R (`Run() {`) and Allman (`Run()` then `{`) declarations. + // Requiring the opening brace on the declaration line silently discarded + // nearly every method in idiomatic C# projects, leaving file nodes but no + // symbol nodes and making valid `file.cs#Method` anchors unresolved. + /^\s*(?:public|private|protected|internal|static|final|abstract|virtual|override|async|sealed|new|extern|unsafe|partial|\s)*(?:[\w<>\[\],.?]+\s+)([A-Za-z_]\w*)(?:<[^;()]+>)?\s*\([^;]*\)\s*(?:\{|=>|$)/, /^\s*(?:public|private|protected|internal|static|abstract|sealed|partial|\s)*(?:class|struct|interface|enum|record)\s+([A-Za-z_]\w*)/, ], ruby: [ diff --git a/jest.config.js b/jest.config.js index 07882eb9b..694a7d4ed 100644 --- a/jest.config.js +++ b/jest.config.js @@ -25,7 +25,11 @@ export default { // Linked worktrees are independent checkouts, not nested test fixtures. If a // worktree lives under this checkout, discovering it would execute stale PR // tests a second time and contaminate the current branch's evidence. - testPathIgnorePatterns: ['/node_modules/', '/dist/', '/worktrees/'], + testPathIgnorePatterns: [ + '/node_modules/', + '/dist/', + '/worktrees/', + ], // Points file backups at a temp directory for every worker, so no test can // write into the developer's real ~/.token-optimizer/backups. See the file // for why this is not left to individual tests to remember. diff --git a/package.json b/package.json index 604f7c59f..0481f11e2 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@ooples/token-optimizer-mcp", "version": "5.7.0", "mcpName": "io.github.ooples/token-optimizer-mcp", - "description": "Cross-client context optimization and durable live knowledge capture for AI coding agents and CLI tools", + "description": "Audit net MCP context avoided per AI coding agent, optimize context, and share a live local knowledge graph across 16 CLI clients", "main": "dist/server/index.js", "types": "dist/server/index.d.ts", "bin": { @@ -103,6 +103,8 @@ "verify:all": "npm run sync:hooks:check && npm run eval:ucr:qualification:verify && npm run verify:clients && npm run verify:certification && npm run verify:harvest && npm run verify:ucr && npm run verify:ucr:study-design && npm run verify:live-graph && npm run verify:ui && npm run verify:interactions", "verify:interactions": "node scripts/verify-wiki-interactions.mjs", "dashboard:verify-live": "node scripts/verify-live-dashboard.mjs", + "dashboard:attribution-smoke": "node scripts/run-dashboard-attribution-smoke.mjs", + "dashboard:audit-savings": "node scripts/audit-token-savings.mjs", "explore:wiki": "node scripts/explore-wiki.mjs" }, "repository": { diff --git a/plugin/hooks/lib/adapter.mjs b/plugin/hooks/lib/adapter.mjs index fee0102b9..9f76bbdea 100644 --- a/plugin/hooks/lib/adapter.mjs +++ b/plugin/hooks/lib/adapter.mjs @@ -188,8 +188,143 @@ function contextOutput(client, eventName, additionalContext) { }; } +/** Convert one static JavaScript string literal without evaluating code. */ +function codexStringLiteral(literal) { + if (typeof literal !== 'string' || literal.length < 2) return null; + const quote = literal[0]; + if (!['"', "'", '`'].includes(quote) || literal.at(-1) !== quote) return null; + + let decoded = ''; + for (let index = 1; index < literal.length - 1; index += 1) { + const character = literal[index]; + if (quote === '`' && character === '$' && literal[index + 1] === '{') + return null; + if (character !== '\\') { + decoded += character; + continue; + } + + index += 1; + if (index >= literal.length - 1) return null; + const escaped = literal[index]; + const simple = { + b: '\b', f: '\f', n: '\n', r: '\r', t: '\t', v: '\v', 0: '\0', + '\\': '\\', "'": "'", '"': '"', '`': '`', '$': '$', + }; + if (Object.hasOwn(simple, escaped)) { + decoded += simple[escaped]; + continue; + } + if (escaped === '\n') continue; + if (escaped === '\r') { + if (literal[index + 1] === '\n') index += 1; + continue; + } + if (escaped === 'x') { + const digits = literal.slice(index + 1, index + 3); + if (!/^[0-9a-f]{2}$/i.test(digits)) return null; + decoded += String.fromCodePoint(Number.parseInt(digits, 16)); + index += 2; + continue; + } + if (escaped === 'u') { + const braced = literal[index + 1] === '{'; + const close = braced ? literal.indexOf('}', index + 2) : index + 5; + const digits = braced + ? literal.slice(index + 2, close) + : literal.slice(index + 1, close); + if ( + close < 0 || + !(braced ? /^[0-9a-f]{1,6}$/i : /^[0-9a-f]{4}$/i).test(digits) + ) return null; + const codePoint = Number.parseInt(digits, 16); + if (codePoint > 0x10ffff) return null; + decoded += String.fromCodePoint(codePoint); + index = close; + continue; + } + // JavaScript identity escapes drop the slash (for example, '\q' is 'q'). + decoded += escaped; + } + return decoded; +} + /** Convert client-specific lifecycle envelopes into the common tool shape. */ +function codexStringBindings(source) { + const values = new Map(); + const declaration = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*((?:"(?:\\.|[^"\\])*")|(?:'(?:\\.|[^'\\])*')|(?:`(?:\\.|[^`\\])*`))/gs; + for (const match of String(source || '').matchAll(declaration)) { + try { + const value = codexStringLiteral(match[2]); + if (value !== null) values.set(match[1], value); + } catch { + // A computed or malformed JavaScript expression is not safe to execute + // merely to understand a hook envelope. Leave it opaque. + } + } + return values; +} + +function codexCallArguments(source, method) { + const calls = []; + const pattern = new RegExp(`(?:tools\\.)?${method}\\s*\\(\\s*([^),]+)`, 'gi'); + for (const match of String(source || '').matchAll(pattern)) + calls.push(match[1].trim()); + return calls; +} + +function codexLiteral(value, bindings) { + if (bindings.has(value)) return String(bindings.get(value)); + return codexStringLiteral(value); +} + +function codexExecCommands(source, bindings) { + const commands = []; + for (const call of String(source || '').matchAll( + /(?:tools\.)?exec_command\s*\(\s*\{([\s\S]*?)\}\s*\)/gi + )) { + const property = /\bcmd\s*:\s*((?:"(?:\\.|[^"\\])*")|(?:'(?:\\.|[^'\\])*')|(?:`(?:\\.|[^`\\])*`)|(?:[A-Za-z_$][\w$]*))/s.exec(call[1]); + if (!property) continue; + const command = codexLiteral(property[1], bindings); + if (command) commands.push(command); + } + return commands; +} + export function normalizeClientPayload(clientName, event, raw) { + if (clientName === 'codex') { + const outerName = String(raw.tool_name ?? raw.toolName ?? raw.tool ?? ''); + if (/^(?:functions\.)?exec$/i.test(outerName)) { + const envelope = raw.tool_input ?? raw.toolInput ?? raw.arguments ?? raw.args; + const source = + typeof envelope === 'string' + ? envelope + : envelope?.code ?? envelope?.input ?? envelope?.source ?? ''; + const bindings = codexStringBindings(source); + const patchCalls = codexCallArguments(source, 'apply_patch'); + const patch = patchCalls + .map((argument) => codexLiteral(argument, bindings)) + .find(Boolean); + const mutation = patchCalls.length > 0; + const commands = codexExecCommands(source, bindings); + + // `functions.exec` is also the orchestration envelope for web, image and + // other non-filesystem tools. Do not reinterpret those as shell commands: + // doing so produced irrelevant cross-project advice and fabricated graph + // activity for operations the filesystem hooks never observed. + if (!mutation && !commands.length) return raw; + return { + ...raw, + tool_name: mutation ? 'apply_patch' : 'run_command', + tool_input: { + ...(envelope && typeof envelope === 'object' ? envelope : {}), + command: String(patch || commands.join('\n') || source || ''), + code_mode_envelope: true, + }, + }; + } + } + if (clientName === 'cline') { const body = event === 'post-tool' ? raw.postToolUse : raw.preToolUse; if (!body) return raw; @@ -385,6 +520,22 @@ function projectBriefing() { function observeAndInject(payload, state, episode, features) { const touched = touchedFiles(payload); const dirFor = (path) => wikiDir(projectRootFor(path, payload.cwd)); + const registerRoot = (root) => { + const dir = wikiDir(root); + registerProject({ + root, + graphDir: dir, + client: episode.client || 'unknown', + }); + return dir; + }; + + // SessionStart is not delivered consistently by every host/version. The + // graph was still written in that case, but the dashboard registry never + // learned it existed. Register every repository observed by a real tool call + // as a second, idempotent production path. + for (const { path } of touched) + registerRoot(projectRootFor(path, payload.cwd)); const bytes = readCostBytes(payload); if (bytes && payload.tool_input.file_path) { @@ -425,7 +576,7 @@ function observeAndInject(payload, state, episode, features) { const command = payload.tool_input?.command; if (command) { const root = commandProjectRoot(payload, payload.cwd); - const dir = wikiDir(root); + const dir = registerRoot(root); const local = forCommand(dir, load(dir), command, { sessionId: payload.session_id, alreadyInjected, @@ -750,7 +901,14 @@ async function runHook(clientName, event, invocation) { client.canDeny && event === 'pre-tool' && !repeat && - mode() !== MODE_ADVISE + mode() !== MODE_ADVISE && + // Codex code mode presents an orchestration program as one outer + // `functions.exec` call. Refusing that envelope is rendered by the host + // as a failed script and can discard several otherwise-safe nested + // operations. Observe it, capture it and advise, but reserve a hard veto + // for legacy one-operation Codex tool calls where the denied operation is + // unambiguous. + !(clientName === 'codex' && payload.tool_input?.code_mode_envelope) ); if (canRefuse) { @@ -759,19 +917,23 @@ async function runHook(clientName, event, invocation) { // Every client's refusal carries the off switch, for the same reason Claude // Code's does: enforcement that hides its own disable is coercive. if (client.denyStyle === 'top-level') { + invocation.block('policy_denied'); emit({ decision: 'deny', reason: withEscape(verdict.reason) }); } else if (client.denyStyle === 'top-level-permission') { + invocation.block('policy_denied'); emit({ permissionDecision: 'deny', permissionDecisionReason: withEscape(verdict.reason), }); } else if (client.denyStyle === 'cline') { + invocation.block('policy_denied'); emit({ cancel: true, contextModification: '', errorMessage: withEscape(verdict.reason), }); } else if (client.denyStyle === 'cursor') { + invocation.block('policy_denied'); emit({ continue: true, permission: 'deny', @@ -780,9 +942,11 @@ async function runHook(clientName, event, invocation) { 'Token Optimizer redirected an expensive built-in operation.', }); } else if (client.denyStyle === 'exit-2') { + invocation.block('policy_denied'); process.stderr.write(withEscape(verdict.reason)); process.exit(2); } else { + invocation.block('policy_denied'); emit({ hookSpecificOutput: { hookEventName: eventName, diff --git a/plugin/hooks/lib/audit.mjs b/plugin/hooks/lib/audit.mjs index b82186586..df8a08b82 100644 --- a/plugin/hooks/lib/audit.mjs +++ b/plugin/hooks/lib/audit.mjs @@ -50,15 +50,17 @@ export function decline(dir, id) { export function declines(dir, { events = readMetrics(dir) } = {}) { const counts = new Map(); for (const event of events) { - if (event.kind !== 'advice' || event.action !== 'declined' || !event.id) continue; + if (event.kind !== 'advice' || event.action !== 'declined' || !event.id) + continue; counts.set(event.id, (counts.get(event.id) || 0) + 1); } return counts; } -const idOf = (finding) => finding.remedy - ? `${finding.remedy.type}:${finding.remedy.anchor || finding.remedy.file || (finding.remedy.anchors || []).join(',')}` - : `${finding.id}:${finding.anchor || finding.file || ''}`; +const idOf = (finding) => + finding.remedy + ? `${finding.remedy.type}:${finding.remedy.anchor || finding.remedy.file || (finding.remedy.anchors || []).join(',')}` + : `${finding.id}:${finding.anchor || finding.file || ''}`; /** * Whether a habit improved after we said something about it. @@ -68,12 +70,18 @@ const idOf = (finding) => finding.remedy * -- a trend computed from one session on each side is a coin flip with a * percentage sign. */ -export function habitTrend(dir, detector, { events = readMetrics(dir), minSessions = 2, anchors = null } = {}) { +export function habitTrend( + dir, + detector, + { events = readMetrics(dir), minSessions = 2, anchors = null } = {} +) { // SPLIT BY POSITION, NOT BY TIMESTAMP. The log is append-only, so its order is // authoritative -- while `at` has millisecond granularity, and a burst of // events inside one millisecond would land on the wrong side of the split or // collapse one side to nothing. Position is exact where the clock is not. - const raisedAt = events.findIndex((e) => e.kind === 'advice' && e.detector === detector); + const raisedAt = events.findIndex( + (e) => e.kind === 'advice' && e.detector === detector + ); if (raisedAt === -1) return null; // SCOPED TO THE FILES THIS DETECTOR NAMED. Counting every read in the window @@ -114,7 +122,12 @@ export function habitTrend(dir, detector, { events = readMetrics(dir), minSessio /** Marks a recommendation as having been raised, so its effect can be scored. */ export function raise(dir, finding) { - record(dir, { kind: 'advice', action: 'raised', id: idOf(finding), detector: finding.id }); + record(dir, { + kind: 'advice', + action: 'raised', + id: idOf(finding), + detector: finding.id, + }); } /** @@ -123,7 +136,11 @@ export function raise(dir, finding) { * `findings` comes from the detectors; `extra` lets the cache and routing * modules contribute without this file importing the world. */ -export function buildQueue(dir, findings = [], { events = readMetrics(dir) } = {}) { +export function buildQueue( + dir, + findings = [], + { events = readMetrics(dir) } = {} +) { const declined = declines(dir, { events }); const ledger = remedyLedger(dir); const applied = new Set(ledger.filter((r) => !r.revertedAt).map((r) => r.id)); @@ -146,7 +163,9 @@ export function buildQueue(dir, findings = [], { events = readMetrics(dir) } = { } return { - queue: queue.sort((a, b) => (b.costPerSession ?? 0) - (a.costPerSession ?? 0)), + queue: queue.sort( + (a, b) => (b.costPerSession ?? 0) - (a.costPerSession ?? 0) + ), suppressed, done: ledger.filter((r) => !r.revertedAt), }; @@ -161,7 +180,11 @@ export function buildQueue(dir, findings = [], { events = readMetrics(dir) } = { * many were withheld -- and what they are collectively worth -- keeps that from * being a silent cap. */ -export function renderAudit(dir, findings = [], { tier = 'opus', full = false, sessionsPerMonth = 60 } = {}) { +export function renderAudit( + dir, + findings = [], + { tier = 'opus', full = false, sessionsPerMonth = 60 } = {} +) { const { queue, suppressed, done } = buildQueue(dir, findings); const lines = []; @@ -175,22 +198,29 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s // read as "this costs nothing" beside a finding whose cost we simply do not // know -- the same unknown-becomes-zero error the rest of this project // corrects, in the one unit that gets quoted to other people. - const priced = item.costPerSession == null ? null : monthly(cost, { tier, sessionsPerMonth }); + const priced = + item.costPerSession == null + ? null + : monthly(cost, { tier, sessionsPerMonth }); // The id appears on EVERY actionable line, not just the appliable ones. It is the only // handle `decline` accepts and it cannot be guessed from the title, so omitting it made the // advertised decline path unreachable for precisely the findings most likely to be // unwanted -- model-routing findings carry no remedy, so they could never be suppressed no // matter how many times a user declined them. - const action = item.remedy?.kind === 'ours' ? `apply: waste_audit action="apply" id="${item.id}"` - : item.remedy?.kind === 'yours' ? `proposed edit -- needs your yes, nothing changed; decline: ${item.id}` - : `advice only -- no automatic fix; decline: ${item.id}`; + const action = + item.remedy?.kind === 'ours' + ? `apply: waste_audit action="apply" id="${item.id}"` + : item.remedy?.kind === 'yours' + ? `proposed edit -- needs your yes, nothing changed; decline: ${item.id}` + : `advice only -- no automatic fix; decline: ${item.id}`; // MEASURED ZERO IS NOT UNMEASURED. Testing `cost` for truthiness sent a real 0 down the // "not yet measurable" branch while `priced` above, which tests for null, still rendered // $0.00 beside it -- producing `cost not yet measurable (~$0.00/month)`, the exact string // the test suite declares must never appear. waste.mjs defaults every finding to 0 and // hard-sets it on the co-occurrence detector, so this is reachable, not theoretical. - const text = ` ${item.title}\n ${item.costPerSession != null ? `${cost.toLocaleString()} tokens/session` : 'cost not yet measurable'}` + + const text = + ` ${item.title}\n ${item.costPerSession != null ? `${cost.toLocaleString()} tokens/session` : 'cost not yet measurable'}` + `${priced ? ` (~${money(priced.amount)}/month)` : ''}; ${action}`; const printCost = estimate(text); @@ -211,14 +241,19 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s lines.push(text); } - const head = shown.length ? ['What to do next, most expensive first:', ...lines] : ['Nothing addressable found.']; + const head = shown.length + ? ['What to do next, most expensive first:', ...lines] + : ['Nothing addressable found.']; const body = [...head]; if (withheld.length) { const worth = withheld.reduce((sum, w) => sum + (w.costPerSession || 0), 0); - body.push('', ` ... ${withheld.length} more finding(s) worth ${worth.toLocaleString()} tokens/session in total, ` + - 'not shown -- printing them costs more than they are worth. Pass full=true to see all.'); + body.push( + '', + ` ... ${withheld.length} more finding(s) worth ${worth.toLocaleString()} tokens/session in total, ` + + 'not shown -- printing them costs more than they are worth. Pass full=true to see all.' + ); } // What we already told you, and what it bought. @@ -226,23 +261,35 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s body.push('', 'Already applied, and what it actually saved:'); for (const rule of done.slice(0, 6)) { const saved = rule.savedPerSession; - const priced = saved == null ? null : monthly(saved, { tier, sessionsPerMonth }); - body.push(` + ${rule.id} -- ${saved == null - ? 'not yet measurable (fewer than two sessions since)' - : `measured ${saved.toLocaleString()} tokens/session${priced ? `, ~${money(priced.amount)}/month` : ''}`}`); + const priced = + saved == null ? null : monthly(saved, { tier, sessionsPerMonth }); + body.push( + ` + ${rule.id} -- ${ + saved == null + ? 'not yet measurable (fewer than two sessions since)' + : `measured ${saved.toLocaleString()} tokens/session${priced ? `, ~${money(priced.amount)}/month` : ''}` + }` + ); } // Never a silent cap: the withheld block above states its remainder and so must this one. // Truncating to six with no disclosure showed a user six savings and let them conclude that // was everything the tool had bought them, which understates its own measured value. if (done.length > 6) { - body.push(` ... and ${done.length - 6} more applied fix(es), not shown.`); + body.push( + ` ... and ${done.length - 6} more applied fix(es), not shown.` + ); } } if (suppressed.length) { - body.push('', `Not repeating ${suppressed.length} recommendation(s) you have declined ` + - `${DECLINE_LIMIT}+ times. Ask for full=true to see them.`); - if (full) for (const item of suppressed) body.push(` - ${item.title} (declined ${item.times}x)`); + body.push( + '', + `Not repeating ${suppressed.length} recommendation(s) you have declined ` + + `${DECLINE_LIMIT}+ times. Ask for full=true to see them.` + ); + if (full) + for (const item of suppressed) + body.push(` - ${item.title} (declined ${item.times}x)`); } // Whether the advice changed anything. @@ -256,20 +303,30 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s for (const a of anchors) anchorsBy.get(f.id).add(a); } const trends = [...new Set(findings.map((f) => f.id))] - .map((detector) => habitTrend(dir, detector, { - anchors: anchorsBy.has(detector) ? [...anchorsBy.get(detector)] : null, - })) + .map((detector) => + habitTrend(dir, detector, { + anchors: anchorsBy.has(detector) ? [...anchorsBy.get(detector)] : null, + }) + ) .filter(Boolean); if (trends.length) { body.push('', 'Since the advice was given:'); for (const trend of trends) { - body.push(` ${trend.detector}: ${trend.before.toLocaleString()} -> ${trend.after.toLocaleString()} tokens/session ` + - `(${trend.improved ? 'improved' : 'worse'})`); + body.push( + ` ${trend.detector}: ${trend.before.toLocaleString()} -> ${trend.after.toLocaleString()} tokens/session ` + + `(${trend.improved ? 'improved' : 'worse'})` + ); } } - const addressable = queue.reduce((sum, item) => sum + (item.costPerSession || 0), 0); + const addressable = queue.reduce( + (sum, item) => sum + (item.costPerSession || 0), + 0 + ); + const addressableMonthly = addressable + ? monthly(addressable, { tier, sessionsPerMonth }) + : null; const note = priceNote(tier); if (note) body.push('', note); @@ -278,9 +335,10 @@ export function renderAudit(dir, findings = [], { tier = 'opus', full = false, s // pushing it left the report's longest line out of its own reported cost, understating it by // 10-20% on a short audit. This figure exists to prove the audit is not a net loss, so // understating the cost side biases exactly the comparison it was written to be honest about. - const closing = (n) => `This report cost about ${n.toLocaleString()} tokens and names ` + + const closing = (n) => + `This report cost about ${n.toLocaleString()} tokens and names ` + `${addressable.toLocaleString()} tokens/session of addressable waste` + - `${addressable ? ` (~${money(monthly(addressable, { tier, sessionsPerMonth })?.amount)}/month across ${sessionsPerMonth} sessions)` : ''}.`; + `${addressableMonthly ? ` (~${money(addressableMonthly.amount)}/month across ${sessionsPerMonth} sessions)` : ''}.`; const selfCost = estimate([...body, '', closing(0)].join('\n')); body.push('', closing(selfCost)); diff --git a/plugin/hooks/lib/cache.mjs b/plugin/hooks/lib/cache.mjs index 9b11173ff..9782c3262 100644 --- a/plugin/hooks/lib/cache.mjs +++ b/plugin/hooks/lib/cache.mjs @@ -3,12 +3,12 @@ /** * Prompt-cache economics. * - * Cache economics is real money in a way most compression is not: a cache read - * costs a tenth of a fresh token and a cache WRITE costs a quarter more than - * one, so a prefix that keeps invalidating can cost more than every saving this - * product makes elsewhere. It is also the one area where we do not have to - * model anything -- the client's own transcript records cache_read_input_tokens - * and cache_creation_input_tokens per turn, which is ground truth. + * Anthropic prompt-cache usage, read from Claude Code transcripts. A cache read + * and a cache write are both billable, at different published multipliers from + * uncached input. Raw cache_read_input_tokens and + * cache_creation_input_tokens are observations. Any multiplier-based value is + * an Anthropic input-cost equivalent, not a universally comparable token count + * and not a provider invoice. * * MEASUREMENT ALONE IS NOT ACTIONABLE. A hit rate tells you the cache missed; * it cannot tell you why, and an unactionable number is the same dead thing as @@ -30,7 +30,15 @@ * cache-safety-by-construction half of this file. */ -import { readFileSync, readdirSync, statSync, existsSync, openSync, readSync, closeSync } from 'node:fs'; +import { + readFileSync, + readdirSync, + statSync, + existsSync, + openSync, + readSync, + closeSync, +} from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; @@ -51,7 +59,8 @@ export const READ_MULTIPLIER = 0.1; const SYNTHETIC_MODEL = ''; /** True for a turn naming a model an inference actually ran under. */ -const realModel = (turn) => Boolean(turn.model) && turn.model !== SYNTHETIC_MODEL; +const realModel = (turn) => + Boolean(turn.model) && turn.model !== SYNTHETIC_MODEL; /* -------------------------------------------------------------- MEASUREMENT */ @@ -72,7 +81,10 @@ export function transcriptFor(cwd = process.cwd()) { try { const files = readdirSync(dir) .filter((name) => name.endsWith('.jsonl')) - .map((name) => ({ path: join(dir, name), at: statSync(join(dir, name)).mtimeMs })) + .map((name) => ({ + path: join(dir, name), + at: statSync(join(dir, name)).mtimeMs, + })) .sort((a, b) => b.at - a.at); return files.length ? files[0].path : null; } catch { @@ -157,7 +169,8 @@ export function cacheHealth(turns) { read += turn.read; written += turn.written; if (!realModel(turn)) continue; - if (!models.has(turn.model)) models.set(turn.model, { read: 0, written: 0, turns: 0 }); + if (!models.has(turn.model)) + models.set(turn.model, { read: 0, written: 0, turns: 0 }); const entry = models.get(turn.model); entry.read += turn.read; entry.written += turn.written; @@ -171,20 +184,28 @@ export function cacheHealth(turns) { // attribution price, made keepWarm answer "unknown", and returned null from // modelSwitchCost under a guard its caller does not share, which crashed // cache_audit outright with "Cannot read properties of null". - const last = turns.reduce((best, turn) => (turn.read + turn.written > 0 ? turn : best), turns[turns.length - 1]); + const last = turns.reduce( + (best, turn) => (turn.read + turn.written > 0 ? turn : best), + turns[turns.length - 1] + ); const total = read + written; return { + provider: 'anthropic', + source: 'claude-code-transcript', turns: turns.length, read, written, hitRate: total ? read / total : null, prefixTokens: last.read + last.written, - // Priced in plain-token equivalents, which is what makes it comparable with - // every other saving this product reports. - writeCost: Math.round(written * WRITE_MULTIPLIER), - readCost: Math.round(read * READ_MULTIPLIER), - savedVersusNoCache: Math.round(read * (1 - READ_MULTIPLIER)), + // These are Anthropic uncached-input cost equivalents. They stay outside + // the verified MCP transport ledger and must not be relabeled as tokens + // saved across clients. + writeInputCostEquivalent: Math.round(written * WRITE_MULTIPLIER), + readInputCostEquivalent: Math.round(read * READ_MULTIPLIER), + inputCostEquivalentAvoidedVersusUncached: Math.round( + read * (1 - READ_MULTIPLIER) + ), models: Object.fromEntries(models), }; } @@ -200,13 +221,17 @@ export function modelSwitchCost(turns) { const health = cacheHealth(turns); if (!health?.prefixTokens) return null; - const switched = new Set(turns.filter(realModel).map((t) => t.model)).size > 1; + const switched = + new Set(turns.filter(realModel).map((t) => t.model)).size > 1; return { prefixTokens: health.prefixTokens, - rewriteCost: Math.round(health.prefixTokens * WRITE_MULTIPLIER), + rewriteInputCostEquivalent: Math.round( + health.prefixTokens * WRITE_MULTIPLIER + ), alreadySwitched: switched, - text: `Switching model now discards a ${health.prefixTokens.toLocaleString()}-token warm prefix; ` + - `re-writing it costs about ${Math.round(health.prefixTokens * WRITE_MULTIPLIER).toLocaleString()} tokens.`, + text: + `Switching model now discards a ${health.prefixTokens.toLocaleString()}-token warm prefix; ` + + `under Anthropic's current 5-minute write multiplier, re-writing it is about ${Math.round(health.prefixTokens * WRITE_MULTIPLIER).toLocaleString()} uncached-input cost-equivalent tokens.`, }; } @@ -221,17 +246,38 @@ export function modelSwitchCost(turns) { * time. That is why this class of bug survives -- nothing about it looks wrong. */ const VOLATILE = [ - { id: 'timestamp', re: /\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/, why: 'an embedded timestamp' }, - { id: 'date', re: /\b(?:today|current date)\b[^\n]{0,40}\d{4}-\d{2}-\d{2}/i, why: 'an embedded current date' }, + { + id: 'timestamp', + re: /\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/, + why: 'an embedded timestamp', + }, + { + id: 'date', + re: /\b(?:today|current date)\b[^\n]{0,40}\d{4}-\d{2}-\d{2}/i, + why: 'an embedded current date', + }, // A bare date is a SUSPICION, not a measurement: "- 2025-10-31: reworked the // loader" is byte-identical every session. Kept, because stableText fails // closed over our own output and dropping a line there costs little -- but // NOT priced, because in the blame report a false positive invents a // per-session cost and tells the user to delete their changelog. - { id: 'iso-date', re: /\b\d{4}-\d{2}-\d{2}\b/, why: 'an embedded date', priced: false }, - { id: 'session-id', re: /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i, why: 'a session id' }, + { + id: 'iso-date', + re: /\b\d{4}-\d{2}-\d{2}\b/, + why: 'an embedded date', + priced: false, + }, + { + id: 'session-id', + re: /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i, + why: 'a session id', + }, { id: 'git-sha', re: /\b[0-9a-f]{40}\b/, why: 'a git sha' }, - { id: 'counter', re: /\b(?:run|build|attempt|iteration)\s*#?\d+\b/i, why: 'a run counter' }, + { + id: 'counter', + re: /\b(?:run|build|attempt|iteration)\s*#?\d+\b/i, + why: 'a run counter', + }, { id: 'epoch', re: /\b1[6-9]\d{11}\b/, why: 'an epoch timestamp' }, ]; @@ -242,7 +288,13 @@ export function volatileLines(text) { for (let i = 0; i < lines.length; i++) { for (const rule of VOLATILE) { if (!rule.re.test(lines[i])) continue; - out.push({ line: i + 1, id: rule.id, why: rule.why, priced: rule.priced !== false, text: lines[i].trim().slice(0, 120) }); + out.push({ + line: i + 1, + id: rule.id, + why: rule.why, + priced: rule.priced !== false, + text: lines[i].trim().slice(0, 120), + }); break; // one cause per line is enough to act on } } @@ -264,7 +316,11 @@ const estimate = (text) => Math.ceil(String(text || '').length / 4); * unknown, and this reports the construct WITHOUT a price rather than inventing * one. */ -export function attributeInvalidation(cwd, prefixTokens = null, { files = null } = {}) { +export function attributeInvalidation( + cwd, + prefixTokens = null, + { files = null } = {} +) { // Ordered as the client assembles them: the client preamble and tool // definitions come first, then project instructions, then the conversation. const candidates = files || ['CLAUDE.md', 'AGENTS.md', '.claude/CLAUDE.md']; @@ -292,7 +348,8 @@ export function attributeInvalidation(cwd, prefixTokens = null, { files = null } // prefix -- 3.75x the entire prefix. Conservative still: the preamble // ahead of the file is not counted, so this remains a floor. const ahead = offset + estimate(lines.slice(0, hit.line - 1).join('\n')); - const downstream = prefixTokens != null ? Math.max(0, prefixTokens - ahead) : null; + const downstream = + prefixTokens != null ? Math.max(0, prefixTokens - ahead) : null; // A prefix cache invalidates ONCE, at the earliest difference. A later // volatile line in the same file costs nothing extra until the first one // is fixed, so it is reported for context but not billed again. @@ -304,7 +361,9 @@ export function attributeInvalidation(cwd, prefixTokens = null, { files = null } // into a reported zero. const firstPriced = found.find((h) => h.priced); const subsumedBy = - hit.priced && firstPriced && firstPriced !== hit ? `${relative}:${firstPriced.line}` : null; + hit.priced && firstPriced && firstPriced !== hit + ? `${relative}:${firstPriced.line}` + : null; out.push({ file: relative, line: hit.line, @@ -316,9 +375,12 @@ export function attributeInvalidation(cwd, prefixTokens = null, { files = null } // decline to price. 0 means measured and genuinely free, which is what a // subsumed hit is: real, but already billed by the earlier line. Folding // those two into one value is the same mistake this file warns about. - costPerSession: downstream == null || !hit.priced - ? null - : (subsumedBy ? 0 : Math.round(downstream * WRITE_MULTIPLIER)), + costPerSession: + downstream == null || !hit.priced + ? null + : subsumedBy + ? 0 + : Math.round(downstream * WRITE_MULTIPLIER), remedy: { kind: 'yours', type: 'edit', @@ -369,7 +431,7 @@ export function stableText(text) { */ export function cacheOrdered(items) { return [...items].sort((a, b) => { - const stability = (item) => (item.volatility ?? (item.fresh ? 1 : 0)); + const stability = (item) => item.volatility ?? (item.fresh ? 1 : 0); return stability(a) - stability(b); }); } diff --git a/plugin/hooks/lib/decide.mjs b/plugin/hooks/lib/decide.mjs index caae7de2f..a61dafdd5 100644 --- a/plugin/hooks/lib/decide.mjs +++ b/plugin/hooks/lib/decide.mjs @@ -392,6 +392,21 @@ export function touchedFiles(payload) { add(input.path); add(input.notebook_path); + // Codex code mode carries apply_patch as the nested program text rather than + // as a first-class file_path. Without reading these headers, successful + // code-mode edits incremented the semantic-harvest counter but produced no + // structural file/symbol nodes, which is why the live dashboard showed very + // few nodes after substantial work. PostToolUse can resolve Add/Update paths + // because the file now exists; deleted paths deliberately remain absent. + for (const match of command.matchAll( + /^\*\*\* (?:Add|Update|Delete) File:\s*(.+)$/gm + )) { + add(match[1].trim().replace(/^['"]|['"]$/g, '')); + } + for (const match of command.matchAll(/^\*\*\* Move to:\s*(.+)$/gm)) { + add(match[1].trim().replace(/^['"]|['"]$/g, '')); + } + // EVERY pipeline segment, not just the first. `fileOperands` looks only at // the head of the pipeline because that is where the COST is -- `git log | // head` must not be mistaken for a file dump. But observation is a different diff --git a/plugin/hooks/lib/experiment.mjs b/plugin/hooks/lib/experiment.mjs index 1f64713f1..c35c6cf5c 100644 --- a/plugin/hooks/lib/experiment.mjs +++ b/plugin/hooks/lib/experiment.mjs @@ -67,16 +67,71 @@ export function episodeMeta({ client, raw = {}, payload = {}, env = process.env } export function usageFrom(raw = {}) { - const usage = raw.usage || raw.token_usage || raw.tokenUsage || raw.metrics?.usage || {}; + const usage = raw.usage || raw.token_usage || raw.tokenUsage || raw.usageMetadata || + raw.usage_metadata || raw.metrics?.usage || raw.response?.usage || + raw.response?.usageMetadata || {}; const number = (...values) => { const value = first(...values); const parsed = Number(value); return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; }; + const cachedInputTokens = number( + usage.cached_input_tokens, usage.cachedInputTokens, + usage.cache_read_input_tokens, usage.cachedContentTokenCount + ); + const cacheCreation = usage.cache_creation || usage.cacheCreation || {}; + const cacheWrite5mInputTokens = number( + usage.cache_write_5m_input_tokens, usage.cacheWrite5mInputTokens, + cacheCreation.ephemeral_5m_input_tokens, cacheCreation.ephemeral5mInputTokens + ); + const cacheWrite1hInputTokens = number( + usage.cache_write_1h_input_tokens, usage.cacheWrite1hInputTokens, + cacheCreation.ephemeral_1h_input_tokens, cacheCreation.ephemeral1hInputTokens + ); + const detailedWrites = (cacheWrite5mInputTokens || 0) + (cacheWrite1hInputTokens || 0); + const reportedWrites = number( + usage.cache_write_input_tokens, usage.cacheWriteInputTokens, + usage.cache_creation_input_tokens, usage.cacheCreationInputTokens + ); + const cacheWriteInputTokens = reportedWrites == null + ? null + : Math.max(0, reportedWrites - detailedWrites); + const explicitUncached = number(usage.uncached_input_tokens, usage.uncachedInputTokens); + const promptInput = number( + usage.promptTokenCount, usage.prompt_token_count, + usage.input_tokens, usage.inputTokens + ); + const isAnthropic = usage.cache_creation_input_tokens !== undefined || + usage.cache_read_input_tokens !== undefined; + const isGemini = usage.promptTokenCount !== undefined || usage.cachedContentTokenCount !== undefined; + const uncachedInputTokens = explicitUncached != null + ? explicitUncached + : promptInput == null + ? null + : isAnthropic + ? promptInput + : isGemini || cachedInputTokens != null || reportedWrites != null + ? Math.max(0, promptInput - (cachedInputTokens || 0) - (reportedWrites || 0)) + : promptInput; + const candidates = number(usage.candidatesTokenCount, usage.candidates_token_count); + const thoughts = number(usage.thoughtsTokenCount, usage.thoughts_token_count); + const outputTokens = candidates != null || thoughts != null + ? (candidates || 0) + (thoughts || 0) + : number(usage.output_tokens, usage.outputTokens); return { - uncachedInputTokens: number(usage.uncached_input_tokens, usage.uncachedInputTokens), - cachedInputTokens: number(usage.cached_input_tokens, usage.cachedInputTokens, usage.cache_read_input_tokens), - outputTokens: number(usage.output_tokens, usage.outputTokens), - totalTokens: number(usage.total_tokens, usage.totalTokens, raw.tokens_used, raw.tokensUsed), + uncachedInputTokens, + cachedInputTokens, + cacheWrite5mInputTokens, + cacheWrite1hInputTokens, + cacheWriteInputTokens, + outputTokens, + totalTokens: number( + usage.total_tokens, usage.totalTokens, usage.totalTokenCount, + usage.total_token_count, raw.tokens_used, raw.tokensUsed + ), + usageMeasurementId: first( + raw.requestId, raw.request_id, raw.responseId, raw.response_id, + usage.responseId, usage.response_id + ) ?? null, }; } diff --git a/plugin/hooks/lib/harvest-write.mjs b/plugin/hooks/lib/harvest-write.mjs index 30433d370..db436c1dc 100644 --- a/plugin/hooks/lib/harvest-write.mjs +++ b/plugin/hooks/lib/harvest-write.mjs @@ -24,7 +24,7 @@ */ import { - putNodeWithEdges, load, nodeId, sharedDir, isSharedDir, putNode, + putNodeWithEdges, load, nodeId, sharedDir, isSharedDir, putNode, putEdge, } from './wiki.mjs'; import { indexFile } from './staleness.mjs'; import { symbolKey } from './symbols.mjs'; @@ -47,6 +47,37 @@ import { ORIGIN_HARVESTED, ORIGIN_AGENT, ORIGIN_HUMAN } from './curate.mjs'; */ export const SHAREABLE_TYPES = new Set(['command', 'failure', 'decision', 'feedback']); +/** + * Synthetic study traffic must never become advice for an unrelated live repo. + * Evaluation roots are intentionally realistic and may carry their own `.git` + * marker, so repository discovery alone cannot distinguish them. Publication + * remains useful inside the isolated study store; this guard protects only the + * default machine-wide store when a harness was accidentally run without an + * isolated TOKEN_OPTIMIZER_SHARED_DIR. + */ +export function isEphemeralProject(projectRoot) { + const root = canonicalPath(projectRoot || '').toLowerCase(); + if (!root) return true; + return ( + /\/appdata\/local\/temp\//.test(root) || + /(^|\/)tmp\//.test(root) || + /\/(artifacts|\.token-optimizer)\/(?:[^/]*\/)*(?:eval|scenario|study|fixture|debug)[^/]*\//.test(`${root}/`) + ); +} + +export function quarantineSharedSource(projectRoot) { + if (process.env.TOKEN_OPTIMIZER_ALLOW_EPHEMERAL_SHARED === '1') return false; + if (!isEphemeralProject(projectRoot)) return false; + // Isolated studies deliberately point the shared tier at scratch storage. + // Their findings may cross their own fixture projects, but cannot escape into + // the user's default machine-wide graph. Strict mode exists for a regression + // probe that verifies the guard without writing to a real user directory. + return ( + !process.env.TOKEN_OPTIMIZER_SHARED_DIR || + process.env.TOKEN_OPTIMIZER_STRICT_SHARED_PROVENANCE === '1' + ); +} + /** Claim text, normalised enough that the same lesson learned twice is one row. */ const claimFingerprint = (claim) => String(claim || '').toLowerCase().replace(/\s+/g, ' ').trim().slice(0, 300); @@ -72,6 +103,7 @@ function promoteToShared(finding, { projectRoot, sessionId, provenance, key }) { if (!projectRoot) return false; const root = canonicalPath(projectRoot); + if (quarantineSharedSource(root)) return false; const rootId = nodeId('file', root); // Same claim, already carried up from anywhere: keep the first. Two repos @@ -367,11 +399,21 @@ export function writeHarvested( && !node.retired && node.type === finding.type && claimFingerprint(node.claim) === fingerprint - && resolved.every((target) => currentGraph.edges.some((edge) => - edge.from === node.id && edge.edge === 'derived_from' && edge.to === target - )) ); if (duplicate) { + // Enrich, do not duplicate. A symbol extractor may learn how to resolve a + // previously file-only anchor (or a later session may add another real + // anchor) after the claim was first stored. Repeating the same semantic + // conclusion should connect that existing node to the newly available + // evidence, not create a second dashboard card with the same text. + for (const target of resolved) { + const alreadyLinked = currentGraph.edges.some((edge) => + edge.from === duplicate.id + && edge.edge === 'derived_from' + && edge.to === target + ); + if (!alreadyLinked) putEdge(dir, duplicate.id, 'derived_from', target); + } written.push(duplicate.key); continue; } @@ -437,7 +479,12 @@ export function writeHarvested( if ( id && !isSharedDir(dir) - && (finding.scope === undefined || finding.scope === 'organization' || finding.scope === 'global') + // Cross-project publication is explicit. `writeHarvested` stores an + // omitted scope as project-local above, so treating `undefined` as global + // here contradicted the stored record and promoted fixture/evaluation + // seeds into real user sessions. This was observed live as "learned in + // scenario" advice while working in AiDotNet. + && (finding.scope === 'organization' || finding.scope === 'global') ) { promoteToShared(finding, { projectRoot, sessionId, provenance, key }); } diff --git a/plugin/hooks/lib/inject.mjs b/plugin/hooks/lib/inject.mjs index b00494a01..c0aa2f1bf 100644 --- a/plugin/hooks/lib/inject.mjs +++ b/plugin/hooks/lib/inject.mjs @@ -31,6 +31,7 @@ import { canonicalPath, resolvableCandidates } from './paths.mjs'; import { annotatedSkeleton } from './skeleton.mjs'; import { substitutionBudget } from './metrics.mjs'; import { assessFindings } from './utility.mjs'; +import { quarantineSharedSource } from './harvest-write.mjs'; // Read per call for the same reason as the holdout fraction in metrics.mjs. const touchBudget = () => Number(process.env.TOKEN_OPTIMIZER_TOUCH_BUDGET) || 500; @@ -562,6 +563,7 @@ export function forRepeatedAct( const home = projectRoot ? canonicalPath(projectRoot) : null; for (const node of graph.nodes.values()) { if (node.kind !== 'finding' || node.retired || !node.claim) continue; + if (quarantineSharedSource(node.sourceProject)) continue; if (home && node.sourceProject && canonicalPath(node.sourceProject) === home) continue; const claimClasses = classesOf(node.claim, 'claim'); if (![...crossedClasses].some((c) => claimClasses.has(c))) continue; @@ -667,6 +669,7 @@ export function forSharedCommand( const candidates = []; for (const node of graph.nodes.values()) { if (node.kind !== 'finding' || node.retired) continue; + if (quarantineSharedSource(node.sourceProject)) continue; if (alreadyInjected.has(node.key)) continue; // ITS OWN LESSON IS NOT NEWS. A finding this project contributed is already // served by the local path; repeating it under a "from elsewhere" label @@ -1054,7 +1057,13 @@ export function standingRules(dir, graph, { budget = standingBudget(), episode = * informative response available -- more useful than the file, not a lossier * version of it. */ -export function substitutionFor(dir, graph, rawPath, source, { sessionId } = {}) { +export function substitutionFor( + dir, + graph, + rawPath, + source, + { sessionId, client = null, clientVersion = null, model = null, modelVersion = null } = {} +) { const filePath = canonicalPath(rawPath); const budget = substitutionBudget(dir, filePath); const built = annotatedSkeleton(graph, rawPath, source, { budget }); @@ -1089,6 +1098,10 @@ export function substitutionFor(dir, graph, rawPath, source, { sessionId } = {}) // on this machine turned out to be the enforcement suite's own fixture and // nothing distinguished them from real work. sessionId, + client, + clientVersion, + model, + modelVersion, holdout, tokens: holdout ? 0 : built.tokens, findings: built.findings, diff --git a/plugin/hooks/lib/metrics.mjs b/plugin/hooks/lib/metrics.mjs index 0bb08f1bc..09f822630 100644 --- a/plugin/hooks/lib/metrics.mjs +++ b/plugin/hooks/lib/metrics.mjs @@ -19,8 +19,15 @@ */ import { - appendFileSync, readFileSync, existsSync, mkdirSync, chmodSync, - statSync, openSync, readSync, closeSync, + appendFileSync, + readFileSync, + existsSync, + mkdirSync, + chmodSync, + statSync, + openSync, + readSync, + closeSync, } from 'node:fs'; import { join } from 'node:path'; import { createHash, randomBytes } from 'node:crypto'; @@ -86,6 +93,10 @@ export const BALANCE_KINDS = new Set([ 'keepwarm', ]); +/** Latest timestamp without passing an unbounded event log as function arguments. */ +export const latestEventTimestamp = (rows) => + rows.reduce((latest, event) => Math.max(latest, Number(event.at) || 0), 0); + /** * Causal records have their own bounded log. Tool outcomes are much rarer * than read/capture telemetry but more frequent than balance records; mixing @@ -130,7 +141,7 @@ export function inHoldout(anchorKey, now = Date.now()) { // other side than before. With nine holdout records in existence that costs // nothing, and stratification is a fresh draw either way. const digest = createHash('sha256').update(`${anchorKey}:${epoch}`).digest(); - return (digest[0] / 256) < fraction; + return digest[0] / 256 < fraction; } /** @@ -258,7 +269,8 @@ export function record(dir, event) { * It is also the right statistics. A saving measured months ago says little * about the code as it stands now. */ -const MAX_BYTES = Number(process.env.TOKEN_OPTIMIZER_METRICS_BYTES) || 2_000_000; +const MAX_BYTES = + Number(process.env.TOKEN_OPTIMIZER_METRICS_BYTES) || 2_000_000; const MAX_EVENTS = Number(process.env.TOKEN_OPTIMIZER_METRICS_WINDOW) || 5000; /** @@ -432,7 +444,10 @@ export function readBalance(dir) { // path for records written before ids existed; it is lossy by nature, which // is precisely why new records carry an id instead. const id = - e.id ?? [e.kind, e.at, e.anchor ?? '', e.sessionId ?? '', e.tokens ?? ''].join('|'); + e.id ?? + [e.kind, e.at, e.anchor ?? '', e.sessionId ?? '', e.tokens ?? ''].join( + '|' + ); if (seen.has(id)) continue; seen.add(id); out.push(e); @@ -493,16 +508,24 @@ export function recordToolOutcome(dir, outcome) { const evidence = readEvidence(dir); const anchor = String(outcome.anchor || '').slice(0, 120); const candidates = evidence - .filter((event) => event.kind === 'inject' && event.episodeId === outcome.episodeId) + .filter( + (event) => + event.kind === 'inject' && event.episodeId === outcome.episodeId + ) .filter((event) => { if (outcome.toolCallId && event.toolCallId) return String(event.toolCallId) === String(outcome.toolCallId); - return event.surface === outcome.surface && String(event.anchor || '').slice(0, 120) === anchor; + return ( + event.surface === outcome.surface && + String(event.anchor || '').slice(0, 120) === anchor + ); }) .sort((a, b) => (b.at || 0) - (a.at || 0)); const injection = candidates[0] || null; const joinMethod = injection - ? (outcome.toolCallId && injection.toolCallId ? 'tool-call-id' : 'episode-anchor') + ? outcome.toolCallId && injection.toolCallId + ? 'tool-call-id' + : 'episode-anchor' : 'none'; return record(dir, { @@ -542,12 +565,11 @@ export function isFixtureAnchor(anchor) { // code: it excluded ALL temp paths -- including a real project checked out // under /tmp -- and on macOS excluded nothing at all, because tmpdir() there // is /var/folders///T/ and did not match. - const underTemp = - new RegExp( - '[\\\\/](AppData[\\\\/]Local[\\\\/])?(Temp|tmp)[\\\\/]' + - '|[\\\\/]var[\\\\/]folders[\\\\/][^\\\\/]+[\\\\/][^\\\\/]+[\\\\/]T[\\\\/]', - 'i' - ).test(p); + const underTemp = new RegExp( + '[\\\\/](AppData[\\\\/]Local[\\\\/])?(Temp|tmp)[\\\\/]' + + '|[\\\\/]var[\\\\/]folders[\\\\/][^\\\\/]+[\\\\/][^\\\\/]+[\\\\/]T[\\\\/]', + 'i' + ).test(p); if (!underTemp) return false; // Only names this suite actually creates. A user's own scratch checkout under @@ -686,8 +708,12 @@ export function balanceSheet(dir) { // THE CONTROL ARM'S ACTUAL COST. `tokensFullFile` was recorded and read // by nothing, so the comparison the holdout exists for was not // computable from the report. - controlArmTokens: withheldSubs.reduce((sum, e) => sum + (e.tokensFullFile || 0), 0), - assumption: 'that the model would have read the file it explicitly requested', + controlArmTokens: withheldSubs.reduce( + (sum, e) => sum + (e.tokensFullFile || 0), + 0 + ), + assumption: + 'that the model would have read the file it explicitly requested', }, estimatedCausal: { what: 'findings injection, from the stratified holdout', @@ -702,7 +728,12 @@ export function balanceSheet(dir) { }; })(), }, - costs: { injection: injectCost, harvest: harvestCost, standing: standingCost, substitution: substitutionCost }, + costs: { + injection: injectCost, + harvest: harvestCost, + standing: standingCost, + substitution: substitutionCost, + }, waste, // Deliberately NOT a single number. The two benefit lines are known // differently; adding them would launder an assumption into a measurement. @@ -727,7 +758,9 @@ export function balanceSheet(dir) { /** Cheap path normalisation for grouping reads; not the identity canonicaliser. */ function canonicalKeyish(p) { - return String(p || '').replace(/\\/g, '/').toLowerCase(); + return String(p || '') + .replace(/\\/g, '/') + .toLowerCase(); } /** @@ -738,7 +771,43 @@ function canonicalKeyish(p) { * arms, multiplied by treated touches. Reporting it as a measured fact would be * the same overclaiming this project criticises competitors for. */ -function buildReport(events, balance) { +function buildReport(events, balance, sourceCoverage = {}) { + const substitutions = balance.filter( + (event) => event.kind === 'substitute' && !isFixtureAnchor(event.anchor) + ); + const servedSubstitutions = substitutions.filter((event) => !event.holdout); + const substitutionTokensSaved = servedSubstitutions.reduce( + (sum, event) => + sum + + (event.tokensNetAvoided ?? + Math.max( + 0, + Math.ceil((event.bytesAvoided || 0) / 4) - (event.tokens || 0) + )), + 0 + ); + const substitutionTokensReturned = servedSubstitutions.reduce( + (sum, event) => sum + (event.tokens || 0), + 0 + ); + const substitutionByClient = {}; + for (const event of servedSubstitutions) { + const name = event.client || 'Historical — client not recorded'; + const current = substitutionByClient[name] || { + substitutions: 0, + tokensReturned: 0, + tokensSaved: 0, + }; + current.substitutions += 1; + current.tokensReturned += event.tokens || 0; + current.tokensSaved += + event.tokensNetAvoided ?? + Math.max( + 0, + Math.ceil((event.bytesAvoided || 0) / 4) - (event.tokens || 0) + ); + substitutionByClient[name] = current; + } // COMMAND INJECTIONS ARE COUNTED SEPARATELY, not mixed into the balance. // @@ -762,13 +831,19 @@ function buildReport(events, balance) { // Report its cost independently so it cannot dilute the file-touch estimate. const isSessionStart = (e) => e.surface === 'session-start'; const sessionStartInjections = allInjections.filter(isSessionStart); - const commandInjections = allInjections.filter((e) => !isSessionStart(e) && isCommand(e)); - const injections = allInjections.filter((e) => !isSessionStart(e) && !isCommand(e)); + const commandInjections = allInjections.filter( + (e) => !isSessionStart(e) && isCommand(e) + ); + const injections = allInjections.filter( + (e) => !isSessionStart(e) && !isCommand(e) + ); const treated = injections.filter((e) => !e.holdout); const withheld = injections.filter((e) => e.holdout); const mean = (rows, field) => - rows.length ? rows.reduce((sum, r) => sum + (r[field] || 0), 0) / rows.length : 0; + rows.length + ? rows.reduce((sum, r) => sum + (r[field] || 0), 0) / rows.length + : 0; const injectedTokens = treated.reduce((sum, e) => sum + (e.tokens || 0), 0); const harvestTokens = balance @@ -819,7 +894,9 @@ function buildReport(events, balance) { const after = event.at ?? 0; const total = bucket.reduce( - (sum, r) => sum + ((r.at ?? 0) >= after ? (r.tokens || 0) : 0), 0); + (sum, r) => sum + ((r.at ?? 0) >= after ? r.tokens || 0 : 0), + 0 + ); // Split across the injections that share this key. Each touch is one // observation, so charging every one the full total would count the same @@ -828,52 +905,198 @@ function buildReport(events, balance) { }; const meanDownstream = (rows) => - rows.length ? rows.reduce((sum, r) => sum + downstreamOf(r), 0) / rows.length : 0; + rows.length + ? rows.reduce((sum, r) => sum + downstreamOf(r), 0) / rows.length + : 0; const treatedCost = meanDownstream(treated); const withheldCost = meanDownstream(withheld); const perTouchSaving = withheldCost - treatedCost; - const estimatedAvoided = Math.max(0, Math.round(perTouchSaving * treated.length)); + const estimatedAvoided = Math.max( + 0, + Math.round(perTouchSaving * treated.length) + ); + + const downstreamSamples = injections.filter((event) => { + if (event.downstream != null) return true; + const key = `${event.sessionId || ''}|${event.anchor}`; + return (reads.get(key) || []).some( + (read) => (read.at ?? 0) >= (event.at ?? 0) + ); + }).length; + const downstreamMeasured = downstreamSamples > 0; // Below this, arm means are noise and a ratio would be theatre. - const sufficient = treated.length >= 20 && withheld.length >= 5; + const sufficient = + downstreamMeasured && treated.length >= 20 && withheld.length >= 5; + + const coverageNumber = (value, fallback) => { + if (value === null || value === undefined || value === '') return fallback; + const number = Number(value); + return Number.isFinite(number) ? Math.max(0, number) : fallback; + }; + const projectCount = coverageNumber(sourceCoverage.projects, 1); + const telemetryProjects = coverageNumber( + sourceCoverage.projectsWithTelemetry, + 0 + ); + const balanceProjects = coverageNumber( + sourceCoverage.projectsWithBalanceEvents, + balance.length ? 1 : 0 + ); + const lastEventAt = Math.max( + latestEventTimestamp(events), + latestEventTimestamp(balance) + ); + const measured = + telemetryProjects > 0 || events.length > 0 || balance.length > 0; + const balanceMeasured = balanceProjects > 0 || balance.length > 0; + const metric = (status, source, samples, extra = {}) => ({ + status, + source, + samples, + ...extra, + }); return { + nativeOptimizer: { + substitutions: servedSubstitutions.length, + holdouts: substitutions.filter((event) => event.holdout).length, + tokensReturned: substitutionTokensReturned, + tokensSaved: substitutionTokensSaved, + byClient: substitutionByClient, + recent: servedSubstitutions + .slice() + .sort((a, b) => (b.at || 0) - (a.at || 0)) + .slice(0, 40) + .map((event) => { + const tokensSaved = + event.tokensNetAvoided ?? + Math.max( + 0, + Math.ceil((event.bytesAvoided || 0) / 4) - (event.tokens || 0) + ); + return { + name: 'live_graph_substitution', + client: event.client || 'Historical — client not recorded', + originalTokens: (event.tokens || 0) + tokensSaved, + optimizedTokens: event.tokens || 0, + tokensSaved, + savingsMeasured: false, + classification: 'modeled-counterfactual', + reportedTokensSaved: tokensSaved, + timestamp: event.at ? new Date(event.at).toISOString() : null, + }; + }), + source: + 'balance.jsonl: modeled full-file counterfactual minus annotated-skeleton tokens; excluded from verified MCP savings', + }, memoryDeliveries: allInjections.filter((event) => !event.holdout).length, memoryHoldouts: allInjections.filter((event) => event.holdout).length, deliveryTokens: allInjections .filter((event) => !event.holdout) .reduce( - (sum, event) => - sum + (event.deliveredTokens ?? event.tokens ?? 0), + (sum, event) => sum + (event.deliveredTokens ?? event.tokens ?? 0), 0 ), injections: treated.length, sessionStartInjections: sessionStartInjections.length, sessionStartInjectedTokens: sessionStartInjections.reduce( - (sum, event) => sum + (event.deliveredTokens ?? event.tokens ?? 0), 0 + (sum, event) => sum + (event.deliveredTokens ?? event.tokens ?? 0), + 0 ), commandInjections: commandInjections.length, commandHoldouts: commandInjections.filter((e) => e.holdout).length, holdouts: withheld.length, staleServed: injections.filter((e) => e.stale).length, - staleRate: injections.length ? injections.filter((e) => e.stale).length / injections.length : 0, + staleRate: injections.length + ? injections.filter((e) => e.stale).length / injections.length + : 0, injectedTokens, harvestTokens, estimatedTokensAvoided: sufficient ? estimatedAvoided : null, - netTokens: sufficient ? estimatedAvoided - injectedTokens - harvestTokens : null, + netTokens: sufficient + ? estimatedAvoided - injectedTokens - harvestTokens + : null, sufficientData: sufficient, verdict: !sufficient ? `insufficient data (${treated.length} treated, ${withheld.length} holdout; need 20 and 5)` : estimatedAvoided > injectedTokens + harvestTokens ? 'the graph is saving more than it costs' : 'the graph is NOT yet paying for itself', + measurement: { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + sourceCoverage: { + projects: projectCount, + projectsWithTelemetry: telemetryProjects || (measured ? 1 : 0), + projectsWithBalanceEvents: balanceProjects, + projectsWithoutTelemetry: Math.max( + 0, + projectCount - (telemetryProjects || (measured ? 1 : 0)) + ), + }, + freshness: { + lastEventAt: lastEventAt || null, + ageMs: lastEventAt ? Math.max(0, Date.now() - lastEventAt) : null, + status: !lastEventAt + ? 'not-measured' + : Date.now() - lastEventAt <= 7 * 86_400_000 + ? 'fresh' + : 'stale', + }, + metrics: { + nativeSubstitutions: metric( + balanceMeasured ? 'measured' : 'not-measured', + 'balance.jsonl: modeled full-file counterfactual minus annotated-skeleton tokens; not a materialized MCP before/after pair', + servedSubstitutions.length, + { classification: 'modeled-counterfactual' } + ), + memoryDeliveries: metric( + balanceMeasured ? 'measured' : 'not-measured', + 'balance.jsonl: inject events in the treated arm', + allInjections.filter((event) => !event.holdout).length + ), + memoryHoldouts: metric( + balanceMeasured ? 'measured' : 'not-measured', + 'balance.jsonl: inject events in the withheld arm', + allInjections.filter((event) => event.holdout).length + ), + rememberingCost: metric( + balanceMeasured ? 'measured' : 'not-measured', + 'balance.jsonl: delivered injection tokens plus semantic harvest tokens', + allInjections.length + + balance.filter((event) => event.kind === 'harvest').length + ), + readingAvoided: metric( + !downstreamMeasured + ? 'not-measured' + : sufficient + ? 'measured' + : 'collecting', + 'stratified file-touch holdout joined to downstream read events', + downstreamSamples, + { + treated: treated.length, + holdouts: withheld.length, + requiredTreated: 20, + requiredHoldouts: 5, + } + ), + }, + }, }; } export function report(dir) { - return buildReport(readAll(dir), readBalance(dir)); + const events = readAll(dir); + const balance = readBalance(dir); + return buildReport(events, balance, { + projects: 1, + projectsWithTelemetry: events.length || balance.length ? 1 : 0, + projectsWithBalanceEvents: balance.length ? 1 : 0, + }); } /** @@ -886,10 +1109,16 @@ export function report(dir) { */ export function reportMany(dirs) { const unique = [...new Set((dirs || []).map((dir) => String(dir)))]; - const report = buildReport( - unique.flatMap((dir) => readAll(dir)), - unique.flatMap((dir) => readBalance(dir)) - ); + const eventSources = unique.map((dir) => readAll(dir)); + const balanceSources = unique.map((dir) => readBalance(dir)); + const report = buildReport(eventSources.flat(), balanceSources.flat(), { + projects: unique.length, + projectsWithTelemetry: eventSources.filter( + (events, index) => events.length || balanceSources[index].length + ).length, + projectsWithBalanceEvents: balanceSources.filter((events) => events.length) + .length, + }); return { ...report, projects: unique.length }; } @@ -907,7 +1136,11 @@ export function reportMany(dirs) { * costs almost nothing. Without a control arm there is no way to tell those two * apart, which is why nobody else can do this. */ -export function substitutionBudget(dir, anchor, { floor = 300, base = 1200, ceiling = 3000 } = {}) { +export function substitutionBudget( + dir, + anchor, + { floor = 300, base = 1200, ceiling = 3000 } = {} +) { const events = readAll(dir); const mine = events.filter((e) => e.kind === 'inject' && e.anchor === anchor); @@ -932,13 +1165,17 @@ export function substitutionBudget(dir, anchor, { floor = 300, base = 1200, ceil for (const row of rows) { const bucket = reads.get(row.sessionId || '') || []; const after = row.at ?? 0; - total += bucket.reduce((sum, r) => sum + ((r.at ?? 0) >= after ? (r.tokens || 0) : 0), 0); + total += bucket.reduce( + (sum, r) => sum + ((r.at ?? 0) >= after ? r.tokens || 0 : 0), + 0 + ); } return total / rows.length; }; const saved = downstream(withheld) - downstream(treated); - const spent = treated.reduce((sum, e) => sum + (e.tokens || 0), 0) / treated.length; + const spent = + treated.reduce((sum, e) => sum + (e.tokens || 0), 0) / treated.length; // Ratio of what annotating this file avoided to what annotating it cost. // Above 1 it is paying for itself and earns room; below, it shrinks. @@ -959,7 +1196,10 @@ export function substitutionBudget(dir, anchor, { floor = 300, base = 1200, ceil * graph whose index leads to queries grows its allowance; a noisy one shrinks * back toward the floor. Nobody configures it, and it cannot run away. */ -export function indexBudget(dir, { floor = 150, base = 300, ceiling = 1200 } = {}) { +export function indexBudget( + dir, + { floor = 150, base = 300, ceiling = 1200 } = {} +) { const events = readAll(dir); const listed = events.filter((e) => e.kind === 'index').length; const queries = events.filter((e) => e.kind === 'query').length; @@ -968,7 +1208,9 @@ export function indexBudget(dir, { floor = 150, base = 300, ceiling = 1200 } = { const hitRate = queries / listed; // 0% hit rate falls to the floor; ~50% and above reaches the ceiling. - const scaled = Math.round(floor + (ceiling - floor) * Math.min(1, hitRate * 2)); + const scaled = Math.round( + floor + (ceiling - floor) * Math.min(1, hitRate * 2) + ); return Math.max(floor, Math.min(ceiling, scaled)); } @@ -1005,7 +1247,10 @@ function seededRandom(seedText) { } /** Percentile bootstrap interval for a mean, paired delta, or other scalar. */ -export function bootstrapMeanInterval(values, { samples = 1000, seed = 'token-optimizer' } = {}) { +export function bootstrapMeanInterval( + values, + { samples = 1000, seed = 'token-optimizer' } = {} +) { const finite = values.map(numeric).filter((value) => value !== null); if (!finite.length) return { mean: null, low: null, high: null, n: 0 }; if (finite.length === 1) { @@ -1037,24 +1282,32 @@ export function proportionInterval(successes, total, z = 1.96) { const denominator = 1 + (z * z) / total; const centre = (rate + (z * z) / (2 * total)) / denominator; const radius = - (z / denominator) * Math.sqrt((rate * (1 - rate)) / total + (z * z) / (4 * total * total)); - return { rate, low: Math.max(0, centre - radius), high: Math.min(1, centre + radius), n: total }; + (z / denominator) * + Math.sqrt((rate * (1 - rate)) / total + (z * z) / (4 * total * total)); + return { + rate, + low: Math.max(0, centre - radius), + high: Math.min(1, centre + radius), + n: total, + }; } -const cohortKey = (run) => [ - run.client || 'unknown', - run.clientVersion || 'unknown', - run.model || 'unknown', - run.modelVersion || 'unknown', - run.taskId || 'unknown', -].join('|'); +const cohortKey = (run) => + [ + run.client || 'unknown', + run.clientVersion || 'unknown', + run.model || 'unknown', + run.modelVersion || 'unknown', + run.taskId || 'unknown', + ].join('|'); function armMetrics(runs) { const correct = runs.filter((run) => run.correct === true).length; - const interval = (field) => bootstrapMeanInterval( - runs.map((run) => run[field]).filter((value) => numeric(value) !== null), - { seed: field } - ); + const interval = (field) => + bootstrapMeanInterval( + runs.map((run) => run[field]).filter((value) => numeric(value) !== null), + { seed: field } + ); return { runs: runs.length, correctness: proportionInterval(correct, runs.length), @@ -1068,29 +1321,40 @@ function armMetrics(runs) { latencyMs: interval('latencyMs'), costUsd: interval('costUsd'), injectedTokens: interval('injectedTokens'), - harmfulFindings: runs.reduce((sum, run) => sum + (numeric(run.harmfulFindings) || 0), 0), + harmfulFindings: runs.reduce( + (sum, run) => sum + (numeric(run.harmfulFindings) || 0), + 0 + ), }; } -function pairedEffects(runs, controlArm, treatmentArm, contrastType = 'incremental') { +function pairedEffects( + runs, + controlArm, + treatmentArm, + contrastType = 'incremental' +) { const pairs = new Map(); for (const run of runs) { if (!run.pairId || ![controlArm, treatmentArm].includes(run.arm)) continue; if (!pairs.has(run.pairId)) pairs.set(run.pairId, {}); pairs.get(run.pairId)[run.arm] = run; } - const complete = [...pairs.values()].filter((pair) => pair[controlArm] && pair[treatmentArm]); - const delta = (field, lowerIsBetter = true) => bootstrapMeanInterval( - complete - .map((pair) => { - const baseline = numeric(pair[controlArm][field]); - const treatment = numeric(pair[treatmentArm][field]); - if (baseline === null || treatment === null) return null; - return lowerIsBetter ? baseline - treatment : treatment - baseline; - }) - .filter((value) => value !== null), - { seed: `${controlArm}:${treatmentArm}:${field}` } + const complete = [...pairs.values()].filter( + (pair) => pair[controlArm] && pair[treatmentArm] ); + const delta = (field, lowerIsBetter = true) => + bootstrapMeanInterval( + complete + .map((pair) => { + const baseline = numeric(pair[controlArm][field]); + const treatment = numeric(pair[treatmentArm][field]); + if (baseline === null || treatment === null) return null; + return lowerIsBetter ? baseline - treatment : treatment - baseline; + }) + .filter((value) => value !== null), + { seed: `${controlArm}:${treatmentArm}:${field}` } + ); return { arm: treatmentArm, controlArm, @@ -1111,21 +1375,32 @@ function pairedEffects(runs, controlArm, treatmentArm, contrastType = 'increment } function matchesFilters(event, filters) { - for (const field of ['client', 'clientVersion', 'model', 'modelVersion', 'taskId', 'arm']) { + for (const field of [ + 'client', + 'clientVersion', + 'model', + 'modelVersion', + 'taskId', + 'arm', + ]) { if (!filters[field]) continue; let actual = event[field]; if (event.kind === 'handoff-run') { - actual = field === 'taskId' - ? event.scenarioId - : field === 'arm' - ? event.arm - : (event.consumer?.[field] ?? event.producer?.[field] ?? event[field]); + actual = + field === 'taskId' + ? event.scenarioId + : field === 'arm' + ? event.arm + : (event.consumer?.[field] ?? + event.producer?.[field] ?? + event[field]); } else if (event.kind === 'concurrency-run') { - actual = field === 'taskId' - ? 'concurrent-combined' - : field === 'arm' - ? event.arm - : (event.consumer?.[field] ?? event[field]); + actual = + field === 'taskId' + ? 'concurrent-combined' + : field === 'arm' + ? event.arm + : (event.consumer?.[field] ?? event[field]); } if (String(actual || '') !== String(filters[field])) return false; } @@ -1134,11 +1409,15 @@ function matchesFilters(event, filters) { function handoffArmMetrics(runs) { const consumers = runs.map((run) => run.consumer || {}); - const count = (field) => consumers.filter((consumer) => consumer[field] === true).length; - const interval = (field) => bootstrapMeanInterval( - consumers.map((consumer) => consumer[field]).filter((value) => numeric(value) !== null), - { seed: `handoff:${field}` } - ); + const count = (field) => + consumers.filter((consumer) => consumer[field] === true).length; + const interval = (field) => + bootstrapMeanInterval( + consumers + .map((consumer) => consumer[field]) + .filter((value) => numeric(value) !== null), + { seed: `handoff:${field}` } + ); return { runs: runs.length, delivery: proportionInterval( @@ -1147,7 +1426,10 @@ function handoffArmMetrics(runs) { ), correctness: proportionInterval(count('correct'), runs.length), firstPass: proportionInterval(count('firstPass'), runs.length), - mistakeAttempted: proportionInterval(count('mistakeAttempted'), runs.length), + mistakeAttempted: proportionInterval( + count('mistakeAttempted'), + runs.length + ), mistakeExecuted: proportionInterval(count('mistakeExecuted'), runs.length), totalTokens: interval('totalTokens'), toolCalls: interval('toolCalls'), @@ -1163,16 +1445,21 @@ function handoffPairedEffect(runs, controlArm, treatmentArm) { if (!pairs.has(run.pairId)) pairs.set(run.pairId, {}); pairs.get(run.pairId)[run.arm] = run; } - const complete = [...pairs.values()].filter((pair) => pair[controlArm] && pair[treatmentArm]); - const effect = (field, positiveWhenLower = true) => bootstrapMeanInterval( - complete.map((pair) => { - const control = numeric(pair[controlArm].consumer?.[field]); - const treatment = numeric(pair[treatmentArm].consumer?.[field]); - if (control === null || treatment === null) return null; - return positiveWhenLower ? control - treatment : treatment - control; - }).filter((value) => value !== null), - { seed: `handoff:${controlArm}:${treatmentArm}:${field}` } + const complete = [...pairs.values()].filter( + (pair) => pair[controlArm] && pair[treatmentArm] ); + const effect = (field, positiveWhenLower = true) => + bootstrapMeanInterval( + complete + .map((pair) => { + const control = numeric(pair[controlArm].consumer?.[field]); + const treatment = numeric(pair[treatmentArm].consumer?.[field]); + if (control === null || treatment === null) return null; + return positiveWhenLower ? control - treatment : treatment - control; + }) + .filter((value) => value !== null), + { seed: `handoff:${controlArm}:${treatmentArm}:${field}` } + ); return { comparison: `${treatmentArm} vs ${controlArm}`, pairs: complete.length, @@ -1187,13 +1474,22 @@ function handoffPairedEffect(runs, controlArm, treatmentArm) { }; } -const handoffKey = (run) => [ - run.producer?.client || 'unknown', run.producer?.model || 'unknown', - run.consumer?.client || 'unknown', run.consumer?.model || 'unknown', - run.scenarioId || 'unknown', -].join('|'); - -const HANDOFF_REPORT_ARMS = ['empty', 'natural', 'oracle', 'irrelevant', 'stale']; +const handoffKey = (run) => + [ + run.producer?.client || 'unknown', + run.producer?.model || 'unknown', + run.consumer?.client || 'unknown', + run.consumer?.model || 'unknown', + run.scenarioId || 'unknown', + ].join('|'); + +const HANDOFF_REPORT_ARMS = [ + 'empty', + 'natural', + 'oracle', + 'irrelevant', + 'stale', +]; const handoffMinimumPairs = () => Math.max(2, Number(process.env.TOKEN_OPTIMIZER_HANDOFF_MIN_PAIRS) || 10); @@ -1206,43 +1502,54 @@ function handoffCohorts(runs) { } const minimumPairs = handoffMinimumPairs(); return [...grouped.entries()].map(([key, rows]) => { - const arms = Object.fromEntries(HANDOFF_REPORT_ARMS.map((arm) => [ - arm, handoffArmMetrics(rows.filter((row) => row.arm === arm)), - ])); + const arms = Object.fromEntries( + HANDOFF_REPORT_ARMS.map((arm) => [ + arm, + handoffArmMetrics(rows.filter((row) => row.arm === arm)), + ]) + ); const naturalRows = rows.filter((row) => row.arm === 'natural'); const naturalVsEmpty = handoffPairedEffect(rows, 'empty', 'natural'); const naturalVsOracle = handoffPairedEffect(rows, 'oracle', 'natural'); const captureRate = naturalRows.length - ? naturalRows.filter((row) => row.producer?.captureSuccess).length / naturalRows.length + ? naturalRows.filter((row) => row.producer?.captureSuccess).length / + naturalRows.length : null; const preActionDeliveryRate = naturalRows.length - ? naturalRows.filter((row) => row.delivery?.beforeFirstExecutedMistake).length / naturalRows.length + ? naturalRows.filter((row) => row.delivery?.beforeFirstExecutedMistake) + .length / naturalRows.length : null; const emptyRecurrence = arms.empty.mistakeExecuted.rate; const naturalRecurrence = arms.natural.mistakeExecuted.rate; - const relativeRecurrenceReduction = emptyRecurrence !== null - && naturalRecurrence !== null - && emptyRecurrence !== 0 - ? (emptyRecurrence - naturalRecurrence) / emptyRecurrence - : null; - const controlsSafe = ['irrelevant', 'stale'].every((arm) => - arms[arm].correctness.rate !== null - && arms.empty.correctness.rate !== null - && arms[arm].correctness.rate >= arms.empty.correctness.rate - 0.1 + const relativeRecurrenceReduction = + emptyRecurrence !== null && + naturalRecurrence !== null && + emptyRecurrence !== 0 + ? (emptyRecurrence - naturalRecurrence) / emptyRecurrence + : null; + const controlsSafe = ['irrelevant', 'stale'].every( + (arm) => + arms[arm].correctness.rate !== null && + arms.empty.correctness.rate !== null && + arms[arm].correctness.rate >= arms.empty.correctness.rate - 0.1 ); - const irrelevantSuppressed = arms.irrelevant.delivery.rate !== null - && arms.irrelevant.delivery.rate === 0; + const irrelevantSuppressed = + arms.irrelevant.delivery.rate !== null && + arms.irrelevant.delivery.rate === 0; const gates = { minimumPairs: naturalVsEmpty.pairs >= minimumPairs, capture: captureRate !== null && captureRate >= 0.8, recurrenceMagnitude: - relativeRecurrenceReduction !== null && relativeRecurrenceReduction >= 0.5, - recurrenceInterval: (naturalVsEmpty.executedMistakesPrevented.low ?? -Infinity) > 0, + relativeRecurrenceReduction !== null && + relativeRecurrenceReduction >= 0.5, + recurrenceInterval: + (naturalVsEmpty.executedMistakesPrevented.low ?? -Infinity) > 0, correctness: - arms.natural.correctness.rate !== null - && arms.empty.correctness.rate !== null - && arms.natural.correctness.rate >= arms.empty.correctness.rate - 0.1, - preActionDelivery: preActionDeliveryRate !== null && preActionDeliveryRate >= 0.8, + arms.natural.correctness.rate !== null && + arms.empty.correctness.rate !== null && + arms.natural.correctness.rate >= arms.empty.correctness.rate - 0.1, + preActionDelivery: + preActionDeliveryRate !== null && preActionDeliveryRate >= 0.8, negativeControls: controlsSafe && irrelevantSuppressed, }; const claimReady = Object.values(gates).every(Boolean); @@ -1269,14 +1576,24 @@ function handoffCohorts(runs) { function concurrencySummary(runs) { const natural = runs.filter((run) => run.arm === 'natural'); const writers = natural.reduce((sum, run) => sum + (run.writerCount || 0), 0); - const captures = natural.reduce((sum, run) => sum + (run.captureSuccesses || 0), 0); - const integrityPasses = natural.filter((run) => - run.integrity?.zeroLoss - && run.integrity?.parseable - && run.integrity?.orphanedFindings === 0 + const captures = natural.reduce( + (sum, run) => sum + (run.captureSuccesses || 0), + 0 + ); + const integrityPasses = natural.filter( + (run) => + run.integrity?.zeroLoss && + run.integrity?.parseable && + run.integrity?.orphanedFindings === 0 ).length; - const delivered = natural.reduce((sum, run) => sum + (run.delivery?.delivered || 0), 0); - const expected = natural.reduce((sum, run) => sum + (run.delivery?.expected || 0), 0); + const delivered = natural.reduce( + (sum, run) => sum + (run.delivery?.delivered || 0), + 0 + ); + const expected = natural.reduce( + (sum, run) => sum + (run.delivery?.expected || 0), + 0 + ); const effect = handoffPairedEffect(runs, 'empty', 'natural'); return { runs: runs.length, @@ -1286,7 +1603,8 @@ function concurrencySummary(runs) { integrityPassRate: natural.length ? integrityPasses / natural.length : null, deliveryCoverage: expected ? delivered / expected : null, naturalCorrectness: proportionInterval( - natural.filter((run) => run.consumer?.correct).length, natural.length + natural.filter((run) => run.consumer?.correct).length, + natural.length ), effect, }; @@ -1297,17 +1615,26 @@ function concurrencySummary(runs) { * live hook traces, and randomized model evals remain visibly distinct: only * `eval-run` records with paired arms can produce a causal effect interval. */ -function buildEvidenceReport(evidence, { filters = {}, episodeLimit = 100 } = {}) { +function buildEvidenceReport( + evidence, + { filters = {}, episodeLimit = 100 } = {} +) { evidence = evidence.filter((event) => matchesFilters(event, filters)); const runs = evidence.filter((event) => event.kind === 'eval-run'); const handoffRuns = evidence.filter((event) => event.kind === 'handoff-run'); - const concurrencyRuns = evidence.filter((event) => event.kind === 'concurrency-run'); + const concurrencyRuns = evidence.filter( + (event) => event.kind === 'concurrency-run' + ); const injections = evidence.filter((event) => event.kind === 'inject'); const outcomes = evidence.filter((event) => event.kind === 'tool-outcome'); - const feedback = evidence.filter((event) => event.kind === 'finding-feedback'); + const feedback = evidence.filter( + (event) => event.kind === 'finding-feedback' + ); const byInjection = new Map( - outcomes.filter((event) => event.injectionId).map((event) => [event.injectionId, event]) + outcomes + .filter((event) => event.injectionId) + .map((event) => [event.injectionId, event]) ); const traced = injections .slice() @@ -1337,7 +1664,10 @@ function buildEvidenceReport(evidence, { filters = {}, episodeLimit = 100 } = {} grouped.get(key).push(run); } - const minimumPairs = Math.max(2, Number(process.env.TOKEN_OPTIMIZER_EVAL_MIN_PAIRS) || 5); + const minimumPairs = Math.max( + 2, + Number(process.env.TOKEN_OPTIMIZER_EVAL_MIN_PAIRS) || 5 + ); const cohorts = [...grouped.entries()].map(([key, cohortRuns]) => { const arms = Object.fromEntries( ['baseline', 'optimizer', 'retrieval', 'full'].map((arm) => [ @@ -1387,7 +1717,9 @@ function buildEvidenceReport(evidence, { filters = {}, episodeLimit = 100 } = {} cohorts: cohorts.length, harmfulFeedback: harmful, harmRate: feedback.length ? harmful / feedback.length : null, - evidenceStatus: cohorts.some((cohort) => cohort.evidenceStatus === 'causal estimate available') + evidenceStatus: cohorts.some( + (cohort) => cohort.evidenceStatus === 'causal estimate available' + ) ? 'causal estimates available' : 'insufficient randomized evidence', }, @@ -1396,7 +1728,8 @@ function buildEvidenceReport(evidence, { filters = {}, episodeLimit = 100 } = {} concurrency, episodes: traced, methodology: { - intervals: 'deterministic percentile bootstrap (95%); Wilson interval for correctness', + intervals: + 'deterministic percentile bootstrap (95%); Wilson interval for correctness', causalRule: 'matched pairs estimate optimizer vs baseline, retrieval vs optimizer, full vs retrieval, and full vs baseline', minimumPairs, diff --git a/plugin/hooks/lib/observability.mjs b/plugin/hooks/lib/observability.mjs index b9980076e..ef1c037af 100644 --- a/plugin/hooks/lib/observability.mjs +++ b/plugin/hooks/lib/observability.mjs @@ -26,7 +26,7 @@ import { createHash, randomUUID } from 'node:crypto'; import { homedir } from 'node:os'; import { join } from 'node:path'; -export const HOOK_LOG_SCHEMA_VERSION = 1; +export const HOOK_LOG_SCHEMA_VERSION = 2; const DEFAULT_MAX_BYTES = 5 * 1024 * 1024; const DEFAULT_RETENTION_DAYS = 14; const DEFAULT_MAX_FILES = 40; @@ -197,6 +197,8 @@ export function beginHookInvocation(client, event, options = {}) { turnIdHash: null, toolUseIdHash: null, toolName: null, + rawToolName: null, + codeModeEnvelope: false, clientVersion: null, model: null, cwdHash: null, @@ -208,12 +210,29 @@ export function beginHookInvocation(client, event, options = {}) { const spanId = hash(invocationId, 16); let traceId = hash(invocationId, 32); + // Write the start before reading stdin or importing project state. If the + // host kills the process, Node aborts, or a native call terminates without a + // JavaScript exception, diagnostics can now reconcile this record with the + // missing completion instead of reporting a false success rate. + writeHookEvent({ + level: 'info', + event: 'hook.started', + invocationId, + traceId, + spanId, + client, + hookEvent: event, + processId: process.pid, + }); + let deadline; const finish = (nextOutcome = outcome, nextReason = reason, nextError = error) => { if (ended) return; ended = true; if (deadline) clearTimeout(deadline); process.removeListener('exit', onExit); + process.removeListener('uncaughtException', onUncaughtException); + process.removeListener('unhandledRejection', onUnhandledRejection); writeHookEvent({ level: nextOutcome === 'failure' || nextOutcome === 'timeout' ? 'error' : 'info', event: 'hook.completed', @@ -231,7 +250,17 @@ export function beginHookInvocation(client, event, options = {}) { }); }; const onExit = () => finish(); + const onUncaughtException = (nextError) => { + finish('failure', 'uncaught_exception', nextError); + process.exit(0); + }; + const onUnhandledRejection = (nextError) => { + finish('failure', 'unhandled_rejection', nextError); + process.exit(0); + }; process.once('exit', onExit); + process.once('uncaughtException', onUncaughtException); + process.once('unhandledRejection', onUnhandledRejection); deadline = setTimeout(() => { outcome = 'timeout'; @@ -250,6 +279,11 @@ export function beginHookInvocation(client, event, options = {}) { dimensions.turnIdHash = hash(raw.turn_id ?? raw.turnId ?? null); dimensions.toolUseIdHash = hash(raw.tool_use_id ?? raw.toolUseId ?? null); dimensions.toolName = payload?.tool_name ?? raw.tool_name ?? raw.toolName ?? null; + dimensions.rawToolName = dimension( + raw.tool_name ?? raw.toolName ?? raw.tool ?? null + ); + dimensions.codeModeEnvelope = + payload?.tool_input?.code_mode_envelope === true; dimensions.clientVersion = dimension(raw.client_version ?? raw.clientVersion ?? null); dimensions.model = dimension(raw.model ?? raw.model_name ?? raw.modelName ?? null); dimensions.cwdHash = hash(raw.cwd ?? raw.working_directory ?? process.cwd()); @@ -291,6 +325,11 @@ export function beginHookInvocation(client, event, options = {}) { error = nextError; finish(outcome, reason, error); }, + block(nextReason = 'policy_denied') { + outcome = 'blocked'; + reason = nextReason; + finish(outcome, reason); + }, succeed(nextReason = 'completed') { outcome = 'success'; reason = nextReason; @@ -321,7 +360,11 @@ export function recordHookBootstrapFailure(client, event, error) { }); } -export function readHookEvents({ limit = 200, sinceMs = 24 * 60 * 60 * 1000 } = {}) { +export function readHookEvents({ + limit = 200, + sinceMs = 24 * 60 * 60 * 1000, + includeLifecycle = false, +} = {}) { try { const dir = hookLogDirectory(); if (!existsSync(dir)) return []; @@ -338,6 +381,7 @@ export function readHookEvents({ limit = 200, sinceMs = 24 * 60 * 60 * 1000 } = try { const event = JSON.parse(line); if (Date.parse(event.timestamp) < cutoff) continue; + if (!includeLifecycle && event.event === 'hook.started') continue; events.push(event); if (events.length >= limit) return events; } catch { @@ -353,38 +397,102 @@ export function readHookEvents({ limit = 200, sinceMs = 24 * 60 * 60 * 1000 } = export function hookHealthSummary(options = {}) { const sinceMs = options.sinceMs ?? 24 * 60 * 60 * 1000; - const events = readHookEvents({ limit: options.limit || 5000, sinceMs }); - const durations = events + const events = readHookEvents({ + limit: options.limit || 10000, + sinceMs, + includeLifecycle: true, + }); + const starts = events.filter((event) => event.event === 'hook.started'); + const completed = events.filter( + (event) => event.event === 'hook.completed' || !event.event + ); + const completedIds = new Set( + completed.map((event) => event.invocationId).filter(Boolean) + ); + const abandoned = starts + .filter( + (event) => + event.invocationId && + !completedIds.has(event.invocationId) && + Date.now() - Date.parse(event.timestamp) > 10_000 + ) + .map((event) => ({ + ...event, + event: 'hook.completed', + outcome: 'failure', + reason: 'process_terminated_before_completion', + durationMs: null, + })); + const runs = [...completed, ...abandoned]; + const durations = runs .map((event) => Number(event.durationMs)) .filter(Number.isFinite) .sort((a, b) => a - b); - const failures = events.filter((event) => event.outcome === 'failure'); - const timeouts = events.filter((event) => event.outcome === 'timeout'); - const skipped = events.filter((event) => event.outcome === 'skipped'); - const successes = events.filter((event) => event.outcome === 'success'); + const failures = runs.filter((event) => event.outcome === 'failure'); + const timeouts = runs.filter((event) => event.outcome === 'timeout'); + const skipped = runs.filter((event) => event.outcome === 'skipped'); + const blocked = runs.filter((event) => event.outcome === 'blocked'); + const successes = runs.filter((event) => event.outcome === 'success'); + const actions = runs.filter( + (event) => event.hookEvent === 'pre-tool' && event.toolName + ); + const byTool = {}; + for (const event of actions) { + const name = String(event.toolName || 'Unknown action'); + byTool[name] = (byTool[name] || 0) + 1; + } const byClient = {}; - for (const event of events) { + for (const event of runs) { const key = event.client || 'unknown'; - const current = byClient[key] || { total: 0, failures: 0, timeouts: 0, skipped: 0 }; + const current = byClient[key] || { + total: 0, + failures: 0, + timeouts: 0, + skipped: 0, + blocked: 0, + hookEvents: [], + }; current.total++; if (event.outcome === 'failure') current.failures++; if (event.outcome === 'timeout') current.timeouts++; if (event.outcome === 'skipped') current.skipped++; + if (event.outcome === 'blocked') current.blocked++; + if (event.hookEvent && !current.hookEvents.includes(event.hookEvent)) + current.hookEvents.push(event.hookEvent); byClient[key] = current; } + for (const current of Object.values(byClient)) current.hookEvents.sort(); const percentile = (p) => durations.length ? durations[Math.min(durations.length - 1, Math.floor(durations.length * p))] : null; const summary = { schemaVersion: HOOK_LOG_SCHEMA_VERSION, - available: events.length > 0, + available: runs.length > 0, windowHours: sinceMs / 3_600_000, - total: events.length, + total: runs.length, failures: failures.length, timeouts: timeouts.length, skipped: skipped.length, - successRate: events.length ? successes.length / events.length : null, + blocked: blocked.length, + abandoned: abandoned.length, + successes: successes.length, + actions: actions.length, + byTool, + // A skipped or policy-blocked hook completed its protocol successfully. + // Runtime health answers whether the hook process worked, not whether the + // optimizer chose to allow the host operation. + successRate: runs.length + ? (successes.length + skipped.length + blocked.length) / runs.length + : null, + healthStatus: + failures.length || timeouts.length + ? 'failing' + : skipped.length + ? 'degraded' + : runs.length + ? 'healthy' + : 'unavailable', p50DurationMs: percentile(0.5), p95DurationMs: percentile(0.95), byClient, diff --git a/plugin/hooks/lib/pricing.mjs b/plugin/hooks/lib/pricing.mjs index a4b09ebed..50b23b9cc 100644 --- a/plugin/hooks/lib/pricing.mjs +++ b/plugin/hooks/lib/pricing.mjs @@ -1,101 +1,72 @@ // GENERATED FILE -- do not edit. // Source of truth: hooks-core/pricing.mjs. Regenerate with `npm run sync:hooks`. /** - * Tokens in the unit people actually feel. + * Optional cost equivalents for token measurements. * - * The same data as everywhere else in this project, converted once, in one - * place, under three rules that keep a dollar figure honest: - * - * THE TABLE IS VISIBLE AND OVERRIDABLE. Prices change. A figure computed from - * rates buried in code goes stale silently and keeps looking authoritative, - * which is worse than no figure. The rates are printed next to the number and - * can be replaced without a release. - * - * DOLLARS BESIDE TOKENS, NOT INSTEAD OF THEM. The token count is exact and - * never goes out of date; the dollar figure is the token count times a rate - * that will. Showing both means the durable number survives the perishable - * one. - * - * NEVER A PRICE ON AN UNMEASURED SAVING. An invented number is worse in - * dollars than in tokens, because dollars get quoted to other people. A - * saving we cannot measure returns null and renders as "not yet measurable", - * never as "$0.00" -- which reads as "saved nothing" and is a different - * claim entirely. + * The runtime cannot infer a user's bill from a client name. A CLI may route + * through a subscription, direct API account, cloud marketplace, enterprise + * agreement, included credits, or a provider whose cache-read/write prices + * differ. Provider usage records are the source of truth when available. + * Everywhere else this module fails closed until the user supplies an + * effective blended input-token rate. */ -/** Cache multipliers, relative to an input token. */ -const CACHE_WRITE = 1.25; -const CACHE_READ = 0.1; +const RATE_ENV = 'TOKEN_OPTIMIZER_EFFECTIVE_INPUT_USD_PER_MILLION'; -/** - * Dollars per million tokens, by tier. - * - * Approximate published list rates as of 2026-07, carried with their date so a - * stale table is visible rather than silent. Override wholesale with - * TOKEN_OPTIMIZER_PRICES as JSON, e.g. - * {"asOf":"2026-09","opus":{"input":15,"output":75}}. - */ -const DEFAULT_PRICES = { - asOf: '2026-07', - haiku: { input: 1, output: 5 }, - sonnet: { input: 3, output: 15 }, - opus: { input: 15, output: 75 }, -}; - -export function prices() { - const raw = process.env.TOKEN_OPTIMIZER_PRICES; - if (raw) { - try { - const parsed = JSON.parse(raw); - if (parsed && typeof parsed === 'object') return { ...DEFAULT_PRICES, ...parsed, overridden: true }; - } catch { /* an unparseable override is ignored rather than fatal */ } - } - return DEFAULT_PRICES; +/** The configured effective input rate, or null when billing is unobservable. */ +export function effectiveRate() { + const rate = Number(process.env[RATE_ENV]); + return Number.isFinite(rate) && rate > 0 ? rate : null; } /** - * What a number of tokens costs. + * Dollars for a measured token quantity. * - * Returns null for a null input and says why: this is the single place the - * "unmeasured must not become $0.00" rule is enforced, so every caller inherits - * it rather than each remembering. + * `kind` is accepted for backwards compatibility but is not multiplied by a + * universal cache constant. The configured rate must already reflect the + * user's actual provider/model/cache/tier/credit mix. */ -export function dollars(tokens, { tier = 'opus', kind = 'input' } = {}) { - if (!Number.isFinite(tokens)) return null; - - const table = prices(); - const rate = table[tier]?.[kind === 'output' ? 'output' : 'input']; - if (!Number.isFinite(rate)) return null; - - const multiplier = kind === 'cacheWrite' ? CACHE_WRITE : kind === 'cacheRead' ? CACHE_READ : 1; - return (tokens / 1_000_000) * rate * multiplier; +export function dollars(tokens, { rate = effectiveRate() } = {}) { + if (!Number.isFinite(tokens) || !Number.isFinite(rate) || rate <= 0) { + return null; + } + return (tokens / 1_000_000) * rate; } /** A dollar figure, or an honest absence of one. */ -export function money(amount, { unmeasured = 'not yet measurable' } = {}) { +export function money(amount, { unmeasured = 'not priced' } = {}) { if (amount == null || !Number.isFinite(amount)) return unmeasured; if (amount === 0) return '$0.00'; if (Math.abs(amount) < 0.01) return '<$0.01'; return `$${amount.toFixed(2)}`; } -/** - * Per-session tokens expressed as a monthly cost. - * - * `sessionsPerMonth` is an assumption, so it is stated in the output rather - * than folded invisibly into the number. - */ -export function monthly(tokensPerSession, { tier = 'opus', sessionsPerMonth = 60 } = {}) { +/** Per-session tokens expressed with a stated monthly-frequency assumption. */ +export function monthly(tokensPerSession, { sessionsPerMonth = 60 } = {}) { if (!Number.isFinite(tokensPerSession)) return null; - const amount = dollars(tokensPerSession * sessionsPerMonth, { tier }); - return amount == null ? null : { amount, sessionsPerMonth, tier }; + const amount = dollars(tokensPerSession * sessionsPerMonth); + return amount == null + ? null + : { + amount, + sessionsPerMonth, + effectiveInputUsdPerMillion: effectiveRate(), + }; +} + +/** The pricing basis printed beside any cost equivalent. */ +export function priceNote() { + const rate = effectiveRate(); + return rate == null + ? `cost not priced: set ${RATE_ENV} to your effective blended input rate after provider, model, cache, tier, plan, and credits` + : `cost equivalent: configured effective input rate $${rate}/M via ${RATE_ENV}`; } -/** The rates behind the figures, for printing beside them. */ -export function priceNote(tier = 'opus') { - const table = prices(); - const rate = table[tier]; - if (!rate) return null; - return `prices: ${tier} $${rate.input}/$${rate.output} per Mtok (${table.asOf}` + - `${table.overridden ? ', overridden' : ''}); override with TOKEN_OPTIMIZER_PRICES`; +/** Compatibility surface: no built-in provider table is claimed. */ +export function prices() { + return { + source: + effectiveRate() == null ? 'unavailable' : 'configured-effective-rate', + effectiveInputUsdPerMillion: effectiveRate(), + }; } diff --git a/plugin/hooks/lib/routing.mjs b/plugin/hooks/lib/routing.mjs index 9b4642e0e..84ac866e4 100644 --- a/plugin/hooks/lib/routing.mjs +++ b/plugin/hooks/lib/routing.mjs @@ -65,7 +65,9 @@ export function modelCosts() { if (raw) { try { return JSON.parse(raw); - } catch { /* fall through to the defaults */ } + } catch { + /* fall through to the defaults */ + } } return { haiku: 1, sonnet: 3, opus: 15 }; } @@ -106,7 +108,12 @@ export function readEpisodes(path, { maxBytes = 6_000_000 } = {}) { let current = null; const close = () => { - if (current && current.turns > 0) episodes.push({ ...current, files: [...current.files], tools: [...current.tools] }); + if (current && current.turns > 0) + episodes.push({ + ...current, + files: [...current.files], + tools: [...current.tools], + }); current = null; }; @@ -123,7 +130,15 @@ export function readEpisodes(path, { maxBytes = 6_000_000 } = {}) { // a new episode. if (row.type === 'user' && typeof row.message?.content === 'string') { close(); - current = { at: Date.parse(row.timestamp) || null, models: new Set(), tools: new Set(), files: new Set(), turns: 0, errors: 0, results: 0 }; + current = { + at: Date.parse(row.timestamp) || null, + models: new Set(), + tools: new Set(), + files: new Set(), + turns: 0, + errors: 0, + results: 0, + }; continue; } if (!current) continue; @@ -157,8 +172,16 @@ export function readEpisodes(path, { maxBytes = 6_000_000 } = {}) { /* ---------------------------------------------------------------- SHAPES */ -const EDIT_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit', 'smart_edit', 'smart_write']); -const TEST_PATTERN = /\b(test|jest|pytest|vitest|dotnet test|npm test|go test)\b/i; +const EDIT_TOOLS = new Set([ + 'Edit', + 'MultiEdit', + 'Write', + 'NotebookEdit', + 'smart_edit', + 'smart_write', +]); +const TEST_PATTERN = + /\b(test|jest|pytest|vitest|dotnet test|npm test|go test)\b/i; /** * What kind of work this was. @@ -171,11 +194,19 @@ export function classifyShape(episode) { const tools = new Set(episode.tools || []); const edited = [...tools].some((t) => EDIT_TOOLS.has(t)); const files = (episode.files || []).length; - const ranTests = [...tools].some((t) => TEST_PATTERN.test(t)) || (episode.tools || []).includes('Bash'); + const ranTests = + [...tools].some((t) => TEST_PATTERN.test(t)) || + (episode.tools || []).includes('Bash'); if (edited && files >= 3) return 'multi-file-change'; if (edited) return 'single-file-change'; - if (files > 0 || tools.has('Grep') || tools.has('Glob') || tools.has('smart_grep')) return 'investigation'; + if ( + files > 0 || + tools.has('Grep') || + tools.has('Glob') || + tools.has('smart_grep') + ) + return 'investigation'; if (ranTests) return 'build-or-test'; return 'conversation'; } @@ -208,7 +239,13 @@ export function outcomeTable(episodes) { const tier = [...tiers][0]; table[shape] = table[shape] || {}; - const cell = table[shape][tier] = table[shape][tier] || { episodes: 0, turns: 0, errors: 0, results: 0, errored: 0 }; + const cell = (table[shape][tier] = table[shape][tier] || { + episodes: 0, + turns: 0, + errors: 0, + results: 0, + errored: 0, + }); cell.episodes += 1; cell.turns += episode.turns; cell.errors += episode.errors; @@ -287,7 +324,8 @@ export function route(shape, table = {}) { shape, recommend: heuristic, basis: 'heuristic', - reason: `every measured tier for ${shape} needed a retry in more than ` + + reason: + `every measured tier for ${shape} needed a retry in more than ` + `${Math.round(MAX_ERROR_RATE * 100)}% of episodes; falling back to the shipped default`, excluded: excluded.map((o) => ({ tier: o.tier, errorRate: o.errorRate })), options, @@ -306,17 +344,24 @@ export function route(shape, table = {}) { recommend: best.tier, basis: 'measured', // What each mistake would cost, from this project's own rates. - overpowered: strongest.tier === best.tier ? null : { - tier: strongest.tier, - wasted: Number((strongest.cost - best.cost).toFixed(2)), - }, - underpowered: cheapest.tier === best.tier ? null : { - tier: cheapest.tier, - expectedCost: Number(cheapest.cost.toFixed(2)), - errorRate: cheapest.errorRate, - excluded: cheapest.errorRate > MAX_ERROR_RATE, - }, - reason: `${shape}: ${best.tier} costs ${best.cost.toFixed(2)} in expectation over ${best.episodes} episodes ` + + overpowered: + strongest.tier === best.tier + ? null + : { + tier: strongest.tier, + wasted: Number((strongest.cost - best.cost).toFixed(2)), + }, + underpowered: + cheapest.tier === best.tier + ? null + : { + tier: cheapest.tier, + expectedCost: Number(cheapest.cost.toFixed(2)), + errorRate: cheapest.errorRate, + excluded: cheapest.errorRate > MAX_ERROR_RATE, + }, + reason: + `${shape}: ${best.tier} costs ${best.cost.toFixed(2)} in expectation over ${best.episodes} episodes ` + `(${Math.round(best.errorRate * 100)}% needed a retry)`, options, }; @@ -340,20 +385,31 @@ export function routingNote(shape, table, { currentModel, switchCost } = {}) { const lines = [decision.reason]; if (decision.underpowered) { - lines.push(` ${decision.underpowered.tier} looks cheaper per token, but needed a retry in ` + - `${Math.round(decision.underpowered.errorRate * 100)}% of episodes -- expected cost ${decision.underpowered.expectedCost}` + - `${decision.underpowered.excluded ? ', which is why it is not a candidate here at any price' : ''}.`); + lines.push( + ` ${decision.underpowered.tier} looks cheaper per token, but needed a retry in ` + + `${Math.round(decision.underpowered.errorRate * 100)}% of episodes -- expected cost ${decision.underpowered.expectedCost}` + + `${decision.underpowered.excluded ? ', which is why it is not a candidate here at any price' : ''}.` + ); } if (decision.overpowered) { - lines.push(` ${decision.overpowered.tier} would spend ${decision.overpowered.wasted} more than needed here.`); + lines.push( + ` ${decision.overpowered.tier} would spend ${decision.overpowered.wasted} more than needed here.` + ); } if (switchCost?.prefixTokens) { - lines.push(` Switching now also discards a ${switchCost.prefixTokens.toLocaleString()}-token warm prefix ` + - `(~${switchCost.rewriteCost.toLocaleString()} tokens to re-write); consider switching at the next break.`); + lines.push( + ` Switching now also discards a ${switchCost.prefixTokens.toLocaleString()}-token warm prefix ` + + `(~${switchCost.rewriteInputCostEquivalent.toLocaleString()} Anthropic uncached-input cost-equivalent tokens to re-write); consider switching at the next break.` + ); } - return { shape, recommend: decision.recommend, basis: decision.basis, text: lines.join('\n') }; + return { + shape, + recommend: decision.recommend, + basis: decision.basis, + text: lines.join('\n'), + }; } /** @@ -371,7 +427,9 @@ export function routingBriefing(table) { const decision = route(shape, table); if (decision.basis !== 'measured') continue; if (decision.recommend === HEURISTIC[shape]) continue; // nothing learned worth saying - lines.push(`${shape.replace(/-/g, ' ')} work goes better on ${decision.recommend} in this project.`); + lines.push( + `${shape.replace(/-/g, ' ')} work goes better on ${decision.recommend} in this project.` + ); } return lines.length ? lines.join('\n') : null; } @@ -389,7 +447,11 @@ export function routingBriefing(table) { * few more episodes accumulated would invalidate the cache behind it. Stability * is the feature; the memo is how it is bought. */ -export function cachedRoutingBriefing(dir, transcriptPath, { growthBytes = 4_000_000, maxAgeMs = 24 * 60 * 60 * 1000 } = {}) { +export function cachedRoutingBriefing( + dir, + transcriptPath, + { growthBytes = 4_000_000, maxAgeMs = 24 * 60 * 60 * 1000 } = {} +) { const memoPath = join(dir, 'routing-brief.json'); let stat; try { @@ -400,17 +462,31 @@ export function cachedRoutingBriefing(dir, transcriptPath, { growthBytes = 4_000 try { const memo = JSON.parse(readFileSync(memoPath, 'utf8')); - const fresh = memo.path === transcriptPath - && Math.abs(stat.size - memo.size) < growthBytes - && Date.now() - memo.at < maxAgeMs; + const fresh = + memo.path === transcriptPath && + Math.abs(stat.size - memo.size) < growthBytes && + Date.now() - memo.at < maxAgeMs; if (fresh) return memo.text || null; - } catch { /* no memo, or an unreadable one; recompute */ } + } catch { + /* no memo, or an unreadable one; recompute */ + } const text = routingBriefing(outcomeTable(readEpisodes(transcriptPath))); try { mkdirSync(dir, { recursive: true, mode: 0o700 }); - writeFileSync(memoPath, JSON.stringify({ path: transcriptPath, size: stat.size, at: Date.now(), text }), { mode: 0o600 }); - } catch { /* the memo is an optimisation; failing to write it costs latency, not correctness */ } + writeFileSync( + memoPath, + JSON.stringify({ + path: transcriptPath, + size: stat.size, + at: Date.now(), + text, + }), + { mode: 0o600 } + ); + } catch { + /* the memo is an optimisation; failing to write it costs latency, not correctness */ + } return text; } @@ -427,9 +503,11 @@ export function routingReport(table) { for (const tier of Object.keys(table[shape])) { const cell = table[shape][tier]; const cost = expectedCost(tier, cell); - lines.push(` ${tier.padEnd(7)} ${cell.episodes} episode(s), ` + - `${cell.meanTurns.toFixed(1)} turns avg, ${Math.round(cell.errorRate * 100)}% needed a retry, ` + - `expected cost ${cost.cost.toFixed(2)}${cell.measured ? '' : ' [too few episodes to trust]'}`); + lines.push( + ` ${tier.padEnd(7)} ${cell.episodes} episode(s), ` + + `${cell.meanTurns.toFixed(1)} turns avg, ${Math.round(cell.errorRate * 100)}% needed a retry, ` + + `expected cost ${cost.cost.toFixed(2)}${cell.measured ? '' : ' [too few episodes to trust]'}` + ); } } return lines.join('\n'); diff --git a/plugin/hooks/lib/symbols.mjs b/plugin/hooks/lib/symbols.mjs index 2a80e1e42..4d80666ac 100644 --- a/plugin/hooks/lib/symbols.mjs +++ b/plugin/hooks/lib/symbols.mjs @@ -50,7 +50,11 @@ const PATTERNS = { /^\s*(?:pub(?:\([^)]*\))?\s+)?(?:struct|enum|trait|impl)\s+([A-Za-z_]\w*)/, ], clike: [ - /^\s*(?:public|private|protected|internal|static|final|abstract|virtual|override|async|\s)*(?:[\w<>\[\],.?]+\s+)([A-Za-z_]\w*)\s*\([^;]*\)\s*\{/, + // Accept both K&R (`Run() {`) and Allman (`Run()` then `{`) declarations. + // Requiring the opening brace on the declaration line silently discarded + // nearly every method in idiomatic C# projects, leaving file nodes but no + // symbol nodes and making valid `file.cs#Method` anchors unresolved. + /^\s*(?:public|private|protected|internal|static|final|abstract|virtual|override|async|sealed|new|extern|unsafe|partial|\s)*(?:[\w<>\[\],.?]+\s+)([A-Za-z_]\w*)(?:<[^;()]+>)?\s*\([^;]*\)\s*(?:\{|=>|$)/, /^\s*(?:public|private|protected|internal|static|abstract|sealed|partial|\s)*(?:class|struct|interface|enum|record)\s+([A-Za-z_]\w*)/, ], ruby: [ diff --git a/plugin/hooks/pretooluse-router.mjs b/plugin/hooks/pretooluse-router.mjs index a2a6561b5..e581d6987 100644 --- a/plugin/hooks/pretooluse-router.mjs +++ b/plugin/hooks/pretooluse-router.mjs @@ -469,7 +469,13 @@ ${nudge}` // Carried through so a substitution can be told apart from a test // fixture later. Without it the balance sheet counted the suite's own // 366 fixture writes as product value. - { sessionId: payload.session_id } + { + sessionId: payload.session_id, + client: episode.client, + clientVersion: episode.clientVersion, + model: episode.model, + modelVersion: episode.modelVersion, + } ); if (substitution) reason = substitution; } diff --git a/scripts/audit-token-savings.mjs b/scripts/audit-token-savings.mjs new file mode 100644 index 000000000..7da3110c2 --- /dev/null +++ b/scripts/audit-token-savings.mjs @@ -0,0 +1,192 @@ +#!/usr/bin/env node +/** + * Read-only audit of the durable token ledger. + * + * This intentionally reports stored claims and verified savings separately. + * It never mutates or migrates the database, so it is safe to run while the + * MCP server and dashboard are active. + */ + +import Database from 'better-sqlite3'; +import { existsSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join, resolve } from 'node:path'; + +const requested = process.argv[2]; +const dbPath = requested + ? resolve(requested) + : process.env.TOKEN_OPTIMIZER_ANALYTICS_DB || + join(homedir(), '.token-optimizer-mcp', 'analytics.db'); + +if (!existsSync(dbPath)) { + throw new Error(`Analytics database not found: ${dbPath}`); +} + +const db = new Database(dbPath, { readonly: true, fileMustExist: true }); +const columns = new Set( + db.prepare('PRAGMA table_info(analytics)').all().map((column) => column.name) +); +const savingsMeasuredSql = columns.has('savings_measured') + ? 'savings_measured' + : '0 AS savings_measured'; +const measurementIdSql = columns.has('measurement_id') + ? 'measurement_id' + : 'NULL AS measurement_id'; + +const rows = db + .prepare( + `SELECT id, tool_name, original_tokens, optimized_tokens, tokens_saved, + ${savingsMeasuredSql}, ${measurementIdSql}, + timestamp, client, metadata + FROM analytics + ORDER BY timestamp DESC` + ) + .all() + .map((row) => { + let metadata = {}; + try { + metadata = row.metadata ? JSON.parse(row.metadata) : {}; + } catch { + metadata = {}; + } + const verified = + Number(row.savings_measured) === 1 && + Number(metadata.measurementSchemaVersion) === 2 && + metadata.measurementClass === 'verified-transport-reduction' && + metadata.baselineKind === 'materialized-undisclosed-mcp-result' && + typeof row.measurement_id === 'string' && + metadata.measurementId === row.measurement_id && + typeof metadata.measurementId === 'string' && + /^[a-f0-9]{16}$/i.test(metadata.disclosureRef || '') && + /^[a-f0-9]{64}$/i.test(metadata.baselineSha256 || '') && + /^[a-f0-9]{64}$/i.test(metadata.returnedSha256 || '') && + metadata.baselineSha256 !== metadata.returnedSha256 && + Number(metadata.baselineBytes) > Number(metadata.returnedBytes) && + Number(metadata.bytesSaved) === + Number(metadata.baselineBytes) - Number(metadata.returnedBytes) && + Number(row.original_tokens) > Number(row.optimized_tokens) && + Number(row.tokens_saved) === + Number(row.original_tokens) - Number(row.optimized_tokens); + const expansionDebit = + Number(metadata.measurementSchemaVersion) === 2 && + metadata.measurementClass === 'verified-transport-expansion-debit' && + typeof row.measurement_id === 'string' && + metadata.measurementId === row.measurement_id && + /^[a-f0-9]{16}$/i.test(metadata.expansionRef || '') && + typeof metadata.creditedMeasurementId === 'string' && + /^[a-f0-9]{64}$/i.test(metadata.returnedSha256 || '') && + Number(metadata.returnedBytes) > 0 && + Number(row.original_tokens) === Number(row.optimized_tokens) && + Number(row.optimized_tokens) > 0 && + Number(row.tokens_saved) === 0; + const observed = + Number(metadata.measurementSchemaVersion) === 2 || + ['actual-return-context-only', 'optimizer-before-actual-return'].includes( + metadata.measurement + ); + const reported = verified || expansionDebit + ? 0 + : Number(metadata.reportedToolSavings?.tokensSaved ?? row.tokens_saved) || + 0; + return { ...row, metadata, verified, expansionDebit, observed, reported }; + }); + +const sum = (items, key) => + items.reduce((total, item) => total + (Number(item[key]) || 0), 0); +const verified = rows.filter((row) => row.verified); +const expansionDebits = rows.filter((row) => row.expansionDebit); +const observed = rows.filter((row) => row.observed); +const unverified = rows.filter((row) => !row.verified && row.reported > 0); +const byTool = new Map(); +for (const row of rows) { + const bucket = byTool.get(row.tool_name) || { + tool: row.tool_name, + operations: 0, + verifiedOperations: 0, + verifiedExpansionOperations: 0, + observedReturnOperations: 0, + verifiedTokensSaved: 0, + unverifiedReportedTokensSaved: 0, + maximumReportedSaving: 0, + verifiedBytesSaved: 0, + }; + bucket.operations += 1; + bucket.verifiedOperations += row.verified ? 1 : 0; + bucket.verifiedExpansionOperations += row.expansionDebit ? 1 : 0; + bucket.observedReturnOperations += row.observed ? 1 : 0; + bucket.verifiedTokensSaved += row.verified ? row.tokens_saved : 0; + bucket.verifiedTokensSaved -= row.expansionDebit ? row.optimized_tokens : 0; + bucket.unverifiedReportedTokensSaved += row.reported; + bucket.maximumReportedSaving = Math.max( + bucket.maximumReportedSaving, + row.reported + ); + bucket.verifiedBytesSaved += row.verified + ? Number(row.metadata.bytesSaved) || 0 + : 0; + byTool.set(row.tool_name, bucket); +} + +const report = { + schemaVersion: 2, + generatedAt: new Date().toISOString(), + database: dbPath, + readOnly: true, + coverage: { + firstSeen: rows.at(-1)?.timestamp || null, + lastSeen: rows[0]?.timestamp || null, + operations: rows.length, + verifiedSavingsOperations: verified.length, + verifiedExpansionOperations: expansionDebits.length, + observedReturnedContextOperations: observed.length, + unverifiedReportedOperations: unverified.length, + }, + totals: { + verifiedTokensSaved: + sum(verified, 'tokens_saved') - sum(expansionDebits, 'optimized_tokens'), + grossVerifiedTokensSaved: sum(verified, 'tokens_saved'), + expansionTokensReturned: sum(expansionDebits, 'optimized_tokens'), + verifiedBytesSaved: verified.reduce( + (total, row) => total + (Number(row.metadata.bytesSaved) || 0), + 0 + ), + observedReturnedTokens: sum(observed, 'optimized_tokens'), + unverifiedReportedTokensSaved: unverified.reduce( + (total, row) => total + row.reported, + 0 + ), + }, + byTool: [...byTool.values()].sort( + (a, b) => + b.verifiedTokensSaved - a.verifiedTokensSaved || + b.unverifiedReportedTokensSaved - a.unverifiedReportedTokensSaved + ), + largestExcludedClaims: unverified + .slice() + .sort((a, b) => b.reported - a.reported) + .slice(0, 20) + .map((row) => ({ + id: row.id, + tool: row.tool_name, + reportedTokensSaved: row.reported, + storedOriginalTokens: row.original_tokens, + storedOptimizedTokens: row.optimized_tokens, + timestamp: row.timestamp, + client: row.client || null, + reason: + row.metadata.measurementSchemaVersion === 2 + ? 'tool-reported estimate lacks a comparable materialized payload' + : 'legacy row lacks versioned measurement provenance', + })), + interpretation: { + verified: + 'Net transport avoided: a materialized payload minus its initial return, less every later linked expansion payload.', + observed: + 'Returned context was measured, but no comparable before payload was reduced.', + unverified: + 'Retained for audit only; excluded from verified totals and cost equivalents.', + }, +}; + +db.close(); +process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); diff --git a/scripts/doctor.mjs b/scripts/doctor.mjs index 1d5c63570..14ccd223c 100644 --- a/scripts/doctor.mjs +++ b/scripts/doctor.mjs @@ -32,13 +32,16 @@ if (hookHealth.total === 0) { } else { console.log( ` ${hookHealth.total} runs; ${hookHealth.failures} failures; ` + - `${hookHealth.timeouts} timeouts; ${hookHealth.skipped} skipped; ` + + `${hookHealth.timeouts} timeouts; ${hookHealth.blocked} policy blocks; ` + + `${hookHealth.abandoned} abandoned; ${hookHealth.skipped} skipped; ` + `p95 ${hookHealth.p95DurationMs ?? 'n/a'} ms.` ); for (const [client, counts] of Object.entries(hookHealth.byClient)) { console.log( ` ${client}: ${counts.total} runs, ${counts.failures} failures, ` + - `${counts.timeouts} timeouts, ${counts.skipped || 0} skipped.` + `${counts.timeouts} timeouts, ${counts.blocked || 0} policy blocks, ` + + `${counts.skipped || 0} skipped; surfaces: ` + + `${counts.hookEvents?.join(', ') || 'unknown'}.` ); } } diff --git a/scripts/run-dashboard-attribution-smoke.mjs b/scripts/run-dashboard-attribution-smoke.mjs new file mode 100644 index 000000000..3eca1940d --- /dev/null +++ b/scripts/run-dashboard-attribution-smoke.mjs @@ -0,0 +1,147 @@ +#!/usr/bin/env node +/** + * Run real MCP calls from several client identities into one local target. + * + * This is deliberately not a database seeder: every row comes through the + * shipped stdio transport, initialize handshake, dispatcher, disclosure policy, + * token counter, and graceful analytics flush. It is useful when verifying the + * dashboard's per-client attribution after an upgrade. + */ + +import { spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const root = resolve(process.argv[2] || process.cwd()); +const target = resolve(process.argv[3] || `${root}/README.md`); +const serverEntry = resolve('dist/server/index.js'); + +if (!existsSync(serverEntry)) + throw new Error(`${serverEntry} is missing; run npm run build first.`); +if (!existsSync(target)) throw new Error(`Target does not exist: ${target}`); + +const clients = [ + { name: 'codex', model: 'gpt-5.6-sol' }, + { name: 'claude-code', model: 'claude-sonnet' }, + { name: 'gemini', model: 'gemini-pro' }, +]; + +async function exercise(client) { + const child = spawn(process.execPath, [serverEntry], { + cwd: root, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + env: { + ...process.env, + TOKEN_OPTIMIZER_MODEL: client.model, + TOKEN_OPTIMIZER_MODEL_VERSION: 'live-dashboard-proof', + }, + }); + child.stdout.setEncoding('utf8'); + let buffer = ''; + let nextId = 1; + const pending = new Map(); + + child.stdout.on('data', (chunk) => { + buffer += chunk; + let newline = buffer.indexOf('\n'); + while (newline !== -1) { + const line = buffer.slice(0, newline).trim(); + buffer = buffer.slice(newline + 1); + if (line) { + try { + const message = JSON.parse(line); + const entry = pending.get(message.id); + if (entry) { + clearTimeout(entry.timer); + pending.delete(message.id); + if (message.error) + entry.reject(new Error(JSON.stringify(message.error))); + else entry.resolve(message.result); + } + } catch { + // Non-protocol diagnostics are ignored; stderr remains visible below. + } + } + newline = buffer.indexOf('\n'); + } + }); + + const call = (method, params = undefined) => { + const id = nextId++; + return new Promise((resolveCall, reject) => { + const timer = setTimeout( + () => + reject(new Error(`${client.name}: timed out waiting for ${method}`)), + 30_000 + ); + pending.set(id, { resolve: resolveCall, reject, timer }); + child.stdin.write( + `${JSON.stringify({ jsonrpc: '2.0', id, method, ...(params === undefined ? {} : { params }) })}\n` + ); + }); + }; + + try { + await call('initialize', { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: client.name, version: 'live-dashboard-proof' }, + }); + child.stdin.write( + `${JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' })}\n` + ); + const knowledge = await call('tools/call', { + name: 'wiki_read', + arguments: { projectRoot: root, limit: 3 }, + }); + const read = await call('tools/call', { + name: 'smart_read', + arguments: { path: target, maxSize: 8_000 }, + }); + const grep = await call('tools/call', { + name: 'smart_grep', + arguments: { + path: target, + pattern: ' ', + limit: 400, + useCache: false, + }, + }); + const knowledgeChars = knowledge?.content?.[0]?.text?.length || 0; + const readChars = read?.content?.[0]?.text?.length || 0; + const grepChars = grep?.content?.[0]?.text?.length || 0; + let expandedChars = 0; + if (client.name === 'codex') { + const ref = /\bexpand\s+([a-f0-9]{16})\b/i.exec( + grep?.content?.[0]?.text || '' + )?.[1]; + if (!ref) + throw new Error( + 'codex: smart_grep preview did not expose an expansion reference' + ); + const expanded = await call('tools/call', { + name: 'expand', + arguments: { ref, reason: 'verify net expansion accounting' }, + }); + expandedChars = expanded?.content?.[0]?.text?.length || 0; + } + process.stdout.write( + `${client.name}: wiki_read ${knowledgeChars} chars; smart_read ${readChars} chars; smart_grep ${grepChars} chars${expandedChars ? `; expand ${expandedChars} chars` : ''}\n` + ); + } finally { + child.stdin.end(); + await new Promise((resolveExit) => { + const timer = setTimeout(() => { + child.kill(); + resolveExit(); + }, 10_000); + child.once('exit', () => { + clearTimeout(timer); + resolveExit(); + }); + }); + } +} + +for (const client of clients) await exercise(client); diff --git a/scripts/verify-live-dashboard.mjs b/scripts/verify-live-dashboard.mjs index 8373f5dc9..113529908 100644 --- a/scripts/verify-live-dashboard.mjs +++ b/scripts/verify-live-dashboard.mjs @@ -2,14 +2,16 @@ /** Read-only Playwright verification against an already running dashboard. */ import { mkdirSync } from 'node:fs'; -import { dirname, join } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { chromium } from 'playwright'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); -const base = process.argv[2] || 'http://localhost:3101'; +const base = process.argv[2] || 'http://localhost:3100'; const output = join(ROOT, 'artifacts', 'live-dashboard'); +const documentationOutput = process.argv[3] ? resolve(process.argv[3]) : null; mkdirSync(output, { recursive: true }); +if (documentationOutput) mkdirSync(documentationOutput, { recursive: true }); const browser = await chromium.launch(); const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); @@ -21,6 +23,38 @@ page.on('pageerror', (error) => errors.push(`page:${error.message}`)); page.on('requestfailed', (request) => errors.push(`request:${request.url()}:${request.failure()?.errorText}`) ); +page.on('response', (response) => { + if (response.url().includes('/api/') && !response.ok()) { + errors.push(`response:${response.status()}:${response.url()}`); + } +}); + +async function waitForCount(selector, minimum, label) { + try { + await page.waitForFunction( + ({ selector: query, minimum: expected }) => + document.querySelectorAll(query).length >= expected, + { selector, minimum }, + { timeout: 30_000 } + ); + } catch (error) { + const diagnostic = await page.evaluate( + (query) => ({ + url: location.href, + count: document.querySelectorAll(query).length, + status: + document.querySelector('#hook-health-status')?.textContent?.trim() || + null, + state: + document.querySelector('#hook-health-status')?.dataset.state || null, + }), + selector + ); + throw new Error(`${label} did not render: ${JSON.stringify(diagnostic)}`, { + cause: error, + }); + } +} async function measureCameraStability(host) { return host.evaluate(async (element) => { @@ -52,7 +86,32 @@ async function measureCameraStability(host) { } try { - await page.goto(base, { waitUntil: 'networkidle' }); + await page.goto(base, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction( + () => { + const saved = document.querySelector('#saved-tokens'); + return ( + ['measured', 'collecting', 'not-measured'].includes( + saved?.dataset.state + ) && + document.querySelectorAll('#accounting-grid .accounting-card') + .length === 7 && + document.querySelectorAll('#client-ledger .client-ledger-row').length > + 0 + ); + }, + undefined, + { timeout: 20_000 } + ); + await page.waitForFunction( + () => + document.querySelectorAll('#provider-accounting-grid .accounting-card') + .length === 6, + undefined, + { timeout: 60_000 } + ); + await page.waitForSelector('#constellation canvas', { timeout: 30_000 }); + await page.waitForTimeout(1_100); const overviewBody = await page.locator('body').innerText(); const overview = { title: await page.title(), @@ -65,45 +124,140 @@ try { staleLegacySession: /november 2025|2025-11-02/i.test(overviewBody), activityCards: await page.locator('#kpis .kpi').evaluateAll((cards) => cards.map((card) => ({ - label: card.querySelector('.kpi-label')?.childNodes[0]?.textContent?.trim() || '', + label: + card + .querySelector('.kpi-label') + ?.childNodes[0]?.textContent?.trim() || '', value: card.querySelector('.kpi-value')?.textContent?.trim() || '', })) ), - cameraStability: await measureCameraStability(page.locator('#constellation')), + savedTokens: await page.locator('#saved-tokens').innerText(), + savedMoney: await page.locator('#saved-money').innerText(), + accessibility: { + savings: await page.locator('#saved-card').ariaSnapshot(), + accounting: await page.locator('#token-accounting').ariaSnapshot(), + agents: await page.locator('#measured-by-agent').ariaSnapshot(), + }, + accountingCards: await page + .locator('#accounting-grid .accounting-card') + .evaluateAll((cards) => + cards.map((card) => ({ + label: + card.querySelector('.accounting-label')?.textContent?.trim() || '', + value: + card.querySelector('.accounting-value')?.textContent?.trim() || '', + detail: + card.querySelector('.accounting-detail')?.textContent?.trim() || '', + })) + ), + providerCards: await page + .locator('#provider-accounting-grid .accounting-card') + .evaluateAll((cards) => + cards.map((card) => ({ + label: + card.querySelector('.accounting-label')?.textContent?.trim() || '', + value: + card.querySelector('.accounting-value')?.textContent?.trim() || '', + detail: + card.querySelector('.accounting-detail')?.textContent?.trim() || '', + })) + ), + providerRows: await page + .locator('#provider-breakdown-body tr') + .evaluateAll((rows) => + rows.map((row) => + [...row.querySelectorAll('td')].map( + (cell) => cell.textContent?.trim() || '' + ) + ) + ), + providerCoverage: await page + .locator('#provider-price-coverage') + .innerText(), + clientRows: await page + .locator('#client-ledger .client-ledger-row') + .evaluateAll((rows) => rows.map((row) => row.textContent?.trim() || '')), + recentRows: await page + .locator('#timeline-container .event') + .evaluateAll((rows) => rows.map((row) => row.textContent?.trim() || '')), + actionRows: await page + .locator('#tool-breakdown-body tr') + .evaluateAll((rows) => + rows.map((row) => + [...row.querySelectorAll('td')].map( + (cell) => cell.textContent?.trim() || '' + ) + ) + ), + cameraStability: await measureCameraStability( + page.locator('#constellation') + ), }; await page.screenshot({ path: join(output, 'overview.png'), fullPage: true, }); + if (documentationOutput) { + await page.screenshot({ + path: join(documentationOutput, 'overview-live.png'), + fullPage: true, + }); + await page.locator('.client-accounting').screenshot({ + path: join(documentationOutput, 'agent-accounting-live.png'), + }); + } await page.goto(`${base}/wiki`, { waitUntil: 'networkidle' }); await page.waitForSelector('#wiki-list li'); - await page.waitForFunction( - () => document.querySelectorAll('#hook-health-grid .stat-card').length === 6 + await waitForCount( + '#hook-health-grid .stat-card', + 14, + 'runtime health cards' ); const captureHealth = await page.evaluate(() => { - const values = [...document.querySelectorAll('#hook-health-grid .stat-card')] - .map((card) => ({ - label: card.querySelector('.stat-label')?.textContent?.trim() || '', - value: card.querySelector('.stat-value')?.textContent?.trim() || '', - })); + const values = [ + ...document.querySelectorAll('#hook-health-grid .stat-card'), + ].map((card) => ({ + label: card.querySelector('.stat-label')?.textContent?.trim() || '', + value: card.querySelector('.stat-value')?.textContent?.trim() || '', + })); return { values, - status: document.querySelector('#hook-health-status')?.textContent?.trim() || '', + status: + document.querySelector('#hook-health-status')?.textContent?.trim() || + '', state: document.querySelector('#hook-health-status')?.dataset.state || '', - detail: document.querySelector('#hook-health-detail')?.textContent?.trim() || '', + detail: + document.querySelector('#hook-health-detail')?.textContent?.trim() || + '', + clients: [ + ...document.querySelectorAll('#hook-health-clients .capture-client'), + ].map((card) => card.textContent?.trim() || ''), }; }); await page.screenshot({ path: join(output, 'wiki-capture-health.png'), fullPage: true, }); + if (documentationOutput) { + await page + .locator('section.wiki-balance') + .nth(0) + .screenshot({ + path: join(documentationOutput, 'capture-health-live.png'), + }); + await page + .locator('section.wiki-balance') + .nth(1) + .screenshot({ + path: join(documentationOutput, 'graph-balance-live.png'), + }); + } const host = page.locator('#wiki-graph-3d'); const graph3d = await host.evaluate((element) => ({ width: element.clientWidth, height: element.clientHeight, - projected: - element.__knowledgeGraph3d?.projectedNodes?.().length ?? null, + projected: element.__knowledgeGraph3d?.projectedNodes?.().length ?? null, selected: element.dataset.selected || null, })); const cameraStability = await measureCameraStability(host); @@ -112,15 +266,17 @@ try { await page.locator('#wiki-list li').first().click(); await page.waitForTimeout(500); const afterSelect = await host.evaluate((element) => ({ - projected: - element.__knowledgeGraph3d?.projectedNodes?.().length ?? null, + projected: element.__knowledgeGraph3d?.projectedNodes?.().length ?? null, selected: element.dataset.selected || null, })); const detailVisible = await page.locator('#wiki-detail').isVisible(); + if (documentationOutput) { + await page.locator('#tab-explore').screenshot({ + path: join(documentationOutput, 'graph-explorer-live.png'), + }); + } await page.locator('.wiki-tab[data-tab="evidence"]').click(); - await page.waitForFunction( - () => document.querySelectorAll('#evidence-summary .stat-card').length === 6 - ); + await waitForCount('#evidence-summary .stat-card', 6, 'evidence cards'); await page.waitForFunction( () => document.querySelectorAll('#ucr-missing tbody tr').length > 0 ); @@ -131,7 +287,8 @@ try { value: card.querySelector('.stat-value')?.textContent?.trim() || '', }) ), - status: document.querySelector('#evidence-status')?.textContent?.trim() || '', + status: + document.querySelector('#evidence-status')?.textContent?.trim() || '', ucrSummary: [...document.querySelectorAll('#ucr-summary .stat-card')].map( (card) => ({ label: card.querySelector('.stat-label')?.textContent?.trim() || '', @@ -144,6 +301,11 @@ try { path: join(output, 'wiki-evidence.png'), fullPage: true, }); + if (documentationOutput) { + await page.locator('#tab-evidence').screenshot({ + path: join(documentationOutput, 'evidence-console-live.png'), + }); + } const wiki = { listCount: await page.locator('#wiki-list li').count(), scopeOptions: await page.locator('#wiki-scope option').count(), @@ -164,12 +326,26 @@ try { missingProjects: inventory.missing, }; }), - balanceCards: await page.locator('#balance-grid .stat-card').evaluateAll((cards) => - cards.map((card) => ({ - label: card.querySelector('.stat-label')?.textContent?.trim() || '', - value: card.querySelector('.stat-value')?.textContent?.trim() || '', - })) - ), + balanceCards: await page + .locator('#balance-grid .stat-card') + .evaluateAll((cards) => + cards.map((card) => ({ + label: card.querySelector('.stat-label')?.textContent?.trim() || '', + value: card.querySelector('.stat-value')?.textContent?.trim() || '', + })) + ), + balanceContract: await page.evaluate(async () => { + const report = await fetch('/api/wiki/balance?scope=all').then( + (response) => response.json() + ); + return { + coverage: report.measurement?.sourceCoverage || null, + freshness: report.measurement?.freshness || null, + metrics: report.measurement?.metrics || null, + method: + document.querySelector('#balance-method')?.textContent?.trim() || '', + }; + }), captureHealth, graph3d, cameraStability, @@ -177,7 +353,9 @@ try { evidence, detailVisible, drawerOverlap: await page.evaluate(() => { - const drawer = document.querySelector('#wiki-detail')?.getBoundingClientRect(); + const drawer = document + .querySelector('#wiki-detail') + ?.getBoundingClientRect(); if (!drawer) return null; const selectors = [ '#hook-health-grid .stat-card', @@ -206,7 +384,48 @@ try { !overview.staleLegacySession && overview.activityCards.length === 4 && overview.activityCards.some( - ({ label, value }) => label === 'Lifecycle events' && Number(value.replaceAll(',', '')) > 0 + ({ label, value }) => + label === 'Lifecycle events' && Number(value.replaceAll(',', '')) > 0 + ) && + /^(?:[\d,]+|—)$/.test(overview.savedTokens) && + /^(?:Not priced|\$[\d,.]+(?: · \d+\/\d+ ops)?)$/.test( + overview.savedMoney + ) && + overview.accessibility.savings.includes(overview.savedTokens) && + /Returned context/i.test(overview.accessibility.accounting) && + /codex|claude-code|gemini/i.test(overview.accessibility.agents) && + overview.accountingCards.length === 7 && + overview.providerCards.length === 6 && + overview.providerCards.some( + ({ label, value }) => + label === 'Cache reads' && value !== 'Not measured' && value !== '0' + ) && + overview.providerCards.some( + ({ label, value, detail }) => + label === 'API-price equivalent' && + /^\$/.test(value) && + /not necessarily your invoice/i.test(detail) + ) && + /% price coverage/.test(overview.providerCoverage) && + overview.providerRows.some( + (row) => + row.length === 7 && + /codex|claude-code|gemini|copilot|qwen|opencode/i.test(row[0]) + ) && + overview.accountingCards.some( + ({ label, value, detail }) => + label === 'Returned context' && + value !== 'Not measured' && + /Not priced|\$/.test(detail) + ) && + overview.clientRows.length >= 2 && + overview.recentRows.some( + (row) => + /Not priced|\$|excluded/i.test(row) && + /saved|baseline|excluded/i.test(row) + ) && + overview.actionRows.some( + (row) => row.length === 6 && /Not priced|^\$/.test(row[3]) ) && overview.cameraStability.widthSpread <= 1 && overview.cameraStability.heightSpread <= 1 && @@ -218,12 +437,52 @@ try { /contain graph data/i.test(wiki.coverage) && wiki.aggregateCoverage.capturedProjects >= 2 && wiki.aggregateCoverage.nodes > wiki.aggregateCoverage.largestProjectNodes && - wiki.aggregateCoverage.missingProjects >= 1 && - wiki.balanceCards.length === 4 && + // Missing-source reporting must not turn complete capture into a failure. + // The coverage assertion above accepts both the partial and complete-copy; + // this field only needs to remain a valid non-negative count. + wiki.aggregateCoverage.missingProjects >= 0 && + wiki.balanceCards.length === 8 && wiki.balanceCards.every(({ value }) => value.length > 0 && value !== '—') && - wiki.captureHealth.values.length === 6 && + wiki.balanceContract.coverage?.projects >= 1 && + wiki.balanceContract.coverage?.projectsWithTelemetry >= 1 && + wiki.balanceContract.freshness?.lastEventAt && + [ + 'nativeSubstitutions', + 'memoryDeliveries', + 'memoryHoldouts', + 'rememberingCost', + 'readingAvoided', + ].every((key) => wiki.balanceContract.metrics?.[key]?.source) && + /selected projects have telemetry/i.test(wiki.balanceContract.method) && + wiki.captureHealth.values.length === 14 && + wiki.captureHealth.clients.length >= 2 && + wiki.captureHealth.clients.every((client) => + /Activity|Runtime|Control|Coverage/.test(client) + ) && + wiki.captureHealth.detail.length < 180 && + wiki.captureHealth.values.some( + ({ label, value }) => + label === 'Hook runs' && Number(value.replaceAll(',', '')) > 0 + ) && + wiki.captureHealth.values.some( + ({ label, value }) => + label === 'Policy blocks' && Number(value.replaceAll(',', '')) >= 0 + ) && + wiki.captureHealth.values.some( + ({ label, value }) => + label === 'Abandoned' && Number(value.replaceAll(',', '')) === 0 + ) && + wiki.captureHealth.values.some( + ({ label, value }) => + label === 'MCP handshakes' && Number(value.replaceAll(',', '')) > 0 + ) && wiki.captureHealth.values.some( - ({ label, value }) => label === 'Hook runs' && Number(value.replaceAll(',', '')) > 0 + ({ label, value }) => + label === 'Tools advertised' && Number(value.replaceAll(',', '')) > 0 + ) && + wiki.captureHealth.values.some( + ({ label, value }) => + label === 'MCP failures' && Number(value.replaceAll(',', '')) === 0 ) && wiki.captureHealth.state === 'ok' && wiki.graph3d.width > 0 && @@ -237,7 +496,9 @@ try { wiki.afterSelect.projected > 0 && Boolean(wiki.afterSelect.selected) && wiki.evidence.summary.length === 6 && - wiki.evidence.summary.every(({ value }) => value.length > 0 && value !== '—') && + wiki.evidence.summary.every( + ({ value }) => value.length > 0 && value !== '—' + ) && wiki.evidence.summary .filter(({ label }) => ['Randomized runs', 'Handoff runs', 'Concurrency runs'].includes(label) @@ -245,7 +506,8 @@ try { .every(({ value }) => value === 'Not run') && /selected projects have evidence events/i.test(wiki.evidence.status) && wiki.evidence.ucrSummary.some( - ({ label, value }) => label === 'UCR runtime events' && value === 'Not exercised' + ({ label, value }) => + label === 'UCR runtime events' && value === 'Not exercised' ) && wiki.evidence.missingMetrics > 0 && wiki.detailVisible && diff --git a/scripts/verify-wiki-ui.mjs b/scripts/verify-wiki-ui.mjs index 534ff257a..edc8eeca6 100644 --- a/scripts/verify-wiki-ui.mjs +++ b/scripts/verify-wiki-ui.mjs @@ -62,6 +62,8 @@ process.env.TOKEN_OPTIMIZER_SHARED_DIR = GRAPH; process.env.TOKEN_OPTIMIZER_PROJECT_REGISTRY = join(GRAPH, 'projects.jsonl'); const UCR = mkdtempSync(join(tmpdir(), 'wiki-ui-ucr-')); process.env.TOKEN_OPTIMIZER_UCR_DIR = UCR; +const ANALYTICS = mkdtempSync(join(tmpdir(), 'wiki-ui-analytics-')); +process.env.TOKEN_OPTIMIZER_ANALYTICS_DB = join(ANALYTICS, 'analytics.db'); const results = []; const check = (name, pass, detail = '') => { @@ -204,6 +206,15 @@ async function seed() { downstream: 2400, }); record(GRAPH, { kind: 'harvest', tokens: 800 }); + record(GRAPH, { + kind: 'substitute', + anchor: join(src, 'auth.ts'), + holdout: false, + tokens: 100, + tokensNetAvoided: 900, + client: 'claude-code', + at: Date.now(), + }); for (let pair = 1; pair <= 5; pair++) { for (const [arm, totalTokens, toolCalls] of [ ['baseline', 1000, 10], @@ -393,8 +404,8 @@ async function main() { els.map((e) => e.textContent.trim()) ); check( - 'balance renders four figures', - tiles.length === 4, + 'balance renders direct and causal token accounting', + tiles.length === 8 && tiles.some((value) => /900 tokens/.test(value)), tiles.join(' | ') ); @@ -879,6 +890,12 @@ async function main() { body: JSON.stringify({ summary: { available: false }, events: [] }), }) ); + await overview.route('**/api/analytics/overview*', (route) => + route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ available: false }), + }) + ); await overview.route('**/api/session-summary*', (route) => route.fulfill({ contentType: 'application/json', @@ -926,7 +943,7 @@ async function main() { const overviewText = await overview.textContent('body'); check( 'overview is provider and CLI neutral', - !/claude(?: code)?/i.test(overviewText || '') + !/what claude did/i.test(overviewText || '') ); const eventText = await overview.locator('.event').first().textContent(); check( @@ -967,6 +984,283 @@ async function main() { }); await overview.close(); + // Preferred path: all successful MCP operations report actual returned + // context, while only rows with a real before-state claim savings. Native + // graph substitutions join the same presentation without being confused + // with the separately gated causal graph-effect estimate. + const measuredOverview = await browser.newPage({ + viewport: { width: 1440, height: 1000 }, + }); + measuredOverview.on('console', (message) => { + if (message.type() === 'error') + consoleErrors.push(`measured overview: ${message.text()}`); + }); + measuredOverview.on('pageerror', (error) => + consoleErrors.push(`measured overview: ${error.message}`) + ); + await measuredOverview.route('**/api/diagnostics/hooks*', async (route) => { + await new Promise((resolve) => setTimeout(resolve, 2_500)); + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + summary: { + available: true, + total: 12, + actions: 6, + activeClients: 3, + p95DurationMs: 14, + successRate: 1, + byTool: { smart_read: 2, wiki_read: 1 }, + byClient: { + codex: { total: 5 }, + 'claude-code': { total: 4 }, + gemini: { total: 3 }, + }, + }, + events: [], + }), + }); + }); + await measuredOverview.route('**/api/analytics/overview*', (route) => + route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + schemaVersion: 2, + available: true, + source: 'browser fixture: actual returned context', + pricing: { + available: false, + effectiveInputUsdPerMillion: null, + source: 'unavailable', + explanation: 'browser fixture: billing is not observable', + }, + summary: { + totalOperations: 3, + totalOriginalTokens: 1400, + totalOptimizedTokens: 500, + measuredOptimizedTokens: 350, + totalTokensSaved: 1050, + unverifiedReportedTokensSaved: 0, + savingsPercentage: 75, + contextUsd: null, + savedUsd: null, + measuredSavingsOperations: 2, + unmeasuredSavingsOperations: 1, + actualReturnedContextOperations: 3, + legacyReportedContextOperations: 0, + observedReturnedContextOperations: 3, + unverifiedReportedOperations: 0, + }, + byAction: [ + { + name: 'smart_read', + totalOperations: 2, + totalOriginalTokens: 1400, + totalOptimizedTokens: 350, + totalTokensSaved: 1050, + unverifiedReportedTokensSaved: 0, + observedReturnedContextOperations: 2, + unverifiedReportedOperations: 0, + contextUsd: null, + savedUsd: null, + }, + { + name: 'wiki_read', + totalOperations: 1, + totalOriginalTokens: 0, + totalOptimizedTokens: 150, + totalTokensSaved: 0, + unverifiedReportedTokensSaved: 0, + observedReturnedContextOperations: 1, + unverifiedReportedOperations: 0, + contextUsd: null, + savedUsd: null, + }, + ], + byClient: [ + { + name: 'codex', + attribution: 'recorded', + totalOperations: 2, + observedReturnedContextOperations: 2, + verifiedSavingsOperations: 2, + unverifiedReportedOperations: 0, + totalOptimizedTokens: 350, + totalTokensSaved: 1050, + unverifiedReportedTokensSaved: 0, + contextUsd: null, + savedUsd: null, + }, + { + name: 'gemini', + attribution: 'recorded', + totalOperations: 1, + observedReturnedContextOperations: 1, + verifiedSavingsOperations: 0, + unverifiedReportedOperations: 0, + totalOptimizedTokens: 150, + totalTokensSaved: null, + unverifiedReportedTokensSaved: 0, + contextUsd: null, + savedUsd: null, + }, + { + name: 'claude-code', + attribution: 'recorded', + totalOperations: 1, + observedReturnedContextOperations: 1, + verifiedSavingsOperations: 0, + unverifiedReportedOperations: 0, + totalOptimizedTokens: 90, + totalTokensSaved: null, + unverifiedReportedTokensSaved: 0, + contextUsd: null, + savedUsd: null, + }, + ], + recent: [ + { + name: 'wiki_read', + originalTokens: 150, + optimizedTokens: 150, + tokensSaved: 0, + reportedTokensSaved: 0, + savingsMeasured: false, + classification: 'observed-return-only', + contextUsd: null, + savedUsd: null, + timestamp: '2026-08-12T15:02:00Z', + }, + { + name: 'smart_read', + originalTokens: 1000, + optimizedTokens: 250, + tokensSaved: 750, + reportedTokensSaved: 0, + savingsMeasured: true, + classification: 'verified-transport-reduction', + contextUsd: null, + savedUsd: null, + timestamp: '2026-08-12T15:01:00Z', + }, + ], + measurement: { + tokenCountMethod: 'browser fixture tokenizer estimate', + }, + }), + }) + ); + await measuredOverview.route('**/api/session-summary*', (route) => + route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ success: false }), + }) + ); + await measuredOverview.route('**/api/session-events*', (route) => + route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ success: true, events: [] }), + }) + ); + // Reproduce the live failure from port 3100: all-project graph endpoints + // can be cold while the small analytics ledger is already available. The + // primary token answer must paint without waiting for those requests. + for (const pattern of [ + '**/api/wiki/balance*', + '**/api/wiki/status*', + '**/api/wiki/constellation*', + ]) { + await measuredOverview.route(pattern, async (route) => { + await new Promise((resolve) => setTimeout(resolve, 2_500)); + await route.continue(); + }); + } + const overviewStartedAt = Date.now(); + await measuredOverview.goto(`${BASE}/`, { waitUntil: 'domcontentloaded' }); + await measuredOverview.waitForFunction( + () => + document.querySelector('#saved-tokens')?.dataset.state === 'measured' && + Number( + document + .querySelector('#saved-tokens') + ?.textContent?.replaceAll(',', '') + ) === 1050 && + document.querySelectorAll('#accounting-grid .accounting-card') + .length === 7 && + document.querySelectorAll('#client-ledger .client-ledger-row').length > + 0, + undefined, + { timeout: 2_000 } + ); + const optimizerPaintMs = Date.now() - overviewStartedAt; + check( + 'optimizer accounting renders before delayed graph APIs', + optimizerPaintMs < 2_500 && + (await measuredOverview.getAttribute('#saved-card', 'aria-busy')) === + 'false' && + /1,050/.test( + await measuredOverview.locator('#saved-card').ariaSnapshot() + ), + `${optimizerPaintMs} ms` + ); + await measuredOverview.waitForFunction( + () => + Number( + document + .querySelector('#saved-tokens') + ?.textContent?.replaceAll(',', '') + ) === 1050, + undefined, + { timeout: 10_000 } + ); + await measuredOverview.waitForTimeout(1_100); + const measuredBody = await measuredOverview.textContent('body'); + check( + 'combined overview keeps modeled graph savings out of the verified headline', + Number( + (await measuredOverview.textContent('#saved-tokens'))?.replaceAll( + ',', + '' + ) || 0 + ) === 1050 && + /modeled graph substitutions remain separate/i.test(measuredBody || '') + ); + check( + 'combined overview attributes optimizer metrics across clients', + /codex/i.test(measuredBody || '') && + /claude-code/i.test(measuredBody || '') && + /gemini/i.test(measuredBody || '') + ); + const recentRows = await measuredOverview + .locator('#timeline-container .event') + .allTextContents(); + check( + 'recent activity marks unpriced context and refuses fake zero savings', + recentRows.some( + (row) => /wiki_read/i.test(row) && /no before baseline/i.test(row) + ) && + recentRows.some( + (row) => /smart_read/i.test(row) && /750 saved/i.test(row) + ) && + !recentRows.some( + (row) => /wiki_read/i.test(row) && /0 saved/i.test(row) + ), + recentRows.join(' | ') + ); + check( + 'every action exposes returned context, verified savings, and pricing absence', + (await measuredOverview.locator('#tool-breakdown-body tr').count()) === + 2 && + /Not priced/.test( + (await measuredOverview.textContent('#tool-breakdown-body')) || '' + ) + ); + await measuredOverview.screenshot({ + path: join(SHOTS, 'overview-combined-accounting.png'), + fullPage: true, + }); + await measuredOverview.close(); + check( 'no console or page errors', consoleErrors.length === 0, @@ -977,6 +1271,7 @@ async function main() { server.kill(); rmSync(GRAPH, { recursive: true, force: true }); rmSync(UCR, { recursive: true, force: true }); + rmSync(ANALYTICS, { recursive: true, force: true }); } const failed = results.filter((r) => !r.pass); diff --git a/src/analysis/project-analyzer.ts b/src/analysis/project-analyzer.ts index 526565592..517accd3e 100644 --- a/src/analysis/project-analyzer.ts +++ b/src/analysis/project-analyzer.ts @@ -12,7 +12,7 @@ export interface ProjectAnalysisOptions { projectPath: string; startDate?: string; endDate?: string; - costPerMillionTokens?: number; // Default: OpenAI GPT-4 pricing + costPerMillionTokens?: number; } export interface SessionSummary { @@ -56,17 +56,17 @@ export interface ProjectAnalysisResult { percentOfTotal: number; }[]; costEstimation: { - totalCost: number; - averageCostPerSession: number; + totalCost: number | null; + averageCostPerSession: number | null; currency: string; model: string; - costPerMillionTokens: number; + costPerMillionTokens: number | null; + source: 'configured-effective-rate' | 'unavailable'; + explanation: string; }; recommendations: string[]; } -const DEFAULT_COST_PER_MILLION = 30; // GPT-4 Turbo pricing (USD) - /** * Discover session logs in the hooks data directory. * @@ -460,12 +460,7 @@ function generateProjectRecommendations( export async function analyzeProjectTokens( options: ProjectAnalysisOptions ): Promise { - const { - projectPath, - startDate, - endDate, - costPerMillionTokens = DEFAULT_COST_PER_MILLION, - } = options; + const { projectPath, startDate, endDate, costPerMillionTokens } = options; // Discover all session files const hooksDataPath = path.join( @@ -589,10 +584,23 @@ export async function analyzeProjectTokens( // Analyze server attribution using cached parsed data const serverBreakdown = analyzeServerAttribution(parsedSessions, totalTokens); - // Calculate cost estimation - const totalCost = (totalTokens / 1000000) * costPerMillionTokens; + // A project log does not identify the final provider route, model, cache mix, + // plan, tier, or credits. Only price the observed tokens when the caller has + // explicitly supplied an effective rate that already accounts for those. + const configuredRate = + typeof costPerMillionTokens === 'number' && + Number.isFinite(costPerMillionTokens) && + costPerMillionTokens >= 0 + ? costPerMillionTokens + : null; + const totalCost = + configuredRate == null ? null : (totalTokens / 1_000_000) * configuredRate; const averageCostPerSession = - sessions.length > 0 ? totalCost / sessions.length : 0; + totalCost == null + ? null + : sessions.length > 0 + ? totalCost / sessions.length + : 0; // Generate recommendations const recommendations = generateProjectRecommendations( @@ -634,11 +642,23 @@ export async function analyzeProjectTokens( topTools, serverBreakdown, costEstimation: { - totalCost: parseFloat(totalCost.toFixed(2)), - averageCostPerSession: parseFloat(averageCostPerSession.toFixed(2)), + totalCost: totalCost == null ? null : parseFloat(totalCost.toFixed(2)), + averageCostPerSession: + averageCostPerSession == null + ? null + : parseFloat(averageCostPerSession.toFixed(2)), currency: 'USD', - model: 'GPT-4 Turbo', - costPerMillionTokens, + model: + configuredRate == null + ? 'Not priced' + : 'Configured effective input rate', + costPerMillionTokens: configuredRate, + source: + configuredRate == null ? 'unavailable' : 'configured-effective-rate', + explanation: + configuredRate == null + ? 'No provider price assumed; supply costPerMillionTokens to show a cost equivalent.' + : 'Configured cost equivalent; not a provider invoice.', }, recommendations, }; diff --git a/src/analysis/report-generator.ts b/src/analysis/report-generator.ts index 6a8a6b486..b37934fd1 100644 --- a/src/analysis/report-generator.ts +++ b/src/analysis/report-generator.ts @@ -730,9 +730,15 @@ function generateProjectMarkdownReport( md += `- **Average Tokens/Operation:** ${analysis.summary.averageTokensPerOperation.toLocaleString()}\n\n`; md += `## Cost Estimation\n\n`; - md += `- **Total Cost:** $${analysis.costEstimation.totalCost.toFixed(2)} ${analysis.costEstimation.currency}\n`; - md += `- **Average Cost/Session:** $${analysis.costEstimation.averageCostPerSession.toFixed(2)}\n`; - md += `- **Pricing Model:** ${analysis.costEstimation.model} ($${analysis.costEstimation.costPerMillionTokens}/M tokens)\n\n`; + if (analysis.costEstimation.totalCost == null) { + md += `- **Total Cost:** Not priced\n`; + md += `- **Reason:** ${analysis.costEstimation.explanation}\n\n`; + } else { + md += `- **Total Cost Equivalent:** $${analysis.costEstimation.totalCost.toFixed(2)} ${analysis.costEstimation.currency}\n`; + md += `- **Average Cost Equivalent/Session:** $${analysis.costEstimation.averageCostPerSession?.toFixed(2)}\n`; + md += `- **Rate:** ${analysis.costEstimation.model} ($${analysis.costEstimation.costPerMillionTokens}/M tokens)\n`; + md += `- **Caveat:** ${analysis.costEstimation.explanation}\n\n`; + } md += `## Top Contributing Sessions\n\n`; md += `| Session ID | Total Tokens | Duration | Top Tool |\n`; @@ -993,12 +999,12 @@ function generateProjectHTMLReport(analysis: ProjectAnalysisResult): string {
Avg Tokens/Session
-
$${analysis.costEstimation.totalCost.toFixed(2)}
-
Total Cost (${analysis.costEstimation.model})
+
${analysis.costEstimation.totalCost == null ? 'Not priced' : `$${analysis.costEstimation.totalCost.toFixed(2)}`}
+
${analysis.costEstimation.totalCost == null ? 'Cost unavailable' : 'Total cost equivalent'}
-
$${analysis.costEstimation.averageCostPerSession.toFixed(2)}
-
Avg Cost/Session
+
${analysis.costEstimation.averageCostPerSession == null ? 'Not priced' : `$${analysis.costEstimation.averageCostPerSession.toFixed(2)}`}
+
${analysis.costEstimation.averageCostPerSession == null ? 'Set an effective rate' : 'Avg cost equivalent/session'}
@@ -1177,7 +1183,9 @@ ${escapeJsonForScript(JSON.stringify(analysis))} md += '## Summary\\n'; md += '- Total Sessions: ' + analysis.summary.totalSessions + '\\n'; md += '- Total Tokens: ' + analysis.summary.totalTokens.toLocaleString() + '\\n'; - md += '- Total Cost: $' + analysis.costEstimation.totalCost.toFixed(2) + '\\n\\n'; + md += analysis.costEstimation.totalCost == null + ? '- Total Cost: Not priced\\n\\n' + : '- Total Cost Equivalent: $' + analysis.costEstimation.totalCost.toFixed(2) + '\\n\\n'; md += '## Top Tools\\n\\n'; analysis.topTools.forEach(function(t, i) { md += (i + 1) + '. **' + t.toolName + '**: ' + t.totalTokens.toLocaleString() + ' tokens\\n'; diff --git a/src/analytics/analytics-manager.ts b/src/analytics/analytics-manager.ts index b854ed6df..92a2eee4a 100644 --- a/src/analytics/analytics-manager.ts +++ b/src/analytics/analytics-manager.ts @@ -12,6 +12,11 @@ import type { HookPhase, } from './analytics-types.js'; import { SqliteAnalyticsStorage } from './analytics-storage.js'; +import { + hasObservedReturnedContext, + isVerifiedSavingsEntry, + verifiedTransportDelta, +} from './savings-classification.js'; /** * Manager for tracking and analyzing token usage @@ -294,15 +299,20 @@ export class AnalyticsManager { entries: AnalyticsEntry[] ): AggregatedStats { const totalOperations = entries.length; - const totalOriginalTokens = entries.reduce( + const savingsEntries = entries.filter(isVerifiedSavingsEntry); + const returnedEntries = entries.filter(hasObservedReturnedContext); + const totalOriginalTokens = savingsEntries.reduce( (sum, e) => sum + e.originalTokens, 0 ); - const totalOptimizedTokens = entries.reduce( + const totalOptimizedTokens = returnedEntries.reduce( (sum, e) => sum + e.optimizedTokens, 0 ); - const totalTokensSaved = entries.reduce((sum, e) => sum + e.tokensSaved, 0); + const totalTokensSaved = entries.reduce( + (sum, e) => sum + verifiedTransportDelta(e), + 0 + ); const averageTokensSaved = totalTokensSaved / totalOperations; const savingsPercentage = totalOriginalTokens > 0 diff --git a/src/analytics/analytics-storage.ts b/src/analytics/analytics-storage.ts index 260ef8dc4..14ac254e1 100644 --- a/src/analytics/analytics-storage.ts +++ b/src/analytics/analytics-storage.ts @@ -25,7 +25,8 @@ export class SqliteAnalyticsStorage implements AnalyticsStorage { '.token-optimizer-mcp', 'analytics.db' ); - const finalPath = dbPath || defaultPath; + const finalPath = + dbPath || process.env.TOKEN_OPTIMIZER_ANALYTICS_DB || defaultPath; // Ensure directory exists const dir = path.dirname(finalPath); @@ -62,6 +63,35 @@ export class SqliteAnalyticsStorage implements AnalyticsStorage { CREATE INDEX IF NOT EXISTS idx_timestamp ON analytics(timestamp); CREATE INDEX IF NOT EXISTS idx_session_id ON analytics(session_id); `); + + const columns = new Set( + ( + this.db.prepare('PRAGMA table_info(analytics)').all() as Array<{ + name: string; + }> + ).map((column) => column.name) + ); + for (const [name, definition] of [ + ['client', 'TEXT'], + ['client_version', 'TEXT'], + ['model', 'TEXT'], + ['model_version', 'TEXT'], + ['measurement_id', 'TEXT'], + // A pre-existing row has no provenance proving that its two token fields + // are comparable. Defaulting this column to true silently certified every + // historical estimate during migration. New writers opt in explicitly. + ['savings_measured', 'INTEGER NOT NULL DEFAULT 0'], + ]) { + if (!columns.has(name)) + this.db.exec(`ALTER TABLE analytics ADD COLUMN ${name} ${definition}`); + } + this.db.exec(` + CREATE INDEX IF NOT EXISTS idx_client ON analytics(client); + CREATE INDEX IF NOT EXISTS idx_model ON analytics(model); + CREATE UNIQUE INDEX IF NOT EXISTS idx_measurement_id + ON analytics(measurement_id) + WHERE measurement_id LIKE 'mcp:%'; + `); } /** @@ -89,8 +119,11 @@ export class SqliteAnalyticsStorage implements AnalyticsStorage { INSERT INTO analytics ( hook_phase, tool_name, mcp_server, original_tokens, optimized_tokens, tokens_saved, - timestamp, session_id, metadata - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + timestamp, session_id, metadata, + client, client_version, model, model_version, savings_measured, + measurement_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT DO NOTHING `); const insertMany = this.db.transaction((entries: AnalyticsEntry[]) => { @@ -104,7 +137,13 @@ export class SqliteAnalyticsStorage implements AnalyticsStorage { entry.tokensSaved, entry.timestamp, entry.sessionId || null, - entry.metadata ? JSON.stringify(entry.metadata) : null + entry.metadata ? JSON.stringify(entry.metadata) : null, + entry.client || null, + entry.clientVersion || null, + entry.model || null, + entry.modelVersion || null, + entry.savingsMeasured === true ? 1 : 0, + entry.measurementId || null ); } }); @@ -238,6 +277,12 @@ export class SqliteAnalyticsStorage implements AnalyticsStorage { timestamp: row.timestamp, sessionId: row.session_id || undefined, metadata: row.metadata ? JSON.parse(row.metadata) : undefined, + client: row.client || undefined, + clientVersion: row.client_version || undefined, + model: row.model || undefined, + modelVersion: row.model_version || undefined, + savingsMeasured: row.savings_measured !== 0, + measurementId: row.measurement_id || undefined, })); } diff --git a/src/analytics/analytics-types.ts b/src/analytics/analytics-types.ts index f95455e82..8951d8283 100644 --- a/src/analytics/analytics-types.ts +++ b/src/analytics/analytics-types.ts @@ -35,6 +35,15 @@ export interface AnalyticsEntry { sessionId?: string; /** Optional additional metadata */ metadata?: Record; + /** MCP handshake attribution for new measurements; absent on historical rows. */ + client?: string; + clientVersion?: string; + model?: string; + modelVersion?: string; + /** Whether a real before-state exists for this operation's savings claim. */ + savingsMeasured?: boolean; + /** Stable per-request identity used to reject transport retries. */ + measurementId?: string; } /** diff --git a/src/analytics/native-provider-usage.ts b/src/analytics/native-provider-usage.ts new file mode 100644 index 000000000..009da26c6 --- /dev/null +++ b/src/analytics/native-provider-usage.ts @@ -0,0 +1,697 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import readline from 'readline'; +import { + inferProviderRoute, + normalizeUsageDimensions, + priceTokenUsage, + type PriceCurrency, + type PricedTokenUsage, + type TokenUsageDimensions, +} from './provider-pricing.js'; + +export interface NativeProviderUsageRecord { + measurementId: string; + timestamp: string; + client: string; + provider: string; + billingRoute: string; + pricingRoute: string; + model: string | null; + plan: string | null; + project: string | null; + sessionId: string | null; + usage: TokenUsageDimensions; + nativeReportedCost: number | null; + nativeReportedCurrency: PriceCurrency | null; + source: string; +} + +export interface ProviderUsageSummary { + available: boolean; + source: string; + window: { + days: number; + since: string | null; + firstSeen: string | null; + lastSeen: string | null; + }; + requestCount: number; + pricedRequestCount: number; + unpricedRequestCount: number; + pricingCoveragePercent: number | null; + usage: TokenUsageDimensions; + totalTokens: number; + apiEquivalentCost: Partial>; + nativeReportedCost: Partial>; + byModel: ProviderUsageGroup[]; + recent: ProviderUsageView[]; +} + +export interface ProviderUsageGroup { + client: string; + provider: string; + billingRoute: string; + pricingRoute: string; + model: string; + plan: string | null; + requestCount: number; + pricedRequestCount: number; + usage: TokenUsageDimensions; + totalTokens: number; + apiEquivalentCost: Partial>; + priceSourceUrl: string | null; + priceSourceLabel: string; +} + +export interface ProviderUsageView extends NativeProviderUsageRecord { + pricing: PricedTokenUsage; +} + +const MAX_FILES_PER_CLIENT = 80; +const DEFAULT_DAYS = 7; +const MAX_TRANSCRIPT_BYTES = Math.max( + 1_000_000, + Number(process.env.TOKEN_OPTIMIZER_PROVIDER_USAGE_MAX_BYTES) || 512_000_000 +); +let liveCache: { + key: string; + expiresAt: number; + report: ProviderUsageSummary; +} | null = null; + +function value(record: unknown, key: string): unknown { + return record && typeof record === 'object' + ? (record as Record)[key] + : undefined; +} + +function number(record: unknown, key: string): number { + const parsed = Number(value(record, key)); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0; +} + +function text(record: unknown, key: string): string | null { + const found = value(record, key); + return typeof found === 'string' && found.trim() ? found.trim() : null; +} + +function usageTotal(usage: TokenUsageDimensions): number { + return Object.values(usage).reduce((sum, item) => sum + item, 0); +} + +function addUsage( + left: TokenUsageDimensions, + right: TokenUsageDimensions +): TokenUsageDimensions { + return { + uncachedInputTokens: left.uncachedInputTokens + right.uncachedInputTokens, + cachedInputTokens: left.cachedInputTokens + right.cachedInputTokens, + cacheWrite5mInputTokens: + left.cacheWrite5mInputTokens + right.cacheWrite5mInputTokens, + cacheWrite1hInputTokens: + left.cacheWrite1hInputTokens + right.cacheWrite1hInputTokens, + cacheWriteInputTokens: + left.cacheWriteInputTokens + right.cacheWriteInputTokens, + outputTokens: left.outputTokens + right.outputTokens, + }; +} + +async function jsonLines( + filePath: string, + visit: (parsed: Record, ordinal: number) => void +): Promise { + let start: number | undefined; + try { + const size = fs.statSync(filePath).size; + if (size > MAX_TRANSCRIPT_BYTES) + start = Math.max(0, size - MAX_TRANSCRIPT_BYTES); + } catch { + return; + } + const input = fs.createReadStream(filePath, { encoding: 'utf8', start }); + const lines = readline.createInterface({ input, crlfDelay: Infinity }); + let ordinal = 0; + let discardFirstPartialLine = start !== undefined && start > 0; + for await (const line of lines) { + ordinal += 1; + if (discardFirstPartialLine) { + discardFirstPartialLine = false; + continue; + } + if (!line.trim()) continue; + try { + const parsed = JSON.parse(line) as unknown; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) + visit(parsed as Record, ordinal); + } catch { + // A CLI can be terminated while appending its final JSONL line. Earlier + // complete usage records are still valid and should remain visible. + } + } +} + +function recentFiles( + root: string, + accept: (filePath: string) => boolean, + sinceMs: number +): string[] { + if (!fs.existsSync(root)) return []; + const found: Array<{ filePath: string; mtimeMs: number }> = []; + const pending = [root]; + while (pending.length) { + const current = pending.pop()!; + let entries: fs.Dirent[] = []; + try { + entries = fs.readdirSync(current, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + const filePath = path.join(current, entry.name); + if (entry.isDirectory()) pending.push(filePath); + else if (entry.isFile() && accept(filePath)) { + try { + const mtimeMs = fs.statSync(filePath).mtimeMs; + if (mtimeMs >= sinceMs) found.push({ filePath, mtimeMs }); + } catch { + // A rotated transcript can disappear between enumeration and stat. + } + } + } + } + return found + .sort((a, b) => b.mtimeMs - a.mtimeMs) + .slice(0, MAX_FILES_PER_CLIENT) + .map((item) => item.filePath); +} + +function parseHookUsageEvent( + event: Record, + source: string, + ordinal: number +): NativeProviderUsageRecord | null { + if ( + !['tool-outcome', 'episode-outcome'].includes(String(value(event, 'kind'))) + ) + return null; + const measurementId = text(event, 'usageMeasurementId'); + const dimensions = normalizeUsageDimensions({ + uncachedInputTokens: number(event, 'uncachedInputTokens'), + cachedInputTokens: number(event, 'cachedInputTokens'), + cacheWrite5mInputTokens: number(event, 'cacheWrite5mInputTokens'), + cacheWrite1hInputTokens: number(event, 'cacheWrite1hInputTokens'), + cacheWriteInputTokens: number(event, 'cacheWriteInputTokens'), + outputTokens: number(event, 'outputTokens'), + }); + if (!measurementId || usageTotal(dimensions) <= 0) return null; + const client = String(text(event, 'client') || 'unknown').toLowerCase(); + if (client === 'codex' || client === 'claude-code' || client === 'gemini') + return null; + const model = text(event, 'model'); + const inferred = inferProviderRoute(client, model, text(event, 'provider')); + const at = number(event, 'at'); + return { + measurementId: `hook:${client}:${measurementId}`, + timestamp: + text(event, 'timestamp') || + (at > 0 ? new Date(at).toISOString() : new Date().toISOString()), + client, + provider: inferred.provider, + billingRoute: client, + pricingRoute: inferred.route, + model, + plan: text(event, 'plan'), + project: null, + sessionId: text(event, 'sessionId'), + usage: dimensions, + nativeReportedCost: null, + nativeReportedCurrency: null, + source: `${source}:${ordinal}`, + }; +} + +async function readHookUsageFile( + filePath: string +): Promise { + const records: NativeProviderUsageRecord[] = []; + await jsonLines(filePath, (event, ordinal) => { + const record = parseHookUsageEvent(event, filePath, ordinal); + if (record) records.push(record); + }); + return records; +} + +function hookEvidenceFiles(home: string, sinceMs: number): string[] { + const roots = [ + path.join(home, '.token-optimizer', 'wiki'), + path.join(home, '.token-optimizer', 'unrooted'), + ]; + const registry = path.join(home, '.token-optimizer', 'projects.jsonl'); + if (fs.existsSync(registry)) { + try { + for (const line of fs.readFileSync(registry, 'utf8').split(/\r?\n/)) { + if (!line) continue; + const record = JSON.parse(line) as Record; + const graphDir = text(record, 'graphDir'); + if (graphDir) roots.push(graphDir); + } + } catch { + // The append-only registry can end with a torn record after a killed hook. + } + } + const unique = new Set(); + for (const root of roots) { + for (const filePath of recentFiles( + root, + (candidate) => path.basename(candidate) === 'evidence.jsonl', + sinceMs + )) + unique.add(filePath); + } + return [...unique]; +} + +export async function readCodexUsageFile( + filePath: string +): Promise { + const output: NativeProviderUsageRecord[] = []; + const seenCumulative = new Set(); + let sessionId: string | null = null; + let project: string | null = null; + let provider = 'openai'; + let model: string | null = null; + let plan: string | null = null; + + // Tail-bounded reads can begin after session_meta/turn_context. The filename + // still carries Codex's stable session id, and the current usage event often + // exposes the model through its rate-limit identity only indirectly. Keep + // unknown model honest; do not price it until a turn_context is observed. + const sessionMatch = path + .basename(filePath) + .match( + /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i + ); + sessionId = sessionMatch?.[1] || null; + + await jsonLines(filePath, (row, ordinal) => { + const payload = value(row, 'payload'); + if (value(row, 'type') === 'session_meta') { + sessionId = text(payload, 'session_id') || text(payload, 'id'); + project = text(payload, 'cwd'); + provider = text(payload, 'model_provider') || provider; + return; + } + if (value(row, 'type') === 'turn_context') { + model = text(payload, 'model') || model; + project = text(payload, 'cwd') || project; + return; + } + if ( + value(row, 'type') !== 'event_msg' || + value(payload, 'type') !== 'token_count' + ) + return; + + const info = value(payload, 'info'); + const last = value(info, 'last_token_usage'); + const cumulative = value(info, 'total_token_usage'); + if (!last || typeof last !== 'object') return; + const fingerprint = JSON.stringify(cumulative || last); + if (seenCumulative.has(fingerprint)) return; + seenCumulative.add(fingerprint); + + const limits = value(payload, 'rate_limits'); + plan = text(limits, 'plan_type') || plan; + const input = number(last, 'input_tokens'); + const cached = number(last, 'cached_input_tokens'); + const writes = number(last, 'cache_write_input_tokens'); + const usage = normalizeUsageDimensions({ + // Codex/OpenAI input_tokens includes cached and cache-write input. + uncachedInputTokens: Math.max(0, input - cached - writes), + cachedInputTokens: cached, + cacheWriteInputTokens: writes, + outputTokens: number(last, 'output_tokens'), + }); + output.push({ + measurementId: `codex:${sessionId || path.basename(filePath)}:${fingerprint}`, + timestamp: + text(row, 'timestamp') || + new Date(fs.statSync(filePath).mtimeMs).toISOString(), + client: 'codex', + provider, + billingRoute: plan ? `codex-${plan}` : 'codex', + pricingRoute: 'openai-api', + model, + plan, + project, + sessionId, + usage, + nativeReportedCost: null, + nativeReportedCurrency: null, + source: `${filePath}:${ordinal}`, + }); + }); + return output; +} + +export async function readClaudeUsageFile( + filePath: string +): Promise { + const byRequest = new Map(); + await jsonLines(filePath, (row, ordinal) => { + const message = value(row, 'message'); + const usageRecord = value(message, 'usage'); + if (!usageRecord || typeof usageRecord !== 'object') return; + const requestId = text(row, 'requestId'); + if (!requestId) return; + + const cacheCreation = value(usageRecord, 'cache_creation'); + const write5m = number(cacheCreation, 'ephemeral_5m_input_tokens'); + const write1h = number(cacheCreation, 'ephemeral_1h_input_tokens'); + const genericWrites = Math.max( + 0, + number(usageRecord, 'cache_creation_input_tokens') - write5m - write1h + ); + const nativeCost = Number( + value(row, 'total_cost_usd') ?? value(row, 'totalCostUsd') + ); + const candidate: NativeProviderUsageRecord = { + measurementId: `claude-code:${requestId}`, + timestamp: + text(row, 'timestamp') || + new Date(fs.statSync(filePath).mtimeMs).toISOString(), + client: 'claude-code', + provider: 'anthropic', + billingRoute: 'claude-code', + pricingRoute: 'anthropic-api', + model: text(message, 'model'), + plan: text(usageRecord, 'service_tier'), + project: text(row, 'cwd'), + sessionId: text(row, 'sessionId'), + usage: normalizeUsageDimensions({ + // Anthropic input_tokens explicitly excludes cache reads and writes. + uncachedInputTokens: number(usageRecord, 'input_tokens'), + cachedInputTokens: number(usageRecord, 'cache_read_input_tokens'), + cacheWrite5mInputTokens: write5m, + cacheWrite1hInputTokens: write1h, + cacheWriteInputTokens: genericWrites, + outputTokens: number(usageRecord, 'output_tokens'), + }), + nativeReportedCost: + Number.isFinite(nativeCost) && nativeCost >= 0 ? nativeCost : null, + nativeReportedCurrency: + Number.isFinite(nativeCost) && nativeCost >= 0 ? 'USD' : null, + source: `${filePath}:${ordinal}`, + }; + const existing = byRequest.get(requestId); + if (!existing) { + byRequest.set(requestId, candidate); + return; + } + // Claude Code writes streaming fragments with the same request id. Input + // dimensions repeat while output grows, so summing fragments double counts + // the prompt and keeping the first fragment undercounts the completion. + byRequest.set(requestId, { + ...candidate, + timestamp: + Date.parse(candidate.timestamp) >= Date.parse(existing.timestamp) + ? candidate.timestamp + : existing.timestamp, + usage: normalizeUsageDimensions({ + uncachedInputTokens: Math.max( + existing.usage.uncachedInputTokens, + candidate.usage.uncachedInputTokens + ), + cachedInputTokens: Math.max( + existing.usage.cachedInputTokens, + candidate.usage.cachedInputTokens + ), + cacheWrite5mInputTokens: Math.max( + existing.usage.cacheWrite5mInputTokens, + candidate.usage.cacheWrite5mInputTokens + ), + cacheWrite1hInputTokens: Math.max( + existing.usage.cacheWrite1hInputTokens, + candidate.usage.cacheWrite1hInputTokens + ), + cacheWriteInputTokens: Math.max( + existing.usage.cacheWriteInputTokens, + candidate.usage.cacheWriteInputTokens + ), + outputTokens: Math.max( + existing.usage.outputTokens, + candidate.usage.outputTokens + ), + }), + nativeReportedCost: + candidate.nativeReportedCost ?? existing.nativeReportedCost, + nativeReportedCurrency: + candidate.nativeReportedCurrency ?? existing.nativeReportedCurrency, + }); + }); + return [...byRequest.values()]; +} + +export function readGeminiUsageFile( + filePath: string +): NativeProviderUsageRecord[] { + let root: unknown; + try { + root = JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown; + } catch { + return []; + } + const sessionId = text(root, 'sessionId'); + const messages = value(root, 'messages'); + if (!Array.isArray(messages)) return []; + const output: NativeProviderUsageRecord[] = []; + for (let index = 0; index < messages.length; index += 1) { + const message = messages[index]; + const tokens = value(message, 'tokens'); + if (!tokens || typeof tokens !== 'object') continue; + const input = number(tokens, 'input'); + const cached = number(tokens, 'cached'); + const usage = normalizeUsageDimensions({ + // Gemini prompt/input totals include cached content. + uncachedInputTokens: Math.max(0, input - cached), + cachedInputTokens: cached, + // Gemini output pricing includes generated candidates and thoughts. + outputTokens: number(tokens, 'output') + number(tokens, 'thoughts'), + }); + const timestamp = + text(message, 'timestamp') || + text(message, 'time') || + text(root, 'lastUpdated') || + new Date(fs.statSync(filePath).mtimeMs).toISOString(); + output.push({ + measurementId: `gemini:${sessionId || path.basename(filePath)}:${index}`, + timestamp, + client: 'gemini', + provider: 'google', + billingRoute: 'gemini-cli', + pricingRoute: 'gemini-api', + model: text(message, 'model'), + plan: null, + project: null, + sessionId, + usage, + nativeReportedCost: null, + nativeReportedCurrency: null, + source: `${filePath}:messages[${index}]`, + }); + } + return output; +} + +export function nativeUsageRecordFromHookEvent( + event: Record, + source = 'hook-evidence', + ordinal = 1 +): NativeProviderUsageRecord | null { + return parseHookUsageEvent(event, source, ordinal); +} + +export function summarizeProviderUsage( + records: NativeProviderUsageRecord[], + recentLimit = 30, + window: { days?: number; since?: string | null } = {} +): ProviderUsageSummary { + const unique = new Map( + records.map((record) => [record.measurementId, record]) + ); + const views = [...unique.values()] + .map( + (record): ProviderUsageView => ({ + ...record, + pricing: priceTokenUsage({ + client: record.client, + provider: record.provider, + route: record.pricingRoute, + model: record.model, + timestamp: record.timestamp, + usage: record.usage, + }), + }) + ) + .sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp)); + const usage = views.reduce( + (sum, row) => addUsage(sum, row.usage), + normalizeUsageDimensions() + ); + const apiEquivalentCost: Partial> = {}; + const nativeReportedCost: Partial> = {}; + for (const row of views) { + if ( + row.pricing.available && + row.pricing.currency && + row.pricing.amount !== null + ) + apiEquivalentCost[row.pricing.currency] = + (apiEquivalentCost[row.pricing.currency] || 0) + row.pricing.amount; + if (row.nativeReportedCurrency && row.nativeReportedCost !== null) + nativeReportedCost[row.nativeReportedCurrency] = + (nativeReportedCost[row.nativeReportedCurrency] || 0) + + row.nativeReportedCost; + } + + const groups = new Map(); + for (const row of views) { + const key = [ + row.client, + row.provider, + row.billingRoute, + row.pricingRoute, + row.model || 'model not reported', + row.plan || '', + ].join('\u0000'); + const group = groups.get(key) || []; + group.push(row); + groups.set(key, group); + } + const byModel = [...groups.values()] + .map((rows): ProviderUsageGroup => { + const row = rows[0]; + const groupUsage = rows.reduce( + (sum, item) => addUsage(sum, item.usage), + normalizeUsageDimensions() + ); + const costs: Partial> = {}; + for (const item of rows) { + if ( + item.pricing.available && + item.pricing.currency && + item.pricing.amount !== null + ) + costs[item.pricing.currency] = + (costs[item.pricing.currency] || 0) + item.pricing.amount; + } + const priced = rows.filter((item) => item.pricing.available); + return { + client: row.client, + provider: row.provider, + billingRoute: row.billingRoute, + pricingRoute: row.pricingRoute, + model: row.model || 'model not reported', + plan: row.plan, + requestCount: rows.length, + pricedRequestCount: priced.length, + usage: groupUsage, + totalTokens: usageTotal(groupUsage), + apiEquivalentCost: costs, + priceSourceUrl: priced[0]?.pricing.sourceUrl || null, + priceSourceLabel: + priced[0]?.pricing.sourceLabel || + rows[0].pricing.reason || + 'No exact price contract', + }; + }) + .sort((a, b) => b.totalTokens - a.totalTokens); + const pricedRequestCount = views.filter( + (row) => row.pricing.available + ).length; + return { + available: views.length > 0, + source: + 'Native local CLI transcripts; request-deduplicated and priced with exact provider/model/route contracts', + window: { + days: Number(window.days) || DEFAULT_DAYS, + since: window.since || null, + firstSeen: views.length ? views.at(-1)!.timestamp : null, + lastSeen: views[0]?.timestamp || null, + }, + requestCount: views.length, + pricedRequestCount, + unpricedRequestCount: views.length - pricedRequestCount, + pricingCoveragePercent: views.length + ? (pricedRequestCount / views.length) * 100 + : null, + usage, + totalTokens: usageTotal(usage), + apiEquivalentCost, + nativeReportedCost, + byModel, + recent: views.slice(0, Math.max(1, recentLimit)), + }; +} + +export async function readNativeProviderUsage( + options: { + days?: number; + recentLimit?: number; + homeDirectory?: string; + } = {} +): Promise { + const home = options.homeDirectory || os.homedir(); + const days = Math.min(365, Math.max(1, Number(options.days) || DEFAULT_DAYS)); + const sinceMs = Date.now() - days * 24 * 60 * 60 * 1000; + const cacheKey = `${home}\u0000${days}\u0000${options.recentLimit || 30}`; + if ( + !options.homeDirectory && + liveCache?.key === cacheKey && + liveCache.expiresAt > Date.now() + ) + return liveCache.report; + const codexFiles = recentFiles( + path.join(home, '.codex', 'sessions'), + (filePath) => filePath.endsWith('.jsonl'), + sinceMs + ); + const claudeFiles = recentFiles( + path.join(home, '.claude', 'projects'), + (filePath) => filePath.endsWith('.jsonl'), + sinceMs + ); + const geminiFiles = recentFiles( + path.join(home, '.gemini', 'tmp'), + (filePath) => /[\\/]chats[\\/]session.+\.json$/i.test(filePath), + sinceMs + ); + const hookFiles = hookEvidenceFiles(home, sinceMs); + const records = ( + await Promise.all([ + ...codexFiles.map(readCodexUsageFile), + ...claudeFiles.map(readClaudeUsageFile), + ...geminiFiles.map(async (filePath) => readGeminiUsageFile(filePath)), + ...hookFiles.map(readHookUsageFile), + ]) + ) + .flat() + .filter((record) => { + const timestamp = Date.parse(record.timestamp); + return Number.isFinite(timestamp) && timestamp >= sinceMs; + }); + const report = summarizeProviderUsage(records, options.recentLimit, { + days, + since: new Date(sinceMs).toISOString(), + }); + if (!options.homeDirectory) { + const cacheMs = Math.max( + 5_000, + Number(process.env.TOKEN_OPTIMIZER_PROVIDER_USAGE_CACHE_MS) || 60_000 + ); + liveCache = { key: cacheKey, expiresAt: Date.now() + cacheMs, report }; + } + return report; +} diff --git a/src/analytics/provider-pricing.ts b/src/analytics/provider-pricing.ts new file mode 100644 index 000000000..0974ef342 --- /dev/null +++ b/src/analytics/provider-pricing.ts @@ -0,0 +1,552 @@ +/** + * Versioned provider/model token prices. + * + * Prices are intentionally request-time contracts, not one global multiplier: + * uncached input, cache reads, cache writes, and output are distinct billable + * dimensions. A catalog match is exact (or an explicitly documented alias), + * and every result carries the source and effective date used to compute it. + */ + +export type PriceCurrency = 'USD' | 'CNY'; + +export interface TokenUsageDimensions { + uncachedInputTokens: number; + cachedInputTokens: number; + cacheWrite5mInputTokens: number; + cacheWrite1hInputTokens: number; + cacheWriteInputTokens: number; + outputTokens: number; +} + +export interface TokenPriceTier { + maxInputTokens?: number; + uncachedInput: number; + cachedInput: number; + cacheWrite5m: number | null; + cacheWrite1h: number | null; + cacheWrite: number | null; + output: number; +} + +export interface ModelPriceContract { + provider: string; + route: string; + model: string; + aliases: readonly string[]; + currency: PriceCurrency; + verifiedAt: string; + effectiveFrom?: string; + effectiveTo?: string; + sourceUrl: string; + sourceLabel: string; + tiers: readonly TokenPriceTier[]; +} + +export interface PricedTokenUsage { + available: boolean; + provider: string; + route: string; + requestedModel: string | null; + resolvedModel: string | null; + currency: PriceCurrency | null; + amount: number | null; + ratesPerMillion: TokenPriceTier | null; + usage: TokenUsageDimensions; + breakdown: { + uncachedInput: number; + cachedInput: number; + cacheWrite5m: number; + cacheWrite1h: number; + cacheWrite: number; + output: number; + } | null; + sourceUrl: string | null; + sourceLabel: string; + effectiveFrom: string | null; + effectiveTo: string | null; + verifiedAt: string | null; + reason: string | null; +} + +const OPENAI_SOURCE = 'https://developers.openai.com/api/docs/models/compare'; +const OPENAI_SOL_SOURCE = + 'https://developers.openai.com/api/docs/models/gpt-5.6-sol'; +const ANTHROPIC_SOURCE = + 'https://platform.claude.com/docs/en/about-claude/pricing'; +const ANTHROPIC_CACHE_SOURCE = + 'https://platform.claude.com/docs/en/build-with-claude/prompt-caching'; +const GEMINI_SOURCE = 'https://ai.google.dev/gemini-api/docs/pricing'; +const COPILOT_SOURCE = + 'https://docs.github.com/en/copilot/reference/copilot-billing/models-and-pricing'; +const CATALOG_VERIFIED_AT = '2026-08-12T00:00:00.000Z'; + +const openAi56Tier = ( + input: number, + cached: number, + output: number +): readonly TokenPriceTier[] => [ + { + maxInputTokens: 272_000, + uncachedInput: input, + cachedInput: cached, + cacheWrite5m: input * 1.25, + cacheWrite1h: input * 1.25, + cacheWrite: input * 1.25, + output, + }, + { + uncachedInput: input * 2, + cachedInput: cached * 2, + cacheWrite5m: input * 2 * 1.25, + cacheWrite1h: input * 2 * 1.25, + cacheWrite: input * 2 * 1.25, + output: output * 1.5, + }, +]; + +const anthropicTier = ( + input: number, + output: number +): readonly TokenPriceTier[] => [ + { + uncachedInput: input, + cachedInput: input * 0.1, + cacheWrite5m: input * 1.25, + cacheWrite1h: input * 2, + cacheWrite: input * 1.25, + output, + }, +]; + +const geminiTier = ( + input: number, + cached: number, + output: number +): readonly TokenPriceTier[] => [ + { + uncachedInput: input, + cachedInput: cached, + // Gemini reports cached reads in generation usage. Explicit cache storage + // is a separate time-based charge and is not invented when unobserved. + cacheWrite5m: 0, + cacheWrite1h: null, + cacheWrite: null, + output, + }, +]; + +/** Official prices that can be resolved without guessing a model generation. */ +export const MODEL_PRICE_CATALOG: readonly ModelPriceContract[] = [ + { + provider: 'openai', + route: 'openai-api', + model: 'gpt-5.6-sol', + aliases: [], + currency: 'USD', + verifiedAt: CATALOG_VERIFIED_AT, + sourceUrl: OPENAI_SOL_SOURCE, + sourceLabel: 'OpenAI API price', + tiers: openAi56Tier(5, 0.5, 30), + }, + { + provider: 'openai', + route: 'openai-api', + model: 'gpt-5.6-terra', + aliases: [], + currency: 'USD', + verifiedAt: CATALOG_VERIFIED_AT, + sourceUrl: OPENAI_SOURCE, + sourceLabel: 'OpenAI API price', + tiers: openAi56Tier(2.5, 0.25, 15), + }, + { + provider: 'openai', + route: 'openai-api', + model: 'gpt-5.6-luna', + aliases: [], + currency: 'USD', + verifiedAt: CATALOG_VERIFIED_AT, + sourceUrl: OPENAI_SOURCE, + sourceLabel: 'OpenAI API price', + tiers: openAi56Tier(1, 0.1, 6), + }, + { + provider: 'anthropic', + route: 'anthropic-api', + model: 'claude-opus-5', + aliases: [], + currency: 'USD', + verifiedAt: CATALOG_VERIFIED_AT, + sourceUrl: ANTHROPIC_SOURCE, + sourceLabel: 'Anthropic API price', + tiers: anthropicTier(5, 25), + }, + { + provider: 'anthropic', + route: 'anthropic-api', + model: 'claude-sonnet-5', + aliases: [], + currency: 'USD', + verifiedAt: CATALOG_VERIFIED_AT, + effectiveTo: '2026-09-01T00:00:00.000Z', + sourceUrl: ANTHROPIC_SOURCE, + sourceLabel: 'Anthropic introductory API price', + tiers: anthropicTier(2, 10), + }, + { + provider: 'anthropic', + route: 'anthropic-api', + model: 'claude-sonnet-5', + aliases: [], + currency: 'USD', + verifiedAt: CATALOG_VERIFIED_AT, + effectiveFrom: '2026-09-01T00:00:00.000Z', + sourceUrl: ANTHROPIC_SOURCE, + sourceLabel: 'Anthropic standard API price', + tiers: anthropicTier(3, 15), + }, + ...[ + 'claude-opus-4-8', + 'claude-opus-4-7', + 'claude-opus-4-6', + 'claude-opus-4-5', + ].map( + (model): ModelPriceContract => ({ + provider: 'anthropic', + route: 'anthropic-api', + model, + aliases: [], + currency: 'USD', + verifiedAt: CATALOG_VERIFIED_AT, + sourceUrl: ANTHROPIC_CACHE_SOURCE, + sourceLabel: 'Anthropic API and prompt-cache price', + tiers: anthropicTier(5, 25), + }) + ), + ...['claude-sonnet-4-6', 'claude-sonnet-4-5'].map( + (model): ModelPriceContract => ({ + provider: 'anthropic', + route: 'anthropic-api', + model, + aliases: [], + currency: 'USD', + verifiedAt: CATALOG_VERIFIED_AT, + sourceUrl: ANTHROPIC_CACHE_SOURCE, + sourceLabel: 'Anthropic API and prompt-cache price', + tiers: anthropicTier(3, 15), + }) + ), + { + provider: 'anthropic', + route: 'anthropic-api', + model: 'claude-haiku-4-5', + aliases: [], + currency: 'USD', + verifiedAt: CATALOG_VERIFIED_AT, + sourceUrl: ANTHROPIC_CACHE_SOURCE, + sourceLabel: 'Anthropic API and prompt-cache price', + tiers: anthropicTier(1, 5), + }, + { + provider: 'google', + route: 'gemini-api', + model: 'gemini-3.5-flash', + aliases: [], + currency: 'USD', + verifiedAt: CATALOG_VERIFIED_AT, + sourceUrl: GEMINI_SOURCE, + sourceLabel: 'Gemini Developer API standard price', + tiers: geminiTier(1.5, 0.15, 9), + }, + { + provider: 'google', + route: 'gemini-api', + model: 'gemini-3.5-flash-lite', + aliases: [], + currency: 'USD', + verifiedAt: CATALOG_VERIFIED_AT, + sourceUrl: GEMINI_SOURCE, + sourceLabel: 'Gemini Developer API standard price', + tiers: geminiTier(0.3, 0.03, 2.5), + }, + { + provider: 'google', + route: 'gemini-api', + model: 'gemini-2.5-flash', + aliases: [], + currency: 'USD', + verifiedAt: CATALOG_VERIFIED_AT, + sourceUrl: GEMINI_SOURCE, + sourceLabel: 'Gemini Developer API standard price', + tiers: geminiTier(0.3, 0.03, 2.5), + }, + { + provider: 'google', + route: 'gemini-api', + model: 'gemini-2.5-flash-lite', + aliases: [], + currency: 'USD', + verifiedAt: CATALOG_VERIFIED_AT, + sourceUrl: GEMINI_SOURCE, + sourceLabel: 'Gemini Developer API standard price', + tiers: geminiTier(0.1, 0.01, 0.4), + }, + // GitHub Copilot is a distinct billing route even when the underlying model + // id is identical to a direct provider model. + ...[ + ['gpt-5.6-sol', 5, 0.5, 30, 272_000], + ['gpt-5.6-terra', 2.5, 0.25, 15, 272_000], + ['gpt-5.6-luna', 1, 0.1, 6, 200_000], + ].map( + ([model, input, cached, output, threshold]): ModelPriceContract => ({ + provider: 'github', + route: 'github-copilot', + model: String(model), + aliases: [], + currency: 'USD', + verifiedAt: CATALOG_VERIFIED_AT, + sourceUrl: COPILOT_SOURCE, + sourceLabel: 'GitHub Copilot AI-credit token price', + tiers: [ + { + maxInputTokens: Number(threshold), + uncachedInput: Number(input), + cachedInput: Number(cached), + cacheWrite5m: null, + cacheWrite1h: null, + cacheWrite: null, + output: Number(output), + }, + { + uncachedInput: Number(input) * 2, + cachedInput: Number(cached) * 2, + cacheWrite5m: null, + cacheWrite1h: null, + cacheWrite: null, + output: Number(output) * 1.5, + }, + ], + }) + ), + { + provider: 'github', + route: 'github-copilot', + model: 'claude-sonnet-5', + aliases: [], + currency: 'USD', + verifiedAt: CATALOG_VERIFIED_AT, + effectiveTo: '2026-09-01T00:00:00.000Z', + sourceUrl: COPILOT_SOURCE, + sourceLabel: 'GitHub Copilot promotional AI-credit token price', + tiers: [ + { + uncachedInput: 2, + cachedInput: 0.2, + cacheWrite5m: 2.5, + cacheWrite1h: 4, + cacheWrite: 2.5, + output: 10, + }, + ], + }, +]; + +function nonnegative(value: unknown): number { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0; +} + +export function normalizeUsageDimensions( + usage: Partial = {} +): TokenUsageDimensions { + return { + uncachedInputTokens: nonnegative(usage.uncachedInputTokens), + cachedInputTokens: nonnegative(usage.cachedInputTokens), + cacheWrite5mInputTokens: nonnegative(usage.cacheWrite5mInputTokens), + cacheWrite1hInputTokens: nonnegative(usage.cacheWrite1hInputTokens), + cacheWriteInputTokens: nonnegative(usage.cacheWriteInputTokens), + outputTokens: nonnegative(usage.outputTokens), + }; +} + +export function inferProviderRoute( + client: string | null | undefined, + model: string | null | undefined, + provider?: string | null +): { provider: string; route: string } { + const clientKey = String(client || '').toLowerCase(); + const modelKey = String(model || '').toLowerCase(); + if (clientKey.includes('copilot')) + return { provider: 'github', route: 'github-copilot' }; + const explicit = String(provider || '').toLowerCase(); + if (explicit.includes('openai')) + return { provider: 'openai', route: 'openai-api' }; + if (explicit.includes('anthropic')) + return { provider: 'anthropic', route: 'anthropic-api' }; + if (explicit.includes('google')) + return { provider: 'google', route: 'gemini-api' }; + if (explicit.includes('alibaba') || explicit.includes('dashscope')) + return { provider: 'alibaba', route: 'dashscope-api' }; + if (/^(gpt-|o\d|chatgpt-)/.test(modelKey)) + return { provider: 'openai', route: 'openai-api' }; + if (modelKey.startsWith('claude-')) + return { provider: 'anthropic', route: 'anthropic-api' }; + if (modelKey.startsWith('gemini-')) + return { provider: 'google', route: 'gemini-api' }; + if (modelKey.startsWith('qwen')) + return { provider: 'alibaba', route: 'dashscope-api' }; + return { provider: explicit || 'unknown', route: 'unknown' }; +} + +function contractAt( + route: string, + model: string, + timestamp: string +): ModelPriceContract | null { + const at = Date.parse(timestamp); + const key = model.toLowerCase(); + return ( + MODEL_PRICE_CATALOG.find((contract) => { + const begins = contract.effectiveFrom + ? Date.parse(contract.effectiveFrom) + : Number.NEGATIVE_INFINITY; + const ends = contract.effectiveTo + ? Date.parse(contract.effectiveTo) + : Number.POSITIVE_INFINITY; + return ( + contract.route === route && + (contract.model === key || contract.aliases.includes(key)) && + at >= begins && + at < ends + ); + }) || null + ); +} + +function tierFor( + contract: ModelPriceContract, + promptTokens: number +): TokenPriceTier { + return ( + contract.tiers.find( + (tier) => + tier.maxInputTokens === undefined || promptTokens <= tier.maxInputTokens + ) || contract.tiers.at(-1)! + ); +} + +function tierInputTokens(usage: TokenUsageDimensions): number { + // Provider threshold rules apply to the logical prompt length. Cache-write + // dimensions describe how those input tokens were billed; adding them to + // uncached/read input a second time can incorrectly push a request over a + // long-context threshold. + return ( + usage.uncachedInputTokens + + usage.cachedInputTokens + + Math.max( + usage.cacheWriteInputTokens, + usage.cacheWrite5mInputTokens + usage.cacheWrite1hInputTokens + ) + ); +} + +export function priceTokenUsage(input: { + client?: string | null; + provider?: string | null; + route?: string | null; + model?: string | null; + timestamp?: string; + usage?: Partial; +}): PricedTokenUsage { + const usage = normalizeUsageDimensions(input.usage); + const inferred = inferProviderRoute( + input.client, + input.model, + input.provider + ); + const provider = input.provider || inferred.provider; + const route = input.route || inferred.route; + const timestamp = input.timestamp || new Date().toISOString(); + const model = String(input.model || '') + .trim() + .toLowerCase(); + const contract = model ? contractAt(route, model, timestamp) : null; + if (!contract) { + return { + available: false, + provider, + route, + requestedModel: input.model || null, + resolvedModel: null, + currency: null, + amount: null, + ratesPerMillion: null, + usage, + breakdown: null, + sourceUrl: null, + sourceLabel: 'No exact versioned price contract', + effectiveFrom: null, + effectiveTo: null, + verifiedAt: null, + reason: model + ? `No exact ${route} price for model ${model} at ${timestamp}` + : 'The client did not report a model id', + }; + } + + const promptTokens = tierInputTokens(usage); + const tier = tierFor(contract, promptTokens); + const unsupportedDimension = [ + [usage.cacheWrite5mInputTokens, tier.cacheWrite5m, '5-minute cache writes'], + [usage.cacheWrite1hInputTokens, tier.cacheWrite1h, '1-hour cache writes'], + [usage.cacheWriteInputTokens, tier.cacheWrite, 'cache writes'], + ].find(([tokens, rate]) => Number(tokens) > 0 && rate === null); + if (unsupportedDimension) { + return { + available: false, + provider: contract.provider, + route: contract.route, + requestedModel: input.model || null, + resolvedModel: contract.model, + currency: contract.currency, + amount: null, + ratesPerMillion: tier, + usage, + breakdown: null, + sourceUrl: contract.sourceUrl, + sourceLabel: contract.sourceLabel, + effectiveFrom: contract.effectiveFrom || null, + effectiveTo: contract.effectiveTo || null, + verifiedAt: contract.verifiedAt, + reason: `The official ${contract.route} price source does not define ${unsupportedDimension[2]} for this model`, + }; + } + const perMillion = (tokens: number, rate: number | null): number => + rate === null ? 0 : (tokens / 1_000_000) * rate; + const breakdown = { + uncachedInput: perMillion(usage.uncachedInputTokens, tier.uncachedInput), + cachedInput: perMillion(usage.cachedInputTokens, tier.cachedInput), + cacheWrite5m: perMillion(usage.cacheWrite5mInputTokens, tier.cacheWrite5m), + cacheWrite1h: perMillion(usage.cacheWrite1hInputTokens, tier.cacheWrite1h), + cacheWrite: perMillion(usage.cacheWriteInputTokens, tier.cacheWrite), + output: perMillion(usage.outputTokens, tier.output), + }; + return { + available: true, + provider: contract.provider, + route: contract.route, + requestedModel: input.model || null, + resolvedModel: contract.model, + currency: contract.currency, + amount: Object.values(breakdown).reduce((sum, value) => sum + value, 0), + ratesPerMillion: tier, + usage, + breakdown, + sourceUrl: contract.sourceUrl, + sourceLabel: contract.sourceLabel, + effectiveFrom: contract.effectiveFrom || null, + effectiveTo: contract.effectiveTo || null, + verifiedAt: contract.verifiedAt, + reason: null, + }; +} diff --git a/src/analytics/record-tool-analytics.ts b/src/analytics/record-tool-analytics.ts index 1b973e695..b035fd8fa 100644 --- a/src/analytics/record-tool-analytics.ts +++ b/src/analytics/record-tool-analytics.ts @@ -16,11 +16,24 @@ import type { AnalyticsManager } from './analytics-manager.js'; import type { HookPhase } from './analytics-types.js'; +import { TokenCounter } from '../core/token-counter.js'; +import { + isVerifiedSavingsEntry, + SAVINGS_MEASUREMENT_SCHEMA_VERSION, +} from './savings-classification.js'; +import { createHash, randomUUID } from 'node:crypto'; /** MCP tool result shape (the parts we read). */ interface McpToolResult { content?: Array<{ type?: string; text?: string }>; isError?: boolean; + _meta?: Record; +} + +const resultTokenCounter = new TokenCounter(); + +function sha256(text: string): string { + return createHash('sha256').update(text, 'utf8').digest('hex'); } /** A normalized savings measurement extracted from a tool result. */ @@ -164,18 +177,29 @@ export function currentHookPhase(): HookPhase { export async function recordToolAnalytics( manager: AnalyticsManager, toolName: string, - result: McpToolResult + result: McpToolResult, + attribution: { + client?: string | null; + clientVersion?: string | null; + model?: string | null; + modelVersion?: string | null; + operationId?: string | null; + } = {}, + baselineResult: McpToolResult | null = null ): Promise { try { if (!result || result.isError) return; - const text = result.content?.find((c) => c?.type !== 'image')?.text; + const text = (result.content || []) + .filter((content) => typeof content?.text === 'string') + .map((content) => content.text || '') + .join('\n'); if (!text) return; - let payload: unknown; + let payload: unknown = null; try { payload = JSON.parse(text); } catch { - return; // non-JSON output (e.g. raw text) — nothing to record + // Plain text still has a directly measurable returned-context cost. } // Don't record failures the tool reported in-band. @@ -187,21 +211,120 @@ export async function recordToolAnalytics( return; } - const savings = extractSavings(payload); - if (!savings) return; + let baselinePayload: unknown = payload; + let baselineText: string | null = null; + if (baselineResult) { + baselineText = (baselineResult.content || []) + .filter((content) => typeof content?.text === 'string') + .map((content) => content.text || '') + .join('\n'); + try { + baselinePayload = JSON.parse(baselineText); + } catch { + baselinePayload = null; + } + } + // Tool-reported fields are retained as an audit trail, never promoted to a + // verified saving. Across the tool fleet they describe incompatible things + // (file size, rows scanned, compressed content, or a modeled alternative). + const reported = extractSavings(baselinePayload); + const returnedTokens = resultTokenCounter.count(text).tokens; + const baselineTokens = baselineText + ? resultTokenCounter.count(baselineText).tokens + : returnedTokens; + const baselineBytes = baselineText + ? Buffer.byteLength(baselineText, 'utf8') + : Buffer.byteLength(text, 'utf8'); + const returnedBytes = Buffer.byteLength(text, 'utf8'); + const savingsMeasured = + baselineText !== null && + baselineText !== text && + baselineTokens > returnedTokens && + baselineBytes > returnedBytes; + const originalTokens = savingsMeasured ? baselineTokens : returnedTokens; + const tokensSaved = savingsMeasured ? baselineTokens - returnedTokens : 0; const sessionId = process.env.TOKEN_OPTIMIZER_SESSION_ID || - ((payload as Record).sessionId as string | undefined); + (payload && typeof payload === 'object' + ? ((payload as Record).sessionId as string | undefined) + : undefined); + + const resultMeta = result._meta?.tokenOptimizer; + const transportMeta = + resultMeta && typeof resultMeta === 'object' + ? (resultMeta as Record) + : {}; + const measurementId = attribution.operationId || randomUUID(); + const expansionRef = + typeof transportMeta.expansionRef === 'string' + ? transportMeta.expansionRef + : null; + let creditedMeasurementId: string | null = null; + if (expansionRef) { + const existing = await manager.getEntries(); + const credited = existing.find( + (entry) => + isVerifiedSavingsEntry(entry) && + entry.metadata?.disclosureRef === expansionRef && + typeof entry.measurementId === 'string' + ); + creditedMeasurementId = credited?.measurementId || null; + } await manager.track({ hookPhase: currentHookPhase(), toolName, mcpServer: 'token-optimizer', - originalTokens: savings.originalTokens, - optimizedTokens: savings.optimizedTokens, - tokensSaved: savings.tokensSaved, + originalTokens, + optimizedTokens: returnedTokens, + tokensSaved, + savingsMeasured, + measurementId, ...(sessionId ? { sessionId } : {}), + ...(attribution.client ? { client: attribution.client } : {}), + ...(attribution.clientVersion + ? { clientVersion: attribution.clientVersion } + : {}), + ...(attribution.model ? { model: attribution.model } : {}), + ...(attribution.modelVersion + ? { modelVersion: attribution.modelVersion } + : {}), + metadata: { + measurementId, + measurementSchemaVersion: SAVINGS_MEASUREMENT_SCHEMA_VERSION, + measurement: savingsMeasured + ? 'materialized-transport-before-after' + : expansionRef && creditedMeasurementId + ? 'actual-expansion-transport-debit' + : 'actual-return-context-only', + measurementClass: savingsMeasured + ? 'verified-transport-reduction' + : expansionRef && creditedMeasurementId + ? 'verified-transport-expansion-debit' + : 'observed-return-only', + baselineKind: savingsMeasured + ? 'materialized-undisclosed-mcp-result' + : null, + baselineBytes, + returnedBytes, + bytesSaved: savingsMeasured ? baselineBytes - returnedBytes : 0, + baselineSha256: baselineText ? sha256(baselineText) : null, + returnedSha256: sha256(text), + disclosureRef: + typeof transportMeta.disclosureRef === 'string' + ? transportMeta.disclosureRef + : null, + expansionRef, + creditedMeasurementId, + tokenCountMethod: 'tiktoken-gpt-4-compatible-local-estimate', + tokenCounterModel: resultTokenCounter.model, + reportedToolSavings: savingsMeasured ? null : reported, + client: attribution.client || 'unattributed', + clientVersion: attribution.clientVersion || null, + model: attribution.model || null, + modelVersion: attribution.modelVersion || null, + }, }); } catch { // Analytics must never break a tool call. diff --git a/src/analytics/savings-classification.ts b/src/analytics/savings-classification.ts new file mode 100644 index 000000000..6e5d6b495 --- /dev/null +++ b/src/analytics/savings-classification.ts @@ -0,0 +1,146 @@ +import type { AnalyticsEntry } from './analytics-types.js'; + +/** + * Version 2 is the first analytics contract that proves a savings claim from + * two materialized MCP payloads. Earlier rows are retained for audit, but are + * not evidence that context was avoided. + */ +export const SAVINGS_MEASUREMENT_SCHEMA_VERSION = 2; + +export type SavingsClassification = + | 'verified-transport-reduction' + | 'verified-transport-expansion-debit' + | 'observed-return-only' + | 'unverified-reported'; + +function metadataOf(entry: AnalyticsEntry): Record { + return entry.metadata || {}; +} + +function finite(value: unknown): number | null { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function sha256(value: unknown): boolean { + return typeof value === 'string' && /^[a-f0-9]{64}$/i.test(value); +} + +function hasConsistentMaterializedDelta( + entry: AnalyticsEntry, + metadata: Record +): boolean { + const baselineBytes = finite(metadata.baselineBytes); + const returnedBytes = finite(metadata.returnedBytes); + const bytesSaved = finite(metadata.bytesSaved); + return ( + typeof entry.measurementId === 'string' && + entry.measurementId.length > 0 && + metadata.measurementId === entry.measurementId && + sha256(metadata.baselineSha256) && + sha256(metadata.returnedSha256) && + metadata.baselineSha256 !== metadata.returnedSha256 && + typeof metadata.disclosureRef === 'string' && + /^[a-f0-9]{16}$/i.test(metadata.disclosureRef) && + baselineBytes !== null && + returnedBytes !== null && + bytesSaved !== null && + baselineBytes > returnedBytes && + bytesSaved === baselineBytes - returnedBytes && + entry.originalTokens > entry.optimizedTokens && + entry.tokensSaved === entry.originalTokens - entry.optimizedTokens + ); +} + +function hasConsistentExpansionDebit( + entry: AnalyticsEntry, + metadata: Record +): boolean { + const returnedBytes = finite(metadata.returnedBytes); + return ( + typeof entry.measurementId === 'string' && + entry.measurementId.length > 0 && + metadata.measurementId === entry.measurementId && + typeof metadata.expansionRef === 'string' && + /^[a-f0-9]{16}$/i.test(metadata.expansionRef) && + typeof metadata.creditedMeasurementId === 'string' && + metadata.creditedMeasurementId.length > 0 && + sha256(metadata.returnedSha256) && + returnedBytes !== null && + returnedBytes > 0 && + entry.originalTokens === entry.optimizedTokens && + entry.optimizedTokens > 0 && + entry.tokensSaved === 0 + ); +} + +export function classifySavings(entry: AnalyticsEntry): SavingsClassification { + const metadata = metadataOf(entry); + const schemaVersion = Number(metadata.measurementSchemaVersion); + const measurementClass = String(metadata.measurementClass || ''); + + if ( + entry.savingsMeasured === true && + schemaVersion === SAVINGS_MEASUREMENT_SCHEMA_VERSION && + measurementClass === 'verified-transport-reduction' && + metadata.baselineKind === 'materialized-undisclosed-mcp-result' && + hasConsistentMaterializedDelta(entry, metadata) + ) { + return 'verified-transport-reduction'; + } + + if ( + schemaVersion === SAVINGS_MEASUREMENT_SCHEMA_VERSION && + measurementClass === 'verified-transport-expansion-debit' && + hasConsistentExpansionDebit(entry, metadata) + ) { + return 'verified-transport-expansion-debit'; + } + + if ( + schemaVersion === SAVINGS_MEASUREMENT_SCHEMA_VERSION || + ['actual-return-context-only', 'optimizer-before-actual-return'].includes( + String(metadata.measurement || '') + ) + ) { + return 'observed-return-only'; + } + + return 'unverified-reported'; +} + +export function isVerifiedSavingsEntry(entry: AnalyticsEntry): boolean { + return classifySavings(entry) === 'verified-transport-reduction'; +} + +export function isVerifiedExpansionDebit(entry: AnalyticsEntry): boolean { + return classifySavings(entry) === 'verified-transport-expansion-debit'; +} + +/** Signed contribution to net MCP transport avoided. */ +export function verifiedTransportDelta(entry: AnalyticsEntry): number { + const classification = classifySavings(entry); + if (classification === 'verified-transport-reduction') { + return Math.max(0, Number(entry.tokensSaved) || 0); + } + if (classification === 'verified-transport-expansion-debit') { + return -Math.max(0, Number(entry.optimizedTokens) || 0); + } + return 0; +} + +export function hasObservedReturnedContext(entry: AnalyticsEntry): boolean { + return classifySavings(entry) !== 'unverified-reported'; +} + +export function reportedSavings(entry: AnalyticsEntry): number { + const metadataReported = (entry.metadata || {}).reportedToolSavings; + if (metadataReported && typeof metadataReported === 'object') { + const value = Number( + (metadataReported as Record).tokensSaved + ); + if (Number.isFinite(value)) return Math.max(0, value); + } + const value = Number(entry.tokensSaved); + return Number.isFinite(value) ? Math.max(0, value) : 0; +} diff --git a/src/dashboard/public/css/styles.css b/src/dashboard/public/css/styles.css index 4f7d8f653..a4af44e6b 100644 --- a/src/dashboard/public/css/styles.css +++ b/src/dashboard/public/css/styles.css @@ -205,6 +205,134 @@ text-decoration: underline; } +/* ---------------------------------------------------------- accounting -- */ + +.accounting { + margin-bottom: var(--gap); +} + +.section-heading-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; +} + +.source-chip { + background: var(--surface-3); + border: 1px solid var(--line); + border-radius: 999px; + color: var(--ink-3); + flex: none; + font-size: 11px; + padding: 5px 10px; +} + +.accounting-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin-top: 18px; +} + +.accounting-card { + background: var(--surface-2); + border: 1px solid var(--line); + border-radius: var(--radius-sm); + display: grid; + gap: 4px; + padding: 14px; +} + +.accounting-label, +.accounting-detail, +.accounting-note { + color: var(--ink-3); + font-size: 11.5px; +} + +.accounting-value { + font-size: 24px; +} + +.accounting-note { + line-height: 1.55; + margin: 12px 0 0; +} + +.saved-cell { + color: var(--saved); +} + +.client-accounting { + margin-bottom: var(--gap); +} + +.client-ledger { + display: grid; + gap: 7px; + margin-top: 16px; +} + +.client-ledger-row { + align-items: center; + border-bottom: 1px solid var(--line); + display: grid; + gap: 12px; + grid-template-columns: minmax(190px, 1.5fr) repeat(5, minmax(105px, 0.7fr)); + padding: 10px 8px; +} + +.client-ledger-row:last-child { + border-bottom: 0; +} + +.client-ledger-row > div { + display: grid; + gap: 3px; +} + +.client-ledger-row span { + color: var(--ink-3); + font-size: 10.5px; +} + +.client-ledger-row strong { + font-size: 12.5px; + overflow-wrap: anywhere; +} + +@media (max-width: 860px) { + .client-ledger-row { + grid-template-columns: 1fr 1fr; + } + + .client-ledger-row > div:first-child { + grid-column: 1 / -1; + } +} + +@media (max-width: 760px) { + .accounting-grid { + grid-template-columns: 1fr 1fr; + } + + .section-heading-row { + display: block; + } + + .source-chip { + display: inline-block; + margin-top: 10px; + } +} + +@media (max-width: 480px) { + .accounting-grid { + grid-template-columns: 1fr; + } +} + /* ---------------------------------------------------------------- kpis -- */ .kpis { @@ -438,6 +566,19 @@ section.card { background: var(--surface-2); } +.provider-table { + margin-top: 18px; +} + +.table-note { + color: var(--ink-3); + font-size: 11.5px; +} + +.table-note a { + color: var(--graph); +} + /* ---------------------------------------------------------------- misc -- */ .footer-note { diff --git a/src/dashboard/public/css/tokens.css b/src/dashboard/public/css/tokens.css index 67e40027b..c450c6a34 100644 --- a/src/dashboard/public/css/tokens.css +++ b/src/dashboard/public/css/tokens.css @@ -404,13 +404,17 @@ a:focus-visible, .knowledge-graph-3d { position: relative; width: 100%; - height: 100%; - min-height: inherit; overflow: hidden; isolation: isolate; + /* The host owns its height (the overview and wiki stages have different + constraints). A percentage height here made the canvas participate in its + parent's intrinsic sizing, so ResizeObserver could grow the card forever. */ + contain: layout paint; } .knowledge-graph-canvas { + position: absolute; + inset: 0; display: block; width: 100%; height: 100%; diff --git a/src/dashboard/public/css/wiki.css b/src/dashboard/public/css/wiki.css index c34eb18bd..324956c07 100644 --- a/src/dashboard/public/css/wiki.css +++ b/src/dashboard/public/css/wiki.css @@ -137,6 +137,93 @@ line-height: 1.55; } +.capture-clients { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(290px, 1fr)); + gap: 9px; + margin-top: 14px; +} + +.capture-client { + border: 1px solid var(--line); + border-radius: var(--radius-sm); + display: grid; + grid-template-columns: minmax(140px, 0.72fr) minmax(0, 1.6fr); + gap: 14px; + padding: 12px 14px; +} + +.capture-client.is-unhealthy { + border-color: var(--bad); +} + +.capture-client-name strong, +.capture-client-name span { + display: block; +} + +.capture-client-name strong { + font-size: 13px; + overflow-wrap: anywhere; +} + +.capture-client-name span, +.capture-client dt { + color: var(--ink-3); + font-size: 10.5px; + margin-top: 3px; +} + +.capture-client dl { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px 14px; + margin: 0; +} + +.capture-client dt, +.capture-client dd { + margin: 0; +} + +.capture-client dd { + font-size: 11.5px; + line-height: 1.4; +} + +.method-facts { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); + gap: 8px; + margin-top: 14px; +} + +.method-fact { + border-left: 2px solid var(--line-bright); + display: grid; + gap: 3px; + padding: 4px 10px; +} + +.method-fact span { + color: var(--ink-3); + font-size: 10.5px; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.method-fact strong { + font-size: 12px; + font-weight: 550; + line-height: 1.4; +} + +@media (max-width: 560px) { + .capture-client { + grid-template-columns: 1fr; + } +} + /* ----------------------------------------------------------------- tabs -- */ .wiki-tabs { diff --git a/src/dashboard/public/index.html b/src/dashboard/public/index.html index 69ba1eb22..3f1ed2de5 100644 --- a/src/dashboard/public/index.html +++ b/src/dashboard/public/index.html @@ -34,7 +34,13 @@
-
+

Saved so far

-
—
-
tokens you did not have to spend
+
+ Loading… +
+
+ net context tokens avoided at the MCP transport +
- — + Loading… - estimated + direct API-price equivalent saved
- — + Loading… smaller than it would have been
@@ -96,9 +113,10 @@ > ? - Graph nodes observed by supported coding agents are remembered here - with their structure, discoveries, and provenance. Any supported - client can reuse that knowledge in a later session or project. + Graph nodes observed by supported coding agents are remembered + here with their structure, discoveries, and provenance. Any + supported client can reuse that knowledge in a later session or + project.

@@ -108,10 +126,96 @@
+
+
+
+

Token accounting

+

+ Gross payload reductions, later expansions, observed returns, + quarantined reports, and graph estimates stay separate. +

+
+ provenance-gated on this machine +
+
+

Loading measured token accounting…

+
+

+
+ +
+
+
+

+ Provider usage & price +

+

+ Native uncached input, cache reads, cache writes, and output from + local CLI transcripts, deduplicated by provider request. +

+
+ Loading +
+
+

Loading native provider usage…

+
+
+ + + + + + + + + + + + + +
CLI & modelRequestsUncached inputCache readCache writeOutputAPI-price equivalent
+
+

+
+
+
+

+ Measured by agent +

+

+ New optimizer measurements use MCP handshake identity. Historical rows + without identity remain unattributed instead of being guessed. +

+
+

Loading agent attribution…

+
+
+

@@ -124,12 +228,14 @@ ? Lifecycle activity is grouped by CLI when cross-client telemetry - is available; older clients may report context categories instead. + is available; older clients may report context categories + instead.

- Cross-client observations are used instead of a single CLI's stale session file. + Cross-client observations are used instead of a single CLI's stale + session file.

@@ -151,16 +257,18 @@

- Ranked by observed calls; no token amount is invented when a CLI omits it. + Ranked by observed calls; no token amount is invented when a CLI + omits it.

-

Recent agent activity

+

Recent measured optimizer activity

- What the active CLI actually did, most recent first. + Verified reductions, observed returns, and excluded historical claims, + most recent first.

@@ -174,6 +282,9 @@ Action Times used Context used + Cost equivalent + Verified saved + Excluded report @@ -182,8 +293,9 @@ diff --git a/src/dashboard/public/js/dashboard.js b/src/dashboard/public/js/dashboard.js index 3072fe9f3..9c8fa3262 100644 --- a/src/dashboard/public/js/dashboard.js +++ b/src/dashboard/public/js/dashboard.js @@ -18,9 +18,6 @@ import { createKnowledgeGraph3D } from './graph3d.js'; const API = '/api'; -/** Reference rate for scale only. Provider and model prices vary. */ -const USD_PER_MILLION_TOKENS = 3; - const CATEGORY_COLORS = { tools: 'var(--graph)', hooks: 'var(--saved)', @@ -48,6 +45,7 @@ const SERVER_COLORS = [ const $ = (id) => document.getElementById(id); let overviewGraph = null; +let loadGeneration = 0; document.addEventListener('DOMContentLoaded', () => { $('refresh-btn').addEventListener('click', load); @@ -55,64 +53,216 @@ document.addEventListener('DOMContentLoaded', () => { setInterval(load, 30_000); }); -async function get(path) { +async function get(path, timeoutMs = 20_000) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); try { - const res = await fetch(`${API}${path}`); + const res = await fetch(`${API}${path}`, { signal: controller.signal }); const body = await res.json().catch(() => null); return { ok: res.ok, body }; } catch { return { ok: false, body: null }; + } finally { + clearTimeout(timeout); } } async function load() { - // Every panel is independent: the graph is worth showing even when there is - // no active session, and vice versa. One missing answer must not blank the - // whole page. - const [diagnostics, balance, status, constellation] = await Promise.all([ - get('/diagnostics/hooks?hours=24&limit=100'), - get('/wiki/balance?scope=all'), - get('/wiki/status?scope=all'), - get('/wiki/constellation?cap=90&scope=all'), - ]); - let summary = { ok: false, body: null }; - let events = { ok: false, body: null }; - if (!diagnostics.ok || !diagnostics.body?.summary?.available) { - [summary, events] = await Promise.all([ - get('/session-summary'), - get('/session-events?limit=100'), - ]); + // Optimizer accounting is the primary product answer. Render it as soon as + // its small local ledger returns; a cold all-project graph can take seconds + // and must never hold Saved so far, agent attribution, or action cost blank. + const generation = ++loadGeneration; + const analytics = await get('/analytics/overview?limit=40'); + if (generation !== loadGeneration) return; + const measuredAnalytics = + analytics.ok && analytics.body?.available ? analytics.body : null; + + // First meaningful paint: MCP measurements do not depend on graph APIs. + renderSavings(measuredAnalytics, null, null); + renderAccounting(measuredAnalytics, null); + renderProviderAccounting(measuredAnalytics); + renderVerdict(measuredAnalytics, null, null); + if (measuredAnalytics) { + const optimizerOnly = joinActivity( + null, + measuredAnalytics, + null, + null, + null + ); + renderClientLedger(optimizerOnly, null); + renderServers(optimizerOnly); + renderTimeline(optimizerOnly.events); + renderBreakdown(optimizerOnly); } + const providerUsagePromise = get( + '/analytics/provider-usage?limit=40', + 60_000 + ); + + // Do not even start the synchronous graph work until the browser has the + // primary accounting response. Starting these requests earlier can let a + // cold graph route monopolize the local Node process before analytics is + // delivered, recreating the same blank hero through server-side contention. + const diagnosticsPromise = get('/diagnostics/hooks?hours=24&limit=100'); + const balancePromise = get('/wiki/balance?scope=all'); + const statusPromise = get('/wiki/status?scope=all'); + const constellationPromise = get('/wiki/constellation?cap=90&scope=all'); - renderSavings(balance.body, status.body); - renderVerdict(balance.body, status.body); - renderGraph(constellation.body, status.body); + const diagnostics = await diagnosticsPromise; + if (generation !== loadGeneration) return; + const needsLegacy = + !(diagnostics.ok && diagnostics.body?.summary?.available) && + !measuredAnalytics; + const [summary, events] = needsLegacy + ? await Promise.all([ + get('/session-summary'), + get('/session-events?limit=100'), + ]) + : [null, null]; - const session = + let session = joinActivity( diagnostics.ok && diagnostics.body?.summary?.available ? activityFromDiagnostics(diagnostics.body) - : summary.ok && summary.body?.success - ? summary.body - : null; + : null, + measuredAnalytics, + summary?.ok && summary.body?.success ? summary.body : null, + events?.ok ? events.body?.events : null, + null + ); renderKpis(session); + renderClientLedger(session, null); renderCategories(session); renderServers(session); - renderTimeline( - session?.activityMode === 'hooks' - ? session.events - : events.ok - ? events.body?.events - : null + renderTimeline(session?.events); + renderBreakdown(session); + + const providerUsage = await providerUsagePromise; + if (generation !== loadGeneration) return; + if (measuredAnalytics && providerUsage.ok) + measuredAnalytics.providerUsage = providerUsage.body; + renderProviderAccounting(measuredAnalytics); + + // Graph data enriches the already-visible optimizer ledger when ready. Each + // request is bounded, so a damaged graph cannot leave a refresh pending + // forever or accumulate overlapping 30-second refreshes. + const [balance, status, constellation] = await Promise.all([ + balancePromise, + statusPromise, + constellationPromise, + ]); + if (generation !== loadGeneration) return; + const graphBalance = balance.ok ? balance.body : null; + const graphStatus = status.ok ? status.body : null; + renderSavings(measuredAnalytics, graphBalance, graphStatus); + renderAccounting(measuredAnalytics, graphBalance); + renderVerdict(measuredAnalytics, graphBalance, graphStatus); + renderGraph(constellation.ok ? constellation.body : null, graphStatus); + + session = joinActivity( + diagnostics.ok && diagnostics.body?.summary?.available + ? activityFromDiagnostics(diagnostics.body) + : null, + measuredAnalytics, + summary?.ok && summary.body?.success ? summary.body : null, + events?.ok ? events.body?.events : null, + graphBalance ); + renderClientLedger(session, graphBalance); + renderServers(session); + renderTimeline(session?.events); renderBreakdown(session); $('last-updated').textContent = new Date().toLocaleTimeString(); } +function joinActivity(hooks, analytics, legacy, legacyEvents, balance) { + if (!hooks && !analytics && !legacy) return null; + if (!hooks && !analytics) return { ...legacy, events: legacyEvents || [] }; + + const measuredRows = analytics?.byAction || []; + const measuredByName = new Map(measuredRows.map((row) => [row.name, row])); + const hookRows = toolRows(hooks); + const names = new Set([ + ...measuredByName.keys(), + ...hookRows.map((row) => row.name), + ]); + const toolBreakdown = [...names].map((name) => { + const measured = measuredByName.get(name); + const observed = hookRows.find((row) => row.name === name); + return { + name, + count: observed?.count ?? measured?.totalOperations ?? 0, + observedCalls: observed?.count ?? null, + measuredOperations: measured?.totalOperations ?? 0, + tokens: measured?.observedReturnedContextOperations + ? measured.totalOptimizedTokens + : null, + totalOriginalTokens: measured?.totalOriginalTokens ?? null, + tokensSaved: measured?.totalTokensSaved ?? null, + unverifiedReportedTokensSaved: + measured?.unverifiedReportedTokensSaved ?? null, + contextUsd: measured?.contextUsd ?? null, + savedUsd: measured?.savedUsd ?? null, + }; + }); + const native = balance?.nativeOptimizer; + if (native?.substitutions) { + toolBreakdown.push({ + name: 'live_graph_substitution', + count: native.substitutions, + observedCalls: native.substitutions, + measuredOperations: native.substitutions, + tokens: native.tokensReturned, + totalOriginalTokens: native.tokensReturned + native.tokensSaved, + tokensSaved: null, + unverifiedReportedTokensSaved: native.tokensSaved, + contextUsd: priceTokens(analytics, native.tokensReturned), + savedUsd: null, + classification: 'modeled-counterfactual', + }); + } + const recentMeasured = [ + ...(analytics?.recent || []), + ...(native?.recent || []).map((row) => ({ + ...row, + classification: row.classification || 'modeled-counterfactual', + savingsMeasured: false, + reportedTokensSaved: Number(row.tokensSaved || 0), + tokensSaved: 0, + contextUsd: priceTokens(analytics, Number(row.optimizedTokens || 0)), + savedUsd: null, + })), + ].sort( + (a, b) => Date.parse(b.timestamp || '') - Date.parse(a.timestamp || '') + ); + + return { + ...(hooks || {}), + activityMode: 'combined', + analytics, + legacy, + totalTokens: + analytics?.summary?.totalOptimizedTokens ?? legacy?.totalTokens ?? null, + totalTools: + hooks?.totalTools ?? + analytics?.summary?.totalOperations ?? + legacy?.totalTools ?? + 0, + toolBreakdown, + events: recentMeasured.length + ? recentMeasured + : legacyEvents || hooks?.events || [], + }; +} + function activityFromDiagnostics(report) { const events = report.events || []; const actions = events.filter( - (event) => event.hookEvent === 'pre-tool' && event.toolName + (event) => + event.event !== 'hook.started' && + event.hookEvent === 'pre-tool' && + event.toolName ); const toolBreakdown = {}; for (const event of actions) { @@ -120,10 +270,13 @@ function activityFromDiagnostics(report) { toolBreakdown[name] ||= { count: 0, tokens: null }; toolBreakdown[name].count += 1; } + for (const [name, count] of Object.entries(report.summary.byTool || {})) { + toolBreakdown[name] = { count, tokens: null }; + } return { activityMode: 'hooks', totalHooks: report.summary.total || 0, - totalTools: actions.length, + totalTools: report.summary.actions ?? actions.length, activeClients: Object.keys(report.summary.byClient || {}).length, p95DurationMs: report.summary.p95DurationMs, successRate: report.summary.successRate, @@ -135,35 +288,52 @@ function activityFromDiagnostics(report) { /* ------------------------------------------------------------- the hero -- */ -function renderSavings(balance, status) { - const saved = Number(balance?.estimatedTokensAvoided); +function renderSavings(analytics, balance, status) { + const mcpSaved = Number(analytics?.summary?.totalTokensSaved || 0); + const saved = mcpSaved; const foot = $('saved-foot'); + const card = $('saved-card'); - if (!balance?.sufficientData || !Number.isFinite(saved) || saved <= 0) { + const hasDirectMeasurement = Boolean( + analytics?.summary?.measuredSavingsOperations + ); + if (!hasDirectMeasurement || !Number.isFinite(saved)) { // Before there is a measurement, say what will appear and why -- and do // NOT show a zero, which reads as failure rather than as "not yet". $('saved-tokens').textContent = '—'; $('saved-money').textContent = '—'; $('saved-percent').textContent = '—'; + $('saved-tokens').dataset.state = analytics?.available + ? 'collecting' + : 'not-measured'; + card.dataset.state = $('saved-tokens').dataset.state; + card.setAttribute('aria-busy', 'false'); $('comparison').innerHTML = ''; - const learned = Number(status?.nodes) || 0; - foot.innerHTML = learned - ? `Still measuring. ${fmt(learned)} things learned so far — the savings ` + - `figure appears once there is enough of a comparison to be honest about.` - : `Nothing measured yet. Work normally in a supported coding agent for a few minutes ` + - `and this fills itself in.`; + const excluded = Number( + analytics?.summary?.unverifiedReportedTokensSaved || 0 + ); + foot.textContent = excluded + ? `${fmt(excluded)} historically reported tokens are quarantined because their baselines cannot be verified. A verified total appears after a materialized MCP payload is actually reduced.` + : `Nothing verified yet. Savings appear after the server observes both a materialized MCP payload and the smaller payload actually returned; later expansions are debited.`; return; } - const spentAnyway = - Number(balance.injectedTokens || 0) + Number(balance.harvestTokens || 0); - const wouldHave = saved + spentAnyway; + $('saved-tokens').dataset.state = 'measured'; + card.dataset.state = 'measured'; + card.setAttribute('aria-busy', 'false'); + + const spentAnyway = Number(analytics?.summary?.measuredOptimizedTokens || 0); + const wouldHave = Number(analytics?.summary?.totalOriginalTokens || 0); const percent = wouldHave > 0 ? (saved / wouldHave) * 100 : 0; countUp($('saved-tokens'), saved); + $('saved-unit').textContent = + saved >= 0 + ? 'net context tokens avoided at the MCP transport' + : 'net context tokens added at the MCP transport'; $('saved-money').textContent = - `$${((saved / 1e6) * USD_PER_MILLION_TOKENS).toFixed(2)}`; + `${formatUsd(analytics?.summary?.savedUsd)} · ${fmt(analytics?.summary?.pricedSavingsOperations || 0)}/${fmt((analytics?.summary?.measuredSavingsOperations || 0) + (analytics?.summary?.verifiedExpansionOperations || 0))} ops`; $('saved-percent').textContent = `${percent.toFixed(0)}%`; comparison($('comparison'), { @@ -174,8 +344,307 @@ function renderSavings(balance, status) { }); foot.textContent = - `Measured against ${fmt(balance.holdouts)} reads deliberately left alone, ` + - `so this is a comparison rather than a guess.`; + `${fmt(analytics?.summary?.measuredSavingsOperations || 0)} of ${fmt(analytics?.summary?.totalOperations || 0)} MCP operations have a verified comparable baseline. ` + + `${fmt(analytics?.summary?.verifiedExpansionOperations || 0)} later expansion(s) returned ${fmt(analytics?.summary?.expansionTokensReturned || 0)} tokens and were debited. Historical reports and ${fmt(balance?.nativeOptimizer?.substitutions || 0)} modeled graph substitutions remain separate.`; +} + +function formatUsd(value) { + if (value === null || value === undefined) return 'Not priced'; + const number = Number(value); + if (!Number.isFinite(number)) return 'Not priced'; + if (number === 0) return '$0.00'; + if (Math.abs(number) < 0.01) return `$${number.toFixed(4)}`; + return `$${number.toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; +} + +function priceTokens(analytics, tokens) { + return null; +} + +function currencyAmount(amounts) { + const entries = Object.entries(amounts || {}).filter(([, amount]) => + Number.isFinite(Number(amount)) + ); + if (!entries.length) return 'Not priced'; + return entries + .map(([currency, amount]) => { + const value = Number(amount); + return currency === 'USD' + ? formatUsd(value) + : `${currency} ${value.toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; + }) + .join(' + '); +} + +function renderProviderAccounting(analytics) { + const section = $('provider-accounting'); + const host = $('provider-accounting-grid'); + const body = $('provider-breakdown-body'); + const note = $('provider-accounting-note'); + const coverage = $('provider-price-coverage'); + const report = analytics?.providerUsage; + if (!report?.available) { + section.dataset.state = 'not-measured'; + section.setAttribute('aria-busy', 'false'); + coverage.textContent = 'No native usage yet'; + host.innerHTML = teach( + 'No provider usage captured', + 'Run Codex, Claude Code, or Gemini CLI. Native usage appears here without estimating omitted dimensions.' + ); + body.innerHTML = ''; + note.textContent = ''; + return; + } + const usage = report.usage || {}; + const writes = + Number(usage.cacheWrite5mInputTokens || 0) + + Number(usage.cacheWrite1hInputTokens || 0) + + Number(usage.cacheWriteInputTokens || 0); + const cards = [ + [ + 'Provider requests', + fmt(report.requestCount), + `${fmt(report.pricedRequestCount)} exactly priced`, + ], + [ + 'Uncached input', + compact(usage.uncachedInputTokens || 0), + 'native billable usage', + ], + [ + 'Cache reads', + compact(usage.cachedInputTokens || 0), + 'native billable usage', + ], + ['Cache writes', compact(writes), '5-minute + 1-hour + generic'], + [ + 'Output', + compact(usage.outputTokens || 0), + 'thinking included when provider bills it', + ], + [ + 'API-price equivalent', + currencyAmount(report.apiEquivalentCost), + 'list price; not necessarily your invoice', + ], + ]; + host.innerHTML = cards + .map( + ([label, value, detail]) => ` +
+ ${escapeHtml(label)} + ${escapeHtml(value)} + ${escapeHtml(detail)} +
` + ) + .join(''); + coverage.textContent = `${Number(report.pricingCoveragePercent || 0).toFixed(1)}% price coverage`; + body.innerHTML = (report.byModel || []) + .map((row) => { + const rowWrites = + Number(row.usage.cacheWrite5mInputTokens || 0) + + Number(row.usage.cacheWrite1hInputTokens || 0) + + Number(row.usage.cacheWriteInputTokens || 0); + const source = row.priceSourceUrl + ? `${escapeHtml(row.priceSourceLabel)}` + : escapeHtml(row.priceSourceLabel); + return ` + ${escapeHtml(row.client)} · ${escapeHtml(row.model)}
${escapeHtml(row.billingRoute)}${row.plan ? ` · ${escapeHtml(row.plan)}` : ''} · ${source} + ${fmt(row.requestCount)} + ${fmt(row.usage.uncachedInputTokens)} + ${fmt(row.usage.cachedInputTokens)} + ${fmt(rowWrites)} + ${fmt(row.usage.outputTokens)} + ${row.pricedRequestCount === row.requestCount ? currencyAmount(row.apiEquivalentCost) : `${currencyAmount(row.apiEquivalentCost)} · ${fmt(row.requestCount - row.pricedRequestCount)} unpriced`} + `; + }) + .join(''); + const nativeCost = currencyAmount(report.nativeReportedCost); + note.textContent = + `${report.source}. Window: ${escapeHtml(report.window?.firstSeen ? new Date(report.window.firstSeen).toLocaleString() : 'no first receipt')} to ${escapeHtml(report.window?.lastSeen ? new Date(report.window.lastSeen).toLocaleString() : 'no last receipt')}. ` + + `API-price equivalent: ${currencyAmount(report.apiEquivalentCost)} across ${fmt(report.pricedRequestCount)}/${fmt(report.requestCount)} requests. ` + + `Provider-reported actual charge: ${nativeCost}. Missing exact model ids and unpublished dimensions stay unpriced instead of using a blended guess.`; + section.dataset.state = 'measured'; + section.setAttribute('aria-busy', 'false'); +} + +function renderAccounting(analytics, balance) { + const host = $('accounting-grid'); + const section = $('token-accounting'); + const note = $('accounting-note'); + const summary = analytics?.summary; + const graphCost = + Number(balance?.deliveryTokens ?? balance?.injectedTokens ?? 0) + + Number(balance?.harvestTokens || 0); + const causalSaved = balance?.sufficientData + ? Number(balance.estimatedTokensAvoided) + : null; + const hasDirect = + Boolean(summary?.totalOperations) || + Number(balance?.nativeOptimizer?.substitutions || 0) > 0; + const hasSavingsMeasurement = Boolean(summary?.measuredSavingsOperations); + const canComputeNet = hasSavingsMeasurement || causalSaved != null; + const combinedNet = canComputeNet + ? Number(summary?.totalTokensSaved || 0) + + Number(causalSaved || 0) - + graphCost + : null; + const cards = [ + [ + 'Original context', + summary?.measuredSavingsOperations + ? compact(summary.totalOriginalTokens) + : 'Not measured', + 'known before-state only', + ], + [ + 'Returned context', + summary ? compact(summary.totalOptimizedTokens) : 'Not measured', + summary + ? `${formatUsd(summary.contextUsd)} from ${fmt(summary.pricedReturnedContextOperations || 0)}/${fmt(summary.observedReturnedContextOperations || 0)} exactly modeled operations · ${fmt(summary.unverifiedReportedOperations || 0)} unverified rows excluded` + : 'actual results returned to clients', + ], + [ + 'Optimizer saved', + hasSavingsMeasurement + ? compact(Number(summary?.totalTokensSaved || 0)) + : 'Not measured', + hasSavingsMeasurement + ? `${formatUsd(summary?.savedUsd)} from ${fmt(summary?.pricedSavingsOperations || 0)} exactly modeled operations · ${compact(summary.grossTokensSaved)} gross − ${compact(summary.expansionTokensReturned)} expanded` + : 'No comparable payload pair yet', + ], + [ + 'Excluded reported saving', + summary?.unverifiedReportedOperations + ? compact(summary.unverifiedReportedTokensSaved) + : 'None', + summary?.unverifiedReportedOperations + ? `${fmt(summary.unverifiedReportedOperations)} legacy/tool rows retained for audit, excluded from totals` + : 'no quarantined claims', + ], + [ + 'Graph memory cost', + balance ? compact(graphCost) : 'Not measured', + balance + ? 'delivery + harvest; price requires model attribution' + : 'delivery + harvest', + ], + [ + 'Causal graph saving', + causalSaved == null ? 'Collecting' : compact(causalSaved), + causalSaved == null + ? `${fmt(balance?.injections || 0)}/20 treated · ${fmt(balance?.holdouts || 0)}/5 holdout` + : 'holdout measured', + ], + [ + 'Verified net', + combinedNet == null ? 'Not measured' : compact(combinedNet), + causalSaved == null + ? 'verified MCP saving − graph cost; modeled graph benefits excluded' + : 'verified MCP + holdout graph saving − graph cost', + ], + ]; + host.innerHTML = cards + .map( + ([label, value, detail]) => ` +
+ ${escapeHtml(label)} + ${escapeHtml(value)} + ${escapeHtml(detail)} +
` + ) + .join(''); + section.dataset.state = hasSavingsMeasurement ? 'measured' : 'not-measured'; + section.setAttribute('aria-busy', 'false'); + note.textContent = hasDirect + ? `${analytics?.source || 'No MCP operation rows yet'}. ${analytics?.measurement?.tokenCountMethod || ''} ${analytics?.pricing?.explanation || 'Cost is not priced.'} Graph counterfactuals remain outside the verified MCP total.` + : 'No direct optimizer before/after measurements have been recorded yet.'; +} + +function renderClientLedger(session, balance) { + const host = $('client-ledger'); + const section = $('measured-by-agent'); + const analytics = session?.analytics; + const activeClients = new Set(Object.keys(session?.clients || {})); + const rows = (analytics?.byClient || []).map((row) => ({ ...row })); + for (const [name, native] of Object.entries( + balance?.nativeOptimizer?.byClient || {} + )) { + const current = rows.find((row) => row.name === name); + if (current) { + current.totalOperations += native.substitutions || 0; + current.unverifiedReportedOperations = + Number(current.unverifiedReportedOperations || 0) + + Number(native.substitutions || 0); + current.unverifiedReportedTokensSaved = + Number(current.unverifiedReportedTokensSaved || 0) + + Number(native.tokensSaved || 0); + current.nativeSubstitutions = native.substitutions || 0; + } else { + rows.push({ + name, + attribution: + name === 'Historical — client not recorded' + ? 'historical-unattributed' + : 'recorded', + totalOperations: native.substitutions || 0, + observedReturnedContextOperations: 0, + verifiedSavingsOperations: 0, + unverifiedReportedOperations: native.substitutions || 0, + totalOptimizedTokens: null, + totalTokensSaved: null, + unverifiedReportedTokensSaved: native.tokensSaved || 0, + contextUsd: null, + savedUsd: null, + nativeSubstitutions: native.substitutions || 0, + }); + } + } + for (const client of activeClients) { + if (!rows.some((row) => row.name === client)) { + rows.push({ + name: client, + attribution: 'lifecycle-only', + totalOperations: 0, + totalOptimizedTokens: null, + totalTokensSaved: null, + contextUsd: null, + }); + } + } + if (!rows.length) { + host.innerHTML = teach( + 'No agent attribution yet', + 'New MCP operations record their client identity after the handshake.' + ); + section.dataset.state = 'not-measured'; + section.setAttribute('aria-busy', 'false'); + return; + } + host.innerHTML = rows + .map( + (row) => ` +
+
+ ${escapeHtml(row.name)} + ${row.attribution === 'historical-unattributed' ? 'historical reports quarantined; client was not stored' : row.attribution === 'lifecycle-only' ? 'lifecycle active; optimizer cost not yet attributed' : `${row.nativeSubstitutions ? `${fmt(row.nativeSubstitutions)} modeled graph substitutions · ` : ''}attributed by client protocol`} +
+
Operations${fmt(row.totalOperations)}
+
Returned context${row.totalOptimizedTokens == null ? 'Not measured' : fmt(row.totalOptimizedTokens)}
+
Cost equivalent${formatUsd(row.contextUsd)}
+
Verified net${row.totalTokensSaved == null ? 'Not measured' : fmt(row.totalTokensSaved)}
+
Excluded report${fmt(row.unverifiedReportedTokensSaved || 0)}
+
` + ) + .join(''); + section.dataset.state = 'measured'; + section.setAttribute('aria-busy', 'false'); } /** A number that lands rather than appearing. Skipped when motion is reduced. */ @@ -195,13 +664,25 @@ function countUp(node, target) { requestAnimationFrame(step); } -function renderVerdict(balance, status) { +function renderVerdict(analytics, balance, status) { const box = $('verdict'); const title = $('verdict-title'); const detail = $('verdict-detail'); box.hidden = false; box.classList.remove('is-ok', 'is-warn', 'is-bad'); + const directSaved = Number(analytics?.summary?.totalTokensSaved || 0); + const graphCost = + Number(balance?.deliveryTokens ?? balance?.injectedTokens ?? 0) + + Number(balance?.harvestTokens || 0); + + if (!balance?.sufficientData && directSaved > 0) { + box.classList.add('is-ok'); + title.textContent = 'Saving context now'; + detail.textContent = `${fmt(directSaved)} tokens were removed by measured optimizer operations. The separate causal graph-effect study is still collecting (${fmt(balance?.injections || 0)}/20 treated, ${fmt(balance?.holdouts || 0)}/5 holdout).`; + return; + } + if (!balance?.sufficientData) { box.classList.add('is-warn'); title.textContent = 'Still learning'; @@ -212,10 +693,9 @@ function renderVerdict(balance, status) { return; } - const net = Number(balance.netTokens || 0); - const cost = - Number(balance.injectedTokens || 0) + Number(balance.harvestTokens || 0); - const ratio = cost > 0 ? (net + cost) / cost : Infinity; + const net = + directSaved + Number(balance.estimatedTokensAvoided || 0) - graphCost; + const ratio = graphCost > 0 ? (net + graphCost) / graphCost : Infinity; if (net > 0) { box.classList.add('is-ok'); @@ -376,7 +856,7 @@ function renderKpis(s) { } const cards = - s.activityMode === 'hooks' + s.activityMode === 'hooks' || s.activityMode === 'combined' ? [ { label: 'Lifecycle events', @@ -446,7 +926,7 @@ function renderKpis(s) { function renderCategories(s) { const host = $('category-chart'); - if (s?.activityMode === 'hooks') { + if (s?.clients) { const slices = Object.entries(s.clients).map(([client, values], index) => ({ label: client, value: values.total || 0, @@ -478,24 +958,24 @@ function renderCategories(s) { function renderServers(s) { const host = $('server-chart'); - if (s?.activityMode === 'hooks') { + if (s?.analytics?.byAction?.length) { const slices = toolRows(s) - .filter((row) => row.count > 0) + .filter((row) => Number(row.tokensSaved) > 0) .map((row, index) => ({ label: row.name || row.tool, - value: row.count, + value: row.tokensSaved, color: SERVER_COLORS[index % SERVER_COLORS.length], })) .sort((a, b) => b.value - a.value); if (!slices.length) { host.innerHTML = teach( - 'No actions observed yet', - 'Lifecycle hooks are healthy, but no pre-tool action is present in the selected 24-hour window.' + 'No measured savings yet', + 'Optimizer operations are recorded here after both their original and returned context sizes are known.' ); return; } donut(host, slices, { - centerLabel: 'actions observed', + centerLabel: 'tokens saved', centerValue: compact(slices.reduce((sum, item) => sum + item.value, 0)), }); return; @@ -547,7 +1027,7 @@ function renderTimeline(events) { ${escapeHtml(action.tool)} ${escapeHtml(action.detail)} - ${action.tokens ? `${compact(action.tokens)} context tokens` : action.costLabel} + ${action.contextTokens != null ? (action.tokens == null ? `${compact(action.contextTokens)} context · ${action.costLabel}` : `${compact(action.contextTokens)} context · ${formatUsd(action.contextUsd)} · ${action.tokens < 0 ? `${compact(Math.abs(action.tokens))} expansion debit` : `${compact(action.tokens)} saved`}`) : action.tokens ? `${compact(action.tokens)} context tokens` : action.costLabel} ${e.timestamp ? new Date(e.timestamp).toLocaleTimeString() : ''} `; }) @@ -555,6 +1035,37 @@ function renderTimeline(events) { } function describeEvent(event) { + if (event.originalTokens != null && event.optimizedTokens != null) { + const isNative = event.name === 'live_graph_substitution'; + const classification = String(event.classification || ''); + const expansion = classification === 'verified-transport-expansion-debit'; + const unverified = + classification === 'unverified-reported' || + classification === 'modeled-counterfactual'; + return { + tool: isNative + ? `Live graph substitution${event.client ? ` · ${event.client}` : ''}` + : `${event.name || event.toolName || 'optimizer operation'}${event.client ? ` · ${event.client}` : ''}`, + detail: unverified + ? `${fmt(event.reportedTokensSaved || 0)} reported tokens excluded; no comparable materialized payload` + : expansion + ? `${fmt(event.optimizedTokens)} expansion tokens returned; debited from the linked preview` + : event.savingsMeasured === false + ? `${fmt(event.optimizedTokens)} returned context tokens; no before baseline` + : `${fmt(event.originalTokens)} → ${fmt(event.optimizedTokens)} context tokens`, + tokens: + event.savingsMeasured === false ? null : Number(event.tokensSaved || 0), + contextTokens: unverified ? null : Number(event.optimizedTokens || 0), + contextUsd: event.contextUsd, + costLabel: unverified + ? 'excluded from verified totals' + : event.savingsMeasured === false + ? `${formatUsd(event.contextUsd)} context · saving not measurable` + : expansion + ? `${formatUsd(event.contextUsd)} context · ${formatUsd(event.savedUsd)} net adjustment` + : `${formatUsd(event.contextUsd)} context · ${formatUsd(event.savedUsd)} saved`, + }; + } const hookEvent = String(event.hookEvent || '').trim(); const client = String(event.client || '').trim(); const tool = String( @@ -599,7 +1110,7 @@ function renderBreakdown(s) { const rows = toolRows(s); if (!rows.length) { body.innerHTML = - '' + + '' + teach( 'No actions counted yet', 'Each kind of observed agent action gets a row here, so repeat costs are easy to spot.' @@ -614,6 +1125,9 @@ function renderBreakdown(s) { ${escapeHtml(t.name || t.tool)} ${fmt(t.count)} ${t.tokens == null ? 'Not measured' : fmt(t.tokens)} + ${formatUsd(t.contextUsd)} + ${t.tokensSaved == null ? 'Not measured' : fmt(t.tokensSaved)} + ${t.unverifiedReportedTokensSaved == null ? 'None' : fmt(t.unverifiedReportedTokensSaved)} ` ) .join(''); diff --git a/src/dashboard/public/js/graph3d.js b/src/dashboard/public/js/graph3d.js index ccee6002f..cd5de2ed6 100644 --- a/src/dashboard/public/js/graph3d.js +++ b/src/dashboard/public/js/graph3d.js @@ -123,6 +123,7 @@ export function createKnowledgeGraph3D(host, initial, options = {}) { let moved = false; let previous = { x: 0, y: 0 }; let destroyed = false; + let resizeFrame = 0; function prepare() { const nodes = (graph.nodes || []).slice(0, options.cap || 150); @@ -156,10 +157,21 @@ export function createKnowledgeGraph3D(host, initial, options = {}) { // host two pixels larger. ResizeObserver observed the growth and repeated // it forever, which looked like a camera continuously zooming into the // overview graph while the entire canvas expanded without bound. - width = Math.max(1, Math.round(host.clientWidth)); - height = Math.max(1, Math.round(host.clientHeight)); - canvas.width = Math.round(width * ratio); - canvas.height = Math.round(height * ratio); + const nextWidth = Math.max(1, Math.round(host.clientWidth)); + const nextHeight = Math.max(1, Math.round(host.clientHeight)); + const nextCanvasWidth = Math.round(nextWidth * ratio); + const nextCanvasHeight = Math.round(nextHeight * ratio); + if ( + nextWidth === width && + nextHeight === height && + canvas.width === nextCanvasWidth && + canvas.height === nextCanvasHeight + ) + return; + width = nextWidth; + height = nextHeight; + canvas.width = nextCanvasWidth; + canvas.height = nextCanvasHeight; // CSS owns the display size (100% x 100%). Setting pixel dimensions here // would reintroduce the child -> auto-sized parent feedback loop. canvas.style.removeProperty('width'); @@ -478,7 +490,10 @@ export function createKnowledgeGraph3D(host, initial, options = {}) { chrome.querySelector('.graph-3d-reset').addEventListener('click', reset); - const observer = new ResizeObserver(resize); + const observer = new ResizeObserver(() => { + cancelAnimationFrame(resizeFrame); + resizeFrame = requestAnimationFrame(resize); + }); observer.observe(host); prepare(); resize(); @@ -505,6 +520,7 @@ export function createKnowledgeGraph3D(host, initial, options = {}) { destroy() { destroyed = true; cancelAnimationFrame(animation); + cancelAnimationFrame(resizeFrame); observer.disconnect(); host.innerHTML = ''; delete host.__knowledgeGraph3d; diff --git a/src/dashboard/public/js/wiki.js b/src/dashboard/public/js/wiki.js index 60775d57b..b2aefe46c 100644 --- a/src/dashboard/public/js/wiki.js +++ b/src/dashboard/public/js/wiki.js @@ -93,6 +93,10 @@ async function loadBalance() { balance = await api(scoped('/api/wiki/balance')); } catch { grid.innerHTML = [ + 'Graph substitutions', + 'Modeled graph tokens avoided', + 'Graph context returned', + 'Graph saving classification', 'Memory deliveries', 'Kept back for comparison', 'Cost of remembering', @@ -113,30 +117,73 @@ async function loadBalance() { return; } + const measurement = balance.measurement || {}; + const metricState = (key) => measurement.metrics?.[key] || null; + const measuredValue = (key, value, suffix = '') => { + const state = metricState(key); + if (state?.status === 'not-measured') return 'Not measured'; + return `${nf.format(Number(value) || 0)}${suffix}`; + }; + // Plain language, because someone reading this page has no reason to know // what "injected" means. The words describe what happened, not what the code // calls it. const tiles = [ + [ + 'Graph substitutions', + measuredValue( + 'nativeSubstitutions', + balance.nativeOptimizer?.substitutions + ), + ], + [ + 'Modeled graph tokens avoided', + metricState('nativeSubstitutions')?.status === 'not-measured' + ? 'Not measured' + : `${nf.format(balance.nativeOptimizer?.tokensSaved || 0)} tokens`, + ], + [ + 'Graph context returned', + metricState('nativeSubstitutions')?.status === 'not-measured' + ? 'Not measured' + : `${nf.format(balance.nativeOptimizer?.tokensReturned || 0)} tokens`, + ], + [ + 'Graph saving classification', + metricState('nativeSubstitutions')?.status === 'not-measured' + ? 'Not measured' + : 'Modeled counterfactual', + ], [ 'Memory deliveries', - nf.format(balance.memoryDeliveries ?? balance.injections), + measuredValue( + 'memoryDeliveries', + balance.memoryDeliveries ?? balance.injections + ), ], [ 'Kept back for comparison', - nf.format(balance.memoryHoldouts ?? balance.holdouts), + measuredValue( + 'memoryHoldouts', + balance.memoryHoldouts ?? balance.holdouts + ), ], [ 'Cost of remembering', - `${nf.format( - Number(balance.deliveryTokens ?? balance.injectedTokens) + - Number(balance.harvestTokens || 0) - )} tokens`, + measuredValue( + 'rememberingCost', + Number(balance.deliveryTokens ?? balance.injectedTokens ?? 0) + + Number(balance.harvestTokens || 0), + ' tokens' + ), ], [ 'Reading avoided', - balance.estimatedTokensAvoided === null - ? 'Not enough comparisons' - : `${nf.format(balance.estimatedTokensAvoided)} tokens`, + metricState('readingAvoided')?.status === 'not-measured' + ? 'Not measured' + : balance.estimatedTokensAvoided == null + ? `Collecting (${nf.format(balance.injections)} treated, ${nf.format(balance.holdouts)} held back)` + : `${nf.format(balance.estimatedTokensAvoided)} tokens`, ], ]; @@ -152,22 +199,74 @@ async function loadBalance() { ) .join(''); - verdict.textContent = balance.verdict; + verdict.textContent = + !balance.sufficientData && Number(balance.nativeOptimizer?.tokensSaved) > 0 + ? `${nf.format(balance.nativeOptimizer.tokensSaved)} tokens are modeled as avoided by ${nf.format(balance.nativeOptimizer.substitutions)} graph substitutions; they are not part of the verified MCP headline. The causal reuse study is still collecting.` + : balance.verdict; verdict.dataset.state = !balance.sufficientData ? 'insufficient' : balance.netTokens > 0 ? 'positive' : 'negative'; - if (balance.sufficientData) { - method.textContent = - `Measured against ${nf.format(balance.holdouts)} withheld control touches — not estimated. ` + - `Net after injection and harvest cost: ${nf.format(balance.netTokens)} tokens.`; - } else { - method.textContent = - `The graph has ${nf.format(balance.injections)} measured file deliveries and ${nf.format(balance.holdouts)} file holdouts; ` + - 'the savings estimate unlocks at 20 and 5. Command and session-start deliveries are counted above but excluded from the file-read comparison because they have no valid downstream file-read join.'; - } + const coverage = measurement.sourceCoverage; + const freshness = measurement.freshness; + const coverageText = + Number.isFinite(Number(coverage?.projects)) && + Number.isFinite(Number(coverage?.projectsWithTelemetry)) + ? `${nf.format(coverage.projectsWithTelemetry)} of ${nf.format(coverage.projects)} selected projects have telemetry` + : 'Telemetry coverage was not reported by this server'; + const freshnessText = + freshness?.lastEventAt && freshness.status !== 'not-measured' + ? `latest event ${new Date(freshness.lastEventAt).toLocaleString()}` + : 'no telemetry event has been observed'; + + const facts = balance.sufficientData + ? [ + [ + 'Study', + `${nf.format(balance.injections)} treated · ${nf.format(balance.holdouts)} holdout`, + ], + [ + 'Causal method', + 'Control-arm comparison of downstream reads; not estimated from file size', + ], + [ + 'Net graph effect', + `${nf.format(balance.netTokens)} tokens after delivery and harvest cost`, + ], + ['Coverage', coverageText], + ['Freshness', freshnessText], + ] + : [ + [ + 'Modeled substitution', + `${nf.format(balance.nativeOptimizer?.tokensSaved || 0)} tokens reported across ${nf.format(balance.nativeOptimizer?.substitutions || 0)} served results; excluded from verified MCP savings`, + ], + [ + 'Causal method', + 'Control-arm comparison of downstream reads; no effect is estimated before the evidence gate', + ], + [ + 'File study', + `${nf.format(balance.injections)}/20 treated · ${nf.format(balance.holdouts)}/5 holdout`, + ], + [ + 'Excluded surfaces', + 'Command and session-start deliveries have no valid downstream file-read join', + ], + ['Coverage', coverageText], + ['Freshness', freshnessText], + ]; + method.innerHTML = facts + .map( + ([label, value]) => ` +
+ ${escapeHtml(label)} + ${escapeHtml(value)} +
` + ) + .join(''); } /* ---- Causal evidence ------------------------------------------------ */ @@ -1064,9 +1163,14 @@ async function loadHookHealth() { const grid = el('hook-health-grid'); const status = el('hook-health-status'); const detail = el('hook-health-detail'); + const clientsHost = el('hook-health-clients'); try { - const report = await api('/api/diagnostics/hooks?hours=24&limit=20'); + const [report, mcpReport] = await Promise.all([ + api('/api/diagnostics/hooks?hours=24&limit=20'), + api('/api/diagnostics/mcp?hours=24&limit=20'), + ]); const summary = report.summary; + const mcp = mcpReport.summary; const success = summary.successRate === null ? '—' @@ -1076,6 +1180,8 @@ async function loadHookHealth() { ['Success rate', success], ['Failures', nf.format(summary.failures)], ['Timeouts', nf.format(summary.timeouts)], + ['Policy blocks', nf.format(summary.blocked || 0)], + ['Abandoned', nf.format(summary.abandoned || 0)], [ 'p50 latency', summary.p50DurationMs == null ? '—' : `${summary.p50DurationMs} ms`, @@ -1084,6 +1190,18 @@ async function loadHookHealth() { 'p95 latency', summary.p95DurationMs == null ? '—' : `${summary.p95DurationMs} ms`, ], + ['MCP processes', nf.format(mcp.processes)], + ['MCP handshakes', nf.format(mcp.initializedClients)], + [ + 'Tools advertised', + mcp.advertisedTools == null ? '—' : nf.format(mcp.advertisedTools), + ], + ['MCP tool calls', nf.format(mcp.toolCalls)], + ['MCP failures', nf.format(mcp.failures)], + [ + 'MCP p95 latency', + mcp.p95DurationMs == null ? '—' : `${mcp.p95DurationMs} ms`, + ], ] .map( ([label, value]) => ` @@ -1094,30 +1212,83 @@ async function loadHookHealth() { ) .join(''); - const unhealthy = summary.failures > 0 || summary.timeouts > 0; - status.textContent = !summary.available - ? 'No lifecycle telemetry has been captured yet.' - : unhealthy - ? `${summary.failures} failure(s) and ${summary.timeouts} timeout(s) need attention.` - : 'Lifecycle capture is healthy across the observed clients.'; - status.dataset.state = !summary.available - ? 'insufficient' - : unhealthy - ? 'bad' - : 'ok'; - const clients = Object.entries(summary.byClient || {}) + const unhealthy = + summary.failures > 0 || + summary.timeouts > 0 || + summary.abandoned > 0 || + mcp.failures > 0; + status.textContent = + !summary.available && !mcp.available + ? 'No hook or MCP lifecycle telemetry has been captured yet.' + : unhealthy + ? `${summary.failures} hook failure(s), ${summary.timeouts} timeout(s), and ${mcp.failures} MCP failure(s) need attention.` + : `${summary.blocked || 0} intentional policy block(s); hook and MCP runtime show no failures.`; + status.dataset.state = + !summary.available && !mcp.available + ? 'insufficient' + : unhealthy + ? 'bad' + : 'ok'; + const clients = new Map(); + for (const [name, value] of Object.entries(summary.byClient || {})) { + clients.set(name, { + name, + kinds: ['Lifecycle hooks'], + activity: [`${nf.format(value.total)} hook runs`], + reliability: [ + `${nf.format(value.failures)} hook failures · ${nf.format(value.timeouts)} timeouts`, + ], + policy: [ + `${nf.format(value.blocked || 0)} blocked · ${nf.format(value.skipped || 0)} skipped`, + ], + coverage: [ + `hooks: ${(value.hookEvents || []).join(', ') || 'surface unknown'}`, + ], + unhealthy: value.failures > 0 || value.timeouts > 0, + }); + } + for (const [name, value] of Object.entries(mcp.clients || {})) { + const client = clients.get(name) || { + name, + kinds: [], + activity: [], + reliability: [], + policy: [], + coverage: [], + unhealthy: false, + }; + client.kinds.push('MCP'); + client.activity.push(`${nf.format(value.calls)} MCP calls`); + client.reliability.push(`${nf.format(value.failures)} MCP failures`); + client.policy.push(`${nf.format(value.initialized)} handshakes`); + client.coverage.push('MCP: stdio tool protocol'); + client.unhealthy ||= value.failures > 0; + clients.set(name, client); + } + clientsHost.innerHTML = [...clients.values()] .map( - ([name, value]) => - `${name}: ${value.total} runs, ${value.failures} failures, ${value.timeouts} timeouts, ${value.skipped || 0} skipped` + (client) => ` +
+
+ ${escapeHtml(client.name)} + ${escapeHtml(client.kinds.join(' + '))} +
+
+
Activity
${escapeHtml(client.activity.join(' · '))}
+
Runtime
${escapeHtml(client.reliability.join(' · '))}
+
Control
${escapeHtml(client.policy.join(' · '))}
+
Coverage
${escapeHtml(client.coverage.join(' · '))}
+
+
` ) - .join(' · '); + .join(''); detail.textContent = - clients || - 'Privacy-safe JSONL diagnostics retain no prompts, commands, or tool output.'; + 'Diagnostics are privacy-safe: no prompts, commands, file paths, or tool output are retained.'; } catch { grid.innerHTML = ''; status.textContent = 'Capture diagnostics unavailable.'; status.dataset.state = 'bad'; + clientsHost.innerHTML = ''; detail.textContent = ''; } } diff --git a/src/dashboard/public/wiki.html b/src/dashboard/public/wiki.html index ca19c1ca9..b7cd304f8 100644 --- a/src/dashboard/public/wiki.html +++ b/src/dashboard/public/wiki.html @@ -32,9 +32,10 @@

What your coding agents have learned and whether it helps

-

Capture health

+

Hook and MCP capture health

+

@@ -56,7 +57,7 @@

-

+