context-doctor 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -90,14 +90,14 @@ Practical upshot: a developer who only wants cheaper, faster API calls never tou
90
90
  | Command | What it does |
91
91
  |---|---|
92
92
  | `context-doctor install` / `uninstall` | Wire (or remove) everything: MCP for Claude Desktop/Code/Cursor/Codex, the Agent Skill, the every-prompt hook |
93
- | `context-doctor instructions [--copy]` | The ~90-token standing rules for claude.ai / ChatGPT preferences, for web and phones where no server runs |
93
+ | `context-doctor instructions [--copy]` | The ~180-token standing rules (~120 on GPT) for claude.ai / ChatGPT preferences, for web and phones where no server runs |
94
94
  | `context-doctor analyze <file>` | Profile a conversation: token breakdown, findings, cost + latency estimates. `--fail-over-budget` exits 1 on a breach, for CI |
95
95
  | `context-doctor optimize <file>` | Apply the safe fixes; add `--strategy trim-tool-calls` for big inline file writes, `--strategy prune-history` for consented lossy compaction |
96
96
  | `context-doctor session [file]` | Profile a Claude Code session: live context, findings, **measured tokens and prompt-cache economics**, **where the wall clock went** per tool, and **what its subagents cost** (their own windows, your bill; never in the parent's profile). Also reads ChatGPT data exports (`conversations.json`) |
97
97
  | `context-doctor init [preset]` | Write a `.contextdoctorrc` from a preset (`chat`, `agent`, `batch`) — a budget you can adopt in one command and tune later |
98
98
  | `context-doctor experiment --task "…"` | Run one task twice from the same commit, in a fresh session and forked from an `--existing` one, same model and tools; compare bill, cache split, wall clock, and whether `--check` passed. The only command here that spends money, so it caps spend per arm and refuses a dirty tree |
99
99
  | `context-doctor diff <before> <after>` | Compare two profiles: what moved by category, which findings were resolved or introduced, and what it saves in money and latency |
100
- | `context-doctor accuracy` | How much of what you are billed for is visible in your transcript — the fixed harness baseline and the per-turn injected content neither you nor the profiler can see |
100
+ | `context-doctor accuracy` | How much of what you are billed for is visible in your transcript (the fixed harness baseline, per-turn injected content), plus a tokenizer check: real chars/token per model from the API's own counts, next to the ratio the estimator uses |
101
101
  | `context-doctor cursor [--list]` | Profile a chat from Cursor's local history (both storage formats) |
102
102
  | `context-doctor report` | Machine-wide impact report (proxy savings persist across restarts): exact proxy savings, hook activity, recoverable waste in recent sessions |
103
103
  | `context-doctor proxy` | Always-on local proxy that optimizes every Anthropic/OpenAI API request in flight (`/stats` for cumulative savings) |
@@ -203,13 +203,13 @@ Your API key still rides in the request headers, as before. The token protects t
203
203
  | **Claude Code** | Yes: hook on every prompt, status line on every refresh | Past ~80k tokens the model receives hygiene guidance naming the largest waste; compaction is offered. Measured: 115 automatic checks, 48 warnings, across 32 sessions on one machine |
204
204
  | **Cursor** | **Yes**, since 0.15: Cursor loads Claude Code's hook config (`~/.claude/settings.json`) and runs the same hook on every agent prompt, passing its own transcript. Output is accepted through Cursor's Claude-compat layer | Same guidance as Claude Code, inside Cursor's agent, for everyone who ran `install`. Before 0.15 the hook fired but could not read Cursor's transcript format, so it said nothing |
205
205
  | **API traffic through the proxy** | Yes: every request rewritten in flight | Fewer tokens, guaranteed, model not consulted |
206
- | **Claude Desktop** | The standing instruction in every chat (we confirmed in the app bundle that Desktop's `LocalMcpServerManager` reads it), a one-click `context_checkup` prompt, and since 0.17 a `profile_context` the model can actually afford to call from chat | Until 0.17 the tool wanted the whole conversation as its argument, so calling it from chat meant re-typing 50k tokens; nobody did, and Desktop's log showed zero calls in a month. Now the model passes a ~100-token **sketch** (turn count, the large or repeated blocks) and gets a sized estimate, findings and the fix to apply. Still a nudge, not a hook: Desktop chat has no hook API and no transcript on disk |
206
+ | **Claude Desktop** | The standing instruction in every chat (we confirmed in the app bundle that Desktop's `LocalMcpServerManager` reads it), a one-click `context_checkup` prompt, and since 0.17 a `profile_context` the model can actually afford to call from chat | Until 0.17 the tool wanted the whole conversation as its argument, so calling it from chat meant re-typing 50k tokens; nobody did, and Desktop's log showed zero calls in a month. Now the model passes a ~120-token **sketch** (turn count, the large or repeated blocks) and gets a sized estimate, findings and the fix to apply. Still a nudge, not a hook: Desktop chat has no hook API and no transcript on disk |
207
207
  | **Codex (OpenAI): ChatGPT.app's Codex tab, the Codex IDE extension, the `codex` CLI** | **Yes**, since 0.16: `install` writes the hook to `~/.codex/hooks.json`, the MCP server to `~/.codex/config.toml`, and the skill to `~/.codex/skills/`. Codex uses Claude Code's hook contract almost verbatim and passes its own rollout transcript, which carries the API's real usage figures | Same guidance as Claude Code, from measured tokens. One extra step, Codex's rule not ours: a new hook runs only after you trust it once (type `/hooks` in Codex). `session` and `session --list` read Codex rollouts too |
208
208
  | **ChatGPT chat UI** | No | No MCP, no hooks, no data path in the chat product itself. Use Codex, or a developer-mode connector at a URL you host |
209
209
 
210
210
  So "every chat inherently better" is true for Claude Code, Cursor, Codex and the proxy; for Claude Desktop it is "the rules ride in every chat and the checkup is one cheap tool call away"; and not a claim we make for the ChatGPT chat UI.
211
211
 
212
- **Where there is no hook and no MCP at all** (claude.ai on the web, the Claude and ChatGPT phone apps, plain ChatGPT): the app's per-account preferences are read on every turn, which is the closest those surfaces have to a hook. `context-doctor instructions --copy` puts the ~90-token rules on your clipboard and tells you where to paste them (claude.ai Settings > Profile; ChatGPT Settings > Personalization > Custom instructions).
212
+ **Where there is no hook and no MCP at all** (claude.ai on the web, the Claude and ChatGPT phone apps, plain ChatGPT): the app's per-account preferences are read on every turn, which is the closest those surfaces have to a hook. `context-doctor instructions --copy` puts the ~180-token rules on your clipboard and tells you where to paste them (claude.ai Settings > Profile; ChatGPT Settings > Personalization > Custom instructions).
213
213
 
214
214
  **Do you need to configure anything by hand? Usually no:**
215
215
 
@@ -238,7 +238,7 @@ For any other MCP client, the server entry is:
238
238
 
239
239
  1. Run `npx context-doctor install` (writes the config above for you) and restart Claude Desktop. Or open the `.mcpb` from the latest release: same server, no npm, installs as an Extension.
240
240
  2. From then on, **every conversation carries context-doctor's standing instructions**. The MCP server hands them to Desktop on connect and Desktop puts them in front of Claude: summarize big pastes instead of re-quoting them, refer to earlier content by name, never inline base64, and past ~30 turns or on any question about tokens, cost, speed or limits, call `profile_context` before answering.
241
- 3. That call is cheap on purpose. Claude cannot export a Desktop chat, so it passes a **sketch**: how many turns, which blocks are large, repeated, stale or images, with one size hint each (~100 tokens). The server sizes it (±30%, and it says so), prices the per-turn re-read (on a subscription that is what spends your usage limit), and returns ranked findings with the action for each: "summarize *the nginx config* into the points still needed", "refer to *test output* by name", "offer a 300-token handoff summary for a fresh chat". The reply ends with an instruction to apply the top one, not just suggest it.
241
+ 3. That call is cheap on purpose. Claude cannot export a Desktop chat, so it passes a **sketch**: how many turns, which blocks are large, repeated, stale or images, with one size hint each (~120 tokens). The server sizes it (usually within ±20%, measured; see "Why token counts are ~"), prices the per-turn re-read (on a subscription that is what spends your usage limit), and returns ranked findings with the action for each: "summarize *the nginx config* into the points still needed", "refer to *test output* by name", "offer a 300-token handoff summary for a fresh chat". The reply ends with an instruction to apply the top one, not just suggest it.
242
242
  4. One click instead of asking: the `context_checkup` prompt in the **+** menu sends that request for you.
243
243
  5. Say *"optimize it"* on an exported conversation and Claude applies the safe fixes; if you agree to pruning old history, **Claude itself writes the replacement summary** (that's the no-API-key summarization).
244
244
  6. For the same rules on your phone and on claude.ai, where no MCP server runs: `context-doctor instructions --copy`, then paste into Settings > Profile > personal preferences.
@@ -359,9 +359,9 @@ The verdict line is the point: cheaper only counts if it also passed. Because th
359
359
 
360
360
  ## Exact counts, and what they teach the estimator
361
361
 
362
- The default token count is a chars-per-token heuristic so everything runs with no key and no tokenizer. Its error is content-dependent, and there is no honest way to fix that from transcripts alone (the billed number includes content the transcript never sees). `analyze --exact` fetches a true count for the exact bytes just estimated (Anthropic's count-tokens API with `ANTHROPIC_API_KEY`; tiktoken for GPT if installed) and prints the drift.
362
+ The default token count is a chars-per-token heuristic so everything runs with no key and no tokenizer, with ratios per provider (see "Why token counts are ~" below). `analyze --exact` fetches a true count for the exact bytes just estimated (Anthropic's count-tokens API with `ANTHROPIC_API_KEY`; tiktoken for GPT if installed) and prints the drift.
363
363
 
364
- Since 0.13.9 it also **remembers the comparison**, per model family, on this machine, and later estimates for that family are scaled by it. Nothing about this is silent: the profile header says `estimates calibrated +12% from 3 exact count(s) you ran on this machine`. No exact count ever run means no calibration and unchanged numbers; out-of-range samples are ignored; `CONTEXT_DOCTOR_NO_CALIBRATION=1` returns to the raw heuristic.
364
+ Since 0.13.9 it also **remembers the comparison**, per model family, on this machine, and later estimates for that family are scaled by it. Nothing about this is silent: the profile header says `estimates calibrated +12% from 3 exact count(s) you ran on this machine`. No exact count ever run means no calibration and unchanged numbers; out-of-range samples are ignored; `CONTEXT_DOCTOR_NO_CALIBRATION=1` returns to the raw heuristic. Samples are tied to the heuristic they were taken against: after 0.19 changed Claude's ratios, older samples are ignored and learning restarts, rather than stacking an old correction on a fixed estimator.
365
365
 
366
366
  ## What it detects
367
367
 
@@ -466,7 +466,7 @@ A tool that promises speed must be near-free. Measured overhead per touchpoint:
466
466
  | Touchpoint | When it runs | Overhead |
467
467
  |---|---|---|
468
468
  | Every-prompt hook (Claude Code) | Every prompt | **~80ms** (Node startup; logic ~1ms). Lean sessions exit on a single `stat()` — the transcript is never read. Full profiling (~200ms on a 4MB session) happens only when the transcript has grown ~40% since last checked |
469
- | MCP server | Spawned once per app session | Tools run only when called; standing instructions cost **~110 tokens per conversation** — deliberately terse |
469
+ | MCP server | Spawned once per app session | Tools run only when called; standing instructions cost **~250 tokens per conversation on Claude, ~170 on GPT** — deliberately terse |
470
470
  | Proxy | Per API request | ~1–3ms of CPU (parse → optimize → re-serialize) against typical model latencies of hundreds of ms; responses stream through chunk-by-chunk, never buffered |
471
471
  | Skill | Loads only when relevant | ~1k tokens while active; its always-present description is ~60 tokens |
472
472
  | Profiling a session | On demand, and on hook growth events | ~160ms for an 8.5MB / 1,855-message transcript (near-duplicate pairs that cannot clear the similarity bar are skipped without comparison) |
@@ -476,18 +476,25 @@ Net effect is strongly negative overhead: the tokens these touchpoints save on e
476
476
 
477
477
  ## Why token counts are "~" (and where they are exact)
478
478
 
479
- Counting exactly needs each provider's tokenizer, so the default is a calibrated chars-per-token heuristic (denser for code and JSON). It is good enough to rank what is heavy and to measure the effect of a fix, and it keeps the tool offline and zero-config.
479
+ Counting exactly needs each provider's tokenizer, so the default is a chars-per-token heuristic, with ratios per provider and denser ones for code and JSON. It keeps the tool offline and zero-config.
480
480
 
481
- Two ways to get real numbers instead:
481
+ | Model | Prose | Code / tool output | Source |
482
+ |---|---|---|---|
483
+ | Claude (Opus 4.7 to 5.x, Fable 5.x, Sonnet 5) | 2.75 chars/token | 2.4 | Measured from the API's own counts, below |
484
+ | GPT, Gemini, unknown | 4.0 | 3.2 | Usual figures for o200k-class tokenizers; not re-measured here |
482
485
 
483
- - **`analyze --exact`** uses the Anthropic count-tokens API for Claude models (set `ANTHROPIC_API_KEY`; opt-in network call, key never stored) or tiktoken for GPT models (install it alongside), and reports how far the heuristic drifted.
484
- - **Sessions report measured tokens automatically.** Claude Code transcripts record what the API actually charged, so `session`, the hook and the reports use that figure when it is present — no key, no estimate.
486
+ Which row applies: the model you pass, else the request's own `model` field, else the request's shape (Anthropic's `system` field or `tool_use` blocks mean Claude). Cursor transcripts record no model and use Anthropic-style blocks, so Cursor sessions are counted at Claude density; for a GPT model in Cursor that reads about 40% high.
487
+
488
+ **How the Claude figures were measured, with no key.** Claude Code transcripts record what the API billed, and two things in them are exact. A reply with no thinking block is billed as exactly its `output_tokens`, and all of it is visible text: 504 replies gave a median of 2.75 chars/token (p10 2.4, p90 3.0). Between two consecutive API calls the prompt grows by exactly what was appended; when that is one large block, its size is the growth minus the previous reply: 474 blocks of code and tool output gave 2.4 (p10 2.1, p90 2.8). The ratios this tool used until 0.19 (4.0 / 3.2 for everything) **undercounted current Claude models by about 40%**: hook warnings came late, savings and costs read low, and the proxy stayed silent on cacheable prefixes between 1,024 and ~1,670 tokens. `context-doctor accuracy` re-runs both measurements on your own sessions and prints them per model beside the ratio in use, so the next tokenizer change shows up as a number, not a surprise. On this machine every model lands within ±9%.
485
489
 
486
- One honest caveat worth knowing: a transcript stores the conversation, **not** the harness's system prompt, tool schemas or skills. Measured against the API's own numbers here, a message-only estimate undercounts the true context by roughly 60%. That is why sessions prefer the reported figure, and why the message breakdown is labelled as covering messages only.
490
+ Two ways to get real numbers instead of estimates:
487
491
 
492
+ - **`analyze --exact`** uses the Anthropic count-tokens API for Claude models (set `ANTHROPIC_API_KEY`; opt-in network call, key never stored) or tiktoken for GPT models (install it alongside), and reports how far the heuristic drifted.
493
+ - **Sessions report measured tokens automatically.** Claude Code transcripts record what the API actually charged, so `session`, the hook and the reports use that figure when it is present — no key, no estimate.
488
494
 
495
+ One honest caveat worth knowing: a transcript stores the conversation, **not** the harness's system prompt, tool schemas or skills (about 54k tokens before the first turn in Claude Code here), nor the reminders it injects each turn. With the corrected ratios the transcript accounts for a median 56% of each turn's billed growth; before 0.19 this read 39%, and about a third of that "invisible" gap was the estimator. That is why sessions prefer the reported figure, and why the message breakdown is labelled as covering messages only.
489
496
 
490
- Exact counts require each provider's private tokenizer. `context-doctor` uses a calibrated chars-per-token heuristic (denser for code/JSON) that lands within ~10% — plenty accurate for finding what's heavy and measuring savings, and it keeps the tool fully offline with zero configuration.
497
+ **The chat-app sketch** (Claude Desktop, see above) is coarser by design, because the model describes the chat instead of sending it. Its sizes are measured, not assumed: a plain exchange is 2,060 chars (median of 1,283), a code line 42 chars (719 source files), a log line 56 (2,095 tool outputs), a word 6.3, all converted with the model's own ratio. Measured error: the total for a chat of 30+ exchanges from its turn count alone is within -20% to +9% (p10 to p90); a code block sized by lines is within about ±25%, by chars about ±15%. Logs vary from 38 to 100 chars a line, so the tool asks for their size in chars.
491
498
 
492
499
  ## Roadmap
493
500
 
@@ -11,10 +11,12 @@
11
11
  * measures the distance between them on your own sessions, so "why is my bill
12
12
  * bigger than the profile?" has an answer with evidence behind it.
13
13
  *
14
- * WHAT THIS IS NOT: a tokenizer benchmark. It cannot be — the content behind
15
- * the gap is unavailable to us, so the gap cannot be attributed to estimator
16
- * drift. To measure the estimator itself, use `analyze --exact`, which counts
17
- * the same bytes with the provider's own tokenizer.
14
+ * The coverage figure uses the model-aware estimator. Estimator drift itself is
15
+ * measured separately (tokenizer-measure.ts) on content the transcript DOES
16
+ * hold: replies whose output_tokens are exact, and single large blocks whose
17
+ * size is the exact prompt growth. Until 0.19 this command attributed the whole
18
+ * gap to invisible content; about a third of it was the estimator undercounting
19
+ * Claude's tokenizer.
18
20
  */
19
21
  export interface AccuracyReport {
20
22
  sessionsScanned: number;
package/dist/accuracy.js CHANGED
@@ -11,10 +11,12 @@
11
11
  * measures the distance between them on your own sessions, so "why is my bill
12
12
  * bigger than the profile?" has an answer with evidence behind it.
13
13
  *
14
- * WHAT THIS IS NOT: a tokenizer benchmark. It cannot be — the content behind
15
- * the gap is unavailable to us, so the gap cannot be attributed to estimator
16
- * drift. To measure the estimator itself, use `analyze --exact`, which counts
17
- * the same bytes with the provider's own tokenizer.
14
+ * The coverage figure uses the model-aware estimator. Estimator drift itself is
15
+ * measured separately (tokenizer-measure.ts) on content the transcript DOES
16
+ * hold: replies whose output_tokens are exact, and single large blocks whose
17
+ * size is the exact prompt growth. Until 0.19 this command attributed the whole
18
+ * gap to invisible content; about a third of it was the estimator undercounting
19
+ * Claude's tokenizer.
18
20
  */
19
21
  import { estimateTokens, formatTokens, MESSAGE_OVERHEAD_TOKENS } from "./tokens.js";
20
22
  import { parseConversation } from "./parse.js";
@@ -57,7 +59,7 @@ export function measureAccuracy(limit = 20, paths) {
57
59
  const normalized = parseConversation(parsed.conversationJson).messages;
58
60
  const estimateAt = (i) => {
59
61
  const m = normalized[i];
60
- return m ? estimateTokens(m.text) + MESSAGE_OVERHEAD_TOKENS : 0;
62
+ return m ? estimateTokens(m.text, parsed.model) + MESSAGE_OVERHEAD_TOKENS : 0;
61
63
  };
62
64
  let baseline = usage[0].input;
63
65
  for (let i = 0; i < usage[0].index; i++)
@@ -118,7 +120,7 @@ export function renderAccuracy(report) {
118
120
  lines.push("still saves real money — it just starts from a higher floor than the profile");
119
121
  lines.push("implies.");
120
122
  lines.push("");
121
- lines.push("This does NOT measure tokenizer drift: the missing content is not available");
122
- lines.push("to compare against. For that, use `analyze --exact` (provider tokenizer).");
123
+ lines.push("The tokenizer check below measures the estimator itself, on content the");
124
+ lines.push("transcript does hold; `analyze --exact` checks it against the provider's tokenizer.");
123
125
  return lines.join("\n");
124
126
  }
@@ -14,6 +14,14 @@
14
14
  * them. No exact count ever run means no calibration, and the numbers are
15
15
  * exactly what they were before.
16
16
  */
17
+ /**
18
+ * Bumped whenever the uncalibrated heuristic changes. A factor learned against
19
+ * an older heuristic would correct for an error that no longer exists (0.19
20
+ * moved Claude from 4.0 to 2.75 chars/token; an old 1.4x factor on top of that
21
+ * would overcount by 1.4x), so records from another version are ignored and
22
+ * restarted rather than blended.
23
+ */
24
+ export declare const HEURISTIC_VERSION = 2;
17
25
  export interface Calibration {
18
26
  /** Multiply heuristic estimates by this. 1 means uncalibrated. */
19
27
  factor: number;
@@ -17,6 +17,14 @@
17
17
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
18
18
  import { dirname, join } from "node:path";
19
19
  import { statePath } from "./ledger.js";
20
+ /**
21
+ * Bumped whenever the uncalibrated heuristic changes. A factor learned against
22
+ * an older heuristic would correct for an error that no longer exists (0.19
23
+ * moved Claude from 4.0 to 2.75 chars/token; an old 1.4x factor on top of that
24
+ * would overcount by 1.4x), so records from another version are ignored and
25
+ * restarted rather than blended.
26
+ */
27
+ export const HEURISTIC_VERSION = 2;
20
28
  /** Anything outside this is a bad sample, not a calibration. */
21
29
  const MIN_FACTOR = 0.5;
22
30
  const MAX_FACTOR = 2.0;
@@ -52,8 +60,14 @@ export function recordCalibration(model, exactTokens, heuristicTokens) {
52
60
  try {
53
61
  const all = readAll();
54
62
  const key = modelFamily(model);
55
- const rec = all[key] ?? { exactSum: 0, heuristicSum: 0, samples: 0 };
56
- all[key] = { exactSum: rec.exactSum + exactTokens, heuristicSum: rec.heuristicSum + heuristicTokens, samples: rec.samples + 1 };
63
+ const prev = all[key];
64
+ const rec = prev && prev.v === HEURISTIC_VERSION ? prev : { exactSum: 0, heuristicSum: 0, samples: 0 };
65
+ all[key] = {
66
+ exactSum: rec.exactSum + exactTokens,
67
+ heuristicSum: rec.heuristicSum + heuristicTokens,
68
+ samples: rec.samples + 1,
69
+ v: HEURISTIC_VERSION,
70
+ };
57
71
  mkdirSync(dirname(calibrationPath()), { recursive: true });
58
72
  writeFileSync(calibrationPath(), JSON.stringify(all, null, 2));
59
73
  }
@@ -66,7 +80,7 @@ export function calibrationFor(model) {
66
80
  if (process.env.CONTEXT_DOCTOR_NO_CALIBRATION)
67
81
  return { factor: 1, samples: 0 };
68
82
  const rec = readAll()[modelFamily(model)];
69
- if (!rec || rec.samples < 1 || rec.heuristicSum <= 0)
83
+ if (!rec || rec.v !== HEURISTIC_VERSION || rec.samples < 1 || rec.heuristicSum <= 0)
70
84
  return { factor: 1, samples: 0 };
71
85
  const factor = rec.exactSum / rec.heuristicSum;
72
86
  if (!Number.isFinite(factor) || factor < MIN_FACTOR || factor > MAX_FACTOR)
package/dist/cli.js CHANGED
@@ -20,6 +20,7 @@ import { runInstall, runUninstall } from "./install.js";
20
20
  import { listSessions, parseSessionFile } from "./session.js";
21
21
  import { runHook } from "./hook.js";
22
22
  import { buildImpactReport } from "./impact.js";
23
+ import { measureTokenizer, renderTokenizer } from "./tokenizer-measure.js";
23
24
  import { renderPreferences, copyToClipboard, CHAT_PREFERENCES } from "./preferences.js";
24
25
  import { recordLedger } from "./ledger.js";
25
26
  import { runDoctor } from "./doctor.js";
@@ -100,7 +101,7 @@ Options:
100
101
  Default: dedupe, trim-tool-results, strip-base64 (lossless-ish set)
101
102
  --keep-recent <n> (optimize) Messages at the tail to leave untouched (default 6)
102
103
  --max-tool-tokens <n> (optimize) Token budget for trimmed tool results (default 300)
103
- --limit <n> (accuracy) Sessions to sample (default 20)
104
+ --limit <n> (accuracy, session --list) Sessions to sample or list (default 20)
104
105
  --check <cmd> (experiment) Command whose exit code is the pass/fail for each arm
105
106
  --existing <id> (experiment) Session id to fork the second arm from (never mutated)
106
107
  --budget <usd> (experiment) Spend cap per arm (default 1)
@@ -327,7 +328,8 @@ function main() {
327
328
  }
328
329
  if (args.command === "accuracy") {
329
330
  const report = measureAccuracy(args.limit ?? 20);
330
- console.log(args.json ? JSON.stringify(report, null, 2) : renderAccuracy(report));
331
+ const tokenizer = measureTokenizer(Math.max(args.limit ?? 20, 60));
332
+ console.log(args.json ? JSON.stringify({ ...report, tokenizer }, null, 2) : `${renderAccuracy(report)}\n\n${renderTokenizer(tokenizer)}`);
331
333
  return;
332
334
  }
333
335
  if (args.command === "report") {
@@ -375,9 +377,9 @@ function main() {
375
377
  }
376
378
  if (args.command === "session") {
377
379
  if (args.list) {
378
- const sessions = listSessions();
380
+ const sessions = listSessions(args.limit ?? 20);
379
381
  if (sessions.length === 0) {
380
- console.log("No Claude Code sessions found under ~/.claude/projects.");
382
+ console.log("No sessions found under ~/.claude/projects or ~/.codex/sessions.");
381
383
  return;
382
384
  }
383
385
  for (const s of sessions) {
package/dist/hook.js CHANGED
@@ -17,7 +17,7 @@ import { recordLedger, statePath } from "./ledger.js";
17
17
  import { parseConversation } from "./parse.js";
18
18
  import { profileConversation } from "./profile.js";
19
19
  import { parseSessionFile } from "./session.js";
20
- import { formatTokens } from "./tokens.js";
20
+ import { formatTokens, CHARS_PER_TOKEN } from "./tokens.js";
21
21
  import { formatUsd } from "./pricing.js";
22
22
  import { checkBudget, loadConfig } from "./config.js";
23
23
  /** Default nudge threshold; a project budget or env var can lower/raise it. */
@@ -37,13 +37,16 @@ function warnThreshold(budgetMaxTokens) {
37
37
  /** Re-nudge only after the context grows another 40% — one reminder, not a nag. */
38
38
  const REGROWTH_FACTOR = 1.4;
39
39
  /**
40
- * Fast-path gate: text tokens are at least ~4 bytes each and the transcript
40
+ * Fast-path gate: no tokenizer we model packs more than one token into fewer
41
+ * bytes than its densest ratio (Claude on code, 2.4), and the transcript
41
42
  * carries JSON overhead on top, so a file smaller than this cannot possibly
42
43
  * hold that many tokens of context. Lean sessions cost one stat() call — the
43
- * transcript is never even read.
44
+ * transcript is never even read. (This used 4 bytes per token until 0.19,
45
+ * which let Claude sessions past the threshold take the fast path.)
44
46
  */
47
+ const DENSEST_CHARS_PER_TOKEN = Math.min(...Object.values(CHARS_PER_TOKEN).map((r) => Math.min(r.prose, r.code)));
45
48
  function minBytesForWarn(threshold) {
46
- return threshold * 4;
49
+ return Math.floor(threshold * DENSEST_CHARS_PER_TOKEN);
47
50
  }
48
51
  /**
49
52
  * State lives in one small file per session, not one shared map.
package/dist/index.d.ts CHANGED
@@ -20,3 +20,4 @@ export type { Provider } from "./tokens.js";
20
20
  export { renderStatusLine, tailUsage } from "./statusline.js";
21
21
  export * from "./sketch.js";
22
22
  export * from "./preferences.js";
23
+ export * from "./tokenizer-measure.js";
package/dist/index.js CHANGED
@@ -15,3 +15,4 @@ export { estimateTokens, contextWindowFor, providerFor, formatTokens } from "./t
15
15
  export { renderStatusLine, tailUsage } from "./statusline.js";
16
16
  export * from "./sketch.js";
17
17
  export * from "./preferences.js";
18
+ export * from "./tokenizer-measure.js";
package/dist/mcp.js CHANGED
@@ -29,7 +29,7 @@ import { runSketch } from "./sketch.js";
29
29
  * "standing context-hygiene instructions in every chat" — no hook needed.
30
30
  */
31
31
  // Kept deliberately terse: these ride in EVERY conversation's context, and a
32
- // context-saving tool must not itself be context overhead (~130 tokens).
32
+ // context-saving tool must not itself be context overhead (~250 Claude tokens).
33
33
  //
34
34
  // Written as conditions and actions, not offers. On Claude Desktop this string
35
35
  // is the only channel that reaches the model without the user asking — MCP has
@@ -40,7 +40,7 @@ import { runSketch } from "./sketch.js";
40
40
  const SERVER_INSTRUCTIONS = `Context hygiene rules (always on):
41
41
  1. Summarize any paste or tool result over ~2k tokens into the points you will use, then work from the summary; never carry it verbatim.
42
42
  2. Reference earlier content by name; never re-quote it. Never inline base64.
43
- 3. When the conversation passes ~30 turns, or holds 3+ large pastes, or the user asks about tokens, cost, speed or limits: call profile_context BEFORE answering and act on its top finding. In a chat app pass a \`sketch\` (turn count + the large/repeated blocks, ~100 tokens), not the conversation. Do not estimate token counts yourself.
43
+ 3. When the conversation passes ~30 turns, or holds 3+ large pastes, or the user asks about tokens, cost, speed or limits: call profile_context BEFORE answering and act on its top finding. In a chat app pass a \`sketch\` (turn count + the large/repeated blocks, ~120 tokens), not the conversation. Do not estimate token counts yourself.
44
44
  4. If optimize_context returns a pruned-turns digest, you write the ≤150-token replacement summary.`;
45
45
  const STRATEGY_IDS = ["dedupe", "trim-tool-results", "trim-tool-calls", "strip-base64", "prune-history"];
46
46
  /**
@@ -49,8 +49,8 @@ const STRATEGY_IDS = ["dedupe", "trim-tool-results", "trim-tool-calls", "strip-b
49
49
  * recommended pattern.
50
50
  */
51
51
  function createServer() {
52
- const server = new McpServer({ name: "context-doctor", version: "0.18.0" }, { instructions: SERVER_INSTRUCTIONS });
53
- server.tool("profile_context", "Profile an LLM conversation or prompt: token breakdown, largest blocks, and actionable findings about wasted context (duplicates, oversized pastes or tool results, base64 blobs, long history). Two inputs, pass ONE: `conversation` (full OpenAI/Anthropic JSON or raw text, for agents, files and proxies) or `sketch` (for chat apps such as Claude Desktop or ChatGPT where you cannot export the conversation: the turn count plus the few blocks that matter, ~100 tokens to write). Call it whenever the user asks about token usage, context size, cost, speed or limits, and on your own once the conversation passes ~30 turns or holds 3+ large pastes. Act on the top finding in your reply.", {
52
+ const server = new McpServer({ name: "context-doctor", version: "0.19.0" }, { instructions: SERVER_INSTRUCTIONS });
53
+ server.tool("profile_context", "Profile an LLM conversation or prompt: token breakdown, largest blocks, and actionable findings about wasted context (duplicates, oversized pastes or tool results, base64 blobs, long history). Two inputs, pass ONE: `conversation` (full OpenAI/Anthropic JSON or raw text, for agents, files and proxies) or `sketch` (for chat apps such as Claude Desktop or ChatGPT where you cannot export the conversation: the turn count plus the few blocks that matter, ~120 tokens to write). Call it whenever the user asks about token usage, context size, cost, speed or limits, and on your own once the conversation passes ~30 turns or holds 3+ large pastes. Act on the top finding in your reply.", {
54
54
  conversation: z.string().optional().describe("Conversation JSON (OpenAI or Anthropic format, or bare message array) or raw prompt text. Omit in chat apps and pass `sketch`."),
55
55
  sketch: z.object({
56
56
  turns: z.number().int().nonnegative().describe("User+assistant exchanges so far"),
@@ -66,7 +66,7 @@ function createServer() {
66
66
  repeated: z.number().int().positive().optional().describe("Times this same content appears (2+ = duplicate)"),
67
67
  stale: z.boolean().optional().describe("Already acted on; nothing in it is still needed"),
68
68
  })).describe("Only the blocks over ~500 tokens, repeated, or images. Plain turns need not be listed."),
69
- }).optional().describe("Coarse description of the conversation for chat apps. Give one size hint per block (lines, words, chars or tokens)."),
69
+ }).optional().describe("Coarse description of the conversation for chat apps. One size hint per block: lines for code, words for prose, chars (or tokens) for logs and tool output, which vary most per line."),
70
70
  model: z.string().optional().describe("Target model name for context-window math, e.g. claude-sonnet-5 or gpt-4o"),
71
71
  }, async ({ conversation, sketch, model }) => {
72
72
  if (sketch) {
@@ -9,6 +9,11 @@
9
9
  export type StrategyId = "dedupe" | "trim-tool-results" | "trim-tool-calls" | "prune-history" | "strip-base64";
10
10
  export interface OptimizeOptions {
11
11
  strategies?: StrategyId[];
12
+ /**
13
+ * Model the conversation is for; picks the tokenizer ratios for savings and
14
+ * trim budgets. Defaults to the conversation's own `model` field.
15
+ */
16
+ model?: string;
12
17
  /** Tool results older than this many messages from the end get trimmed. */
13
18
  keepRecent?: number;
14
19
  /** Max tokens a trimmed tool result — or tool-call argument set — keeps. */
package/dist/optimize.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * so the output is a drop-in replacement for the input conversation.
8
8
  */
9
9
  import { createHash } from "node:crypto";
10
- import { estimateTokens } from "./tokens.js";
10
+ import { charsPerTokenFor, estimateTokens } from "./tokens.js";
11
11
  import { hasBase64Blob, stripBase64Blobs } from "./blob.js";
12
12
  const TRIM_BOUNDARY_STEP = 10;
13
13
  const DEFAULTS = {
@@ -63,8 +63,10 @@ function editedLaterWithoutRead(messages, writeIndex, path) {
63
63
  }
64
64
  return false;
65
65
  }
66
- function trimCallArguments(input, maxTokens) {
67
- const budgetChars = maxTokens * 4;
66
+ function trimCallArguments(input, maxTokens, model) {
67
+ // Prose ratio: argument strings are mostly file content and commands, and
68
+ // the prose ratio keeps a little more than the budget rather than less.
69
+ const budgetChars = Math.round(maxTokens * charsPerTokenFor("", model));
68
70
  const out = {};
69
71
  for (const [key, value] of Object.entries(input)) {
70
72
  if (typeof value === "string" && value.length > budgetChars) {
@@ -158,8 +160,8 @@ function replaceText(content, newText) {
158
160
  out.push({ type: "text", text: newText });
159
161
  return out;
160
162
  }
161
- function truncateToTokens(text, maxTokens) {
162
- const approxChars = maxTokens * 4;
163
+ function truncateToTokens(text, maxTokens, model) {
164
+ const approxChars = Math.round(maxTokens * charsPerTokenFor(text, model));
163
165
  if (text.length <= approxChars)
164
166
  return text;
165
167
  const head = text.slice(0, approxChars);
@@ -229,7 +231,15 @@ export function optimizeConversation(input, options = {}) {
229
231
  if (!messages[i] || typeof messages[i] !== "object")
230
232
  messages.splice(i, 1);
231
233
  }
232
- const tokensBefore = messages.reduce((s, m) => s + estimateTokens(textOf(m.content)), 0);
234
+ // The request's own model, else Anthropic's shape (a top-level system, or
235
+ // tool_use/tool_result blocks) says whose tokenizer counts it.
236
+ const looksAnthropic = (!Array.isArray(data) && data?.system != null) ||
237
+ messages.some((m) => Array.isArray(m?.content) && m.content.some((b) => b?.type === "tool_use" || b?.type === "tool_result"));
238
+ const model = options.model ??
239
+ (!Array.isArray(data) && typeof data?.model === "string" ? data.model : undefined) ??
240
+ (looksAnthropic ? "claude" : undefined);
241
+ const tok = (text) => estimateTokens(text, model);
242
+ const tokensBefore = messages.reduce((s, m) => s + tok(textOf(m.content)), 0);
233
243
  const applied = [];
234
244
  // -- strip-base64: replace inline blobs with a placeholder --------------------
235
245
  if (opts.strategies.includes("strip-base64")) {
@@ -237,9 +247,9 @@ export function optimizeConversation(input, options = {}) {
237
247
  const text = textOf(m.content);
238
248
  if (!hasBase64Blob(text))
239
249
  return;
240
- const before = estimateTokens(text);
250
+ const before = tok(text);
241
251
  const cleaned = stripBase64Blobs(text);
242
- const saved = before - estimateTokens(cleaned);
252
+ const saved = before - tok(cleaned);
243
253
  if (saved > 50) {
244
254
  m.content = replaceText(m.content, cleaned);
245
255
  applied.push({ strategy: "strip-base64", messageIndex: i, tokensSaved: saved, note: "Removed inline base64 data" });
@@ -272,7 +282,7 @@ export function optimizeConversation(input, options = {}) {
272
282
  seen.set(h, i);
273
283
  return;
274
284
  }
275
- const saved = estimateTokens(text);
285
+ const saved = tok(text);
276
286
  m.content = replaceText(m.content, `[context-doctor: identical to message #${first} — content removed]`);
277
287
  applied.push({ strategy: "dedupe", messageIndex: i, tokensSaved: saved, note: `Duplicate of message #${first}` });
278
288
  });
@@ -284,20 +294,20 @@ export function optimizeConversation(input, options = {}) {
284
294
  if (i >= cutoff || !isToolResultMessage(m))
285
295
  return;
286
296
  const text = textOf(m.content);
287
- const before = estimateTokens(text);
297
+ const before = tok(text);
288
298
  if (before <= opts.maxToolResultTokens)
289
299
  return;
290
- const trimmed = truncateToTokens(text, opts.maxToolResultTokens);
300
+ const trimmed = truncateToTokens(text, opts.maxToolResultTokens, model);
291
301
  // The truncation notice has a length of its own, so a result only just
292
302
  // over the budget can come back LARGER than it went in. Measured on a
293
303
  // real session: 2,941 tokens "optimized" to 2,947.
294
- if (estimateTokens(trimmed) >= before)
304
+ if (tok(trimmed) >= before)
295
305
  return;
296
306
  m.content = replaceText(m.content, trimmed);
297
307
  applied.push({
298
308
  strategy: "trim-tool-results",
299
309
  messageIndex: i,
300
- tokensSaved: before - estimateTokens(trimmed),
310
+ tokensSaved: before - tok(trimmed),
301
311
  note: "Stale tool result truncated",
302
312
  });
303
313
  });
@@ -322,12 +332,12 @@ export function optimizeConversation(input, options = {}) {
322
332
  // those alone; the 63% never touched again are still pure gain.
323
333
  if (editedLaterWithoutRead(messages, i, writtenPath(b)))
324
334
  continue;
325
- const before = estimateTokens(JSON.stringify(b.input));
335
+ const before = tok(JSON.stringify(b.input));
326
336
  if (before <= opts.maxToolResultTokens)
327
337
  continue;
328
- const trimmedInput = trimCallArguments(b.input, opts.maxToolResultTokens);
338
+ const trimmedInput = trimCallArguments(b.input, opts.maxToolResultTokens, model);
329
339
  // Same trap as tool results: the marker can outweigh what it replaces.
330
- const after = estimateTokens(JSON.stringify(trimmedInput));
340
+ const after = tok(JSON.stringify(trimmedInput));
331
341
  if (after >= before)
332
342
  continue;
333
343
  b.input = trimmedInput;
@@ -339,11 +349,11 @@ export function optimizeConversation(input, options = {}) {
339
349
  const args = tc?.function?.arguments;
340
350
  if (typeof args !== "string")
341
351
  continue;
342
- const before = estimateTokens(args);
352
+ const before = tok(args);
343
353
  if (before <= opts.maxToolResultTokens)
344
354
  continue;
345
- tc.function.arguments = truncateToTokens(args, opts.maxToolResultTokens);
346
- saved += before - estimateTokens(tc.function.arguments);
355
+ tc.function.arguments = truncateToTokens(args, opts.maxToolResultTokens, model);
356
+ saved += before - tok(tc.function.arguments);
347
357
  }
348
358
  if (saved > 0) {
349
359
  applied.push({
@@ -375,7 +385,7 @@ export function optimizeConversation(input, options = {}) {
375
385
  // 1,011-message session, which pruned to 7 messages with one orphan.
376
386
  dropOrphanedToolResults(messages.slice(keepFrom));
377
387
  const pruned = messages.slice(0, keepFrom);
378
- const prunedTokens = pruned.reduce((s, m) => s + estimateTokens(textOf(m.content)), 0);
388
+ const prunedTokens = pruned.reduce((s, m) => s + tok(textOf(m.content)), 0);
379
389
  // Digest: first ~200 chars of each pruned turn — enough for a host LLM to
380
390
  // write a faithful summary, small enough not to defeat the pruning.
381
391
  prunedDigest = pruned
@@ -390,11 +400,11 @@ export function optimizeConversation(input, options = {}) {
390
400
  applied.push({
391
401
  strategy: "prune-history",
392
402
  messageIndex: 0,
393
- tokensSaved: prunedTokens - estimateTokens(stub.content),
403
+ tokensSaved: prunedTokens - tok(stub.content),
394
404
  note: `Pruned ${pruned.length} old messages`,
395
405
  });
396
406
  }
397
407
  }
398
- const tokensAfter = messages.reduce((s, m) => s + estimateTokens(textOf(m.content)), 0);
408
+ const tokensAfter = messages.reduce((s, m) => s + tok(textOf(m.content)), 0);
399
409
  return { conversation: data, tokensBefore, tokensAfter, applied, prunedDigest };
400
410
  }
package/dist/parse.d.ts CHANGED
@@ -34,6 +34,8 @@ export interface NormalizedConversation {
34
34
  messages: NormalizedMessage[];
35
35
  /** Format detected, for reporting. */
36
36
  sourceFormat: "openai" | "anthropic" | "array" | "text";
37
+ /** The request's own `model` field, when it has one. Picks the tokenizer ratios. */
38
+ model?: string;
37
39
  /**
38
40
  * Set when the input could not be read as a conversation. Silently profiling
39
41
  * a broken file as one big "user message" produces a confident, wrong report
package/dist/parse.js CHANGED
@@ -146,5 +146,6 @@ export function parseConversation(input) {
146
146
  : messages.length === 0
147
147
  ? "This JSON has no `messages` array (and no `system`) — it does not look like a conversation. Expected {\"messages\":[{\"role\":…,\"content\":…}]}."
148
148
  : undefined;
149
- return { sourceFormat: isAnthropic ? "anthropic" : "openai", parseWarning, messages };
149
+ const model = typeof obj.model === "string" && obj.model ? obj.model : undefined;
150
+ return { sourceFormat: isAnthropic ? "anthropic" : "openai", parseWarning, messages, model };
150
151
  }
@@ -4,7 +4,7 @@
4
4
  * "custom instructions" are read on every turn of every chat, which is the
5
5
  * closest thing those surfaces have to a UserPromptSubmit hook.
6
6
  *
7
- * Kept short (~90 tokens): it rides in every conversation.
7
+ * Kept short (~180 Claude tokens, ~120 GPT): it rides in every conversation.
8
8
  */
9
9
  export declare const CHAT_PREFERENCES: string;
10
10
  /** Where to paste, per app. Paths are the UI labels, not URLs, so they survive redesigns. */
@@ -4,7 +4,7 @@
4
4
  * "custom instructions" are read on every turn of every chat, which is the
5
5
  * closest thing those surfaces have to a UserPromptSubmit hook.
6
6
  *
7
- * Kept short (~90 tokens): it rides in every conversation.
7
+ * Kept short (~180 Claude tokens, ~120 GPT): it rides in every conversation.
8
8
  */
9
9
  import { spawnSync } from "node:child_process";
10
10
  export const CHAT_PREFERENCES = [
package/dist/profile.js CHANGED
@@ -146,11 +146,18 @@ function filesReadBy(toolName, toolCallText) {
146
146
  return [...paths];
147
147
  }
148
148
  export function profileConversation(conv, model) {
149
+ // An explicit model wins; otherwise the request's own model field decides
150
+ // which tokenizer ratios apply.
151
+ model = model ?? conv.model;
152
+ // With no model at all, Anthropic's request format still says whose
153
+ // tokenizer counts it. Only the ratios use this; pricing and the window
154
+ // stay unknown rather than guessed.
155
+ const tokenizerModel = model ?? (conv.sourceFormat === "anthropic" ? "claude" : undefined);
149
156
  // Learned from the user's own exact counts, if they ever fetched any.
150
- const calibration = calibrationFor(model);
157
+ const calibration = calibrationFor(tokenizerModel);
151
158
  const perMessage = conv.messages.map((m) => ({
152
159
  msg: m,
153
- tokens: Math.round(estimateTokens(m.text) * calibration.factor) + MESSAGE_OVERHEAD_TOKENS,
160
+ tokens: Math.round(estimateTokens(m.text, tokenizerModel) * calibration.factor) + MESSAGE_OVERHEAD_TOKENS,
154
161
  }));
155
162
  const totalTokens = perMessage.reduce((sum, p) => sum + p.tokens, 0);
156
163
  const categories = {
package/dist/proxy.js CHANGED
@@ -14,7 +14,7 @@
14
14
  import http from "node:http";
15
15
  import { timingSafeEqual } from "node:crypto";
16
16
  import { optimizeConversation } from "./optimize.js";
17
- import { formatTokens } from "./tokens.js";
17
+ import { formatTokens, CHARS_PER_TOKEN, providerFor } from "./tokens.js";
18
18
  import { formatUsd, inputCostUsd, pricingFor } from "./pricing.js";
19
19
  import { recordLedger } from "./ledger.js";
20
20
  /** Connection-level headers that must not be forwarded. */
@@ -155,7 +155,9 @@ export function startProxy(opts = {}) {
155
155
  if (url.startsWith("/v1/messages") && requestModel) {
156
156
  const stablePrefix = JSON.stringify(parsedBody.tools ?? null) + JSON.stringify(parsedBody.system ?? null);
157
157
  const hasBreakpoint = body.includes("cache_control");
158
- if (stablePrefix.length > 4000 && !hasBreakpoint) {
158
+ const stablePrefixTokens = Math.round(stablePrefix.length / CHARS_PER_TOKEN[providerFor(requestModel)].code);
159
+ // Anthropic will not cache a prefix under ~1024 tokens; below that the advice is useless.
160
+ if (stablePrefixTokens >= 1024 && !hasBreakpoint) {
159
161
  // Say WHERE, not just that. A breakpoint caches everything up
160
162
  // to and including the block it sits on, so it belongs on the
161
163
  // LAST stable block: the final tool definition if there are
@@ -163,7 +165,7 @@ export function startProxy(opts = {}) {
163
165
  const where = Array.isArray(parsedBody.tools) && parsedBody.tools.length > 0
164
166
  ? `the last entry in "tools" (tools come before system in the cached prefix)`
165
167
  : `the last block of "system"`;
166
- advise(`~${Math.round(stablePrefix.length / 4)}+ tokens of stable system/tools on ${requestModel} without cache_control. ` +
168
+ advise(`~${stablePrefixTokens}+ tokens of stable system/tools on ${requestModel} without cache_control. ` +
167
169
  `Add {"cache_control":{"type":"ephemeral"}} to ${where}; everything before it then bills at ~10% on every call`);
168
170
  }
169
171
  const fp = fnv1a(stablePrefix);
@@ -188,7 +190,7 @@ export function startProxy(opts = {}) {
188
190
  stable++;
189
191
  if (stable >= 2) {
190
192
  const stableChars = msgs.slice(0, stable).reduce((n, m) => n + JSON.stringify(m).length, 0);
191
- const stableTokens = Math.round(stableChars / 4);
193
+ const stableTokens = Math.round(stableChars / CHARS_PER_TOKEN[providerFor(requestModel)].code);
192
194
  // Anthropic will not cache a prefix under ~1024 tokens (2048 on Haiku).
193
195
  if (stableTokens >= 1024) {
194
196
  advise(`messages #0-#${stable - 1} (~${stableTokens} tokens) were identical to the previous ${requestModel} request and carry no cache_control. ` +
package/dist/sketch.d.ts CHANGED
@@ -6,8 +6,10 @@
6
6
  * JSON means re-typing 50k+ tokens as a tool argument. No model does that, and
7
7
  * it would double the context it is meant to measure. A sketch is ~100 output
8
8
  * tokens: turn count plus the handful of blocks that matter (pastes, tool
9
- * results, images, repeats). The estimate is coarse (±30%) and says so, but it
10
- * turns "call profile_context" from an impossible instruction into a cheap one.
9
+ * results, images, repeats). Measured error: -20% to +9% on the conversation
10
+ * total from turn count alone, ±25% on a code block sized by lines, ±15% on
11
+ * one sized by chars. Coarse, stated, and enough to find what to drop; it turns
12
+ * "call profile_context" from an impossible instruction into a cheap one.
11
13
  */
12
14
  export type SketchKind = "paste" | "code" | "tool_result" | "image" | "base64" | "text";
13
15
  export interface SketchBlock {
@@ -52,7 +54,9 @@ export interface SketchProfile {
52
54
  perTurnUsd?: number;
53
55
  perTurnCachedUsd?: number;
54
56
  }
55
- export declare function blockTokens(b: SketchBlock): number;
57
+ export declare function blockTokens(b: SketchBlock, model?: string): number;
58
+ /** Tokens for one plain exchange under this model's tokenizer. */
59
+ export declare function exchangeTokens(model?: string): number;
56
60
  export declare function profileSketch(sketch: ConversationSketch): SketchProfile;
57
61
  export declare function renderSketchProfile(p: SketchProfile): string;
58
62
  /** Profile a sketch, log it to the ledger like a hook check, and render. */
package/dist/sketch.js CHANGED
@@ -6,51 +6,72 @@
6
6
  * JSON means re-typing 50k+ tokens as a tool argument. No model does that, and
7
7
  * it would double the context it is meant to measure. A sketch is ~100 output
8
8
  * tokens: turn count plus the handful of blocks that matter (pastes, tool
9
- * results, images, repeats). The estimate is coarse (±30%) and says so, but it
10
- * turns "call profile_context" from an impossible instruction into a cheap one.
9
+ * results, images, repeats). Measured error: -20% to +9% on the conversation
10
+ * total from turn count alone, ±25% on a code block sized by lines, ±15% on
11
+ * one sized by chars. Coarse, stated, and enough to find what to drop; it turns
12
+ * "call profile_context" from an impossible instruction into a cheap one.
11
13
  */
12
- import { contextWindowFor } from "./tokens.js";
13
- import { formatTokens } from "./tokens.js";
14
+ import { CHARS_PER_TOKEN, contextWindowFor, formatTokens, providerFor } from "./tokens.js";
14
15
  import { formatUsd, inputCostUsd, pricingFor } from "./pricing.js";
15
16
  import { recordLedger } from "./ledger.js";
16
- // A plain chat turn without attachments: a short user message and a normal
17
- // assistant reply. Measured across Claude Code transcripts the median user turn
18
- // is ~120 tokens and the median assistant turn ~450; chat apps run similar.
19
- const BASELINE_TOKENS_PER_TURN = 570;
20
- // Tokens per unit when the model reports size in lines/words/chars. Code and
21
- // tool output are denser per line than prose; words are ~1.35 tokens each.
22
- const TOKENS_PER_LINE = {
23
- code: 12, tool_result: 12, paste: 14, text: 14, base64: 40, image: 0,
17
+ // Every size here is in CHARACTERS, measured on real data, and converted to
18
+ // tokens with the model's own ratio (tokens.ts), so a Claude chat and a GPT
19
+ // chat of the same text get different, correct counts.
20
+ //
21
+ // A plain exchange: the user's message plus the assistant's reply. Median over
22
+ // 1,283 exchanges in 59 Claude Code sessions (p25 1,177, p75 3,008). Sizing a
23
+ // 30+ exchange chat from its turn count alone landed within -20% to +9% of the
24
+ // real visible total (p10 to p90, 12 sessions): individual turns vary a lot,
25
+ // long chats average it out.
26
+ const CHARS_PER_EXCHANGE = 2060;
27
+ // Mean chars per non-empty line. Code: median over 719 source files (a line
28
+ // count for code is within about ±25%). Logs and tool output: median over
29
+ // 2,095 tool results, but they range 38 to 100 chars a line, so the schema
30
+ // asks for chars or tokens on those, and lines are the fallback.
31
+ const CHARS_PER_LINE = {
32
+ code: 42, tool_result: 56, paste: 56, text: 80, base64: 76, image: 0,
24
33
  };
25
- const TOKENS_PER_WORD = 1.35;
26
- const CHARS_PER_TOKEN = 4;
27
- // When a block carries no size at all. Images are billed at a near-fixed rate.
28
- const DEFAULT_TOKENS = {
29
- image: 1500, paste: 800, code: 800, tool_result: 800, base64: 4000, text: 300,
34
+ // Prose, including the space after each word (6.3 measured on assistant text).
35
+ const CHARS_PER_WORD = 6.3;
36
+ // When a block carries no size at all.
37
+ const DEFAULT_CHARS = {
38
+ paste: 2400, code: 2400, tool_result: 2400, text: 900, base64: 12000, image: 0,
30
39
  };
40
+ // Images are billed by pixels, not bytes: ~1,600 tokens for a full-size
41
+ // screenshot on Claude, fewer on smaller images and on GPT. One figure is enough
42
+ // for a sketch.
43
+ const IMAGE_TOKENS = 1500;
31
44
  const LARGE_BLOCK_TOKENS = 2000;
32
45
  const LONG_HISTORY_TURNS = 30;
33
46
  const HANDOFF_SUMMARY_TOKENS = 300;
34
47
  const RECENT_TURNS_KEPT = 6;
35
- export function blockTokens(b) {
48
+ /** Code, tool output and base64 tokenize at the code ratio; pastes and text at the prose ratio. */
49
+ function ratioFor(kind, model) {
50
+ const r = CHARS_PER_TOKEN[providerFor(model)];
51
+ return kind === "code" || kind === "tool_result" || kind === "base64" ? r.code : r.prose;
52
+ }
53
+ export function blockTokens(b, model) {
36
54
  if (b.kind === "image")
37
- return b.approx_tokens ?? DEFAULT_TOKENS.image;
55
+ return b.approx_tokens && b.approx_tokens > 0 ? Math.round(b.approx_tokens) : IMAGE_TOKENS;
38
56
  if (b.approx_tokens && b.approx_tokens > 0)
39
57
  return Math.round(b.approx_tokens);
40
- if (b.approx_lines && b.approx_lines > 0)
41
- return Math.round(b.approx_lines * TOKENS_PER_LINE[b.kind]);
42
- if (b.approx_words && b.approx_words > 0)
43
- return Math.round(b.approx_words * TOKENS_PER_WORD);
44
- if (b.approx_chars && b.approx_chars > 0)
45
- return Math.round(b.approx_chars / CHARS_PER_TOKEN);
46
- return DEFAULT_TOKENS[b.kind];
58
+ const chars = b.approx_chars && b.approx_chars > 0 ? b.approx_chars
59
+ : b.approx_lines && b.approx_lines > 0 ? b.approx_lines * CHARS_PER_LINE[b.kind]
60
+ : b.approx_words && b.approx_words > 0 ? b.approx_words * CHARS_PER_WORD
61
+ : DEFAULT_CHARS[b.kind];
62
+ return Math.round(chars / ratioFor(b.kind, model));
63
+ }
64
+ /** Tokens for one plain exchange under this model's tokenizer. */
65
+ export function exchangeTokens(model) {
66
+ return Math.round(CHARS_PER_EXCHANGE / CHARS_PER_TOKEN[providerFor(model)].prose);
47
67
  }
48
68
  export function profileSketch(sketch) {
49
69
  const turns = Math.max(0, Math.floor(sketch.turns || 0));
50
70
  const blocks = Array.isArray(sketch.blocks) ? sketch.blocks : [];
51
- const baselineTokens = turns * BASELINE_TOKENS_PER_TURN;
71
+ const perExchange = exchangeTokens(sketch.model);
72
+ const baselineTokens = turns * perExchange;
52
73
  // A repeated block costs its size every time it appears.
53
- const sized = blocks.map((b) => ({ block: b, tokens: blockTokens(b), copies: Math.max(1, Math.floor(b.repeated ?? 1)) }));
74
+ const sized = blocks.map((b) => ({ block: b, tokens: blockTokens(b, sketch.model), copies: Math.max(1, Math.floor(b.repeated ?? 1)) }));
54
75
  const blockTotal = sized.reduce((n, s) => n + s.tokens * s.copies, 0);
55
76
  const totalTokens = baselineTokens + blockTotal;
56
77
  const findings = [];
@@ -101,7 +122,7 @@ export function profileSketch(sketch) {
101
122
  });
102
123
  }
103
124
  if (turns >= LONG_HISTORY_TURNS) {
104
- const afterHandoff = RECENT_TURNS_KEPT * BASELINE_TOKENS_PER_TURN + HANDOFF_SUMMARY_TOKENS;
125
+ const afterHandoff = RECENT_TURNS_KEPT * perExchange + HANDOFF_SUMMARY_TOKENS;
105
126
  findings.push({
106
127
  id: "long_history",
107
128
  severity: totalTokens > 100_000 ? "high" : "warn",
@@ -140,7 +161,7 @@ export function renderSketchProfile(p) {
140
161
  const lines = [];
141
162
  const window = p.usagePct !== undefined ? ` (~${p.usagePct}% of ${formatTokens(p.contextWindow)})` : "";
142
163
  lines.push(`Context estimate from sketch: ~${formatTokens(p.totalTokens)} tokens${window}, ${p.turns} turns.`);
143
- lines.push(` ${formatTokens(p.baselineTokens)} plain conversation + ${formatTokens(p.blockTokens)} in pastes, tool output and images. Estimate, ±30%.`);
164
+ lines.push(` ${formatTokens(p.baselineTokens)} plain conversation + ${formatTokens(p.blockTokens)} in pastes, tool output and images. Estimate, usually within ±20%.`);
144
165
  if (p.perTurnUsd !== undefined) {
145
166
  lines.push(` Re-read on every turn: ${formatUsd(p.perTurnUsd)} at list price, ${formatUsd(p.perTurnCachedUsd)} when cached. On a subscription this is what spends the usage limit.`);
146
167
  }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Measure a model's real chars-per-token from Claude Code transcripts, with no
3
+ * API key and no tokenizer: the API's own counts are already in the file.
4
+ *
5
+ * Two independent measurements, so one can check the other:
6
+ *
7
+ * - PROSE. An assistant reply with no thinking block is billed as exactly
8
+ * `output_tokens`, and all of it is visible text in the transcript. Visible
9
+ * chars / output_tokens is the tokenizer's ratio on the model's own prose.
10
+ *
11
+ * - BLOCKS (code, tool output, pastes). Between two consecutive API calls in
12
+ * one session the prompt is the old prompt plus what was appended: the
13
+ * previous reply (all of `output_tokens`, thinking included, since a tool
14
+ * loop re-sends it) and the new user-side content. When that content is a
15
+ * single large block, (growth − previous output_tokens) is its exact size.
16
+ *
17
+ * The injected reminders the harness adds make the block figure slightly
18
+ * pessimistic (a few dozen tokens on blocks of thousands), which is why only
19
+ * blocks over 6k chars count.
20
+ */
21
+ export interface RatioStats {
22
+ samples: number;
23
+ median: number;
24
+ p10: number;
25
+ p90: number;
26
+ }
27
+ export interface ModelRatios {
28
+ model: string;
29
+ prose?: RatioStats;
30
+ blocks?: RatioStats;
31
+ /** What the estimator uses for this model: prose, code. */
32
+ assumed: {
33
+ prose: number;
34
+ code: number;
35
+ };
36
+ }
37
+ export interface TokenizerReport {
38
+ sessionsScanned: number;
39
+ models: ModelRatios[];
40
+ }
41
+ export declare function measureTokenizer(limit?: number, paths?: string[]): TokenizerReport;
42
+ export declare function renderTokenizer(report: TokenizerReport): string;
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Measure a model's real chars-per-token from Claude Code transcripts, with no
3
+ * API key and no tokenizer: the API's own counts are already in the file.
4
+ *
5
+ * Two independent measurements, so one can check the other:
6
+ *
7
+ * - PROSE. An assistant reply with no thinking block is billed as exactly
8
+ * `output_tokens`, and all of it is visible text in the transcript. Visible
9
+ * chars / output_tokens is the tokenizer's ratio on the model's own prose.
10
+ *
11
+ * - BLOCKS (code, tool output, pastes). Between two consecutive API calls in
12
+ * one session the prompt is the old prompt plus what was appended: the
13
+ * previous reply (all of `output_tokens`, thinking included, since a tool
14
+ * loop re-sends it) and the new user-side content. When that content is a
15
+ * single large block, (growth − previous output_tokens) is its exact size.
16
+ *
17
+ * The injected reminders the harness adds make the block figure slightly
18
+ * pessimistic (a few dozen tokens on blocks of thousands), which is why only
19
+ * blocks over 6k chars count.
20
+ */
21
+ import { forEachLine, listSessions } from "./session.js";
22
+ import { CHARS_PER_TOKEN, providerFor } from "./tokens.js";
23
+ const MIN_PROSE_CHARS = 1500;
24
+ const MIN_BLOCK_CHARS = 6000;
25
+ function stats(values) {
26
+ if (values.length === 0)
27
+ return undefined;
28
+ const v = [...values].sort((a, b) => a - b);
29
+ const at = (q) => v[Math.min(v.length - 1, Math.floor(q * (v.length - 1)))];
30
+ const mid = Math.floor(v.length / 2);
31
+ const median = v.length % 2 ? v[mid] : (v[mid - 1] + v[mid]) / 2;
32
+ return { samples: v.length, median, p10: at(0.1), p90: at(0.9) };
33
+ }
34
+ function promptTotal(u) {
35
+ const n = (k) => (typeof u[k] === "number" ? u[k] : 0);
36
+ return n("input_tokens") + n("cache_read_input_tokens") + n("cache_creation_input_tokens");
37
+ }
38
+ function userText(content) {
39
+ if (typeof content === "string")
40
+ return content;
41
+ if (!Array.isArray(content))
42
+ return "";
43
+ let out = "";
44
+ for (const p of content) {
45
+ if (p?.type === "text" && typeof p.text === "string")
46
+ out += p.text;
47
+ else if (p?.type === "tool_result") {
48
+ const c = p.content;
49
+ if (typeof c === "string")
50
+ out += c;
51
+ else if (Array.isArray(c))
52
+ for (const x of c)
53
+ if (typeof x?.text === "string")
54
+ out += x.text;
55
+ }
56
+ }
57
+ return out;
58
+ }
59
+ /** Scan one transcript into per-request records. Never throws on bad lines. */
60
+ function readRequests(path) {
61
+ const reqs = [];
62
+ let cur;
63
+ forEachLine(path, (line) => {
64
+ let e;
65
+ try {
66
+ e = JSON.parse(line);
67
+ }
68
+ catch {
69
+ return;
70
+ }
71
+ if (!e || e.isSidechain)
72
+ return;
73
+ if (e.type === "assistant" && e.message && typeof e.message === "object") {
74
+ const m = e.message;
75
+ const u = (m.usage ?? {});
76
+ if (!cur || cur.id !== m.id) {
77
+ cur = { id: String(m.id), model: m.model, prompt: promptTotal(u), output: 0, types: new Set(), textChars: 0, after: [] };
78
+ reqs.push(cur);
79
+ }
80
+ if (typeof u.output_tokens === "number")
81
+ cur.output = Math.max(cur.output, u.output_tokens);
82
+ for (const p of Array.isArray(m.content) ? m.content : []) {
83
+ cur.types.add(String(p?.type));
84
+ if (p?.type === "text" && typeof p.text === "string")
85
+ cur.textChars += p.text.length;
86
+ }
87
+ }
88
+ else if (e.type === "user" && cur) {
89
+ const meta = Boolean(e.isMeta || e.isCompactSummary);
90
+ const text = userText(e.message?.content);
91
+ cur.after.push({ chars: text.length, meta });
92
+ }
93
+ });
94
+ return reqs;
95
+ }
96
+ export function measureTokenizer(limit = 60, paths) {
97
+ const prose = new Map();
98
+ const blocks = new Map();
99
+ const push = (m, k, v) => (m.get(k) ?? m.set(k, []).get(k)).push(v);
100
+ let scanned = 0;
101
+ const targets = paths ?? listSessions(limit).map((s) => s.path);
102
+ for (const path of targets) {
103
+ let reqs;
104
+ try {
105
+ reqs = readRequests(path);
106
+ }
107
+ catch {
108
+ continue;
109
+ }
110
+ if (reqs.length === 0)
111
+ continue;
112
+ scanned++;
113
+ for (const r of reqs) {
114
+ if (!r.model || r.model.startsWith("<"))
115
+ continue;
116
+ if (r.types.size === 1 && r.types.has("text") && r.textChars >= MIN_PROSE_CHARS && r.output > 0) {
117
+ push(prose, r.model, r.textChars / r.output);
118
+ }
119
+ }
120
+ for (let i = 0; i + 1 < reqs.length; i++) {
121
+ const a = reqs[i], b = reqs[i + 1];
122
+ if (!a.model || a.model !== b.model)
123
+ continue;
124
+ const real = a.after.filter((x) => !x.meta);
125
+ if (a.after.some((x) => x.meta) || real.length !== 1 || real[0].chars < MIN_BLOCK_CHARS)
126
+ continue;
127
+ const exact = b.prompt - a.prompt - a.output;
128
+ if (exact <= 0)
129
+ continue; // a compaction or cache reset, not growth
130
+ push(blocks, a.model, real[0].chars / exact);
131
+ }
132
+ }
133
+ const models = [...new Set([...prose.keys(), ...blocks.keys()])]
134
+ .map((model) => ({
135
+ model,
136
+ prose: stats(prose.get(model) ?? []),
137
+ blocks: stats(blocks.get(model) ?? []),
138
+ assumed: CHARS_PER_TOKEN[providerFor(model)],
139
+ }))
140
+ .sort((x, y) => ((y.prose?.samples ?? 0) + (y.blocks?.samples ?? 0)) - ((x.prose?.samples ?? 0) + (x.blocks?.samples ?? 0)));
141
+ return { sessionsScanned: scanned, models };
142
+ }
143
+ export function renderTokenizer(report) {
144
+ const lines = [];
145
+ lines.push("Tokenizer check — chars per token, measured from the API's own counts");
146
+ lines.push("─".repeat(56));
147
+ if (report.models.length === 0) {
148
+ lines.push("No usable samples (needs Claude Code sessions with usage recorded).");
149
+ return lines.join("\n");
150
+ }
151
+ const f = (n) => n.toFixed(2);
152
+ const err = (assumed, real) => {
153
+ // Estimated tokens / real tokens − 1, from chars/token on each side.
154
+ const pct = Math.round((real / assumed - 1) * 100);
155
+ return pct === 0 ? "exact" : `estimates ${pct > 0 ? "+" : ""}${pct}%`;
156
+ };
157
+ const row = (label, st, unit, assumed) => ` ${label.padEnd(7)} ${f(st.median)} ${`(p10 ${f(st.p10)}, p90 ${f(st.p90)}, ${st.samples} ${unit})`.padEnd(34)} ` +
158
+ `estimator ${f(assumed)} → ${err(assumed, st.median)}`;
159
+ for (const m of report.models) {
160
+ lines.push(m.model);
161
+ if (m.prose) {
162
+ lines.push(row("prose", m.prose, "replies", m.assumed.prose));
163
+ }
164
+ if (m.blocks) {
165
+ lines.push(row("blocks", m.blocks, "blocks", m.assumed.code));
166
+ }
167
+ }
168
+ lines.push("");
169
+ lines.push("\"estimates +x%\" means the estimator reports x% more tokens than the API bills for");
170
+ lines.push("the same text (negative: fewer). Blocks are mostly code and tool output.");
171
+ return lines.join("\n");
172
+ }
package/dist/tokens.d.ts CHANGED
@@ -11,7 +11,37 @@
11
11
  export type Provider = "anthropic" | "openai" | "google" | "generic";
12
12
  export declare function contextWindowFor(model?: string): number | undefined;
13
13
  export declare function providerFor(model?: string): Provider;
14
- export declare function estimateTokens(text: string): number;
14
+ /**
15
+ * Characters per token, by provider and by content type.
16
+ *
17
+ * Anthropic: measured 2026-09 against the API's own counts in 59 Claude Code
18
+ * sessions (Opus 4.7 to 5, Fable 5.x), two independent ways that agree.
19
+ * Prose: 504 assistant replies with no thinking block, visible text divided by
20
+ * the exact output_tokens: median 2.75 (p10 2.4, p90 3.0). Code and tool
21
+ * output: 474 single appended blocks over 6k chars, sized by the exact growth
22
+ * of the billed prompt between consecutive calls: median 2.4 (p10 2.1, p90 2.8).
23
+ * The ratios this tool used before (4.0 / 3.2) undercounted current Claude
24
+ * models by about 1.45x on prose and 1.33x on code.
25
+ *
26
+ * OpenAI, Google and unknown models keep 4.0 / 3.2, the usual figures for
27
+ * o200k-class tokenizers on English and code. They are not re-measured here:
28
+ * Codex rollouts truncate tool output before the model sees it, so the same
29
+ * delta method does not isolate a block. `analyze --exact` calibrates any
30
+ * provider from its own tokenizer on your machine.
31
+ */
32
+ export declare const CHARS_PER_TOKEN: Record<Provider, {
33
+ prose: number;
34
+ code: number;
35
+ }>;
36
+ /** True when text is dense with code/JSON symbols and tokenizes more finely. */
37
+ export declare function isCodeLike(text: string): boolean;
38
+ /** Chars per token to use for this text under this model's tokenizer. */
39
+ export declare function charsPerTokenFor(text: string, model?: string): number;
40
+ /**
41
+ * Estimated tokens for `text`. Pass the model when you know it: Claude's
42
+ * tokenizer produces ~40% more tokens than the provider-neutral default.
43
+ */
44
+ export declare function estimateTokens(text: string, model?: string): number;
15
45
  /** Per-message structural overhead (role markers, delimiters) is roughly constant. */
16
46
  export declare const MESSAGE_OVERHEAD_TOKENS = 4;
17
47
  export declare function formatTokens(n: number): string;
package/dist/tokens.js CHANGED
@@ -49,17 +49,51 @@ function symbolDensity(text) {
49
49
  const symbols = text.match(/[{}[\]()<>;:=_\/\\|"'`#$%&*+^~-]/g);
50
50
  return (symbols?.length ?? 0) / text.length;
51
51
  }
52
- export function estimateTokens(text) {
52
+ /**
53
+ * Characters per token, by provider and by content type.
54
+ *
55
+ * Anthropic: measured 2026-09 against the API's own counts in 59 Claude Code
56
+ * sessions (Opus 4.7 to 5, Fable 5.x), two independent ways that agree.
57
+ * Prose: 504 assistant replies with no thinking block, visible text divided by
58
+ * the exact output_tokens: median 2.75 (p10 2.4, p90 3.0). Code and tool
59
+ * output: 474 single appended blocks over 6k chars, sized by the exact growth
60
+ * of the billed prompt between consecutive calls: median 2.4 (p10 2.1, p90 2.8).
61
+ * The ratios this tool used before (4.0 / 3.2) undercounted current Claude
62
+ * models by about 1.45x on prose and 1.33x on code.
63
+ *
64
+ * OpenAI, Google and unknown models keep 4.0 / 3.2, the usual figures for
65
+ * o200k-class tokenizers on English and code. They are not re-measured here:
66
+ * Codex rollouts truncate tool output before the model sees it, so the same
67
+ * delta method does not isolate a block. `analyze --exact` calibrates any
68
+ * provider from its own tokenizer on your machine.
69
+ */
70
+ export const CHARS_PER_TOKEN = {
71
+ anthropic: { prose: 2.75, code: 2.4 },
72
+ openai: { prose: 4.0, code: 3.2 },
73
+ google: { prose: 4.0, code: 3.2 },
74
+ generic: { prose: 4.0, code: 3.2 },
75
+ };
76
+ /** True when text is dense with code/JSON symbols and tokenizes more finely. */
77
+ export function isCodeLike(text) {
78
+ return symbolDensity(text) > 0.08;
79
+ }
80
+ /** Chars per token to use for this text under this model's tokenizer. */
81
+ export function charsPerTokenFor(text, model) {
82
+ const ratios = CHARS_PER_TOKEN[providerFor(model)];
83
+ return isCodeLike(text) ? ratios.code : ratios.prose;
84
+ }
85
+ /**
86
+ * Estimated tokens for `text`. Pass the model when you know it: Claude's
87
+ * tokenizer produces ~40% more tokens than the provider-neutral default.
88
+ */
89
+ export function estimateTokens(text, model) {
53
90
  // Public API: callers outside this package pass whatever they have, and a
54
91
  // TypeError from a token estimator is never the useful answer.
55
92
  if (typeof text !== "string")
56
93
  text = String(text ?? "");
57
94
  if (!text)
58
95
  return 0;
59
- // Denser tokenization for code/JSON-like content, lighter for plain prose.
60
- const density = symbolDensity(text);
61
- const charsPerToken = density > 0.08 ? 3.2 : 4.0;
62
- return Math.ceil(text.length / charsPerToken);
96
+ return Math.ceil(text.length / charsPerTokenFor(text, model));
63
97
  }
64
98
  /** Per-message structural overhead (role markers, delimiters) is roughly constant. */
65
99
  export const MESSAGE_OVERHEAD_TOKENS = 4;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-doctor",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Profile and optimize LLM context windows. See what's eating your tokens and fix it — works with Claude Code, Claude Desktop, Cursor, Codex (OpenAI), and any MCP-capable AI app.",
5
5
  "keywords": [
6
6
  "claude",