context-doctor 0.14.3 → 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`) |
@@ -182,15 +183,29 @@ Because prompt caching matches byte-identical prefixes, deterministic strategies
182
183
 
183
184
  ## Use with the Claude & ChatGPT apps
184
185
 
185
- `context-doctor` ships an MCP server, so the AI itself can profile and slim context on demand.
186
+ `context-doctor` ships an MCP server, so the AI itself can profile and slim context on demand. Before the setup table, the honest question: **on which surfaces does it act by itself, and on which does it only nudge?** MCP gives a server no way to see the conversation or intercept a turn; only a hook or a place in the data path can do that.
187
+
188
+ | Surface | Runs by itself | What that means |
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 |
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 |
192
+ | **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 ~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 |
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 |
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 |
196
+
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).
186
200
 
187
201
  **Do you need to configure anything by hand? Usually no:**
188
202
 
189
203
  | App | Setup |
190
204
  |---|---|
191
- | 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 |
192
206
  | Claude Code | Same command — MCP + skill + every-prompt hook, all automatic |
193
- | Cursor | Same command — writes `~/.cursor/mcp.json` |
207
+ | Cursor | Same command — writes `~/.cursor/mcp.json`; the every-prompt hook is picked up from Claude Code's config, which Cursor reads |
208
+ | Codex (OpenAI) | Same command — `~/.codex/config.toml`, `~/.codex/hooks.json`, `~/.codex/skills/`. Then, once, `/hooks` in Codex to trust the hook |
194
209
  | ChatGPT (developer mode) | **Manual + a reachable URL** — ChatGPT connects to servers over the internet, never local commands. Run `context-doctor-mcp --http` on a host/tunnel, then add the URL as a connector. Normal ChatGPT (no dev mode) has no MCP — use the CLI |
195
210
 
196
211
  For any other MCP client, the server entry is:
@@ -208,10 +223,14 @@ For any other MCP client, the server entry is:
208
223
 
209
224
  ### How it works in Claude Desktop, step by step
210
225
 
211
- 1. Run `npx context-doctor install` (writes the config above for you) and restart Claude Desktop.
212
- 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.
213
- 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.
214
- 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.
215
234
 
216
235
  ### How it works in ChatGPT, step by step (honest version)
217
236
 
@@ -223,15 +242,15 @@ ChatGPT's MCP support differs fundamentally from Claude Desktop's: **it never sp
223
242
 
224
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.
225
244
 
226
- ### claude.ai on the web
245
+ ### claude.ai on the web and the phone apps
227
246
 
228
- 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.
229
248
 
230
249
  ### MCP tools
231
250
 
232
251
  | Tool | What it does |
233
252
  |---|---|
234
- | `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) |
235
254
  | `optimize_context` | Rewrites the conversation: dedupe, trim stale tool results, strip base64, optional history pruning |
236
255
  | `context_best_practices` | Curated checklist, optionally specialized for Anthropic / OpenAI |
237
256
 
@@ -367,7 +386,7 @@ Everything the optimizer does is inspectable: it prints exactly which messages c
367
386
 
368
387
  ## The Agent Skill
369
388
 
370
- `skills/context-doctor/SKILL.md` (installed by `npx context-doctor install`) teaches Claude to practice context hygiene proactively: summarize big tool results after consuming them, never re-paste duplicated content, keep stable content cache-friendly, and offer compaction when a session gets heavy — so sessions get inherently leaner without you asking.
389
+ `skills/context-doctor/SKILL.md` (installed by `npx context-doctor install`) teaches Claude to practice context hygiene proactively: summarize big tool results after consuming them, never re-paste duplicated content, keep stable content cache-friendly, and offer compaction when a session gets heavy. In Claude Code and Cursor the every-prompt hook enforces the heavy-session part; the skill covers the habits in between.
371
390
 
372
391
  ## Measuring the impact: `context-doctor report`
373
392
 
@@ -467,9 +486,10 @@ Contributions welcome — this project is small on purpose. Open an issue before
467
486
 
468
487
  ```bash
469
488
  npm version patch # or minor/major — bumps package.json + git tag
470
- 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
471
490
  npm publish # prompts for the npm 2FA code
472
491
  git push --follow-tags
492
+ npm run build:mcpb # context-doctor-<version>.mcpb for Claude Desktop; attach it to the GitHub release
473
493
  ```
474
494
 
475
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/doctor.js CHANGED
@@ -135,6 +135,35 @@ export async function runDoctor() {
135
135
  else {
136
136
  checks.push({ label: "Every-prompt hook", status: "skip", detail: "Claude Code not detected" });
137
137
  }
138
+ // Codex (OpenAI): MCP table in config.toml, hook in hooks.json, skill.
139
+ const codexHome = join(homedir(), ".codex");
140
+ if (existsSync(codexHome)) {
141
+ const toml = existsSync(join(codexHome, "config.toml")) ? readFileSync(join(codexHome, "config.toml"), "utf8") : "";
142
+ checks.push(toml.includes("[mcp_servers.context-doctor]")
143
+ ? { label: "Codex MCP", status: "ok", detail: "wired in ~/.codex/config.toml" }
144
+ : { label: "Codex MCP", status: "fail", detail: "Codex detected but not wired — run: context-doctor install" });
145
+ try {
146
+ const hooksPath = join(codexHome, "hooks.json");
147
+ const hooks = existsSync(hooksPath) ? JSON.parse(readFileSync(hooksPath, "utf8")) : {};
148
+ const entries = hooks.hooks?.UserPromptSubmit ?? [];
149
+ const ours = entries.map((e) => e.hooks?.[0]?.command ?? "").find((c) => /context-doctor|cli\.js"?\s+hook/.test(c));
150
+ if (!ours) {
151
+ checks.push({ label: "Codex hook", status: "fail", detail: "not registered — run: context-doctor install" });
152
+ }
153
+ else {
154
+ const missing = hookBinaryMissing(ours);
155
+ checks.push(missing
156
+ ? { label: "Codex hook", status: "fail", detail: `registered, but ${missing} no longer exists — re-run: context-doctor install` }
157
+ : { label: "Codex hook", status: "ok", detail: "registered in ~/.codex/hooks.json (Codex must trust it once: /hooks)" });
158
+ }
159
+ }
160
+ catch (e) {
161
+ checks.push({ label: "Codex hook", status: "fail", detail: `~/.codex/hooks.json unreadable (${e.message})` });
162
+ }
163
+ }
164
+ else {
165
+ checks.push({ label: "Codex", status: "skip", detail: "not detected (ChatGPT's Codex agent, IDE extension or CLI)" });
166
+ }
138
167
  // Status line (opt-in, so absence is a note, not a failure)
139
168
  if (existsSync(settingsPath)) {
140
169
  try {
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.d.ts CHANGED
@@ -27,6 +27,18 @@ export declare function npxLauncher(platformName: string): {
27
27
  * ours. Returns what happened so install can print the truth.
28
28
  */
29
29
  export declare function installStatusLine(): "installed" | "already" | "kept-foreign" | "no-claude-code";
30
+ export declare function codexDir(): string;
31
+ /**
32
+ * Add or replace our [mcp_servers.context-doctor] table in config.toml without
33
+ * a TOML library: the file is the user's, so everything outside our own table
34
+ * is copied through byte for byte. Our table is delimited by its header and the
35
+ * next header (or EOF).
36
+ */
37
+ export declare function upsertCodexMcpTable(toml: string, entry: {
38
+ command: string;
39
+ args: string[];
40
+ }): string;
41
+ export declare function removeCodexMcpTable(toml: string): string;
30
42
  /** Outcome of an install run, so the CLI can set a truthful exit code. */
31
43
  export interface InstallResult {
32
44
  /** Detected targets that could not be configured, with the reason. */
package/dist/install.js CHANGED
@@ -196,9 +196,17 @@ function isOurHookEntry(entry) {
196
196
  * Claude Code query gets a context-size check. Idempotent.
197
197
  */
198
198
  function installHook() {
199
- const settingsPath = join(homedir(), ".claude", "settings.json");
200
- if (!existsSync(join(homedir(), ".claude")))
201
- return null; // no Claude Code here
199
+ return installHookInto(join(homedir(), ".claude", "settings.json"), join(homedir(), ".claude"));
200
+ }
201
+ /**
202
+ * Register the UserPromptSubmit hook in a hooks file. Claude Code's
203
+ * settings.json and Codex's hooks.json share the exact same shape
204
+ * ({hooks:{UserPromptSubmit:[{hooks:[{type:"command",command}]}]}}), so one
205
+ * writer serves both; only the path differs.
206
+ */
207
+ function installHookInto(settingsPath, gateDir) {
208
+ if (!existsSync(gateDir))
209
+ return null; // that app is not on this machine
202
210
  const settings = readJson(settingsPath);
203
211
  settings.hooks = settings.hooks ?? {};
204
212
  const entries = settings.hooks.UserPromptSubmit ?? [];
@@ -227,7 +235,10 @@ function installHook() {
227
235
  return settingsPath;
228
236
  }
229
237
  function uninstallHook() {
230
- const settingsPath = join(homedir(), ".claude", "settings.json");
238
+ uninstallHookFrom(join(homedir(), ".claude", "settings.json"), "Claude Code every-prompt hook");
239
+ uninstallHookFrom(join(homedir(), ".codex", "hooks.json"), "Codex every-prompt hook");
240
+ }
241
+ function uninstallHookFrom(settingsPath, label) {
231
242
  if (!existsSync(settingsPath))
232
243
  return;
233
244
  const settings = readJson(settingsPath);
@@ -240,20 +251,135 @@ function uninstallHook() {
240
251
  if (filtered.length === 0)
241
252
  delete settings.hooks.UserPromptSubmit;
242
253
  writeJsonWithBackup(settingsPath, settings);
243
- console.log("✓ Claude Code every-prompt hook removed");
254
+ console.log(`✓ ${label} removed`);
244
255
  }
245
256
  }
246
257
  function installSkill() {
258
+ return installSkillInto(join(homedir(), ".claude", "skills", "context-doctor"));
259
+ }
260
+ /** Agent Skills are one format across Claude Code and Codex; only the home differs. */
261
+ function installSkillInto(skillDest) {
247
262
  const selfDir = dirname(fileURLToPath(import.meta.url));
248
263
  // dist/install.js → package root is one level up; skills/ ships in the package.
249
264
  const skillSource = join(selfDir, "..", "skills", "context-doctor", "SKILL.md");
250
265
  if (!existsSync(skillSource))
251
266
  return null;
252
- const skillDest = join(homedir(), ".claude", "skills", "context-doctor");
253
267
  mkdirSync(skillDest, { recursive: true });
254
268
  copyFileSync(skillSource, join(skillDest, "SKILL.md"));
255
269
  return join(skillDest, "SKILL.md");
256
270
  }
271
+ // -- Codex (OpenAI's agent: the Codex tab in ChatGPT.app, the IDE extension, the CLI)
272
+ //
273
+ // Codex supports the same three integration points as Claude Code, in almost
274
+ // the same formats: MCP servers ([mcp_servers.<name>] in ~/.codex/config.toml),
275
+ // hooks (~/.codex/hooks.json, identical shape to Claude's settings.json, same
276
+ // hookSpecificOutput.additionalContext response), and Agent Skills
277
+ // (~/.codex/skills/<name>/SKILL.md). One difference matters: Codex requires the
278
+ // user to trust a new hook once, via /hooks inside Codex, before it runs.
279
+ export function codexDir() {
280
+ return join(homedir(), ".codex");
281
+ }
282
+ /**
283
+ * Add or replace our [mcp_servers.context-doctor] table in config.toml without
284
+ * a TOML library: the file is the user's, so everything outside our own table
285
+ * is copied through byte for byte. Our table is delimited by its header and the
286
+ * next header (or EOF).
287
+ */
288
+ export function upsertCodexMcpTable(toml, entry) {
289
+ const header = "[mcp_servers.context-doctor]";
290
+ const table = `${header}\n` +
291
+ `command = ${JSON.stringify(entry.command)}\n` +
292
+ `args = [${entry.args.map((a) => JSON.stringify(a)).join(", ")}]\n`;
293
+ const lines = toml.split("\n");
294
+ const start = lines.findIndex((l) => l.trim() === header);
295
+ if (start === -1) {
296
+ const sep = toml.length === 0 || toml.endsWith("\n\n") ? "" : toml.endsWith("\n") ? "\n" : "\n\n";
297
+ return toml + sep + table;
298
+ }
299
+ let end = start + 1;
300
+ while (end < lines.length && !/^\s*\[/.test(lines[end]))
301
+ end++;
302
+ // Trim trailing blank lines inside our block so we do not accumulate them.
303
+ const before = lines.slice(0, start).join("\n");
304
+ const after = lines.slice(end).join("\n");
305
+ return `${before}${before.length ? "\n" : ""}${table}${after.length ? "\n" + after : ""}`;
306
+ }
307
+ export function removeCodexMcpTable(toml) {
308
+ const header = "[mcp_servers.context-doctor]";
309
+ const lines = toml.split("\n");
310
+ const start = lines.findIndex((l) => l.trim() === header);
311
+ if (start === -1)
312
+ return toml;
313
+ let end = start + 1;
314
+ while (end < lines.length && !/^\s*\[/.test(lines[end]))
315
+ end++;
316
+ return [...lines.slice(0, start), ...lines.slice(end)].join("\n").replace(/\n{3,}/g, "\n\n");
317
+ }
318
+ function installCodex(entry, failures) {
319
+ const dir = codexDir();
320
+ if (!existsSync(dir))
321
+ return; // Codex is not on this machine
322
+ const configPath = join(dir, "config.toml");
323
+ try {
324
+ const current = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
325
+ const next = upsertCodexMcpTable(current, entry);
326
+ if (next !== current) {
327
+ if (existsSync(configPath))
328
+ copyFileSync(configPath, configPath + ".context-doctor.backup");
329
+ writeFileSync(configPath, next);
330
+ }
331
+ console.log(`✓ Codex: MCP server added (${configPath})`);
332
+ }
333
+ catch (e) {
334
+ console.error(`✗ Codex MCP: ${e.message}`);
335
+ failures.push("Codex MCP");
336
+ }
337
+ try {
338
+ const skill = installSkillInto(join(dir, "skills", "context-doctor"));
339
+ if (skill)
340
+ console.log(`✓ Agent Skill installed for Codex (${skill})`);
341
+ }
342
+ catch (e) {
343
+ console.error(`✗ Codex skill: ${e.message}`);
344
+ failures.push("Codex skill");
345
+ }
346
+ try {
347
+ const hook = installHookInto(join(dir, "hooks.json"), dir);
348
+ if (hook) {
349
+ console.log(`✓ Codex every-prompt hook installed (${hook})`);
350
+ console.log(" Codex runs a new hook only after you trust it once: open Codex, type /hooks, and trust context-doctor.");
351
+ }
352
+ }
353
+ catch (e) {
354
+ console.error(`✗ Codex hook: ${e.message}`);
355
+ failures.push("Codex hook");
356
+ }
357
+ }
358
+ function uninstallCodex() {
359
+ const dir = codexDir();
360
+ if (!existsSync(dir))
361
+ return;
362
+ const configPath = join(dir, "config.toml");
363
+ try {
364
+ if (existsSync(configPath)) {
365
+ const current = readFileSync(configPath, "utf8");
366
+ const next = removeCodexMcpTable(current);
367
+ if (next !== current) {
368
+ copyFileSync(configPath, configPath + ".context-doctor.backup");
369
+ writeFileSync(configPath, next);
370
+ console.log("✓ Codex: MCP server removed");
371
+ }
372
+ }
373
+ }
374
+ catch (e) {
375
+ console.error(`✗ Codex MCP: ${e.message}`);
376
+ }
377
+ const skillDir = join(dir, "skills", "context-doctor");
378
+ if (existsSync(skillDir)) {
379
+ rmSync(skillDir, { recursive: true });
380
+ console.log("✓ Codex skill removed");
381
+ }
382
+ }
257
383
  /**
258
384
  * Install into every detected app.
259
385
  *
@@ -267,7 +393,7 @@ function installSkill() {
267
393
  export function runInstall(options = {}) {
268
394
  const entry = serverEntry();
269
395
  const found = targets().filter((t) => t.detect());
270
- if (found.length === 0) {
396
+ if (found.length === 0 && !existsSync(codexDir())) {
271
397
  console.log("No supported AI apps detected (Claude Desktop, Claude Code, Cursor).");
272
398
  console.log("Manual setup — add to your app's MCP config:");
273
399
  console.log(JSON.stringify({ mcpServers: { "context-doctor": entry } }, null, 2));
@@ -314,6 +440,7 @@ export function runInstall(options = {}) {
314
440
  console.error(`✗ Claude Code every-prompt hook: ${e.message}`);
315
441
  failures.push("Claude Code hook");
316
442
  }
443
+ installCodex(entry, failures);
317
444
  if (options.statusLine) {
318
445
  try {
319
446
  switch (installStatusLine()) {
@@ -343,6 +470,8 @@ export function runInstall(options = {}) {
343
470
  else {
344
471
  console.log("\nDone. Restart the apps to pick up the new tools, then try:");
345
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).");
346
475
  }
347
476
  return { failures };
348
477
  }
@@ -369,6 +498,7 @@ export function runUninstall() {
369
498
  }
370
499
  uninstallHook();
371
500
  uninstallStatusLine();
501
+ uninstallCodex();
372
502
  // Remove our bookkeeping files too — uninstall means gone.
373
503
  for (const file of [".context-doctor-hook-state.json", ".context-doctor-ledger.jsonl"]) {
374
504
  const p = join(homedir(), ".claude", file);
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.
@@ -28,8 +29,19 @@ import { recordLedger } from "./ledger.js";
28
29
  * "standing context-hygiene instructions in every chat" — no hook needed.
29
30
  */
30
31
  // Kept deliberately terse: these ride in EVERY conversation's context, and a
31
- // context-saving tool must not itself be context overhead (~110 tokens).
32
- const SERVER_INSTRUCTIONS = `Context hygiene, always: summarize large pastes/tool results instead of carrying them verbatim; reference earlier content, don't re-quote; never inline base64. Past ~30 turns or several large pastes, proactively offer to run profile_context. Any question about tokens, cost, or latency: call profile_context, don't estimate. If optimize_context returns a pruned-turns digest, you write the ≤150-token replacement summary.`;
32
+ // context-saving tool must not itself be context overhead (~130 tokens).
33
+ //
34
+ // Written as conditions and actions, not offers. On Claude Desktop this string
35
+ // is the only channel that reaches the model without the user asking — MCP has
36
+ // no hook and no data path there — so "offer to run" leaves it to a model that
37
+ // will usually not, while "when X, call Y before answering" is followed. The
38
+ // thresholds keep it from firing on every turn, which would cost more context
39
+ // than it saves.
40
+ const SERVER_INSTRUCTIONS = `Context hygiene rules (always on):
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
+ 2. Reference earlier content by name; never re-quote it. Never inline base64.
43
+ 3. When the conversation passes ~30 turns, or holds 3+ large pastes, or the user asks about tokens, cost, speed or limits: call profile_context BEFORE answering and act on its top finding. In a chat app pass a \`sketch\` (turn count + the large/repeated blocks, ~100 tokens), not the conversation. Do not estimate token counts yourself.
44
+ 4. If optimize_context returns a pruned-turns digest, you write the ≤150-token replacement summary.`;
33
45
  const STRATEGY_IDS = ["dedupe", "trim-tool-results", "trim-tool-calls", "strip-base64", "prune-history"];
34
46
  /**
35
47
  * Build a fully-configured server instance. A factory (not a singleton) so the
@@ -37,11 +49,35 @@ const STRATEGY_IDS = ["dedupe", "trim-tool-results", "trim-tool-calls", "strip-b
37
49
  * recommended pattern.
38
50
  */
39
51
  function createServer() {
40
- const server = new McpServer({ name: "context-doctor", version: "0.14.3" }, { instructions: SERVER_INSTRUCTIONS });
41
- 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.", {
42
- 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)."),
43
70
  model: z.string().optional().describe("Target model name for context-window math, e.g. claude-sonnet-5 or gpt-4o"),
44
- }, 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
+ }
45
81
  const profile = profileConversation(parseConversation(conversation), model);
46
82
  return { content: [{ type: "text", text: renderProfile(profile) }] };
47
83
  });
@@ -93,6 +129,23 @@ function createServer() {
93
129
  }
94
130
  return { content };
95
131
  });
132
+ // A prompt shows up in Claude Desktop's "+" menu, so a user can run a checkup
133
+ // with one click instead of typing a request. It is the only proactive
134
+ // surface MCP offers besides `instructions`.
135
+ server.prompt("context_checkup", "Profile this conversation's context and apply the top fix. One click, no typing.", async () => ({
136
+ messages: [
137
+ {
138
+ role: "user",
139
+ content: {
140
+ type: "text",
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). " +
142
+ "Report the total, the top three findings, and the estimated recoverable tokens in under 120 words. " +
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. " +
144
+ "Do not re-quote the content you are summarizing.",
145
+ },
146
+ },
147
+ ],
148
+ }));
96
149
  server.tool("context_best_practices", "Get a curated checklist of context-management best practices, optionally specialized for a provider (anthropic, openai).", {
97
150
  provider: z.enum(["general", "anthropic", "openai"]).optional().describe("Provider to specialize tips for (default: general)"),
98
151
  }, async ({ provider }) => {
@@ -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/session.js CHANGED
@@ -49,6 +49,37 @@ export function listSessions(limit = 20) {
49
49
  sessions.push({ path, project, modifiedAt: stat.mtime, sizeBytes: stat.size });
50
50
  }
51
51
  }
52
+ // Codex keeps rollouts by date: ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl.
53
+ const codexRoot = join(homedir(), ".codex", "sessions");
54
+ if (existsSync(codexRoot)) {
55
+ const walk = (dir, depth) => {
56
+ let entries;
57
+ try {
58
+ entries = readdirSync(dir);
59
+ }
60
+ catch {
61
+ return;
62
+ }
63
+ for (const name of entries) {
64
+ const path = join(dir, name);
65
+ let stat;
66
+ try {
67
+ stat = statSync(path);
68
+ }
69
+ catch {
70
+ continue;
71
+ }
72
+ if (stat.isDirectory()) {
73
+ if (depth < 3)
74
+ walk(path, depth + 1);
75
+ }
76
+ else if (name.endsWith(".jsonl")) {
77
+ sessions.push({ path, project: "codex", modifiedAt: stat.mtime, sizeBytes: stat.size });
78
+ }
79
+ }
80
+ };
81
+ walk(codexRoot, 0);
82
+ }
52
83
  return sessions.sort((a, b) => b.modifiedAt.getTime() - a.modifiedAt.getTime()).slice(0, limit);
53
84
  }
54
85
  /**
@@ -156,6 +187,93 @@ function startsWithArray(path) {
156
187
  closeSync(fd);
157
188
  }
158
189
  }
190
+ /**
191
+ * Codex (OpenAI's agent: the Codex tab in ChatGPT.app, the IDE extension, the
192
+ * CLI) writes rollouts to ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl as
193
+ * {timestamp, type, payload}. Convert one line into the Claude-shaped entry the
194
+ * rest of this parser understands, or return null for lines that carry no
195
+ * context (reasoning is encrypted, world_state is bookkeeping).
196
+ *
197
+ * response_item/message → user or assistant text
198
+ * response_item/function_call | custom_tool_call → assistant tool_use
199
+ * response_item/*_call_output → user tool_result
200
+ * event_msg/token_count → the API's own usage figures
201
+ * turn_context → model
202
+ */
203
+ function fromCodexLine(entry) {
204
+ const payload = entry.payload;
205
+ if (!payload || typeof payload !== "object")
206
+ return null;
207
+ const timestamp = entry.timestamp;
208
+ if (entry.type === "turn_context") {
209
+ return typeof payload.model === "string" ? { codexModel: payload.model } : null;
210
+ }
211
+ if (entry.type === "event_msg" && payload.type === "token_count") {
212
+ const last = payload.info?.last_token_usage;
213
+ if (!last || !(last.input_tokens > 0))
214
+ return null;
215
+ // Codex's input_tokens already includes the cached portion, so it maps to
216
+ // Anthropic's (input + cache_read) with cached carried separately.
217
+ const cached = last.cached_input_tokens ?? 0;
218
+ return {
219
+ type: "assistant",
220
+ timestamp,
221
+ codexUsageOnly: true,
222
+ message: {
223
+ role: "assistant",
224
+ content: [],
225
+ usage: {
226
+ input_tokens: Math.max(0, last.input_tokens - cached),
227
+ cache_read_input_tokens: cached,
228
+ cache_creation_input_tokens: last.cache_write_input_tokens ?? 0,
229
+ output_tokens: last.output_tokens ?? 0,
230
+ },
231
+ },
232
+ };
233
+ }
234
+ if (entry.type !== "response_item")
235
+ return null;
236
+ const text = (parts) => Array.isArray(parts)
237
+ ? parts.map((b) => (typeof b === "string" ? b : typeof b?.text === "string" ? b.text : "")).join("\n")
238
+ : typeof parts === "string" ? parts : "";
239
+ switch (payload.type) {
240
+ case "message": {
241
+ const role = payload.role === "assistant" ? "assistant" : payload.role === "user" ? "user" : null;
242
+ if (!role)
243
+ return null;
244
+ return { type: role, timestamp, message: { role, content: text(payload.content) } };
245
+ }
246
+ case "function_call":
247
+ case "custom_tool_call":
248
+ case "web_search_call": {
249
+ const id = payload.call_id ?? payload.id;
250
+ let input = payload.arguments ?? payload.input ?? payload.action ?? {};
251
+ if (typeof input === "string") {
252
+ try {
253
+ input = JSON.parse(input);
254
+ }
255
+ catch {
256
+ input = { input };
257
+ }
258
+ }
259
+ return {
260
+ type: "assistant",
261
+ timestamp,
262
+ message: { role: "assistant", content: [{ type: "tool_use", id, name: payload.name ?? payload.type, input }] },
263
+ };
264
+ }
265
+ case "function_call_output":
266
+ case "custom_tool_call_output": {
267
+ return {
268
+ type: "user",
269
+ timestamp,
270
+ message: { role: "user", content: [{ type: "tool_result", tool_use_id: payload.call_id, content: text(payload.output) }] },
271
+ };
272
+ }
273
+ default:
274
+ return null;
275
+ }
276
+ }
159
277
  export function parseSessionFile(path) {
160
278
  // ChatGPT exports are one big JSON array, not JSONL — and small enough to
161
279
  // read whole. Only peek first, so multi-hundred-MB JSONL is never slurped.
@@ -197,6 +315,37 @@ export function parseSessionFile(path) {
197
315
  title = entry.customTitle;
198
316
  if (entry.type === "ai-title" && entry.aiTitle && !title)
199
317
  title = entry.aiTitle;
318
+ // Codex rollouts: translate, then fall through to the shared handling.
319
+ if (entry.payload && typeof entry.payload === "object" && typeof entry.type === "string" && ["response_item", "event_msg", "turn_context"].includes(entry.type)) {
320
+ const converted = fromCodexLine(entry);
321
+ if (!converted)
322
+ return;
323
+ if (converted.codexModel) {
324
+ model = converted.codexModel;
325
+ return;
326
+ }
327
+ if (converted.codexUsageOnly) {
328
+ // A usage-only line attaches to the conversation position, not a message.
329
+ const u = converted.message.usage;
330
+ const total = usageNumber(u.input_tokens) + usageNumber(u.cache_read_input_tokens) + usageNumber(u.cache_creation_input_tokens);
331
+ if (total > 0) {
332
+ reportedInputTokens = total;
333
+ usageSamples.push({ index: messages.length, input: total });
334
+ }
335
+ return;
336
+ }
337
+ entry = converted;
338
+ }
339
+ // Cursor's agent transcripts (~/.cursor/projects/*/agent-transcripts/) are
340
+ // one {role, message:{content}} per line: no `type`, no `message.role`, no
341
+ // usage. Cursor hands this path to hooks it loads from ~/.claude/settings.json,
342
+ // so until this shape parsed, our hook fired on every Cursor prompt and
343
+ // returned nothing.
344
+ if (!entry.type && (entry.role === "user" || entry.role === "assistant") && entry.message && entry.message.content != null) {
345
+ const message = entry.message;
346
+ messages.push({ role: entry.role, content: message.content });
347
+ return;
348
+ }
200
349
  if ((entry.type !== "user" && entry.type !== "assistant") || !entry.message)
201
350
  return;
202
351
  if (entry.isSidechain)
@@ -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,18 +1,21 @@
1
1
  {
2
2
  "name": "context-doctor",
3
- "version": "0.14.3",
4
- "description": "Profile and optimize LLM context windows. See what's eating your tokens and fix it — works with Claude, GPT, Gemini, and any MCP-capable AI app.",
3
+ "version": "0.17.0",
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
- "llm",
6
+ "claude",
7
+ "claude-code",
8
+ "codex",
9
+ "context-management",
7
10
  "context-window",
8
- "tokens",
9
- "prompt-engineering",
11
+ "cursor",
12
+ "llm",
10
13
  "mcp",
11
14
  "model-context-protocol",
12
- "claude",
13
15
  "openai",
14
- "context-management",
15
- "prompt-caching"
16
+ "prompt-caching",
17
+ "prompt-engineering",
18
+ "tokens"
16
19
  ],
17
20
  "license": "MIT",
18
21
  "author": "gAI Ventures (https://gai.ventures)",
@@ -42,7 +45,8 @@
42
45
  "build": "tsc && node -e \"const fs=require('fs');['dist/cli.js','dist/mcp.js'].forEach(f=>fs.chmodSync(f,0o755))\"",
43
46
  "prepublishOnly": "npm test",
44
47
  "dev": "tsc --watch",
45
- "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"
46
50
  },
47
51
  "dependencies": {
48
52
  "@modelcontextprotocol/sdk": "^1.0.0",