context-doctor 0.16.0 → 0.18.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
@@ -89,7 +89,8 @@ Practical upshot: a developer who only wants cheaper, faster API calls never tou
89
89
 
90
90
  | Command | What it does |
91
91
  |---|---|
92
- | `context-doctor install` / `uninstall` | Wire (or remove) everything: MCP for Claude Desktop/Code/Cursor, the Agent Skill, the every-prompt hook |
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
94
  | `context-doctor analyze <file>` | Profile a conversation: token breakdown, findings, cost + latency estimates. `--fail-over-budget` exits 1 on a breach, for CI |
94
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 |
95
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`) |
@@ -178,7 +179,20 @@ The proxy dedupes repeated content, trims stale tool results, and strips base64
178
179
 
179
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.
180
181
 
181
- > **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 and add a line to your custom instructions like: *"When a conversation gets long or includes large pasted content, proactively use context-doctor's profile_context tool and tell me what to trim."* The model will then invoke it on its own.
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.
182
196
 
183
197
  ## Use with the Claude & ChatGPT apps
184
198
 
@@ -189,17 +203,19 @@ Because prompt caching matches byte-identical prefixes, deterministic strategies
189
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 |
190
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 |
191
205
  | **API traffic through the proxy** | Yes: every request rewritten in flight | Fewer tokens, guaranteed, model not consulted |
192
- | **Claude Desktop** | Only the ~150-token standing instruction, plus a one-click `context_checkup` prompt in the + menu | The instruction now tells the model *when* to call `profile_context` (past ~30 turns, 3+ large pastes, any cost/speed question) rather than offering. It is a strong nudge, not enforcement: Desktop chat has no hook and no data path, and we checked the app bundle to be sure |
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 |
193
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 |
194
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 |
195
209
 
196
- So "every chat inherently better" is true for Claude Code, Cursor, Codex and the proxy; an honest "reminded in every chat, tools one click away" for Claude Desktop; and not a claim we make for the ChatGPT chat UI.
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
+
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).
197
213
 
198
214
  **Do you need to configure anything by hand? Usually no:**
199
215
 
200
216
  | App | Setup |
201
217
  |---|---|
202
- | Claude Desktop | `npx context-doctor install` writes the config — just restart the app |
218
+ | Claude Desktop | `npx context-doctor install` writes the config — just restart the app. Or one click: download `context-doctor-<version>.mcpb` from the [latest release](https://github.com/KushalP1/context-doctor/releases) and open it (Settings > Extensions). The bundle runs on Desktop's own Node, no npm needed |
203
219
  | Claude Code | Same command — MCP + skill + every-prompt hook, all automatic |
204
220
  | Cursor | Same command — writes `~/.cursor/mcp.json`; the every-prompt hook is picked up from Claude Code's config, which Cursor reads |
205
221
  | Codex (OpenAI) | Same command — `~/.codex/config.toml`, `~/.codex/hooks.json`, `~/.codex/skills/`. Then, once, `/hooks` in Codex to trust the hook |
@@ -220,10 +236,14 @@ For any other MCP client, the server entry is:
220
236
 
221
237
  ### How it works in Claude Desktop, step by step
222
238
 
223
- 1. Run `npx context-doctor install` (writes the config above for you) and restart Claude Desktop.
224
- 2. From then on, **every conversation automatically carries context-doctor's standing instructions** — the MCP server hands Claude hygiene rules on connect: summarize big pastes instead of re-quoting them, offer profiling when the chat gets long, never inline base64.
225
- 3. Chat normally. When a conversation grows heavy, Claude proactively offers: *"this chat is getting large — want me to profile it?"* — or you ask *"what's eating my context?"* and it calls `profile_context` and shows the token/cost breakdown.
226
- 4. Say *"optimize it"* 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).
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
+ 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.
242
+ 4. One click instead of asking: the `context_checkup` prompt in the **+** menu sends that request for you.
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
+ 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.
245
+
246
+ What this does not do: read the chat behind Claude's back or trim it for you. Desktop chat has no hook API and no transcript on disk (checked in the app bundle, v2.2553). The model does the trimming, when the rules and the checkup tell it to.
227
247
 
228
248
  ### How it works in ChatGPT, step by step (honest version)
229
249
 
@@ -235,15 +255,15 @@ ChatGPT's MCP support differs fundamentally from Claude Desktop's: **it never sp
235
255
 
236
256
  Security note for step 2: the HTTP endpoint is unauthenticated — put it behind your tunnel's auth or a reverse proxy if it stays up long-term.
237
257
 
238
- ### claude.ai on the web
258
+ ### claude.ai on the web and the phone apps
239
259
 
240
- Your local MCP server can't reach the website, but the behavior can: upload `skills/context-doctor/SKILL.md` under Settings → Capabilities → Skills, and web conversations gain the same standing context-hygiene habits (summarize-don't-requote, offer compaction when heavy).
260
+ Your local MCP server can't reach the website, but the behavior can. Two options: `context-doctor instructions --copy` and paste into Settings > Profile > personal preferences (applies everywhere you are signed in, phone included), or upload `skills/context-doctor/SKILL.md` under Settings → Capabilities → Skills. Either way web and mobile conversations gain the same standing habits: summarize, don't re-quote, offer a handoff when heavy.
241
261
 
242
262
  ### MCP tools
243
263
 
244
264
  | Tool | What it does |
245
265
  |---|---|
246
- | `profile_context` | Token breakdown by category, largest messages, findings with estimated savings |
266
+ | `profile_context` | Token breakdown by category, largest messages, findings with estimated savings. Takes either `conversation` (full JSON or text) or `sketch` (turns + large/repeated blocks, for chat apps) |
247
267
  | `optimize_context` | Rewrites the conversation: dedupe, trim stale tool results, strip base64, optional history pruning |
248
268
  | `context_best_practices` | Curated checklist, optionally specialized for Anthropic / OpenAI |
249
269
 
@@ -479,9 +499,10 @@ Contributions welcome — this project is small on purpose. Open an issue before
479
499
 
480
500
  ```bash
481
501
  npm version patch # or minor/major — bumps package.json + git tag
482
- npm test # 14 tests must pass; CI runs the same on 3 OSes
502
+ npm test # 131 tests must pass; CI runs the same on 3 OSes x Node 20/22/24
483
503
  npm publish # prompts for the npm 2FA code
484
504
  git push --follow-tags
505
+ npm run build:mcpb # context-doctor-<version>.mcpb for Claude Desktop; attach it to the GitHub release
485
506
  ```
486
507
 
487
508
  **What the npm download number measures.** `install` writes `npx -y context-doctor-mcp` into MCP configs, and npx re-fetches the tarball whenever a new version exists. So every release is downloaded once by every active install within about a day, and the daily count is almost entirely those refreshes: on this package, release days run ~170 downloads and non-release days ~27. Read it as "size of the active installed base × number of releases", not as new users — a quiet week with no releases will look like a decline while nothing has changed. Two corollaries: the release-day figure is a live count of machines running context-doctor, and a broken release reaches all of them automatically, which is why `prepublishOnly` runs the full test suite. npm's stats also lag by several days and occasionally record a day as zero; a zero on a release day is a gap in their pipeline, not in usage.
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 { renderPreferences, copyToClipboard, CHAT_PREFERENCES } from "./preferences.js";
23
24
  import { recordLedger } from "./ledger.js";
24
25
  import { runDoctor } from "./doctor.js";
25
26
  import { measureAccuracy, renderAccuracy } from "./accuracy.js";
@@ -46,6 +47,8 @@ Usage:
46
47
  context-doctor install Wire the MCP server + skill into Claude Desktop,
47
48
  Claude Code, and Cursor automatically
48
49
  context-doctor uninstall Undo install
50
+ context-doctor instructions [--copy] Standing context rules to paste into claude.ai or
51
+ ChatGPT preferences (works on web and mobile too)
49
52
  context-doctor session [file] Profile a Claude Code session transcript or a
50
53
  ChatGPT export (default: most recent; --list to browse)
51
54
  context-doctor cursor [--list] Profile a Cursor chat from its local history
@@ -107,6 +110,9 @@ Options:
107
110
  overwrites a statusLine you already have)
108
111
  --port <n> (proxy) Port to listen on (default 8787)
109
112
  --host <addr> (proxy) Bind address (default 127.0.0.1; use 0.0.0.0 to expose)
113
+ --token <secret> (proxy) Require /t/<secret>/ in every request path; needed before
114
+ putting the proxy on a public URL (Cursor BYO-key, tunnels).
115
+ Also read from CONTEXT_DOCTOR_PROXY_TOKEN
110
116
  --config <file> (proxy) Per-route overrides: {"routes":[{"modelPrefix":"gpt","strategies":[...],
111
117
  "keepRecent":n,"maxToolResultTokens":n}]} — first prefix match wins
112
118
  --upstream-anthropic <url> (proxy) Override Anthropic upstream (testing)
@@ -184,6 +190,9 @@ function parseArgs(argv) {
184
190
  case "--dry-run":
185
191
  args.dryRun = true;
186
192
  break;
193
+ case "--copy":
194
+ args.copy = true;
195
+ break;
187
196
  case "--allow-dirty":
188
197
  args.allowDirty = true;
189
198
  break;
@@ -193,6 +202,9 @@ function parseArgs(argv) {
193
202
  case "--host":
194
203
  args.host = argv[++i];
195
204
  break;
205
+ case "--token":
206
+ args.token = argv[++i];
207
+ break;
196
208
  case "--config":
197
209
  args.config = argv[++i];
198
210
  break;
@@ -431,6 +443,10 @@ function main() {
431
443
  process.exitCode = 1;
432
444
  return;
433
445
  }
446
+ if (args.command === "instructions") {
447
+ console.log(renderPreferences(args.copy ? copyToClipboard(CHAT_PREFERENCES) : undefined));
448
+ return;
449
+ }
434
450
  if (args.command === "uninstall") {
435
451
  runUninstall();
436
452
  return;
@@ -451,6 +467,7 @@ function main() {
451
467
  routes: routes,
452
468
  port: args.port,
453
469
  host: args.host,
470
+ token: args.token ?? process.env.CONTEXT_DOCTOR_PROXY_TOKEN,
454
471
  anthropicUpstream: args.upstreamAnthropic,
455
472
  openaiUpstream: args.upstreamOpenai,
456
473
  strategies: args.strategies.length > 0 ? args.strategies : loadedRc.config.strategies,
package/dist/index.d.ts CHANGED
@@ -18,3 +18,5 @@ export type { ModelPricing } from "./pricing.js";
18
18
  export { estimateTokens, contextWindowFor, providerFor, formatTokens } from "./tokens.js";
19
19
  export type { Provider } from "./tokens.js";
20
20
  export { renderStatusLine, tailUsage } from "./statusline.js";
21
+ export * from "./sketch.js";
22
+ export * from "./preferences.js";
package/dist/index.js CHANGED
@@ -13,3 +13,5 @@ export { estimateTokens, contextWindowFor, providerFor, formatTokens } from "./t
13
13
  // Status-line building blocks, for editor integrations (the VS Code / Cursor
14
14
  // extension in ./vscode) that show the same number in their own status bar.
15
15
  export { renderStatusLine, tailUsage } from "./statusline.js";
16
+ export * from "./sketch.js";
17
+ export * from "./preferences.js";
package/dist/install.js CHANGED
@@ -470,6 +470,8 @@ export function runInstall(options = {}) {
470
470
  else {
471
471
  console.log("\nDone. Restart the apps to pick up the new tools, then try:");
472
472
  console.log(' "What\'s eating my context?" — or paste a conversation and ask for a profile.');
473
+ console.log("Chat apps (Claude Desktop, claude.ai, ChatGPT, phones) have no hook. To make the rules standing there:");
474
+ console.log(" context-doctor instructions --copy then paste into the app's preferences (it tells you where).");
473
475
  }
474
476
  return { failures };
475
477
  }
package/dist/mcp.js CHANGED
@@ -21,6 +21,7 @@ import { optimizeConversation } from "./optimize.js";
21
21
  import { renderProfile } from "./report.js";
22
22
  import { formatTokens } from "./tokens.js";
23
23
  import { recordLedger } from "./ledger.js";
24
+ import { runSketch } from "./sketch.js";
24
25
  /**
25
26
  * Server instructions are injected by MCP clients (Claude Desktop, Cursor, …)
26
27
  * into the system context of EVERY conversation where this server is enabled.
@@ -39,7 +40,7 @@ import { recordLedger } from "./ledger.js";
39
40
  const SERVER_INSTRUCTIONS = `Context hygiene rules (always on):
40
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.
41
42
  2. Reference earlier content by name; never re-quote it. Never inline base64.
42
- 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 on the conversation BEFORE answering and act on its top finding. 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, ~100 tokens), not the conversation. Do not estimate token counts yourself.
43
44
  4. If optimize_context returns a pruned-turns digest, you write the ≤150-token replacement summary.`;
44
45
  const STRATEGY_IDS = ["dedupe", "trim-tool-results", "trim-tool-calls", "strip-base64", "prune-history"];
45
46
  /**
@@ -48,11 +49,35 @@ const STRATEGY_IDS = ["dedupe", "trim-tool-results", "trim-tool-calls", "strip-b
48
49
  * recommended pattern.
49
50
  */
50
51
  function createServer() {
51
- const server = new McpServer({ name: "context-doctor", version: "0.16.0" }, { instructions: SERVER_INSTRUCTIONS });
52
- server.tool("profile_context", "Profile an LLM conversation or prompt: token breakdown by category, largest messages, and actionable findings about wasted context (duplicates, oversized tool results, base64 blobs, cache-unfriendly ordering). Accepts OpenAI/Anthropic conversation JSON or raw text. Call this immediately whenever the user asks about token usage, context size, LLM cost, or latency — and proactively offer it once a conversation grows long or accumulates large pasted content.", {
53
- conversation: z.string().describe("Conversation JSON (OpenAI or Anthropic format, or bare message array) or raw prompt text"),
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.", {
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
+ sketch: z.object({
56
+ turns: z.number().int().nonnegative().describe("User+assistant exchanges so far"),
57
+ model: z.string().optional().describe("Model this chat runs as, e.g. claude-sonnet-5, gpt-5"),
58
+ blocks: z.array(z.object({
59
+ turn: z.number().int().positive().describe("1-based turn the block sits in"),
60
+ kind: z.enum(["paste", "code", "tool_result", "image", "base64", "text"]),
61
+ label: z.string().describe("Short name you can refer to later, e.g. 'the nginx config'"),
62
+ approx_tokens: z.number().positive().optional(),
63
+ approx_lines: z.number().positive().optional(),
64
+ approx_words: z.number().positive().optional(),
65
+ approx_chars: z.number().positive().optional(),
66
+ repeated: z.number().int().positive().optional().describe("Times this same content appears (2+ = duplicate)"),
67
+ stale: z.boolean().optional().describe("Already acted on; nothing in it is still needed"),
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)."),
54
70
  model: z.string().optional().describe("Target model name for context-window math, e.g. claude-sonnet-5 or gpt-4o"),
55
- }, async ({ conversation, model }) => {
71
+ }, async ({ conversation, sketch, model }) => {
72
+ if (sketch) {
73
+ return { content: [{ type: "text", text: runSketch({ ...sketch, model: sketch.model ?? model }) }] };
74
+ }
75
+ if (!conversation) {
76
+ return {
77
+ isError: true,
78
+ content: [{ type: "text", text: "Pass either `conversation` (full JSON or text) or `sketch` (turns + large/repeated blocks). In a chat app, use `sketch`." }],
79
+ };
80
+ }
56
81
  const profile = profileConversation(parseConversation(conversation), model);
57
82
  return { content: [{ type: "text", text: renderProfile(profile) }] };
58
83
  });
@@ -113,7 +138,7 @@ function createServer() {
113
138
  role: "user",
114
139
  content: {
115
140
  type: "text",
116
- text: "Run profile_context on our conversation so far (model: the one you are running as). " +
141
+ text: "Run profile_context on our conversation so far: pass a `sketch` (turn count, the model you are running as, and every block over ~500 tokens, repeated, or an image; one size hint each). " +
117
142
  "Report the total, the top three findings, and the estimated recoverable tokens in under 120 words. " +
118
143
  "Then, if the top finding is recoverable, apply it: summarize the offending content into the points still needed and tell me what you dropped. " +
119
144
  "Do not re-quote the content you are summarizing.",
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Standing context rules for chat apps that have no hook and, on phones and the
3
+ * web, no MCP server either: claude.ai "personal preferences" and ChatGPT
4
+ * "custom instructions" are read on every turn of every chat, which is the
5
+ * closest thing those surfaces have to a UserPromptSubmit hook.
6
+ *
7
+ * Kept short (~90 tokens): it rides in every conversation.
8
+ */
9
+ export declare const CHAT_PREFERENCES: string;
10
+ /** Where to paste, per app. Paths are the UI labels, not URLs, so they survive redesigns. */
11
+ export declare const PREFERENCE_TARGETS: string[];
12
+ /** Copy text to the system clipboard; false when no clipboard tool is available. */
13
+ export declare function copyToClipboard(text: string, platform?: NodeJS.Platform): boolean;
14
+ export declare function renderPreferences(copied: boolean | undefined): string;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Standing context rules for chat apps that have no hook and, on phones and the
3
+ * web, no MCP server either: claude.ai "personal preferences" and ChatGPT
4
+ * "custom instructions" are read on every turn of every chat, which is the
5
+ * closest thing those surfaces have to a UserPromptSubmit hook.
6
+ *
7
+ * Kept short (~90 tokens): it rides in every conversation.
8
+ */
9
+ import { spawnSync } from "node:child_process";
10
+ export const CHAT_PREFERENCES = [
11
+ "Context hygiene: when I paste something long, summarize it into the points you will use and work from the summary;",
12
+ "do not carry it verbatim or re-quote it later, refer to it by name. Never inline base64.",
13
+ "Past ~30 turns, or when I ask about tokens, limits, cost or speed: if a profile_context tool is available,",
14
+ "call it with a sketch before answering and act on its top finding; otherwise offer me a 300-token handoff",
15
+ "summary (decisions, current state, open items) to start a fresh chat.",
16
+ ].join(" ");
17
+ /** Where to paste, per app. Paths are the UI labels, not URLs, so they survive redesigns. */
18
+ export const PREFERENCE_TARGETS = [
19
+ "claude.ai / Claude Desktop / Claude mobile: Settings > Profile > \"What personal preferences should Claude consider in responses?\"",
20
+ "ChatGPT: Settings > Personalization > Custom instructions > \"How would you like ChatGPT to respond?\"",
21
+ ];
22
+ /** Copy text to the system clipboard; false when no clipboard tool is available. */
23
+ export function copyToClipboard(text, platform = process.platform) {
24
+ const candidates = platform === "darwin" ? [["pbcopy", []]]
25
+ : platform === "win32" ? [["clip", []]]
26
+ : [["wl-copy", []], ["xclip", ["-selection", "clipboard"]], ["xsel", ["--clipboard", "--input"]]];
27
+ for (const [cmd, args] of candidates) {
28
+ const r = spawnSync(cmd, args, { input: text, stdio: ["pipe", "ignore", "ignore"] });
29
+ if (!r.error && r.status === 0)
30
+ return true;
31
+ }
32
+ return false;
33
+ }
34
+ export function renderPreferences(copied) {
35
+ const lines = [
36
+ "Paste this into your chat app's standing preferences. It applies to every chat, including on your phone:",
37
+ "",
38
+ CHAT_PREFERENCES,
39
+ "",
40
+ ...PREFERENCE_TARGETS.map((t) => ` ${t}`),
41
+ ];
42
+ if (copied === true)
43
+ lines.push("", "Copied to the clipboard.");
44
+ if (copied === false)
45
+ lines.push("", "No clipboard tool found; copy the text above by hand.");
46
+ return lines.join("\n");
47
+ }
package/dist/proxy.d.ts CHANGED
@@ -23,9 +23,24 @@ export interface ProxyOptions extends OptimizeOptions {
23
23
  * the user explicitly opts in (e.g. --host 0.0.0.0 inside a container).
24
24
  */
25
25
  host?: string;
26
+ /**
27
+ * When set, every request except /health must arrive under the path prefix
28
+ * `/t/<token>/`, which is stripped before routing. This is what makes the
29
+ * proxy safe to put on a public URL (a tunnel) for apps whose servers call
30
+ * the base URL, such as Cursor with your own OpenAI key: those apps can set a
31
+ * URL but not a header, so the secret rides in the path. Compared with
32
+ * constant time; a wrong or missing prefix gets 401 and no upstream call.
33
+ */
34
+ token?: string;
26
35
  anthropicUpstream?: string;
27
36
  openaiUpstream?: string;
28
37
  }
38
+ /**
39
+ * Remove a leading `/t/<token>` from a request path, or return undefined when
40
+ * the prefix is absent or the token differs. The comparison is constant time
41
+ * so the token cannot be guessed a character at a time.
42
+ */
43
+ export declare function stripToken(url: string, token: string): string | undefined;
29
44
  export interface ProxyStats {
30
45
  startedAt: string;
31
46
  requests: number;
package/dist/proxy.js CHANGED
@@ -12,6 +12,7 @@
12
12
  * Streaming responses are piped through unchanged.
13
13
  */
14
14
  import http from "node:http";
15
+ import { timingSafeEqual } from "node:crypto";
15
16
  import { optimizeConversation } from "./optimize.js";
16
17
  import { formatTokens } from "./tokens.js";
17
18
  import { formatUsd, inputCostUsd, pricingFor } from "./pricing.js";
@@ -19,6 +20,23 @@ import { recordLedger } from "./ledger.js";
19
20
  /** Connection-level headers that must not be forwarded. */
20
21
  const SKIP_REQUEST_HEADERS = new Set(["host", "content-length", "connection", "transfer-encoding", "accept-encoding", "expect"]);
21
22
  const SKIP_RESPONSE_HEADERS = new Set(["content-length", "content-encoding", "transfer-encoding", "connection"]);
23
+ /**
24
+ * Remove a leading `/t/<token>` from a request path, or return undefined when
25
+ * the prefix is absent or the token differs. The comparison is constant time
26
+ * so the token cannot be guessed a character at a time.
27
+ */
28
+ export function stripToken(url, token) {
29
+ const prefix = "/t/";
30
+ if (!url.startsWith(prefix))
31
+ return undefined;
32
+ const end = url.indexOf("/", prefix.length);
33
+ const candidate = end === -1 ? url.slice(prefix.length) : url.slice(prefix.length, end);
34
+ const a = Buffer.from(candidate), b = Buffer.from(token);
35
+ if (a.length !== b.length || !timingSafeEqual(a, b))
36
+ return undefined;
37
+ const rest = end === -1 ? "/" : url.slice(end);
38
+ return rest;
39
+ }
22
40
  function upstreamFor(url, opts) {
23
41
  if (url.startsWith("/v1/messages"))
24
42
  return opts.anthropicUpstream ?? "https://api.anthropic.com";
@@ -75,13 +93,23 @@ export function startProxy(opts = {}) {
75
93
  console.error(`[context-doctor] cache advisor: ${msg}`);
76
94
  };
77
95
  const server = http.createServer(async (req, res) => {
78
- const url = req.url ?? "/";
96
+ let url = req.url ?? "/";
79
97
  try {
80
98
  if (url === "/health") {
81
99
  res.setHeader("content-type", "application/json");
82
100
  res.end(JSON.stringify({ ok: true, service: "context-doctor-proxy" }));
83
101
  return;
84
102
  }
103
+ if (opts.token) {
104
+ const stripped = stripToken(url, opts.token);
105
+ if (stripped === undefined) {
106
+ res.statusCode = 401;
107
+ res.setHeader("content-type", "application/json");
108
+ res.end(JSON.stringify({ error: "context-doctor proxy: this proxy requires its token in the path: /t/<token>/v1/..." }));
109
+ return;
110
+ }
111
+ url = stripped;
112
+ }
85
113
  if (url === "/stats") {
86
114
  res.setHeader("content-type", "application/json");
87
115
  res.end(JSON.stringify({ ...stats, estUsdSaved: Number(stats.estUsdSaved.toFixed(4)) }, null, 2));
@@ -278,11 +306,18 @@ export function startProxy(opts = {}) {
278
306
  server.on("close", checkpoint);
279
307
  const host = opts.host ?? "127.0.0.1";
280
308
  server.listen(port, host, () => {
309
+ // Print the token as <token>, never the value: this log is what people paste into bug reports.
310
+ const prefix = opts.token ? "/t/<token>" : "";
281
311
  console.error(`context-doctor proxy listening on http://${host}:${port}`);
282
- console.error(` Anthropic apps/SDKs: export ANTHROPIC_BASE_URL=http://localhost:${port}`);
283
- console.error(` OpenAI apps/SDKs: export OPENAI_BASE_URL=http://localhost:${port}/v1`);
312
+ console.error(` Anthropic apps/SDKs: export ANTHROPIC_BASE_URL=http://localhost:${port}${prefix}`);
313
+ console.error(` OpenAI apps/SDKs: export OPENAI_BASE_URL=http://localhost:${port}${prefix}/v1`);
284
314
  console.error(` Every request's context is optimized in flight; savings are logged here.`);
285
- console.error(` Cumulative savings: http://localhost:${port}/stats`);
315
+ console.error(` Cumulative savings: http://localhost:${port}${prefix}/stats`);
316
+ if (opts.token) {
317
+ console.error(` Token required: every path except /health must start with /t/<token>/.`);
318
+ console.error(` Cursor with your own OpenAI key: expose this port on HTTPS (a tunnel), then Settings > Models > OpenAI API Key >`);
319
+ console.error(` "Override OpenAI Base URL" = https://<your-host>/t/<token>/v1. Cursor's servers call that URL, so 127.0.0.1 will not work there.`);
320
+ }
286
321
  });
287
322
  return server;
288
323
  }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Profile a conversation from a SKETCH instead of the conversation itself.
3
+ *
4
+ * Why this exists: in a chat app (Claude Desktop, ChatGPT) the model cannot
5
+ * export the conversation, so asking it to call profile_context with the full
6
+ * JSON means re-typing 50k+ tokens as a tool argument. No model does that, and
7
+ * it would double the context it is meant to measure. A sketch is ~100 output
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.
11
+ */
12
+ export type SketchKind = "paste" | "code" | "tool_result" | "image" | "base64" | "text";
13
+ export interface SketchBlock {
14
+ /** 1-based turn the block sits in. */
15
+ turn: number;
16
+ kind: SketchKind;
17
+ /** Short name the model can refer to later ("the nginx config", "test output #2"). */
18
+ label: string;
19
+ approx_tokens?: number;
20
+ approx_lines?: number;
21
+ approx_words?: number;
22
+ approx_chars?: number;
23
+ /** Times this same content appears in the conversation (2+ = duplicate). */
24
+ repeated?: number;
25
+ /** Already acted on; nothing in it is still needed. */
26
+ stale?: boolean;
27
+ }
28
+ export interface ConversationSketch {
29
+ /** User+assistant exchanges so far. */
30
+ turns: number;
31
+ /** Model the chat runs as, for window and price math. */
32
+ model?: string;
33
+ blocks: SketchBlock[];
34
+ }
35
+ export interface SketchFinding {
36
+ id: "large_block" | "duplicate_block" | "base64_blob" | "many_images" | "long_history" | "near_window_limit";
37
+ severity: "info" | "warn" | "high";
38
+ estSavings: number;
39
+ message: string;
40
+ action: string;
41
+ }
42
+ export interface SketchProfile {
43
+ totalTokens: number;
44
+ baselineTokens: number;
45
+ blockTokens: number;
46
+ turns: number;
47
+ model?: string;
48
+ contextWindow?: number;
49
+ usagePct?: number;
50
+ findings: SketchFinding[];
51
+ totalEstSavings: number;
52
+ perTurnUsd?: number;
53
+ perTurnCachedUsd?: number;
54
+ }
55
+ export declare function blockTokens(b: SketchBlock): number;
56
+ export declare function profileSketch(sketch: ConversationSketch): SketchProfile;
57
+ export declare function renderSketchProfile(p: SketchProfile): string;
58
+ /** Profile a sketch, log it to the ledger like a hook check, and render. */
59
+ export declare function runSketch(sketch: ConversationSketch): string;
package/dist/sketch.js ADDED
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Profile a conversation from a SKETCH instead of the conversation itself.
3
+ *
4
+ * Why this exists: in a chat app (Claude Desktop, ChatGPT) the model cannot
5
+ * export the conversation, so asking it to call profile_context with the full
6
+ * JSON means re-typing 50k+ tokens as a tool argument. No model does that, and
7
+ * it would double the context it is meant to measure. A sketch is ~100 output
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.
11
+ */
12
+ import { contextWindowFor } from "./tokens.js";
13
+ import { formatTokens } from "./tokens.js";
14
+ import { formatUsd, inputCostUsd, pricingFor } from "./pricing.js";
15
+ 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,
24
+ };
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,
30
+ };
31
+ const LARGE_BLOCK_TOKENS = 2000;
32
+ const LONG_HISTORY_TURNS = 30;
33
+ const HANDOFF_SUMMARY_TOKENS = 300;
34
+ const RECENT_TURNS_KEPT = 6;
35
+ export function blockTokens(b) {
36
+ if (b.kind === "image")
37
+ return b.approx_tokens ?? DEFAULT_TOKENS.image;
38
+ if (b.approx_tokens && b.approx_tokens > 0)
39
+ 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];
47
+ }
48
+ export function profileSketch(sketch) {
49
+ const turns = Math.max(0, Math.floor(sketch.turns || 0));
50
+ const blocks = Array.isArray(sketch.blocks) ? sketch.blocks : [];
51
+ const baselineTokens = turns * BASELINE_TOKENS_PER_TURN;
52
+ // 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)) }));
54
+ const blockTotal = sized.reduce((n, s) => n + s.tokens * s.copies, 0);
55
+ const totalTokens = baselineTokens + blockTotal;
56
+ const findings = [];
57
+ for (const { block, tokens, copies } of sized) {
58
+ if (copies > 1) {
59
+ findings.push({
60
+ id: "duplicate_block",
61
+ severity: "warn",
62
+ estSavings: tokens * (copies - 1),
63
+ message: `"${block.label}" appears ${copies} times (~${formatTokens(tokens)} each).`,
64
+ action: `Refer to "${block.label}" by name from now on; never re-quote it.`,
65
+ });
66
+ }
67
+ if (block.kind === "base64") {
68
+ findings.push({
69
+ id: "base64_blob",
70
+ severity: "high",
71
+ estSavings: Math.max(0, tokens - 50),
72
+ message: `"${block.label}" is inline base64 (~${formatTokens(tokens)}), which the model cannot read anyway.`,
73
+ action: `Drop it: describe "${block.label}" in one line and ask the user to attach it as a file if needed.`,
74
+ });
75
+ continue;
76
+ }
77
+ if (block.kind !== "image" && tokens >= LARGE_BLOCK_TOKENS) {
78
+ // A summary keeps roughly 30% of a block that is still in use; a stale
79
+ // block needs only a one-line pointer.
80
+ const est = block.stale ? Math.max(0, tokens - 100) : Math.round(tokens * 0.7);
81
+ findings.push({
82
+ id: "large_block",
83
+ severity: tokens >= 4 * LARGE_BLOCK_TOKENS ? "high" : "warn",
84
+ estSavings: est,
85
+ message: `"${block.label}" (turn ${block.turn}, ${block.kind}) is ~${formatTokens(tokens)}${block.stale ? " and already acted on" : ""}.`,
86
+ action: block.stale
87
+ ? `Replace "${block.label}" with a one-line note of what it was and what came of it.`
88
+ : `Summarize "${block.label}" into the points still needed (facts, identifiers, decisions) and work from the summary.`,
89
+ });
90
+ }
91
+ }
92
+ const images = sized.filter((s) => s.block.kind === "image");
93
+ if (images.length >= 3) {
94
+ const imgTokens = images.reduce((n, s) => n + s.tokens * s.copies, 0);
95
+ findings.push({
96
+ id: "many_images",
97
+ severity: "info",
98
+ estSavings: 0,
99
+ message: `${images.length} images (~${formatTokens(imgTokens)}) ride along on every turn.`,
100
+ action: "Once an image has been read, describe what it showed in a sentence so it need not be looked at again; new chats should get only the images still needed.",
101
+ });
102
+ }
103
+ if (turns >= LONG_HISTORY_TURNS) {
104
+ const afterHandoff = RECENT_TURNS_KEPT * BASELINE_TOKENS_PER_TURN + HANDOFF_SUMMARY_TOKENS;
105
+ findings.push({
106
+ id: "long_history",
107
+ severity: totalTokens > 100_000 ? "high" : "warn",
108
+ estSavings: Math.max(0, totalTokens - afterHandoff),
109
+ message: `${turns} turns: every new message re-reads ~${formatTokens(totalTokens)}.`,
110
+ action: `Offer a handoff: write a ≤${HANDOFF_SUMMARY_TOKENS}-token summary (decisions, current state, open items, key identifiers) for the user to start a new chat with. A fresh chat re-reads ~${formatTokens(afterHandoff)} instead.`,
111
+ });
112
+ }
113
+ const contextWindow = contextWindowFor(sketch.model);
114
+ const usagePct = contextWindow ? Math.round((totalTokens / contextWindow) * 100) : undefined;
115
+ if (usagePct !== undefined && usagePct >= 70) {
116
+ findings.push({
117
+ id: "near_window_limit",
118
+ severity: "high",
119
+ estSavings: 0,
120
+ message: `~${usagePct}% of the ${formatTokens(contextWindow)} window. Quality drops and the app may start dropping early turns.`,
121
+ action: "Do the handoff now rather than at the next problem.",
122
+ });
123
+ }
124
+ const order = { high: 0, warn: 1, info: 2 };
125
+ findings.sort((a, b) => order[a.severity] - order[b.severity] || b.estSavings - a.estSavings);
126
+ // A handoff to a fresh chat subsumes every per-block fix, so "recoverable" is
127
+ // the larger of the two routes, not their sum, capped at what is there.
128
+ const handoff = findings.find((f) => f.id === "long_history")?.estSavings ?? 0;
129
+ const perBlock = findings.filter((f) => f.id !== "long_history").reduce((n, f) => n + f.estSavings, 0);
130
+ const totalEstSavings = Math.min(totalTokens, Math.max(handoff, perBlock));
131
+ const pricing = pricingFor(sketch.model);
132
+ return {
133
+ totalTokens, baselineTokens, blockTokens: blockTotal, turns, model: sketch.model, contextWindow, usagePct,
134
+ findings, totalEstSavings,
135
+ perTurnUsd: pricing ? inputCostUsd(totalTokens, pricing) : undefined,
136
+ perTurnCachedUsd: pricing ? (totalTokens / 1_000_000) * pricing.cacheReadPerM : undefined,
137
+ };
138
+ }
139
+ export function renderSketchProfile(p) {
140
+ const lines = [];
141
+ const window = p.usagePct !== undefined ? ` (~${p.usagePct}% of ${formatTokens(p.contextWindow)})` : "";
142
+ 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%.`);
144
+ if (p.perTurnUsd !== undefined) {
145
+ 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
+ }
147
+ else {
148
+ lines.push(" Re-read on every turn. On a subscription this is what spends the usage limit.");
149
+ }
150
+ if (p.findings.length === 0) {
151
+ lines.push("No findings: nothing large, repeated, or stale in the sketch. Keep going.");
152
+ return lines.join("\n");
153
+ }
154
+ lines.push("");
155
+ lines.push(`Findings (recoverable ~${formatTokens(p.totalEstSavings)} tokens):`);
156
+ p.findings.forEach((f, i) => {
157
+ const save = f.estSavings > 0 ? ` [~${formatTokens(f.estSavings)}]` : "";
158
+ lines.push(`${i + 1}. ${f.severity.toUpperCase()}${save} ${f.message}`);
159
+ lines.push(` → ${f.action}`);
160
+ });
161
+ lines.push("");
162
+ lines.push("Act on #1 in your reply (do it, do not just suggest it), and tell the user in one line what you dropped and roughly what it saves per turn.");
163
+ return lines.join("\n");
164
+ }
165
+ /** Profile a sketch, log it to the ledger like a hook check, and render. */
166
+ export function runSketch(sketch) {
167
+ const profile = profileSketch(sketch);
168
+ recordLedger({ ev: "check", sid: "mcp-sketch", src: "mcp", tok: profile.totalTokens, warn: profile.findings.some((f) => f.severity !== "info"), model: sketch.model });
169
+ return renderSketchProfile(profile);
170
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-doctor",
3
- "version": "0.16.0",
3
+ "version": "0.18.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",
@@ -45,7 +45,8 @@
45
45
  "build": "tsc && node -e \"const fs=require('fs');['dist/cli.js','dist/mcp.js'].forEach(f=>fs.chmodSync(f,0o755))\"",
46
46
  "prepublishOnly": "npm test",
47
47
  "dev": "tsc --watch",
48
- "test": "npm run build && node scripts/test.mjs"
48
+ "test": "npm run build && node scripts/test.mjs",
49
+ "build:mcpb": "npm run build && node scripts/build-mcpb.mjs"
49
50
  },
50
51
  "dependencies": {
51
52
  "@modelcontextprotocol/sdk": "^1.0.0",