context-doctor 0.18.0 → 0.20.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 +120 -37
- package/dist/accuracy.d.ts +6 -4
- package/dist/accuracy.js +9 -7
- package/dist/autoclear.d.ts +128 -0
- package/dist/autoclear.js +295 -0
- package/dist/autopilot.d.ts +55 -0
- package/dist/autopilot.js +325 -0
- package/dist/calibration.d.ts +8 -0
- package/dist/calibration.js +17 -3
- package/dist/cli.js +49 -6
- package/dist/doctor.js +22 -0
- package/dist/hook.js +13 -4
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/install.d.ts +4 -0
- package/dist/install.js +3 -3
- package/dist/mcp.js +5 -5
- package/dist/optimize.d.ts +5 -0
- package/dist/optimize.js +32 -22
- package/dist/parse.d.ts +2 -0
- package/dist/parse.js +2 -1
- package/dist/preferences.d.ts +1 -1
- package/dist/preferences.js +1 -1
- package/dist/profile.js +9 -2
- package/dist/proxy.d.ts +28 -0
- package/dist/proxy.js +63 -6
- package/dist/sketch.d.ts +7 -3
- package/dist/sketch.js +51 -30
- package/dist/tokenizer-measure.d.ts +42 -0
- package/dist/tokenizer-measure.js +172 -0
- package/dist/tokens.d.ts +31 -1
- package/dist/tokens.js +39 -5
- package/package.json +18 -2
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Autopilot: clear stale tool output from agent requests in flight, without
|
|
3
|
+
* ever costing more than it saves.
|
|
4
|
+
*
|
|
5
|
+
* Why this and not the proxy's general strategies: replayed over 33 real
|
|
6
|
+
* Claude Code sessions with prompt-cache pricing, the general trim saved 6.7%
|
|
7
|
+
* overall but made one session 13% MORE expensive, because every change to
|
|
8
|
+
* history the cache already holds re-bills everything after it at the write
|
|
9
|
+
* rate. Most of the bill sits in long sessions (94% of input cost came from
|
|
10
|
+
* requests above 200k tokens), so the win is real, but only if each change
|
|
11
|
+
* pays for its cache rewrite.
|
|
12
|
+
*
|
|
13
|
+
* The rules, modelled on the microcompact logic Claude Code ships (and keeps
|
|
14
|
+
* switched off by a server flag), tightened for "never worse":
|
|
15
|
+
*
|
|
16
|
+
* 1. Only output of read-only, re-runnable tools is eligible (file reads,
|
|
17
|
+
* shell, search, web fetch). Answers the user gave, subagent reports and
|
|
18
|
+
* MCP results are never touched.
|
|
19
|
+
* 2. The most recent `keepRecent` tool results always stay.
|
|
20
|
+
* 3. Clearing happens in batches of at least `minClearTokens`, so each cache
|
|
21
|
+
* rewrite buys a lot.
|
|
22
|
+
* 4. A cleared result stays cleared on every later request (ids are
|
|
23
|
+
* remembered, across restarts), so the prefix is byte-stable between
|
|
24
|
+
* batches and the cache keeps hitting.
|
|
25
|
+
* 5. By default a batch is taken ONLY when the cache is cold anyway (idle
|
|
26
|
+
* longer than the request's own cache TTL), because the whole prompt is
|
|
27
|
+
* re-written on that request regardless, so clearing costs nothing and
|
|
28
|
+
* every later request is smaller. That makes it never-worse by
|
|
29
|
+
* construction, and measured: replaying every Claude Code session on the
|
|
30
|
+
* author's machine through this class, with real timestamps and cache
|
|
31
|
+
* pricing, it saved 9.8% of cache-weighted input cost (9.2% of raw input
|
|
32
|
+
* tokens, up to 37.7% in one long session) and made no session worse.
|
|
33
|
+
* Clearing on a warm cache when the saving "should" repay the rewrite
|
|
34
|
+
* (`paybackSafety` > 0) added 0.1% and made one session 0.13% worse: the
|
|
35
|
+
* future is not knowable, so it is off unless asked for.
|
|
36
|
+
*
|
|
37
|
+
* The model sees a one-line note in place of the output and can simply re-run
|
|
38
|
+
* the tool; the file, command or URL is still in the tool call.
|
|
39
|
+
*/
|
|
40
|
+
import { createHash } from "node:crypto";
|
|
41
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
42
|
+
import { dirname } from "node:path";
|
|
43
|
+
import { estimateTokens } from "./tokens.js";
|
|
44
|
+
/** Tools whose output can be regenerated by calling them again (read-only or re-runnable). */
|
|
45
|
+
export const CLEARABLE_TOOLS = new Set([
|
|
46
|
+
// Claude Code
|
|
47
|
+
"Read", "Bash", "BashOutput", "Grep", "Glob", "LS", "WebFetch", "WebSearch", "NotebookRead",
|
|
48
|
+
// Codex and other OpenAI-API agents
|
|
49
|
+
"shell", "exec_command", "local_shell", "read_file", "list_dir", "grep_search", "file_search", "web_search",
|
|
50
|
+
// Cursor's agent
|
|
51
|
+
"Shell", "ReadFile", "rg", "run_terminal_cmd", "codebase_search",
|
|
52
|
+
]);
|
|
53
|
+
/** Tokens billed for one image inside a tool result (a full-size screenshot is ~1.6k on Claude). */
|
|
54
|
+
const IMAGE_TOKENS = 1600;
|
|
55
|
+
const HOUR = 3_600_000;
|
|
56
|
+
export function clearedNote(tokens) {
|
|
57
|
+
return `[context-doctor: old tool output cleared to keep context lean (~${tokens} tokens). Run the tool again if you need it.]`;
|
|
58
|
+
}
|
|
59
|
+
function resultTokens(content, model) {
|
|
60
|
+
if (typeof content === "string")
|
|
61
|
+
return estimateTokens(content, model);
|
|
62
|
+
if (!Array.isArray(content))
|
|
63
|
+
return estimateTokens(JSON.stringify(content ?? ""), model);
|
|
64
|
+
let n = 0;
|
|
65
|
+
for (const part of content) {
|
|
66
|
+
if (part?.type === "image" || part?.type === "image_url" || part?.type === "input_image")
|
|
67
|
+
n += IMAGE_TOKENS;
|
|
68
|
+
else if (typeof part?.text === "string")
|
|
69
|
+
n += estimateTokens(part.text, model);
|
|
70
|
+
else
|
|
71
|
+
n += estimateTokens(JSON.stringify(part ?? ""), model);
|
|
72
|
+
}
|
|
73
|
+
return n;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* How long the provider may keep this prompt cached, taking the LONGER
|
|
77
|
+
* possibility when unsure: calling a warm cache cold would pay a rewrite,
|
|
78
|
+
* calling a cold one warm only delays a free clearing.
|
|
79
|
+
* - Anthropic: the request's own cache_control, "1h" or the 5-minute default.
|
|
80
|
+
* - OpenAI: caches live up to an hour (in-memory retention), or 24 hours when
|
|
81
|
+
* the request sets prompt_cache_retention "24h".
|
|
82
|
+
*/
|
|
83
|
+
export function requestTtlMs(body) {
|
|
84
|
+
const openai = Array.isArray(body.input) || (Array.isArray(body.messages) && isOpenAIChat(body.messages));
|
|
85
|
+
if (openai)
|
|
86
|
+
return body.prompt_cache_retention === "24h" ? 24 * HOUR : HOUR;
|
|
87
|
+
const json = JSON.stringify(body.system ?? "") + JSON.stringify(body.tools ?? "") + JSON.stringify(body.messages?.slice(-3) ?? "");
|
|
88
|
+
return /"ttl"\s*:\s*"1h"/.test(json) ? HOUR : 300_000;
|
|
89
|
+
}
|
|
90
|
+
function isOpenAIChat(messages) {
|
|
91
|
+
return messages.some((m) => m?.role === "tool" || (m?.role === "assistant" && Array.isArray(m.tool_calls)) || m?.role === "system" || m?.role === "developer");
|
|
92
|
+
}
|
|
93
|
+
export function viewOf(body) {
|
|
94
|
+
const toolName = new Map();
|
|
95
|
+
const results = [];
|
|
96
|
+
let firstToolId = "";
|
|
97
|
+
if (Array.isArray(body.input)) {
|
|
98
|
+
const units = body.input;
|
|
99
|
+
units.forEach((it, i) => {
|
|
100
|
+
const t = it?.type;
|
|
101
|
+
if ((t === "function_call" || t === "custom_tool_call" || t === "local_shell_call") && typeof it.call_id === "string") {
|
|
102
|
+
toolName.set(it.call_id, t === "local_shell_call" ? "shell" : String(it.name ?? ""));
|
|
103
|
+
firstToolId ||= it.call_id;
|
|
104
|
+
}
|
|
105
|
+
else if ((t === "function_call_output" || t === "custom_tool_call_output" || t === "local_shell_call_output") && typeof it.call_id === "string") {
|
|
106
|
+
results.push({ unit: i, id: it.call_id, content: it.output, set: (note) => { units[i] = { ...it, output: note }; } });
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
const firstUser = units.find((u) => u?.role === "user")?.content;
|
|
110
|
+
return { format: "openai-responses", units, toolName, results, firstUser, firstToolId, ttlMs: requestTtlMs(body) };
|
|
111
|
+
}
|
|
112
|
+
const messages = body.messages;
|
|
113
|
+
if (!Array.isArray(messages) || messages.length === 0)
|
|
114
|
+
return undefined;
|
|
115
|
+
const firstUser = messages.find((m) => m?.role === "user")?.content;
|
|
116
|
+
if (isOpenAIChat(messages)) {
|
|
117
|
+
messages.forEach((m, i) => {
|
|
118
|
+
if (m?.role === "assistant" && Array.isArray(m.tool_calls)) {
|
|
119
|
+
for (const c of m.tool_calls) {
|
|
120
|
+
if (typeof c?.id !== "string")
|
|
121
|
+
continue;
|
|
122
|
+
toolName.set(c.id, String(c.function?.name ?? c.name ?? ""));
|
|
123
|
+
firstToolId ||= c.id;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
else if (m?.role === "tool" && typeof m.tool_call_id === "string") {
|
|
127
|
+
results.push({ unit: i, id: m.tool_call_id, content: m.content, set: (note) => { messages[i] = { ...m, content: note }; } });
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
return { format: "openai-chat", units: messages, toolName, results, firstUser, firstToolId, ttlMs: requestTtlMs(body) };
|
|
131
|
+
}
|
|
132
|
+
messages.forEach((m, i) => {
|
|
133
|
+
if (!Array.isArray(m?.content))
|
|
134
|
+
return;
|
|
135
|
+
const blocks = m.content;
|
|
136
|
+
blocks.forEach((b, j) => {
|
|
137
|
+
if (m.role === "assistant" && b?.type === "tool_use" && typeof b.id === "string") {
|
|
138
|
+
toolName.set(b.id, String(b.name ?? ""));
|
|
139
|
+
firstToolId ||= b.id;
|
|
140
|
+
}
|
|
141
|
+
else if (m.role === "user" && b?.type === "tool_result" && typeof b.tool_use_id === "string") {
|
|
142
|
+
results.push({ unit: i, id: b.tool_use_id, content: b.content, set: (note) => { blocks[j] = { ...b, content: note }; } });
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
return { format: "anthropic", units: messages, toolName, results, firstUser, firstToolId, ttlMs: requestTtlMs(body) };
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* A conversation's identity: its first user message plus its first tool call
|
|
150
|
+
* id. The message alone can repeat across sessions (the same project context
|
|
151
|
+
* opens every session in a folder); tool call ids are unique per session.
|
|
152
|
+
* The model is left out on purpose: switching models mid-session keeps the
|
|
153
|
+
* conversation (and the cleared set) but not the cache, which the TTL check
|
|
154
|
+
* cannot see, so a model switch is treated as warm, the safe direction.
|
|
155
|
+
*/
|
|
156
|
+
function conversationKey(view) {
|
|
157
|
+
return createHash("sha1").update(JSON.stringify(view.firstUser ?? "").slice(0, 4000)).update(view.firstToolId).digest("hex");
|
|
158
|
+
}
|
|
159
|
+
export class AutoClearer {
|
|
160
|
+
cleared = new Map();
|
|
161
|
+
/** Tool results never change once sent, so their size is computed once per id. */
|
|
162
|
+
sizes = new Map();
|
|
163
|
+
convs = new Map();
|
|
164
|
+
opts;
|
|
165
|
+
dirty = false;
|
|
166
|
+
constructor(options = {}) {
|
|
167
|
+
this.opts = {
|
|
168
|
+
keepRecent: options.keepRecent ?? 3,
|
|
169
|
+
minClearTokens: options.minClearTokens ?? 20_000,
|
|
170
|
+
paybackSafety: options.paybackSafety ?? 0,
|
|
171
|
+
statePath: options.statePath,
|
|
172
|
+
};
|
|
173
|
+
this.load();
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Rewrite `body` in place: an Anthropic Messages, OpenAI Chat Completions or
|
|
177
|
+
* OpenAI Responses request. Never throws; anything unrecognised is left alone.
|
|
178
|
+
*/
|
|
179
|
+
apply(body, now = Date.now()) {
|
|
180
|
+
const none = (reason, cold = false) => ({ changed: false, newlyCleared: 0, tokensRemoved: 0, cold, reason, firstChanged: -1 });
|
|
181
|
+
try {
|
|
182
|
+
const view = viewOf(body);
|
|
183
|
+
if (!view)
|
|
184
|
+
return none("no messages");
|
|
185
|
+
const model = typeof body.model === "string" ? body.model : undefined;
|
|
186
|
+
const key = conversationKey(view);
|
|
187
|
+
const conv = this.convs.get(key);
|
|
188
|
+
const cold = !conv || now - conv.last > view.ttlMs;
|
|
189
|
+
this.convs.set(key, { last: now, requests: (conv?.requests ?? 0) + 1 });
|
|
190
|
+
if (this.convs.size > 2000)
|
|
191
|
+
this.convs.delete(this.convs.keys().next().value);
|
|
192
|
+
const results = view.results.map((r) => {
|
|
193
|
+
let tokens = this.sizes.get(r.id);
|
|
194
|
+
if (tokens === undefined) {
|
|
195
|
+
tokens = resultTokens(r.content, model);
|
|
196
|
+
this.sizes.set(r.id, tokens);
|
|
197
|
+
if (this.sizes.size > 100_000)
|
|
198
|
+
this.sizes.delete(this.sizes.keys().next().value);
|
|
199
|
+
}
|
|
200
|
+
return { ...r, tokens };
|
|
201
|
+
});
|
|
202
|
+
const eligible = results.slice(0, Math.max(0, results.length - this.opts.keepRecent))
|
|
203
|
+
.filter((r) => CLEARABLE_TOOLS.has(view.toolName.get(r.id) ?? ""));
|
|
204
|
+
const pending = eligible.filter((r) => !this.cleared.has(r.id) && r.tokens > 200);
|
|
205
|
+
const pendingTokens = pending.reduce((n, r) => n + r.tokens, 0);
|
|
206
|
+
let reason = "nothing to clear";
|
|
207
|
+
let taken = [];
|
|
208
|
+
if (pendingTokens >= this.opts.minClearTokens) {
|
|
209
|
+
if (cold) {
|
|
210
|
+
taken = pending;
|
|
211
|
+
reason = "cache cold: cleared for free";
|
|
212
|
+
}
|
|
213
|
+
else if (this.opts.paybackSafety > 0 && this.shouldClearWarm(view.units, pending, pendingTokens, model, (conv?.requests ?? 0) + 1)) {
|
|
214
|
+
taken = pending;
|
|
215
|
+
reason = "cache warm: saving repays the rewrite";
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
reason = "cache warm: waiting, rewrite would not pay back yet";
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
else if (pendingTokens > 0) {
|
|
222
|
+
reason = `waiting for a ${Math.round(this.opts.minClearTokens / 1000)}k batch`;
|
|
223
|
+
}
|
|
224
|
+
for (const r of taken)
|
|
225
|
+
this.cleared.set(r.id, r.tokens);
|
|
226
|
+
if (taken.length)
|
|
227
|
+
this.dirty = true;
|
|
228
|
+
// Apply every clearing ever decided for this conversation, so the
|
|
229
|
+
// prefix is identical to what was sent last time.
|
|
230
|
+
let removed = 0;
|
|
231
|
+
let changed = false;
|
|
232
|
+
let firstChanged = -1;
|
|
233
|
+
for (const r of results) {
|
|
234
|
+
if (!this.cleared.has(r.id))
|
|
235
|
+
continue;
|
|
236
|
+
const note = clearedNote(this.cleared.get(r.id));
|
|
237
|
+
if (r.content === note)
|
|
238
|
+
continue;
|
|
239
|
+
removed += Math.max(0, r.tokens - estimateTokens(note, model));
|
|
240
|
+
r.set(note);
|
|
241
|
+
changed = true;
|
|
242
|
+
if (firstChanged === -1 || r.unit < firstChanged)
|
|
243
|
+
firstChanged = r.unit;
|
|
244
|
+
}
|
|
245
|
+
this.save();
|
|
246
|
+
return { changed, newlyCleared: taken.length, tokensRemoved: removed, cold, reason, firstChanged };
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
return none("error: request forwarded unchanged");
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Warm cache: clearing rewrites everything after the first cleared result
|
|
254
|
+
* at the write rate (1.25x instead of 0.1x: 1.15x extra, once), and saves
|
|
255
|
+
* the cleared tokens at the read rate (0.1x) on every later request.
|
|
256
|
+
* Later requests are estimated as the number made so far (a conversation's
|
|
257
|
+
* age is the best predictor of how long it keeps going), and the saving must
|
|
258
|
+
* cover the rewrite `paybackSafety` times over.
|
|
259
|
+
*/
|
|
260
|
+
shouldClearWarm(messages, pending, pendingTokens, model, requestsSoFar) {
|
|
261
|
+
const first = Math.min(...pending.map((r) => r.unit));
|
|
262
|
+
let suffix = -pendingTokens;
|
|
263
|
+
for (let i = first; i < messages.length; i++)
|
|
264
|
+
suffix += estimateTokens(JSON.stringify(messages[i]), model);
|
|
265
|
+
const rewriteCost = 1.15 * Math.max(0, suffix);
|
|
266
|
+
const saving = 0.1 * pendingTokens * requestsSoFar;
|
|
267
|
+
return saving >= this.opts.paybackSafety * rewriteCost;
|
|
268
|
+
}
|
|
269
|
+
load() {
|
|
270
|
+
if (!this.opts.statePath || !existsSync(this.opts.statePath))
|
|
271
|
+
return;
|
|
272
|
+
try {
|
|
273
|
+
const data = JSON.parse(readFileSync(this.opts.statePath, "utf8"));
|
|
274
|
+
for (const [id, tokens] of data.cleared ?? [])
|
|
275
|
+
this.cleared.set(id, tokens);
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
/* a corrupt state file only means results may be re-cleared once */
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
save() {
|
|
282
|
+
if (!this.dirty || !this.opts.statePath)
|
|
283
|
+
return;
|
|
284
|
+
this.dirty = false;
|
|
285
|
+
try {
|
|
286
|
+
// Newest 50k ids: far more than any live session holds.
|
|
287
|
+
const entries = [...this.cleared.entries()].slice(-50_000);
|
|
288
|
+
mkdirSync(dirname(this.opts.statePath), { recursive: true });
|
|
289
|
+
writeFileSync(this.opts.statePath, JSON.stringify({ cleared: entries }));
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
/* persistence is best effort */
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `context-doctor autopilot on|off|pause|resume|status`
|
|
3
|
+
*
|
|
4
|
+
* Puts the proxy, in autopilot mode, in front of every Claude Code session on
|
|
5
|
+
* this machine, as a background service that starts at login and restarts if
|
|
6
|
+
* it dies, then points Claude Code at it through ~/.claude/settings.json.
|
|
7
|
+
*
|
|
8
|
+
* Order matters, because a base URL pointing at a dead port breaks every new
|
|
9
|
+
* session: the service is installed and must answer /health BEFORE settings
|
|
10
|
+
* are touched, and `off` removes the setting BEFORE stopping the service.
|
|
11
|
+
* `pause` leaves everything wired and makes the proxy a pure passthrough,
|
|
12
|
+
* which is the way to switch autopilot off without breaking sessions that
|
|
13
|
+
* are already running against it.
|
|
14
|
+
*
|
|
15
|
+
* What this cannot reach, stated rather than implied: Claude Desktop's chat
|
|
16
|
+
* tab and claude.ai (their requests leave from Anthropic's app, not from a
|
|
17
|
+
* process that reads settings.json), Cursor's own models (sent from Cursor's
|
|
18
|
+
* servers), and Codex signed in with ChatGPT (its traffic goes to ChatGPT's
|
|
19
|
+
* backend, not an API base URL).
|
|
20
|
+
*/
|
|
21
|
+
export declare const DEFAULT_AUTOPILOT_PORT = 8787;
|
|
22
|
+
export interface AutopilotPaths {
|
|
23
|
+
settings: string;
|
|
24
|
+
config: string;
|
|
25
|
+
pauseFile: string;
|
|
26
|
+
statePath: string;
|
|
27
|
+
log: string;
|
|
28
|
+
}
|
|
29
|
+
export declare function autopilotPaths(home?: string): AutopilotPaths;
|
|
30
|
+
export declare function proxyUrl(port: number): string;
|
|
31
|
+
/** The command the service runs. Absolute paths: services start with an empty PATH. */
|
|
32
|
+
export declare function serviceCommand(node: string, cli: string, port: number, paths: AutopilotPaths): string[];
|
|
33
|
+
export declare function launchdPlist(args: string[], log: string): string;
|
|
34
|
+
export declare function systemdUnit(args: string[], log: string): string;
|
|
35
|
+
export declare function windowsTaskCommand(args: string[]): string;
|
|
36
|
+
export declare function startDetached(args: string[], log: string): void;
|
|
37
|
+
export interface Health {
|
|
38
|
+
ok: boolean;
|
|
39
|
+
autopilot?: boolean;
|
|
40
|
+
version?: string;
|
|
41
|
+
}
|
|
42
|
+
export declare function health(port: number, timeoutMs?: number): Promise<Health>;
|
|
43
|
+
export declare function currentCli(): string;
|
|
44
|
+
export declare function autopilotOn(port?: number, paths?: AutopilotPaths): Promise<{
|
|
45
|
+
ok: boolean;
|
|
46
|
+
lines: string[];
|
|
47
|
+
}>;
|
|
48
|
+
export declare function autopilotOff(paths?: AutopilotPaths): Promise<string[]>;
|
|
49
|
+
export declare function autopilotPause(paused: boolean, paths?: AutopilotPaths): string;
|
|
50
|
+
export declare function autopilotStatus(paths?: AutopilotPaths): Promise<string[]>;
|
|
51
|
+
/**
|
|
52
|
+
* Called by the every-prompt hook: if autopilot is on and the proxy is down,
|
|
53
|
+
* start it before the prompt's request goes out. Returns quickly either way.
|
|
54
|
+
*/
|
|
55
|
+
export declare function ensureProxyUp(paths?: AutopilotPaths): Promise<void>;
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `context-doctor autopilot on|off|pause|resume|status`
|
|
3
|
+
*
|
|
4
|
+
* Puts the proxy, in autopilot mode, in front of every Claude Code session on
|
|
5
|
+
* this machine, as a background service that starts at login and restarts if
|
|
6
|
+
* it dies, then points Claude Code at it through ~/.claude/settings.json.
|
|
7
|
+
*
|
|
8
|
+
* Order matters, because a base URL pointing at a dead port breaks every new
|
|
9
|
+
* session: the service is installed and must answer /health BEFORE settings
|
|
10
|
+
* are touched, and `off` removes the setting BEFORE stopping the service.
|
|
11
|
+
* `pause` leaves everything wired and makes the proxy a pure passthrough,
|
|
12
|
+
* which is the way to switch autopilot off without breaking sessions that
|
|
13
|
+
* are already running against it.
|
|
14
|
+
*
|
|
15
|
+
* What this cannot reach, stated rather than implied: Claude Desktop's chat
|
|
16
|
+
* tab and claude.ai (their requests leave from Anthropic's app, not from a
|
|
17
|
+
* process that reads settings.json), Cursor's own models (sent from Cursor's
|
|
18
|
+
* servers), and Codex signed in with ChatGPT (its traffic goes to ChatGPT's
|
|
19
|
+
* backend, not an API base URL).
|
|
20
|
+
*/
|
|
21
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
22
|
+
import { existsSync, mkdirSync, openSync, rmSync, writeFileSync } from "node:fs";
|
|
23
|
+
import { homedir, platform } from "node:os";
|
|
24
|
+
import { dirname, join } from "node:path";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
26
|
+
import { isEphemeralPath, readJson, writeJsonWithBackup } from "./install.js";
|
|
27
|
+
import { formatTokens } from "./tokens.js";
|
|
28
|
+
export const DEFAULT_AUTOPILOT_PORT = 8787;
|
|
29
|
+
const LABEL = "com.gai-ventures.context-doctor.proxy";
|
|
30
|
+
export function autopilotPaths(home = homedir()) {
|
|
31
|
+
const dir = join(home, ".claude");
|
|
32
|
+
return {
|
|
33
|
+
settings: join(dir, "settings.json"),
|
|
34
|
+
config: join(dir, ".context-doctor-autopilot.json"),
|
|
35
|
+
pauseFile: join(dir, ".context-doctor-autopilot-paused"),
|
|
36
|
+
statePath: join(dir, ".context-doctor-autopilot-cleared.json"),
|
|
37
|
+
log: join(dir, ".context-doctor-proxy.log"),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export function proxyUrl(port) {
|
|
41
|
+
return `http://127.0.0.1:${port}`;
|
|
42
|
+
}
|
|
43
|
+
/** The command the service runs. Absolute paths: services start with an empty PATH. */
|
|
44
|
+
export function serviceCommand(node, cli, port, paths) {
|
|
45
|
+
return [node, cli, "proxy", "--autopilot", "--port", String(port), "--autopilot-state", paths.statePath, "--autopilot-pause-file", paths.pauseFile];
|
|
46
|
+
}
|
|
47
|
+
export function launchdPlist(args, log) {
|
|
48
|
+
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
49
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
50
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
51
|
+
<plist version="1.0">
|
|
52
|
+
<dict>
|
|
53
|
+
<key>Label</key><string>${LABEL}</string>
|
|
54
|
+
<key>ProgramArguments</key>
|
|
55
|
+
<array>
|
|
56
|
+
${args.map((a) => ` <string>${esc(a)}</string>`).join("\n")}
|
|
57
|
+
</array>
|
|
58
|
+
<key>RunAtLoad</key><true/>
|
|
59
|
+
<key>KeepAlive</key><true/>
|
|
60
|
+
<key>ThrottleInterval</key><integer>5</integer>
|
|
61
|
+
<key>StandardOutPath</key><string>${esc(log)}</string>
|
|
62
|
+
<key>StandardErrorPath</key><string>${esc(log)}</string>
|
|
63
|
+
</dict>
|
|
64
|
+
</plist>
|
|
65
|
+
`;
|
|
66
|
+
}
|
|
67
|
+
export function systemdUnit(args, log) {
|
|
68
|
+
const q = (s) => (/[\s"\\]/.test(s) ? `"${s.replace(/(["\\])/g, "\\$1")}"` : s);
|
|
69
|
+
return `[Unit]
|
|
70
|
+
Description=context-doctor proxy (autopilot)
|
|
71
|
+
|
|
72
|
+
[Service]
|
|
73
|
+
ExecStart=${args.map(q).join(" ")}
|
|
74
|
+
Restart=always
|
|
75
|
+
RestartSec=2
|
|
76
|
+
StandardOutput=append:${log}
|
|
77
|
+
StandardError=append:${log}
|
|
78
|
+
|
|
79
|
+
[Install]
|
|
80
|
+
WantedBy=default.target
|
|
81
|
+
`;
|
|
82
|
+
}
|
|
83
|
+
export function windowsTaskCommand(args) {
|
|
84
|
+
return args.map((a) => `"${a}"`).join(" ");
|
|
85
|
+
}
|
|
86
|
+
function servicePaths(home = homedir()) {
|
|
87
|
+
return {
|
|
88
|
+
plist: join(home, "Library", "LaunchAgents", `${LABEL}.plist`),
|
|
89
|
+
unit: join(home, ".config", "systemd", "user", "context-doctor-proxy.service"),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function uid() {
|
|
93
|
+
return String(process.getuid?.() ?? "");
|
|
94
|
+
}
|
|
95
|
+
function run(cmd, args) {
|
|
96
|
+
execFileSync(cmd, args, { stdio: "ignore" });
|
|
97
|
+
}
|
|
98
|
+
function tryRun(cmd, args) {
|
|
99
|
+
try {
|
|
100
|
+
run(cmd, args);
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/** Install and start the background service for this platform. */
|
|
108
|
+
function installService(args, log) {
|
|
109
|
+
const os = platform();
|
|
110
|
+
const sp = servicePaths();
|
|
111
|
+
if (os === "darwin") {
|
|
112
|
+
mkdirSync(dirname(sp.plist), { recursive: true });
|
|
113
|
+
writeFileSync(sp.plist, launchdPlist(args, log));
|
|
114
|
+
tryRun("launchctl", ["bootout", `gui/${uid()}`, sp.plist]);
|
|
115
|
+
run("launchctl", ["bootstrap", `gui/${uid()}`, sp.plist]);
|
|
116
|
+
return `launchd agent ${sp.plist}`;
|
|
117
|
+
}
|
|
118
|
+
if (os === "linux") {
|
|
119
|
+
mkdirSync(dirname(sp.unit), { recursive: true });
|
|
120
|
+
writeFileSync(sp.unit, systemdUnit(args, log));
|
|
121
|
+
if (tryRun("systemctl", ["--user", "daemon-reload"]) && tryRun("systemctl", ["--user", "enable", "--now", "context-doctor-proxy.service"])) {
|
|
122
|
+
tryRun("systemctl", ["--user", "restart", "context-doctor-proxy.service"]);
|
|
123
|
+
return `systemd user service ${sp.unit}`;
|
|
124
|
+
}
|
|
125
|
+
startDetached(args, log);
|
|
126
|
+
return "detached process (systemd --user unavailable; the every-prompt hook restarts it if it stops)";
|
|
127
|
+
}
|
|
128
|
+
if (os === "win32") {
|
|
129
|
+
tryRun("schtasks", ["/Create", "/F", "/SC", "ONLOGON", "/TN", "context-doctor-proxy", "/TR", windowsTaskCommand(args)]);
|
|
130
|
+
startDetached(args, log);
|
|
131
|
+
return "logon task context-doctor-proxy + detached process now";
|
|
132
|
+
}
|
|
133
|
+
startDetached(args, log);
|
|
134
|
+
return "detached process";
|
|
135
|
+
}
|
|
136
|
+
function removeService() {
|
|
137
|
+
const os = platform();
|
|
138
|
+
const sp = servicePaths();
|
|
139
|
+
if (os === "darwin") {
|
|
140
|
+
tryRun("launchctl", ["bootout", `gui/${uid()}`, sp.plist]);
|
|
141
|
+
if (existsSync(sp.plist))
|
|
142
|
+
rmSync(sp.plist);
|
|
143
|
+
}
|
|
144
|
+
else if (os === "linux") {
|
|
145
|
+
tryRun("systemctl", ["--user", "disable", "--now", "context-doctor-proxy.service"]);
|
|
146
|
+
if (existsSync(sp.unit))
|
|
147
|
+
rmSync(sp.unit);
|
|
148
|
+
tryRun("systemctl", ["--user", "daemon-reload"]);
|
|
149
|
+
}
|
|
150
|
+
else if (os === "win32") {
|
|
151
|
+
tryRun("schtasks", ["/Delete", "/F", "/TN", "context-doctor-proxy"]);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
export function startDetached(args, log) {
|
|
155
|
+
mkdirSync(dirname(log), { recursive: true });
|
|
156
|
+
const out = openSync(log, "a");
|
|
157
|
+
const child = spawn(args[0], args.slice(1), { detached: true, stdio: ["ignore", out, out], windowsHide: true });
|
|
158
|
+
child.unref();
|
|
159
|
+
}
|
|
160
|
+
export async function health(port, timeoutMs = 800) {
|
|
161
|
+
try {
|
|
162
|
+
const r = await fetch(`${proxyUrl(port)}/health`, { signal: AbortSignal.timeout(timeoutMs) });
|
|
163
|
+
const j = (await r.json());
|
|
164
|
+
return { ok: Boolean(j.ok && j.service === "context-doctor-proxy"), autopilot: j.autopilot, version: j.version };
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return { ok: false };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
async function waitHealthy(port, ms) {
|
|
171
|
+
const end = Date.now() + ms;
|
|
172
|
+
for (;;) {
|
|
173
|
+
const h = await health(port);
|
|
174
|
+
if (h.ok && h.autopilot)
|
|
175
|
+
return h;
|
|
176
|
+
if (Date.now() > end)
|
|
177
|
+
return h;
|
|
178
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function readConfig(paths) {
|
|
182
|
+
try {
|
|
183
|
+
return readJson(paths.config);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
export function currentCli() {
|
|
190
|
+
return join(dirname(fileURLToPath(import.meta.url)), "cli.js");
|
|
191
|
+
}
|
|
192
|
+
export async function autopilotOn(port = DEFAULT_AUTOPILOT_PORT, paths = autopilotPaths()) {
|
|
193
|
+
const lines = [];
|
|
194
|
+
const cli = currentCli();
|
|
195
|
+
if (isEphemeralPath(cli)) {
|
|
196
|
+
return {
|
|
197
|
+
ok: false,
|
|
198
|
+
lines: [
|
|
199
|
+
"✗ Running from the npx cache, which npm deletes at will; a background service cannot point there.",
|
|
200
|
+
" Install it once, then re-run: npm install -g context-doctor && context-doctor autopilot on",
|
|
201
|
+
],
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
const existing = await health(port);
|
|
205
|
+
if (existing.ok && !existing.autopilot) {
|
|
206
|
+
return { ok: false, lines: [`✗ A context-doctor proxy without autopilot is already on port ${port}. Stop it, or pass --port.`] };
|
|
207
|
+
}
|
|
208
|
+
if (!existing.ok) {
|
|
209
|
+
// Something that is not us on the port would receive Claude Code's traffic.
|
|
210
|
+
try {
|
|
211
|
+
await fetch(proxyUrl(port), { signal: AbortSignal.timeout(500) });
|
|
212
|
+
return { ok: false, lines: [`✗ Port ${port} is taken by another program. Pass --port <free port>.`] };
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
/* nothing listening: good */
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const args = serviceCommand(process.execPath, cli, port, paths);
|
|
219
|
+
const how = installService(args, paths.log);
|
|
220
|
+
const h = await waitHealthy(port, 15_000);
|
|
221
|
+
if (!h.ok || !h.autopilot) {
|
|
222
|
+
removeService();
|
|
223
|
+
return { ok: false, lines: [`✗ The proxy did not come up on port ${port} (log: ${paths.log}). Nothing was changed in Claude Code's settings.`] };
|
|
224
|
+
}
|
|
225
|
+
lines.push(`✓ Proxy running with autopilot on ${proxyUrl(port)} (${how})`);
|
|
226
|
+
const settings = readJson(paths.settings);
|
|
227
|
+
const env = (settings.env ??= {});
|
|
228
|
+
const prev = readConfig(paths);
|
|
229
|
+
const previousBaseUrl = prev?.previousBaseUrl !== undefined ? prev.previousBaseUrl : (env.ANTHROPIC_BASE_URL ?? null);
|
|
230
|
+
if (previousBaseUrl && previousBaseUrl !== proxyUrl(port)) {
|
|
231
|
+
lines.push(`! settings.json already routed Claude Code to ${previousBaseUrl}; autopilot forwards to api.anthropic.com instead. \`autopilot off\` restores it.`);
|
|
232
|
+
}
|
|
233
|
+
env.ANTHROPIC_BASE_URL = proxyUrl(port);
|
|
234
|
+
writeJsonWithBackup(paths.settings, settings);
|
|
235
|
+
writeFileSync(paths.config, JSON.stringify({ port, previousBaseUrl, node: process.execPath, cli }, null, 2));
|
|
236
|
+
if (existsSync(paths.pauseFile))
|
|
237
|
+
rmSync(paths.pauseFile);
|
|
238
|
+
lines.push(`✓ Claude Code routed through it (env.ANTHROPIC_BASE_URL in ${paths.settings})`);
|
|
239
|
+
lines.push(" Applies to Claude Code sessions started from now on (CLI, IDE, and the desktop app's Code tab).");
|
|
240
|
+
lines.push(" Sessions already open keep their old route until restarted.");
|
|
241
|
+
lines.push(` GPT apps on your own OpenAI key get the same: export OPENAI_BASE_URL=${proxyUrl(port)}/v1`);
|
|
242
|
+
return { ok: true, lines };
|
|
243
|
+
}
|
|
244
|
+
export async function autopilotOff(paths = autopilotPaths()) {
|
|
245
|
+
const lines = [];
|
|
246
|
+
const cfg = readConfig(paths);
|
|
247
|
+
const settings = readJson(paths.settings);
|
|
248
|
+
const env = settings.env;
|
|
249
|
+
if (env?.ANTHROPIC_BASE_URL && cfg && env.ANTHROPIC_BASE_URL === proxyUrl(cfg.port)) {
|
|
250
|
+
if (cfg.previousBaseUrl)
|
|
251
|
+
env.ANTHROPIC_BASE_URL = cfg.previousBaseUrl;
|
|
252
|
+
else
|
|
253
|
+
delete env.ANTHROPIC_BASE_URL;
|
|
254
|
+
if (Object.keys(env).length === 0)
|
|
255
|
+
delete settings.env;
|
|
256
|
+
writeJsonWithBackup(paths.settings, settings);
|
|
257
|
+
lines.push("✓ Claude Code no longer routed through the proxy (new sessions)");
|
|
258
|
+
}
|
|
259
|
+
removeService();
|
|
260
|
+
if (existsSync(paths.config))
|
|
261
|
+
rmSync(paths.config);
|
|
262
|
+
if (existsSync(paths.pauseFile))
|
|
263
|
+
rmSync(paths.pauseFile);
|
|
264
|
+
lines.push("✓ Background service removed");
|
|
265
|
+
lines.push(" Sessions started while autopilot was on still point at the proxy: restart them.");
|
|
266
|
+
lines.push(" To switch off without restarting anything, use `autopilot pause` instead.");
|
|
267
|
+
return lines;
|
|
268
|
+
}
|
|
269
|
+
export function autopilotPause(paused, paths = autopilotPaths()) {
|
|
270
|
+
if (paused) {
|
|
271
|
+
writeFileSync(paths.pauseFile, new Date().toISOString());
|
|
272
|
+
return "✓ Paused: the proxy now forwards every request unchanged. `autopilot resume` turns it back on.";
|
|
273
|
+
}
|
|
274
|
+
if (existsSync(paths.pauseFile))
|
|
275
|
+
rmSync(paths.pauseFile);
|
|
276
|
+
return "✓ Resumed: stale tool output is cleared again.";
|
|
277
|
+
}
|
|
278
|
+
export async function autopilotStatus(paths = autopilotPaths()) {
|
|
279
|
+
const cfg = readConfig(paths);
|
|
280
|
+
if (!cfg)
|
|
281
|
+
return ["Autopilot is off. `context-doctor autopilot on` turns it on for every new Claude Code session."];
|
|
282
|
+
const lines = [];
|
|
283
|
+
const h = await health(cfg.port);
|
|
284
|
+
const env = (readJson(paths.settings).env ?? {});
|
|
285
|
+
lines.push(`${h.ok ? "✓" : "✗"} Proxy ${proxyUrl(cfg.port)} ${h.ok ? `up (v${h.version ?? "?"})` : "NOT RESPONDING (the next Claude Code prompt restarts it via the hook)"}`);
|
|
286
|
+
lines.push(`${env.ANTHROPIC_BASE_URL === proxyUrl(cfg.port) ? "✓" : "✗"} Claude Code routed through it`);
|
|
287
|
+
if (existsSync(paths.pauseFile))
|
|
288
|
+
lines.push("! Paused: requests pass through unchanged");
|
|
289
|
+
if (h.ok) {
|
|
290
|
+
try {
|
|
291
|
+
const s = (await fetch(`${proxyUrl(cfg.port)}/stats`, { signal: AbortSignal.timeout(1000) }).then((r) => r.json()));
|
|
292
|
+
const a = s.autopilot;
|
|
293
|
+
if (a) {
|
|
294
|
+
lines.push("");
|
|
295
|
+
lines.push(`Since ${s.startedAt.slice(0, 16).replace("T", " ")} UTC: ${a.requests} requests, ${a.changedRequests} sent lighter`);
|
|
296
|
+
lines.push(` ${a.resultsCleared} stale tool outputs cleared in ${a.batches} batches (${a.coldBatches} while the cache was cold anyway)`);
|
|
297
|
+
lines.push(` ~${formatTokens(a.tokensRemoved)} tokens not sent`);
|
|
298
|
+
const billed = s.upstreamInputTokens + s.upstreamCacheReadTokens + s.upstreamCacheWriteTokens;
|
|
299
|
+
if (billed > 0)
|
|
300
|
+
lines.push(` Billed input: ${formatTokens(billed)} (${Math.round((s.upstreamCacheReadTokens / billed) * 100)}% cache reads)`);
|
|
301
|
+
if (a.lastReason)
|
|
302
|
+
lines.push(` Last decision: ${a.lastReason}`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
/* stats are informational */
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return lines;
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Called by the every-prompt hook: if autopilot is on and the proxy is down,
|
|
313
|
+
* start it before the prompt's request goes out. Returns quickly either way.
|
|
314
|
+
*/
|
|
315
|
+
export async function ensureProxyUp(paths = autopilotPaths()) {
|
|
316
|
+
const cfg = readConfig(paths);
|
|
317
|
+
if (!cfg)
|
|
318
|
+
return;
|
|
319
|
+
if ((await health(cfg.port, 300)).ok)
|
|
320
|
+
return;
|
|
321
|
+
if (!existsSync(cfg.node) || !existsSync(cfg.cli))
|
|
322
|
+
return;
|
|
323
|
+
startDetached(serviceCommand(cfg.node, cfg.cli, cfg.port, paths), paths.log);
|
|
324
|
+
await waitHealthy(cfg.port, 2500);
|
|
325
|
+
}
|