context-doctor 0.16.0 → 0.17.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`) |
@@ -189,17 +190,19 @@ Because prompt caching matches byte-identical prefixes, deterministic strategies
189
190
  | **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
191
  | **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
192
  | **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 |
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 ~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
194
  | **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
195
  | **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
196
 
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.
197
+ 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
+
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 ~90-token rules on your clipboard and tells you where to paste them (claude.ai Settings > Profile; ChatGPT Settings > Personalization > Custom instructions).
197
200
 
198
201
  **Do you need to configure anything by hand? Usually no:**
199
202
 
200
203
  | App | Setup |
201
204
  |---|---|
202
- | Claude Desktop | `npx context-doctor install` writes the config — just restart the app |
205
+ | 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
206
  | Claude Code | Same command — MCP + skill + every-prompt hook, all automatic |
204
207
  | Cursor | Same command — writes `~/.cursor/mcp.json`; the every-prompt hook is picked up from Claude Code's config, which Cursor reads |
205
208
  | Codex (OpenAI) | Same command — `~/.codex/config.toml`, `~/.codex/hooks.json`, `~/.codex/skills/`. Then, once, `/hooks` in Codex to trust the hook |
@@ -220,10 +223,14 @@ For any other MCP client, the server entry is:
220
223
 
221
224
  ### How it works in Claude Desktop, step by step
222
225
 
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).
226
+ 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
+ 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 (~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.
229
+ 4. One click instead of asking: the `context_checkup` prompt in the **+** menu sends that request for you.
230
+ 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
+ 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.
232
+
233
+ 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
234
 
228
235
  ### How it works in ChatGPT, step by step (honest version)
229
236
 
@@ -235,15 +242,15 @@ ChatGPT's MCP support differs fundamentally from Claude Desktop's: **it never sp
235
242
 
236
243
  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
244
 
238
- ### claude.ai on the web
245
+ ### claude.ai on the web and the phone apps
239
246
 
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).
247
+ 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
248
 
242
249
  ### MCP tools
243
250
 
244
251
  | Tool | What it does |
245
252
  |---|---|
246
- | `profile_context` | Token breakdown by category, largest messages, findings with estimated savings |
253
+ | `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
254
  | `optimize_context` | Rewrites the conversation: dedupe, trim stale tool results, strip base64, optional history pruning |
248
255
  | `context_best_practices` | Curated checklist, optionally specialized for Anthropic / OpenAI |
249
256
 
@@ -479,9 +486,10 @@ Contributions welcome — this project is small on purpose. Open an issue before
479
486
 
480
487
  ```bash
481
488
  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
489
+ npm test # 131 tests must pass; CI runs the same on 3 OSes x Node 20/22/24
483
490
  npm publish # prompts for the npm 2FA code
484
491
  git push --follow-tags
492
+ npm run build:mcpb # context-doctor-<version>.mcpb for Claude Desktop; attach it to the GitHub release
485
493
  ```
486
494
 
487
495
  **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
@@ -184,6 +187,9 @@ function parseArgs(argv) {
184
187
  case "--dry-run":
185
188
  args.dryRun = true;
186
189
  break;
190
+ case "--copy":
191
+ args.copy = true;
192
+ break;
187
193
  case "--allow-dirty":
188
194
  args.allowDirty = true;
189
195
  break;
@@ -431,6 +437,10 @@ function main() {
431
437
  process.exitCode = 1;
432
438
  return;
433
439
  }
440
+ if (args.command === "instructions") {
441
+ console.log(renderPreferences(args.copy ? copyToClipboard(CHAT_PREFERENCES) : undefined));
442
+ return;
443
+ }
434
444
  if (args.command === "uninstall") {
435
445
  runUninstall();
436
446
  return;
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.17.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
+ }
@@ -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.17.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",