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 +33 -13
- package/dist/cli.js +10 -0
- package/dist/doctor.js +29 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/install.d.ts +12 -0
- package/dist/install.js +137 -7
- package/dist/mcp.js +59 -6
- package/dist/preferences.d.ts +14 -0
- package/dist/preferences.js +47 -0
- package/dist/session.js +149 -0
- package/dist/sketch.d.ts +59 -0
- package/dist/sketch.js +170 -0
- package/package.json +13 -9
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
|
|
213
|
-
3.
|
|
214
|
-
4.
|
|
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
|
|
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
|
|
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 #
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
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(
|
|
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 (~
|
|
32
|
-
|
|
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.
|
|
41
|
-
server.tool("profile_context", "Profile an LLM conversation or prompt: token breakdown
|
|
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)
|
package/dist/sketch.d.ts
ADDED
|
@@ -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.
|
|
4
|
-
"description": "Profile and optimize LLM context windows. See what's eating your tokens and fix it — works with Claude,
|
|
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
|
-
"
|
|
6
|
+
"claude",
|
|
7
|
+
"claude-code",
|
|
8
|
+
"codex",
|
|
9
|
+
"context-management",
|
|
7
10
|
"context-window",
|
|
8
|
-
"
|
|
9
|
-
"
|
|
11
|
+
"cursor",
|
|
12
|
+
"llm",
|
|
10
13
|
"mcp",
|
|
11
14
|
"model-context-protocol",
|
|
12
|
-
"claude",
|
|
13
15
|
"openai",
|
|
14
|
-
"
|
|
15
|
-
"prompt-
|
|
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",
|