Skip to content

fix(always-on): the turn-1 prefix includes what it READ from cache - #27

Merged
pa-arth merged 1 commit into
mainfrom
fix/standing-context-cache-read
Aug 13, 2026
Merged

fix(always-on): the turn-1 prefix includes what it READ from cache#27
pa-arth merged 1 commit into
mainfrom
fix/standing-context-cache-read

Conversation

@pa-arth

@pa-arth pa-arth commented Aug 13, 2026

Copy link
Copy Markdown
Owner

computeAlwaysOn sampled input + cacheWrite5m + cacheWrite1h from the first main-chain turn (src/alwaysOn.ts:212-214). That is the prefix only when the session opened cold. A session resuming in a project it has run in before finds the whole prefix already cached — it reads ~everything and writes ~nothing — so observed standing context came out smallest exactly where the standing context is largest.

The measurement

522 local sessions, main-chain turn 1, /subagents/ files excluded:

turn 1 reads from cache in 97.9% of sessions
median prefix, input + cacheWrite 40,272
median prefix, + cacheRead 59,645 (1.48x)

59,645 lands within 1.8% of the same quantity measured independently by differencing claude -p "ok" --output-format json runs with one context component removed at a time (60,738). Two methods sharing no code agreeing to 1.8% is what makes this a correction rather than a preference.

Why no test caught it

Every existing turn-1 fixture in audit.test.ts carries cache_read_input_tokens: 0 — the spawn-economics block asserts 11,000 and 17,500 on exactly that shape. The suite covered the cold case thoroughly and the warm case not at all, which is the 97.9% case. New test locks a warm prefix at 54,200 and asserts explicitly it is not 4,200, so a future edit dropping the term again lands there.

Related

The same defect shipped in the product: promptster-backend/packages/engine/src/jobs/costEfficiency.ts:591 had the identical formula, fixed in pa-arth/promptster-backend#713 (which also needs a rail term — its rows can be OpenAI, where cached tokens sit inside input; cc-audit is Claude-Code-only so it does not).

Still open, not addressed here

report.ts printed 31,365 for this number, where a faithful replication of the pre-fix formula over the same transcripts gives 40,272. So ~9k of the originally-observed gap is something else in the span/merge handling — mergeUsage folds streamed rows by per-field max, and the main-chain span selection is stricter than my replication. Worth chasing separately; this PR does not explain it.

Checks

src/__tests__/audit.test.ts 50/50 · full suite 386 passed, 1 failed — pricingDrift.test.ts, which fails identically on clean main (our pricing table vs litellm) and is unrelated · tsc --noEmit clean.

🤖 Generated with Claude Code

computeAlwaysOn sampled `input + cacheWrite5m + cacheWrite1h` from the
first main-chain turn. That is the prefix only when the session opened
COLD. A session resuming in a project it has run in before finds the whole
prefix already cached — it reads ~everything and writes ~nothing — so the
observed standing context was smallest exactly where it is largest.

Measured over 522 local sessions:

  turn 1 reads from cache in       97.9% of sessions
  median prefix, input+cacheWrite  40,272
  median prefix, + cacheRead       59,645   (1.48x)

59,645 lands within 1.8% of the same quantity measured independently by
differencing `claude -p` runs with one context component removed at a
time (60,738). Two methods with no shared code agreeing to 1.8% is the
evidence the term belongs.

New test locks the warm-prefix case at 54,200 rather than 4,200. The
existing spawn-economics fixtures all carry cache_read 0 on turn 1 and are
unchanged, which is why nothing caught this.

The same defect shipped in promptster-backend costEfficiency.ts:591 and is
fixed there in pa-arth/promptster-backend#713.

Note pricingDrift.test.ts fails on clean main (our table vs litellm) and is
unrelated to this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Aug 13, 2026

Copy link
Copy Markdown

Greptile Summary

This PR corrects the observed turn-1 standing-context measurement by including tokens read from cache, with a regression test for warm-cache sessions.

  • Adds cacheRead to the first main-chain turn’s sampled prefix.
  • Adds a warm-cache fixture asserting the complete 54,200-token prefix.
  • Adds a pnpm lockfile without changing declared dependency constraints.

Confidence Score: 5/5

The PR appears safe to merge, with the corrected calculation matching the documented empirical turn-1-prefix contract.

The implementation adds the previously omitted cache-read component, and the new regression test covers the common warm-cache shape without revealing a reachable behavioral or security failure.

Important Files Changed

Filename Overview
src/alwaysOn.ts Includes cache-read tokens in the empirical first-turn prefix, aligning warm and cold cache representations without changing downstream pricing structure.
src/tests/audit.test.ts Adds focused regression coverage demonstrating that a warm-cache prefix includes input, cache-write, and cache-read tokens.
pnpm-lock.yaml Adds deterministic pnpm dependency resolution; no changed-code-reachable security issue was established.

Fix All in Greploop

Reviews (1): Last reviewed commit: "fix(always-on): the turn-1 prefix includ..." | Re-trigger Greptile

@pa-arth
pa-arth merged commit df3e237 into main Aug 13, 2026
1 of 3 checks passed
@pa-arth
pa-arth deleted the fix/standing-context-cache-read branch August 13, 2026 05:08
pa-arth added a commit that referenced this pull request Aug 14, 2026
)

* feat(always-on): measure the injected prefix, don't census the disk

The always-on breakdown answered "what did you put in your context" by
walking files under ~/.claude. The context is not assembled from disk —
Claude Code assembles it and records it, itemised, as typed `attachment`
rows ahead of the first assistant turn, and none of those were read.

Measured over 501 local sessions, `--json --since-days 30`:

  skill listing   967 tok  ->  5,683 tok   (skillCount 15 -> 78)
  hook output     no field ->    948 tok
  auto-memory     folded   ->  2,990 tok   (projectClaudeMd falls to match)
  fixedPrefix     no field -> 49,623 tok   (82% of the floor, not yours to cut)

WHY THIS IS A CLASS, NOT FOUR BUGS. A disk census has no subscriber to a
Claude Code release: built-in skills ship inside the binary and are never
on disk, and the agent listing / deferred-tool delta move when the tool
updates while nothing in ~/.claude does. So it cannot be wrong once and
then fixed — it goes stale every release, silently.

It was also NOT stale in one direction, which the plan assumed. It never
read `settings.json > skillOverrides`, so six skills the user had switched
off were sized and billed every turn; and `readdirSync().isDirectory()` is
false for a symlinked directory, so four more were invisible. Net under,
per-skill over. "Scale the floor up" would not have fixed it — the
membership set was wrong, not just the magnitude. The listing's own
`names[]` is now the authority, and `skillCarry[].loaded` says so.

The disk census is retained and DEMOTED: it attributes a measured block to
the user's files, it no longer decides how big the block is. Plugin and
user-command listings appear INSIDE the injected skill listing, so they are
slices of it rather than addends to it — the old formula added them twice
the moment the listing became measured.

Also:
- `~/.claude/commands/**` is walked at all now. Six `opsx:*` commands load
  into every listing and nothing had ever counted them.
- `mcpDeferred` is read from `deferred_tools_delta` in the sessions AS THEY
  RAN, not inferred from `ENABLE_TOOL_SEARCH` in the auditing process.
- Unknown is `null` + a named reason, never 0. A session that recorded no
  attachments does not vote a 0 into any median, and does not vote on
  deferral either — that conflation was caught by a test mid-implementation.
- A negative reconciliation remainder FAILS and is reported per session.
  Clamping is how this defect class hides: the number stays plausible while
  the model underneath is wrong.

VERIFIED NOT TO MOVE, run against origin/main at the same instant:
standingContextTokens (60,457 — already correct as of #27, and the 40,487
in the plan was a pre-#27 figure), pluginSkillTokens (a true zero: the walk
runs, the enabled plugin ships no skills/ dir), spend.totalUsd, fluency.score.

Fixtures are RECORDED from real transcripts by
scripts/record-injected-fixture.mjs, never hand-authored — structure, field
names, string lengths and usage numbers preserved, content replaced with
same-length filler. A fixture written from the design doc would have passed
against a broken parser, because the doc had the kind list and the per-kind
content field wrong. All four deliberate mutations of this logic are caught
by the tests written for them.

Aggregate schemaVersion 9 -> 10. `skillDescriptionTokens` is REMOVED rather
than kept beside `skillListingTokens`: a field that keeps its name while
changing meaning lets a downstream comparison silently mix two quantities.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(alwaysOn): bound the unmeasured reasons — they now ride into the upload

v10 put the first sentence-shaped strings into the uploaded aggregate. Everything
before it was a model id or a hash, 25 chars at the longest, so the solo-capture
endpoint's 200-char per-string ceiling had no plausible way to bite. These
sentences are 82 and 118 chars — inside the bound, with the margin now moving every
time someone rewords one.

What a breach costs is why this is a test and not a comment: the server screens
every string leaf and 400s the WHOLE capture on a breach, and sendCapture() ignores
the response by design so telemetry can never break a local run. The failure mode
is therefore the entire record silently not arriving, for every user, from the
release that lengthened a sentence. Nothing downstream can detect it; the corpus
just thins.

Extracts the three literals into UNMEASURED_REASONS so they are enumerable, and
bounds them. The second test checks the call sites actually use the set — a bound
over a constant nobody references is worth nothing. Mutation-checked both ways:
lengthening a reason past the ceiling, and writing an inline literal at a call site.

MAX_UPLOADED_STRING_CHARS restates the server's constant rather than importing it;
cc-audit does not depend on the backend, and a copied number with a test behind it
beats a coupling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@pa-arth pa-arth mentioned this pull request Aug 15, 2026
pa-arth added a commit that referenced this pull request Aug 15, 2026
Four merged PRs since 0.7.0 (#27 #28 #29 #30), and two of them change numbers a
user has already read, which is what makes this a release rather than a merge.

MINOR, not patch: #28 changes what the always-on breakdown MEANS — measured off the
transcript instead of censused off disk — and takes the aggregate schemaVersion 9 ->
10, removing `skillDescriptionTokens` rather than keeping a field whose name outlived
its meaning.

THE ONE TO SHIP FOR: 0.7.0 prices gpt-5.6-luna 5x over and terra 1.25x over on every
Codex session it has audited since the vendor repriced (backend f4500a27, 2026-07-31,
two days after 0.7.0 went out). That was fixed on main on 2026-08-14 and reaches
nobody until this publishes — a merged pricing fix in an unreleased CLI is a fix that
does not exist for users.

Verified before tagging, not just built:
- 414/414 tests, typecheck and lint clean
- `npm run build:npm` -> 411.9kb single file, `--version` reports 0.8.0 (so the
  esbuild --define baked the bump, which is the thing a version bump can silently
  miss)
- ran the BUNDLE against the real local corpus, not just `--help`: schemaVersion 10,
  standingContextTokens 60,963 — within 0.4% of the 60,738 measured independently by
  differencing `claude -p` runs — `skillDescriptionTokens` absent from the payload as
  #28 intended, `unmeasured` empty.

Tag v0.8.0 after merge; .github/workflows/publish.yml guards tag == package.json and
publishes + attaches the standalone binaries.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant