context-doctor 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -179,7 +179,20 @@ The proxy dedupes repeated content, trims stale tool results, and strips base64
179
179
 
180
180
  Because prompt caching matches byte-identical prefixes, deterministic strategies are chosen so repeated requests stay stable — but if you rely on aggressive cache prefixes, start with `--strategy strip-base64 --strategy dedupe` and add more as you verify.
181
181
 
182
- > **Note on desktop chat apps:** Claude Desktop and the ChatGPT app talk to their own backends — no tool can sit in that path. For those, use the MCP integration below and add a line to your custom instructions like: *"When a conversation gets long or includes large pasted content, proactively use context-doctor's profile_context tool and tell me what to trim."* The model will then invoke it on its own.
182
+ ### Putting the proxy on a public URL (Cursor with your own OpenAI key, remote apps)
183
+
184
+ Some apps let you set a base URL but call it from *their* servers, not your machine. Cursor is one: with your own OpenAI key, "Override OpenAI Base URL" is sent to Cursor's backend inside the model configuration, and Cursor's servers make the request (only the key-verification ping is client-side; we checked the app bundle, 3.18.25). So `127.0.0.1` cannot work there; the proxy has to be reachable from the internet, and an open relay on the internet is a bad idea. Hence the token:
185
+
186
+ ```bash
187
+ npx context-doctor proxy --token "$(openssl rand -hex 16)" # or CONTEXT_DOCTOR_PROXY_TOKEN=...
188
+ ngrok http 8787 # or any HTTPS tunnel / reverse proxy
189
+ ```
190
+
191
+ With `--token`, every path except `/health` must start with `/t/<token>/`; anything else gets 401 before any upstream call, and the comparison is constant time. Then in Cursor: Settings > Models > OpenAI API Key > Override OpenAI Base URL = `https://<your-host>/t/<token>/v1`. Every agent request Cursor makes with your key now passes through the proxy: deduped, stale tool results trimmed, base64 stripped, real usage counted in `/t/<token>/stats`. This is the one Cursor path that is model-independent and needs no hook. It applies only to BYO-key traffic; Cursor's own subscription models never leave Cursor's servers.
192
+
193
+ Your API key still rides in the request headers, as before. The token protects the relay, not the key; keep the tunnel HTTPS.
194
+
195
+ > **Note on desktop chat apps:** Claude Desktop and the ChatGPT app talk to their own backends — no tool can sit in that path. For those, use the MCP integration below (Claude Desktop gets the standing rules and a cheap `profile_context` sketch call) and `context-doctor instructions --copy` for the per-account preferences.
183
196
 
184
197
  ## Use with the Claude & ChatGPT apps
185
198
 
package/dist/cli.js CHANGED
@@ -110,6 +110,9 @@ Options:
110
110
  overwrites a statusLine you already have)
111
111
  --port <n> (proxy) Port to listen on (default 8787)
112
112
  --host <addr> (proxy) Bind address (default 127.0.0.1; use 0.0.0.0 to expose)
113
+ --token <secret> (proxy) Require /t/<secret>/ in every request path; needed before
114
+ putting the proxy on a public URL (Cursor BYO-key, tunnels).
115
+ Also read from CONTEXT_DOCTOR_PROXY_TOKEN
113
116
  --config <file> (proxy) Per-route overrides: {"routes":[{"modelPrefix":"gpt","strategies":[...],
114
117
  "keepRecent":n,"maxToolResultTokens":n}]} — first prefix match wins
115
118
  --upstream-anthropic <url> (proxy) Override Anthropic upstream (testing)
@@ -199,6 +202,9 @@ function parseArgs(argv) {
199
202
  case "--host":
200
203
  args.host = argv[++i];
201
204
  break;
205
+ case "--token":
206
+ args.token = argv[++i];
207
+ break;
202
208
  case "--config":
203
209
  args.config = argv[++i];
204
210
  break;
@@ -461,6 +467,7 @@ function main() {
461
467
  routes: routes,
462
468
  port: args.port,
463
469
  host: args.host,
470
+ token: args.token ?? process.env.CONTEXT_DOCTOR_PROXY_TOKEN,
464
471
  anthropicUpstream: args.upstreamAnthropic,
465
472
  openaiUpstream: args.upstreamOpenai,
466
473
  strategies: args.strategies.length > 0 ? args.strategies : loadedRc.config.strategies,
package/dist/mcp.js CHANGED
@@ -49,7 +49,7 @@ const STRATEGY_IDS = ["dedupe", "trim-tool-results", "trim-tool-calls", "strip-b
49
49
  * recommended pattern.
50
50
  */
51
51
  function createServer() {
52
- const server = new McpServer({ name: "context-doctor", version: "0.17.0" }, { instructions: SERVER_INSTRUCTIONS });
52
+ const server = new McpServer({ name: "context-doctor", version: "0.18.0" }, { instructions: SERVER_INSTRUCTIONS });
53
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
54
  conversation: z.string().optional().describe("Conversation JSON (OpenAI or Anthropic format, or bare message array) or raw prompt text. Omit in chat apps and pass `sketch`."),
55
55
  sketch: z.object({
package/dist/proxy.d.ts CHANGED
@@ -23,9 +23,24 @@ export interface ProxyOptions extends OptimizeOptions {
23
23
  * the user explicitly opts in (e.g. --host 0.0.0.0 inside a container).
24
24
  */
25
25
  host?: string;
26
+ /**
27
+ * When set, every request except /health must arrive under the path prefix
28
+ * `/t/<token>/`, which is stripped before routing. This is what makes the
29
+ * proxy safe to put on a public URL (a tunnel) for apps whose servers call
30
+ * the base URL, such as Cursor with your own OpenAI key: those apps can set a
31
+ * URL but not a header, so the secret rides in the path. Compared with
32
+ * constant time; a wrong or missing prefix gets 401 and no upstream call.
33
+ */
34
+ token?: string;
26
35
  anthropicUpstream?: string;
27
36
  openaiUpstream?: string;
28
37
  }
38
+ /**
39
+ * Remove a leading `/t/<token>` from a request path, or return undefined when
40
+ * the prefix is absent or the token differs. The comparison is constant time
41
+ * so the token cannot be guessed a character at a time.
42
+ */
43
+ export declare function stripToken(url: string, token: string): string | undefined;
29
44
  export interface ProxyStats {
30
45
  startedAt: string;
31
46
  requests: number;
package/dist/proxy.js CHANGED
@@ -12,6 +12,7 @@
12
12
  * Streaming responses are piped through unchanged.
13
13
  */
14
14
  import http from "node:http";
15
+ import { timingSafeEqual } from "node:crypto";
15
16
  import { optimizeConversation } from "./optimize.js";
16
17
  import { formatTokens } from "./tokens.js";
17
18
  import { formatUsd, inputCostUsd, pricingFor } from "./pricing.js";
@@ -19,6 +20,23 @@ import { recordLedger } from "./ledger.js";
19
20
  /** Connection-level headers that must not be forwarded. */
20
21
  const SKIP_REQUEST_HEADERS = new Set(["host", "content-length", "connection", "transfer-encoding", "accept-encoding", "expect"]);
21
22
  const SKIP_RESPONSE_HEADERS = new Set(["content-length", "content-encoding", "transfer-encoding", "connection"]);
23
+ /**
24
+ * Remove a leading `/t/<token>` from a request path, or return undefined when
25
+ * the prefix is absent or the token differs. The comparison is constant time
26
+ * so the token cannot be guessed a character at a time.
27
+ */
28
+ export function stripToken(url, token) {
29
+ const prefix = "/t/";
30
+ if (!url.startsWith(prefix))
31
+ return undefined;
32
+ const end = url.indexOf("/", prefix.length);
33
+ const candidate = end === -1 ? url.slice(prefix.length) : url.slice(prefix.length, end);
34
+ const a = Buffer.from(candidate), b = Buffer.from(token);
35
+ if (a.length !== b.length || !timingSafeEqual(a, b))
36
+ return undefined;
37
+ const rest = end === -1 ? "/" : url.slice(end);
38
+ return rest;
39
+ }
22
40
  function upstreamFor(url, opts) {
23
41
  if (url.startsWith("/v1/messages"))
24
42
  return opts.anthropicUpstream ?? "https://api.anthropic.com";
@@ -75,13 +93,23 @@ export function startProxy(opts = {}) {
75
93
  console.error(`[context-doctor] cache advisor: ${msg}`);
76
94
  };
77
95
  const server = http.createServer(async (req, res) => {
78
- const url = req.url ?? "/";
96
+ let url = req.url ?? "/";
79
97
  try {
80
98
  if (url === "/health") {
81
99
  res.setHeader("content-type", "application/json");
82
100
  res.end(JSON.stringify({ ok: true, service: "context-doctor-proxy" }));
83
101
  return;
84
102
  }
103
+ if (opts.token) {
104
+ const stripped = stripToken(url, opts.token);
105
+ if (stripped === undefined) {
106
+ res.statusCode = 401;
107
+ res.setHeader("content-type", "application/json");
108
+ res.end(JSON.stringify({ error: "context-doctor proxy: this proxy requires its token in the path: /t/<token>/v1/..." }));
109
+ return;
110
+ }
111
+ url = stripped;
112
+ }
85
113
  if (url === "/stats") {
86
114
  res.setHeader("content-type", "application/json");
87
115
  res.end(JSON.stringify({ ...stats, estUsdSaved: Number(stats.estUsdSaved.toFixed(4)) }, null, 2));
@@ -278,11 +306,18 @@ export function startProxy(opts = {}) {
278
306
  server.on("close", checkpoint);
279
307
  const host = opts.host ?? "127.0.0.1";
280
308
  server.listen(port, host, () => {
309
+ // Print the token as <token>, never the value: this log is what people paste into bug reports.
310
+ const prefix = opts.token ? "/t/<token>" : "";
281
311
  console.error(`context-doctor proxy listening on http://${host}:${port}`);
282
- console.error(` Anthropic apps/SDKs: export ANTHROPIC_BASE_URL=http://localhost:${port}`);
283
- console.error(` OpenAI apps/SDKs: export OPENAI_BASE_URL=http://localhost:${port}/v1`);
312
+ console.error(` Anthropic apps/SDKs: export ANTHROPIC_BASE_URL=http://localhost:${port}${prefix}`);
313
+ console.error(` OpenAI apps/SDKs: export OPENAI_BASE_URL=http://localhost:${port}${prefix}/v1`);
284
314
  console.error(` Every request's context is optimized in flight; savings are logged here.`);
285
- console.error(` Cumulative savings: http://localhost:${port}/stats`);
315
+ console.error(` Cumulative savings: http://localhost:${port}${prefix}/stats`);
316
+ if (opts.token) {
317
+ console.error(` Token required: every path except /health must start with /t/<token>/.`);
318
+ console.error(` Cursor with your own OpenAI key: expose this port on HTTPS (a tunnel), then Settings > Models > OpenAI API Key >`);
319
+ console.error(` "Override OpenAI Base URL" = https://<your-host>/t/<token>/v1. Cursor's servers call that URL, so 127.0.0.1 will not work there.`);
320
+ }
286
321
  });
287
322
  return server;
288
323
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-doctor",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Profile and optimize LLM context windows. See what's eating your tokens and fix it — works with Claude Code, Claude Desktop, Cursor, Codex (OpenAI), and any MCP-capable AI app.",
5
5
  "keywords": [
6
6
  "claude",