context-doctor 0.17.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 +35 -15
- package/dist/accuracy.d.ts +6 -4
- package/dist/accuracy.js +9 -7
- package/dist/calibration.d.ts +8 -0
- package/dist/calibration.js +17 -3
- package/dist/cli.js +13 -4
- package/dist/hook.js +7 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mcp.js +5 -5
- package/dist/optimize.d.ts +5 -0
- package/dist/optimize.js +32 -22
- package/dist/parse.d.ts +2 -0
- package/dist/parse.js +2 -1
- package/dist/preferences.d.ts +1 -1
- package/dist/preferences.js +1 -1
- package/dist/profile.js +9 -2
- package/dist/proxy.d.ts +15 -0
- package/dist/proxy.js +45 -8
- package/dist/sketch.d.ts +7 -3
- package/dist/sketch.js +51 -30
- package/dist/tokenizer-measure.d.ts +42 -0
- package/dist/tokenizer-measure.js +172 -0
- package/dist/tokens.d.ts +31 -1
- package/dist/tokens.js +39 -5
- package/package.json +1 -1
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 ~
|
|
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
|
|
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) |
|
|
@@ -179,7 +179,20 @@ The proxy dedupes repeated content, trims stale tool results, and strips base64
|
|
|
179
179
|
|
|
180
180
|
Because prompt caching matches byte-identical prefixes, deterministic strategies are chosen so repeated requests stay stable — but if you rely on aggressive cache prefixes, start with `--strategy strip-base64 --strategy dedupe` and add more as you verify.
|
|
181
181
|
|
|
182
|
-
|
|
182
|
+
### Putting the proxy on a public URL (Cursor with your own OpenAI key, remote apps)
|
|
183
|
+
|
|
184
|
+
Some apps let you set a base URL but call it from *their* servers, not your machine. Cursor is one: with your own OpenAI key, "Override OpenAI Base URL" is sent to Cursor's backend inside the model configuration, and Cursor's servers make the request (only the key-verification ping is client-side; we checked the app bundle, 3.18.25). So `127.0.0.1` cannot work there; the proxy has to be reachable from the internet, and an open relay on the internet is a bad idea. Hence the token:
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
npx context-doctor proxy --token "$(openssl rand -hex 16)" # or CONTEXT_DOCTOR_PROXY_TOKEN=...
|
|
188
|
+
ngrok http 8787 # or any HTTPS tunnel / reverse proxy
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
With `--token`, every path except `/health` must start with `/t/<token>/`; anything else gets 401 before any upstream call, and the comparison is constant time. Then in Cursor: Settings > Models > OpenAI API Key > Override OpenAI Base URL = `https://<your-host>/t/<token>/v1`. Every agent request Cursor makes with your key now passes through the proxy: deduped, stale tool results trimmed, base64 stripped, real usage counted in `/t/<token>/stats`. This is the one Cursor path that is model-independent and needs no hook. It applies only to BYO-key traffic; Cursor's own subscription models never leave Cursor's servers.
|
|
192
|
+
|
|
193
|
+
Your API key still rides in the request headers, as before. The token protects the relay, not the key; keep the tunnel HTTPS.
|
|
194
|
+
|
|
195
|
+
> **Note on desktop chat apps:** Claude Desktop and the ChatGPT app talk to their own backends — no tool can sit in that path. For those, use the MCP integration below (Claude Desktop gets the standing rules and a cheap `profile_context` sketch call) and `context-doctor instructions --copy` for the per-account preferences.
|
|
183
196
|
|
|
184
197
|
## Use with the Claude & ChatGPT apps
|
|
185
198
|
|
|
@@ -190,13 +203,13 @@ Because prompt caching matches byte-identical prefixes, deterministic strategies
|
|
|
190
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 |
|
|
191
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 |
|
|
192
205
|
| **API traffic through the proxy** | Yes: every request rewritten in flight | Fewer tokens, guaranteed, model not consulted |
|
|
193
|
-
| **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 ~
|
|
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 |
|
|
194
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 |
|
|
195
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 |
|
|
196
209
|
|
|
197
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.
|
|
198
211
|
|
|
199
|
-
**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 ~
|
|
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).
|
|
200
213
|
|
|
201
214
|
**Do you need to configure anything by hand? Usually no:**
|
|
202
215
|
|
|
@@ -225,7 +238,7 @@ For any other MCP client, the server entry is:
|
|
|
225
238
|
|
|
226
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.
|
|
227
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.
|
|
228
|
-
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 (~
|
|
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.
|
|
229
242
|
4. One click instead of asking: the `context_checkup` prompt in the **+** menu sends that request for you.
|
|
230
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).
|
|
231
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.
|
|
@@ -346,9 +359,9 @@ The verdict line is the point: cheaper only counts if it also passed. Because th
|
|
|
346
359
|
|
|
347
360
|
## Exact counts, and what they teach the estimator
|
|
348
361
|
|
|
349
|
-
The default token count is a chars-per-token heuristic so everything runs with no key and no tokenizer
|
|
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.
|
|
350
363
|
|
|
351
|
-
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.
|
|
352
365
|
|
|
353
366
|
## What it detects
|
|
354
367
|
|
|
@@ -453,7 +466,7 @@ A tool that promises speed must be near-free. Measured overhead per touchpoint:
|
|
|
453
466
|
| Touchpoint | When it runs | Overhead |
|
|
454
467
|
|---|---|---|
|
|
455
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 |
|
|
456
|
-
| MCP server | Spawned once per app session | Tools run only when called; standing instructions cost **~
|
|
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 |
|
|
457
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 |
|
|
458
471
|
| Skill | Loads only when relevant | ~1k tokens while active; its always-present description is ~60 tokens |
|
|
459
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) |
|
|
@@ -463,18 +476,25 @@ Net effect is strongly negative overhead: the tokens these touchpoints save on e
|
|
|
463
476
|
|
|
464
477
|
## Why token counts are "~" (and where they are exact)
|
|
465
478
|
|
|
466
|
-
Counting exactly needs each provider's tokenizer, so the default is a
|
|
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.
|
|
467
480
|
|
|
468
|
-
|
|
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 |
|
|
469
485
|
|
|
470
|
-
|
|
471
|
-
- **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.
|
|
472
487
|
|
|
473
|
-
|
|
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%.
|
|
474
489
|
|
|
490
|
+
Two ways to get real numbers instead of estimates:
|
|
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.
|
|
475
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.
|
|
476
496
|
|
|
477
|
-
|
|
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.
|
|
478
498
|
|
|
479
499
|
## Roadmap
|
|
480
500
|
|
package/dist/accuracy.d.ts
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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* the
|
|
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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* the
|
|
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("
|
|
122
|
-
lines.push("
|
|
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
|
}
|
package/dist/calibration.d.ts
CHANGED
|
@@ -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;
|
package/dist/calibration.js
CHANGED
|
@@ -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
|
|
56
|
-
|
|
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)
|
|
@@ -110,6 +111,9 @@ Options:
|
|
|
110
111
|
overwrites a statusLine you already have)
|
|
111
112
|
--port <n> (proxy) Port to listen on (default 8787)
|
|
112
113
|
--host <addr> (proxy) Bind address (default 127.0.0.1; use 0.0.0.0 to expose)
|
|
114
|
+
--token <secret> (proxy) Require /t/<secret>/ in every request path; needed before
|
|
115
|
+
putting the proxy on a public URL (Cursor BYO-key, tunnels).
|
|
116
|
+
Also read from CONTEXT_DOCTOR_PROXY_TOKEN
|
|
113
117
|
--config <file> (proxy) Per-route overrides: {"routes":[{"modelPrefix":"gpt","strategies":[...],
|
|
114
118
|
"keepRecent":n,"maxToolResultTokens":n}]} — first prefix match wins
|
|
115
119
|
--upstream-anthropic <url> (proxy) Override Anthropic upstream (testing)
|
|
@@ -199,6 +203,9 @@ function parseArgs(argv) {
|
|
|
199
203
|
case "--host":
|
|
200
204
|
args.host = argv[++i];
|
|
201
205
|
break;
|
|
206
|
+
case "--token":
|
|
207
|
+
args.token = argv[++i];
|
|
208
|
+
break;
|
|
202
209
|
case "--config":
|
|
203
210
|
args.config = argv[++i];
|
|
204
211
|
break;
|
|
@@ -321,7 +328,8 @@ function main() {
|
|
|
321
328
|
}
|
|
322
329
|
if (args.command === "accuracy") {
|
|
323
330
|
const report = measureAccuracy(args.limit ?? 20);
|
|
324
|
-
|
|
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)}`);
|
|
325
333
|
return;
|
|
326
334
|
}
|
|
327
335
|
if (args.command === "report") {
|
|
@@ -369,9 +377,9 @@ function main() {
|
|
|
369
377
|
}
|
|
370
378
|
if (args.command === "session") {
|
|
371
379
|
if (args.list) {
|
|
372
|
-
const sessions = listSessions();
|
|
380
|
+
const sessions = listSessions(args.limit ?? 20);
|
|
373
381
|
if (sessions.length === 0) {
|
|
374
|
-
console.log("No
|
|
382
|
+
console.log("No sessions found under ~/.claude/projects or ~/.codex/sessions.");
|
|
375
383
|
return;
|
|
376
384
|
}
|
|
377
385
|
for (const s of sessions) {
|
|
@@ -461,6 +469,7 @@ function main() {
|
|
|
461
469
|
routes: routes,
|
|
462
470
|
port: args.port,
|
|
463
471
|
host: args.host,
|
|
472
|
+
token: args.token ?? process.env.CONTEXT_DOCTOR_PROXY_TOKEN,
|
|
464
473
|
anthropicUpstream: args.upstreamAnthropic,
|
|
465
474
|
openaiUpstream: args.upstreamOpenai,
|
|
466
475
|
strategies: args.strategies.length > 0 ? args.strategies : loadedRc.config.strategies,
|
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:
|
|
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 *
|
|
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
package/dist/index.js
CHANGED
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 (~
|
|
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, ~
|
|
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.
|
|
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, ~
|
|
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.
|
|
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) {
|
package/dist/optimize.d.ts
CHANGED
|
@@ -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
|
-
|
|
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 *
|
|
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
|
-
|
|
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 =
|
|
250
|
+
const before = tok(text);
|
|
241
251
|
const cleaned = stripBase64Blobs(text);
|
|
242
|
-
const saved = before -
|
|
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 =
|
|
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 =
|
|
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 (
|
|
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 -
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 -
|
|
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 +
|
|
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 -
|
|
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 +
|
|
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
|
-
|
|
149
|
+
const model = typeof obj.model === "string" && obj.model ? obj.model : undefined;
|
|
150
|
+
return { sourceFormat: isAnthropic ? "anthropic" : "openai", parseWarning, messages, model };
|
|
150
151
|
}
|
package/dist/preferences.d.ts
CHANGED
|
@@ -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 (~
|
|
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. */
|
package/dist/preferences.js
CHANGED
|
@@ -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 (~
|
|
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 = [
|