Expand context attribution: MCP, /context fetch, per-span snapshots - #17
Merged
Conversation
Align the cc.* token attribution with what Claude Code actually assembles into the request, validated against /context. Memory (extractors.go): - Count only MEMORY.md from the auto-memory dir, not the fact files beside it (those are recalled on demand, not loaded up front — was overcounting by several ktok). - Add the .claude/rules/**/*.md tree (imported via CLAUDE.md). - Drop AGENTS.md — Claude Code does not load it by default. Custom agents (new extractAgentsSnapshot): - New cc.agents category. Project (.claude/agents), user (~/.claude/agents), and plugin agents resolved from installed_plugins.json -> pinned versioned cache installPath, namespaced <plugin>:<agent>. - Count frontmatter only (the always-loaded dispatch blurb), not the system-prompt body. Skills (skill_hash.go, attribution.go): - Resolve plugin skills/commands from installed_plugins.json installPath instead of the wrong marketplaces/<ns>/skills path (every plugin skill was falling through to "bundled" with zero tokens). - Per-skill menu_tokens = name + frontmatter description only (not the whole frontmatter — compatibility/metadata fields don't reach the menu). - summary.menu_tokens now sums per-skill; bundled_count + menu_tokens_attachment surface the irreducible in-binary gap. - Source buckets: project | user | plugin | bundled. installed_plugins.json parsing is shared (memoized) between agents and skills. All four platform binaries rebuilt. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tribution-fixes # Conflicts: # bin/opik-logger-darwin-amd64 # bin/opik-logger-darwin-arm64 # bin/opik-logger-linux-amd64 # bin/opik-logger-windows-amd64.exe
Layers actual /context numbers and per-span cost-attribution data onto
every trace. The estimate paths now stay within ~1% of /context for
measurable categories and surface deferred-vs-always-on so naive sums
no longer over-count tokens that don't actually bill.
Detached /context capture (context_fetch.go)
Background-spawn `claude --resume <id> --fork-session
--no-session-persistence -p /context` after each Stop. ~$0 (local),
~1-3s async, zero session pollution. PATCHes metadata.cc.context_runtime
with the exact category breakdown CC itself reports. Recursion-guarded
via OPIK_CC_SKIP, timeout-bounded at 15s, fails soft. Detach via Setsid
on Unix / CREATE_NEW_PROCESS_GROUP on Windows.
Per-span context snapshot (context_snapshot.go)
Every LLM span carries metadata.cc.context_snapshot — denormalized
category sizes so cost dashboards can compute per-category billed
tokens with a single-row query (span.usage × category_share × rate),
no JOIN back to the trace. Marked source="estimated_sync" to
distinguish from the async context_runtime ground truth.
MCP support (tools_extract.go + transcript.go)
Parses the previously-ignored mcp_instructions_delta attachment for
server-level instructions tokens. Adds a per-tool overhead estimate
(130 tokens, calibrated against the everything reference server)
surfaced under cc.tools.by_source.mcp.estimated_deferred_tokens.
Closes the MCP gap from -99% to within ~15% of /context's MCP row.
Slash-command skill loads (skill_hash.go)
Detects /opik:opik style invocations that bypass the Skill tool_use
and inject "Base directory for this skill:" user text directly.
Previously cc.skills.loaded was empty for these and the body
double-counted under cc.user_prompts.
Plugin catalog integration (plugin_catalog.go)
Reads ~/.claude/plugins/plugin-catalog-cache.json and stamps marketplace
skill spans with catalog_body_tokens — CC's own tokenizer output for
on_invoke skill bodies, more precise than our chars/3.5 estimate. Each
plugin's chars→token ratio is derived from its published per-model
tokens to match /context's binary internals.
Plugin enablement filter (settings.go)
Reads enabledPlugins across managed/user/local/project settings layers
and filters extractAgentsSnapshot to only include plugins resolved to
true. Fixes ghost plugin agents from disabled plugins
(e.g. plugin-dev@claude-plugins-official) inflating cc.agents.
Calibration retuning (attribution.go)
Adds memory_file (2.4), agent_frontmatter (3.1) content types and
recalibrates skill_listing_menu (3.9 -> 3.0) — derived empirically
from per-row /context tokens vs file/attachment bytes. Lands memory
within ±1.4%, agents ±1.2%, skills ±0.2%.
Display name from frontmatter (extractors.go)
Agents use the YAML `name:` field rather than the filename. Matches
/context (e.g. meta-auditor.md now exposes as `config-auditor`).
Per-skill menu from attachment (skill_hash.go)
Skills menu tokens parsed per-block from the skill_listing attachment
text — the canonical source CC's /context itself measures. Covers
bundled skills (previously 0 tokens) and namespaced names like
comet:create-jira-ticket via longest-match against the canonical
names array.
Always-on vs deferred semantics (context_snapshot.go + cc_builtin.go)
total_tokens now excludes deferred categories (system_tools_deferred,
mcp_tools_deferred) so it matches /context's visible total and the
API's billed input + cache_*. deferred_tokens surfaced separately as
informational ("if loaded, this is what it would cost"). Eliminates
the naive-sum trap that over-counted by ~19k tokens per session.
Per-CC-version constants (cc_builtin.go)
Versioned table for system_prompt / system_tools / system_tools_deferred
— the only categories whose values aren't derivable from the transcript
(CC holds the schemas internally). Patch-version fallback within the
same major.minor. Currently seeded for 2.1.150 only; bumps as needed.
58 tests pass, all 4 platforms build clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The per-span snapshot was previously missing the conversation-content bucket (prior user prompts + assistant output + tool_results), so total_tokens under-stated API billing on long sessions by however many tokens of history had accumulated. Always-on categories are flat per turn, but Messages grows linearly — a 50-turn session can drift well past 10k tokens. Add cumulativeMessagesTokens(fullEntries) and stamp it as context_snapshot.categories.messages. Assistant output uses usage.output_tokens (exact, no estimation drift); user text and tool_results fall back to our calibrated estimates. Loaded skill bodies are explicitly excluded — they live in skills_loaded already. E2E verified on a 3-turn skill-load session: snapshot total 35,112 vs API billed 34,957 (+0.4% drift, well within calibration tolerance). messages category landed at 140 tokens (the actual conversation content across all 3 turns). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
claude -p "/context"reports — within ±1% for every category we can directly measure./contextcapture per trace via a detached background subprocess. ~$0 cost (local), ~1-3s async, zero session pollution.context_snapshotso cost dashboards can attribute billed tokens to categories with a single-row query (no JOIN to the trace)./context's MCP-tools-deferred bucket by harvestingmcp_instructions_deltaand adding a per-tool overhead estimate.total_tokensfield so naive sums no longer over-count tokens that don't actually bill (system_tools_deferred wasn't shipping in the request envelope when ToolSearch is enabled).Headline numbers vs
/contextfor thecomet-ml/opikrepo:What ships
cc.context_runtime(trace-level, async) — actual/contextoutput captured per trace viaclaude --resume <id> --fork-session --no-session-persistence -p /context. Recursion-guarded withOPIK_CC_SKIP, 15s timeout, fails soft.span.cc.context_snapshot(per-LLM-span, sync) — denormalized category breakdown so per-category cost is a single-row calc:Includes a cumulative
messagescategory covering every prior user prompt + assistant output + tool_result in the session, so per-span total_tokens stays within ~1% of API billing even on 50+ turn sessions where conversation history accumulates.cc.cc_builtin(trace-level, sync) — version-keyed estimates forsystem_prompt/system_tools/system_tools_deferred. Currently seeded for 2.1.150. Patch-version fallback in the same major.minor.Slash-command skill loads —
/opik:opikstyle invocations no longer fall through silent. Detected via<command-name>/<X></command-name>preamble +Base directory for this skill:follow-up text.Plugin enablement filter — agents from disabled plugins (
enabledPlugins[key] != trueacross managed/user/local/project settings) are filtered out ofcc.agents. Was producing ghost rows.Plugin catalog adoption — marketplace skills get
catalog_body_tokensfrom~/.claude/plugins/plugin-catalog-cache.json(CC's own tokenizer output) for cross-checking the estimate.Calibration retuning — new content types:
memory_file(2.4 chars/token),agent_frontmatter(3.1);skill_listing_menuretuned 3.9 → 3.0.Always-on vs deferred semantics — every
total_tokensnow matches API billing;deferred_tokenssurfaced separately. Eliminates the trap where naiveSUM(total_tokens)over-counted by 19k+ per session.Data flow: where each field is produced
The trace-level and per-span context fields are computed by separate code paths with different timing. Spans never receive
/contextdata directly — that lands on the trace only. Read this if a dashboard is mixing the two.What each field actually contains
/contextspan.metadata.cc.context_snapshot.categories.*trace.metadata.cc.context_runtime.categories.*/contextoutputtrace.metadata.cc.cc_builtin.summary.*trace.metadata.cc.{memory,agents,skills,tools}.*Why no back-propagation to spans
Considered: have the detached subprocess also walk every LLM span in the trace and PATCH each with
context_runtime's exact numbers. Rejected — it would require one extraPATCH /spans/<id>per LLM call (5+ per multi-block turn), adding complexity for marginal accuracy gain (~1-15%). The dual-field setup with explicitsourcemarkers lets consumers pick:span.context_snapshot(no JOIN, sync estimate)trace.context_runtime(exact)Honest limitations
These are called out so consumers know where the data is exact vs estimated.
1. Per-CC-version constants are manually maintained
cc.cc_builtinand the system-prompt/tools row ofspan.context_snapshotrely on a hardcoded table keyed by Claude Code CLI version (currently 2.1.150 only). The transcript doesn't carry these values — Claude Code holds the schemas internally and serializes them straight into the request envelope.When CC ships a new major.minor with a different bundled prompt or tool catalog, the constants drift. Patch-version fallback (2.1.151 → use 2.1.150 row) handles minor changes; bigger bumps need a table update.
Mitigation:
context_runtime(the async/contextfetch) lands the exact numbers ~3s after Stop, regardless of CC version. Treatcc_builtinas a sync-time placeholder.2. MCP per-tool schema overhead is a constant approximation
We can't read MCP tool schemas from the transcript — Claude Code calls
tools/listat handshake, caches the schemas in RAM, and serializes them into the request envelope per turn. They never hit disk or the transcript.Our
cc.tools.by_source.mcp.estimated_schema_tokensuses a flattool_count × 130constant, calibrated against@modelcontextprotocol/server-everything(15 tools, /context = 2.5k tokens). Real per-tool schemas vary 80-430 tokens; the constant is ±15-30% per server.Mitigation: marked
estimated: truein the payload.context_runtime(async) has the exact MCP-tools-deferred number for the trace.Considered alternative: spawning each MCP server at hook time to call
tools/listourselves. Rejected — too fragile (auth, OAuth flows, network) and too slow.3. Sync
context_snapshotdrifts ~1-5% from the asynccontext_runtimeThe per-span snapshot uses synchronously-computed extractors at flush time, including a cumulative
messagescategory that covers the conversation history. The trace-levelcontext_runtimeuses the actual/contextoutput landed ~3s later via the detached subprocess.The two are intentionally NOT reconciled — patching every LLM span retroactively when
context_runtimelands would add complexity for marginal accuracy gain. They live side by side with explicitsourcemarkers:context_snapshot.source = "estimated_sync"context_runtime.source = "claude_context_command"Pick which to use based on the query: per-span attribution →
context_snapshot(no JOIN, ~1-5% drift); trace-level totals →context_runtime(exact).Bucketing difference to watch:
/contextlumps loaded skill bodies into its Messages row. Our snapshot splits them intoskills_loaded. Same tokens, different bucket names — don't sumskills_loadedandmessagestogether (they're already disjoint by design).4. Plugin catalog only covers
@claude-plugins-officialplugins~/.claude/plugins/plugin-catalog-cache.jsonis populated by Claude Code for the official marketplace (~222 plugins). User-installed plugins from other marketplaces (e.g.opik@opik) aren't there.Mitigation: catalog data is additive (
catalog_body_tokensis a parallel field). Skills without catalog coverage still get the calibratedbody_tokensestimate.5. Per-turn
/contextfetch adds background CPUEach Stop fires a detached
claudesubprocess that takes ~1-3s. Not on the user's path (the subprocess is detached viaSetsid/CREATE_NEW_PROCESS_GROUP), but does add background work proportional to turn count.For a 30-turn session: ~30 spawned subprocesses, ~60s of background CPU, ~200MB peak memory (one process at a time, with brief overlap if turns are <3s apart).
No API cost (
/contextis local) and no transcript pollution (--fork-session --no-session-persistence).6. Naive
SUMacross traces in a session is wrong for most fieldsMixed additivity semantics — documented in detail [in the discussion thread but worth surfacing here]:
cc.cc_builtin.*,cc.memory.*,cc.agents.*,cc.skills.summary.menu_tokens,cc.tools.*cc.skills.summary.loaded_tokens,cc.context_runtime.*cc.user_prompts.*,cc.assistant_text.*,cc.thinking.*,cc.tool_results.*span.usage.*cc.prior_assistant.*For billing analysis: use
SUM(span.usage.*). For per-category cost: usespan.context_snapshot+ the formula above. Thecc.*metadata describes structure/size, not what was billed N times.Test plan
go test ./src/...(58 tests pass)go vet ./src/...cleanmake buildfor darwin/arm64, darwin/amd64, linux/amd64, windows/amd64../opik, verify trace'scc.context_runtime.categoriesmatchesclaude -p /contextoutput/opik:opikskill load — verifycc.skills.loadedpopulated, no user_prompts double-count--mcp-config everything— verifycc.tools.by_source.mcp.estimated_deferred_tokenslands within ~15% of/context's MCP rowspan.cc.context_snapshot.total_tokensmatchesspan.usage.input_tokens + cache_read + cache_creationwithin ~1% (verified 3-turn skill-load session: 35,112 vs 34,957 = +0.4%)context_snapshot.categories.messagesaccumulates across turns and excludes loaded skill bodies (no double-count with skills_loaded)🤖 Generated with Claude Code