context-doctor 0.26.0 → 0.27.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 +101 -6
- package/dist/autoclear.d.ts +9 -2
- package/dist/autoclear.js +41 -3
- package/dist/ci.d.ts +39 -0
- package/dist/ci.js +144 -0
- package/dist/cli.js +117 -0
- package/dist/doctor.js +27 -0
- package/dist/embed.d.ts +30 -0
- package/dist/embed.js +127 -0
- package/dist/extract.d.ts +17 -0
- package/dist/extract.js +88 -0
- package/dist/gemini.d.ts +23 -0
- package/dist/gemini.js +192 -0
- package/dist/hook.js +3 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/install.d.ts +2 -0
- package/dist/install.js +32 -9
- package/dist/mcp.d.ts +1 -0
- package/dist/mcp.js +36 -8
- package/dist/mcpschema.d.ts +51 -0
- package/dist/mcpschema.js +125 -0
- package/dist/overhead.d.ts +76 -0
- package/dist/overhead.js +372 -0
- package/dist/pack.d.ts +105 -0
- package/dist/pack.js +395 -0
- package/dist/proxy.d.ts +8 -1
- package/dist/proxy.js +23 -6
- package/dist/session.js +4 -0
- package/dist/split.d.ts +44 -0
- package/dist/split.js +165 -0
- package/dist/wrap.d.ts +44 -0
- package/dist/wrap.js +96 -0
- package/package.json +2 -2
- package/skills/context-doctor/SKILL.md +2 -1
package/dist/gemini.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemini CLI chat recordings, read into the same shape as Claude Code and
|
|
3
|
+
* Codex transcripts so `session`, `watch` and the every-prompt hook work on
|
|
4
|
+
* them.
|
|
5
|
+
*
|
|
6
|
+
* Gemini CLI writes ~/.gemini/tmp/<project>/chats/session-*.jsonl: a metadata
|
|
7
|
+
* record, then one record per message (`user`, `gemini`, `info`, `error`).
|
|
8
|
+
* A message written again with the same id replaces the earlier copy (tool
|
|
9
|
+
* results and token counts arrive after the text), `{"$rewindTo": id}` drops
|
|
10
|
+
* that message and everything after it, and `{"$set": …}` updates metadata.
|
|
11
|
+
* Older versions wrote the whole conversation as one JSON document with a
|
|
12
|
+
* `messages` array; both are read.
|
|
13
|
+
*
|
|
14
|
+
* Each `gemini` message records the API's own usage (`tokens.input` is the
|
|
15
|
+
* request's promptTokenCount, cached part included), so the live context size
|
|
16
|
+
* is exact, as it is for Claude Code and Codex.
|
|
17
|
+
*/
|
|
18
|
+
import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, statSync } from "node:fs";
|
|
19
|
+
import { homedir } from "node:os";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { forEachLine } from "./session.js";
|
|
22
|
+
/** True when the file's first record looks like a Gemini CLI chat (cheap: reads 4 KB). */
|
|
23
|
+
export function isGeminiChat(path) {
|
|
24
|
+
if (!/\.jsonl?$/.test(path))
|
|
25
|
+
return false;
|
|
26
|
+
try {
|
|
27
|
+
const buf = Buffer.alloc(4096);
|
|
28
|
+
const fd = openSync(path, "r");
|
|
29
|
+
const n = readSync(fd, buf, 0, 4096, 0);
|
|
30
|
+
closeSync(fd);
|
|
31
|
+
const head = buf.subarray(0, n).toString("utf8");
|
|
32
|
+
return /"projectHash"\s*:/.test(head) && /"sessionId"\s*:/.test(head);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Text of a Gemini `content`: a string, or Part[] with `text` fields. */
|
|
39
|
+
function text(content) {
|
|
40
|
+
if (typeof content === "string")
|
|
41
|
+
return content;
|
|
42
|
+
if (Array.isArray(content))
|
|
43
|
+
return content.map((p) => (typeof p === "string" ? p : typeof p?.text === "string" ? p.text : "")).filter(Boolean).join("\n");
|
|
44
|
+
return "";
|
|
45
|
+
}
|
|
46
|
+
/** What a tool call returned, as the model saw it. */
|
|
47
|
+
function resultText(call) {
|
|
48
|
+
const r = call?.result;
|
|
49
|
+
if (typeof r === "string")
|
|
50
|
+
return r;
|
|
51
|
+
if (Array.isArray(r)) {
|
|
52
|
+
return r
|
|
53
|
+
.map((p) => {
|
|
54
|
+
const resp = p?.functionResponse?.response;
|
|
55
|
+
if (resp !== undefined)
|
|
56
|
+
return typeof resp?.output === "string" ? resp.output : JSON.stringify(resp);
|
|
57
|
+
return typeof p?.text === "string" ? p.text : "";
|
|
58
|
+
})
|
|
59
|
+
.filter(Boolean)
|
|
60
|
+
.join("\n");
|
|
61
|
+
}
|
|
62
|
+
return typeof call?.resultDisplay === "string" ? call.resultDisplay : r === undefined ? "" : JSON.stringify(r);
|
|
63
|
+
}
|
|
64
|
+
/** The ordered, de-duplicated, rewind-applied messages of a chat file. */
|
|
65
|
+
function readMessages(path) {
|
|
66
|
+
const order = [];
|
|
67
|
+
const byId = new Map();
|
|
68
|
+
let anon = 0;
|
|
69
|
+
const upsert = (m) => {
|
|
70
|
+
const id = typeof m.id === "string" ? m.id : `anon-${anon++}`;
|
|
71
|
+
if (!byId.has(id))
|
|
72
|
+
order.push(id);
|
|
73
|
+
byId.set(id, m);
|
|
74
|
+
};
|
|
75
|
+
const raw = readFileSync(path, "utf8");
|
|
76
|
+
const trimmed = raw.trimStart();
|
|
77
|
+
if (trimmed.startsWith("{") && !trimmed.includes("\n{")) {
|
|
78
|
+
// Legacy: one JSON document.
|
|
79
|
+
try {
|
|
80
|
+
const doc = JSON.parse(raw);
|
|
81
|
+
for (const m of doc.messages ?? [])
|
|
82
|
+
upsert(m);
|
|
83
|
+
return order.map((id) => byId.get(id));
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
/* fall through to JSONL */
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
forEachLine(path, (line) => {
|
|
90
|
+
if (!line.trim())
|
|
91
|
+
return;
|
|
92
|
+
let r;
|
|
93
|
+
try {
|
|
94
|
+
r = JSON.parse(line);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (!r || typeof r !== "object")
|
|
100
|
+
return;
|
|
101
|
+
if (typeof r.$rewindTo === "string") {
|
|
102
|
+
const at = order.indexOf(r.$rewindTo);
|
|
103
|
+
if (at >= 0)
|
|
104
|
+
for (const id of order.splice(at))
|
|
105
|
+
byId.delete(id);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (typeof r.type === "string" && ("content" in r || "toolCalls" in r))
|
|
109
|
+
upsert(r);
|
|
110
|
+
});
|
|
111
|
+
return order.map((id) => byId.get(id));
|
|
112
|
+
}
|
|
113
|
+
export function parseGeminiChat(path) {
|
|
114
|
+
const messages = [];
|
|
115
|
+
const usageSamples = [];
|
|
116
|
+
let model;
|
|
117
|
+
let reportedInputTokens;
|
|
118
|
+
for (const m of readMessages(path)) {
|
|
119
|
+
if (m.type === "user") {
|
|
120
|
+
const t = text(m.content);
|
|
121
|
+
if (t)
|
|
122
|
+
messages.push({ role: "user", content: t });
|
|
123
|
+
}
|
|
124
|
+
else if (m.type === "gemini") {
|
|
125
|
+
if (typeof m.model === "string")
|
|
126
|
+
model = m.model;
|
|
127
|
+
const input = Number(m.tokens?.input);
|
|
128
|
+
if (Number.isFinite(input) && input > 0) {
|
|
129
|
+
reportedInputTokens = input;
|
|
130
|
+
usageSamples.push({ index: messages.length, input });
|
|
131
|
+
}
|
|
132
|
+
const blocks = [];
|
|
133
|
+
const t = text(m.content);
|
|
134
|
+
if (t)
|
|
135
|
+
blocks.push({ type: "text", text: t });
|
|
136
|
+
const calls = Array.isArray(m.toolCalls) ? m.toolCalls : [];
|
|
137
|
+
for (const c of calls)
|
|
138
|
+
blocks.push({ type: "tool_use", id: String(c.id ?? c.name), name: String(c.name ?? "tool"), input: c.args ?? {} });
|
|
139
|
+
if (blocks.length)
|
|
140
|
+
messages.push({ role: "assistant", content: blocks });
|
|
141
|
+
const results = calls.filter((c) => c.result !== undefined || c.resultDisplay !== undefined);
|
|
142
|
+
if (results.length) {
|
|
143
|
+
messages.push({ role: "user", content: results.map((c) => ({ type: "tool_result", tool_use_id: String(c.id ?? c.name), content: resultText(c) })) });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
conversationJson: JSON.stringify({ messages }),
|
|
149
|
+
model,
|
|
150
|
+
messageCount: messages.length,
|
|
151
|
+
reportedInputTokens,
|
|
152
|
+
usageSamples,
|
|
153
|
+
path,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
/** Gemini CLI chats on this machine: ~/.gemini/tmp/<project>/chats/session-*.json[l]. */
|
|
157
|
+
export function listGeminiChats(home = homedir()) {
|
|
158
|
+
const root = join(home, ".gemini", "tmp");
|
|
159
|
+
if (!existsSync(root))
|
|
160
|
+
return [];
|
|
161
|
+
const out = [];
|
|
162
|
+
let projects;
|
|
163
|
+
try {
|
|
164
|
+
projects = readdirSync(root);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
for (const project of projects) {
|
|
170
|
+
const dir = join(root, project, "chats");
|
|
171
|
+
let files;
|
|
172
|
+
try {
|
|
173
|
+
files = readdirSync(dir);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
for (const f of files) {
|
|
179
|
+
if (!/^session-.*\.jsonl?$/.test(f))
|
|
180
|
+
continue;
|
|
181
|
+
const path = join(dir, f);
|
|
182
|
+
try {
|
|
183
|
+
const st = statSync(path);
|
|
184
|
+
out.push({ path, project: "gemini", modifiedAt: st.mtime, sizeBytes: st.size });
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
/* vanished */
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return out;
|
|
192
|
+
}
|
package/dist/hook.js
CHANGED
|
@@ -199,8 +199,10 @@ export async function runHook() {
|
|
|
199
199
|
// systemMessage is shown to the user in the app; additionalContext goes to the model.
|
|
200
200
|
if (notice)
|
|
201
201
|
out.systemMessage = notice;
|
|
202
|
+
// Gemini CLI's prompt hook is BeforeAgent; it reads the same output shape.
|
|
203
|
+
const event = input.hook_event_name === "BeforeAgent" ? "BeforeAgent" : "UserPromptSubmit";
|
|
202
204
|
if (lines.length > 0)
|
|
203
|
-
out.hookSpecificOutput = { hookEventName:
|
|
205
|
+
out.hookSpecificOutput = { hookEventName: event, additionalContext: block };
|
|
204
206
|
console.log(JSON.stringify(out));
|
|
205
207
|
};
|
|
206
208
|
if (prev.b > 0 && sizeBytes < prev.b * REGROWTH_FACTOR) {
|
package/dist/index.d.ts
CHANGED
|
@@ -26,3 +26,9 @@ export * from "./autopilot.js";
|
|
|
26
26
|
export * from "./savings.js";
|
|
27
27
|
export * from "./coldresume.js";
|
|
28
28
|
export * from "./compactwindow.js";
|
|
29
|
+
export * from "./pack.js";
|
|
30
|
+
export * from "./overhead.js";
|
|
31
|
+
export * from "./split.js";
|
|
32
|
+
export * from "./mcpschema.js";
|
|
33
|
+
export * from "./wrap.js";
|
|
34
|
+
export * from "./ci.js";
|
package/dist/index.js
CHANGED
|
@@ -21,3 +21,9 @@ export * from "./autopilot.js";
|
|
|
21
21
|
export * from "./savings.js";
|
|
22
22
|
export * from "./coldresume.js";
|
|
23
23
|
export * from "./compactwindow.js";
|
|
24
|
+
export * from "./pack.js";
|
|
25
|
+
export * from "./overhead.js";
|
|
26
|
+
export * from "./split.js";
|
|
27
|
+
export * from "./mcpschema.js";
|
|
28
|
+
export * from "./wrap.js";
|
|
29
|
+
export * from "./ci.js";
|
package/dist/install.d.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* Every config edit is a careful JSON merge with a .backup file written first.
|
|
7
7
|
* `context-doctor uninstall` reverses it.
|
|
8
8
|
*/
|
|
9
|
+
export declare function geminiSettingsPath(): string;
|
|
10
|
+
export declare function geminiCliPresent(): boolean;
|
|
9
11
|
/**
|
|
10
12
|
* How to invoke the published package as an MCP server on a given platform.
|
|
11
13
|
*
|
package/dist/install.js
CHANGED
|
@@ -25,8 +25,19 @@ function targets() {
|
|
|
25
25
|
{ name: "Claude Desktop", configPath: desktop, detect: () => existsSync(dirname(desktop)) },
|
|
26
26
|
{ name: "Claude Code", configPath: claudeCode, detect: () => existsSync(claudeCode) || existsSync(join(homedir(), ".claude")) },
|
|
27
27
|
{ name: "Cursor", configPath: cursor, detect: () => existsSync(join(homedir(), ".cursor")) },
|
|
28
|
+
{ name: "Gemini CLI", configPath: geminiSettingsPath(), detect: geminiCliPresent },
|
|
28
29
|
];
|
|
29
30
|
}
|
|
31
|
+
// -- Gemini CLI: MCP servers and hooks both live in ~/.gemini/settings.json,
|
|
32
|
+
// hooks in Claude Code's shape under the event BeforeAgent (its prompt hook).
|
|
33
|
+
// ~/.gemini alone is not enough to call it present: Google's Antigravity IDE
|
|
34
|
+
// creates it too, without the CLI.
|
|
35
|
+
export function geminiSettingsPath() {
|
|
36
|
+
return join(homedir(), ".gemini", "settings.json");
|
|
37
|
+
}
|
|
38
|
+
export function geminiCliPresent() {
|
|
39
|
+
return existsSync(geminiSettingsPath()) || existsSync(join(homedir(), ".gemini", "tmp")) || binOnPath("gemini") !== null;
|
|
40
|
+
}
|
|
30
41
|
/**
|
|
31
42
|
* The server command to write into configs.
|
|
32
43
|
*
|
|
@@ -204,12 +215,12 @@ function installHook() {
|
|
|
204
215
|
* ({hooks:{UserPromptSubmit:[{hooks:[{type:"command",command}]}]}}), so one
|
|
205
216
|
* writer serves both; only the path differs.
|
|
206
217
|
*/
|
|
207
|
-
function installHookInto(settingsPath, gateDir) {
|
|
218
|
+
function installHookInto(settingsPath, gateDir, event = "UserPromptSubmit") {
|
|
208
219
|
if (!existsSync(gateDir))
|
|
209
220
|
return null; // that app is not on this machine
|
|
210
221
|
const settings = readJson(settingsPath);
|
|
211
222
|
settings.hooks = settings.hooks ?? {};
|
|
212
|
-
const entries = settings.hooks
|
|
223
|
+
const entries = settings.hooks[event] ?? [];
|
|
213
224
|
const want = hookCommand();
|
|
214
225
|
// Re-running install must REPAIR a stale entry, not skip it. Earlier versions
|
|
215
226
|
// wrote a version-pinned node binary; if we only checked "is it present?" an
|
|
@@ -223,20 +234,21 @@ function installHookInto(settingsPath, gateDir) {
|
|
|
223
234
|
// Replace every entry of ours with exactly one correct entry.
|
|
224
235
|
const others = entries.filter((e) => !isOurHookEntry(e));
|
|
225
236
|
others.push({ hooks: [{ type: "command", command: want }] });
|
|
226
|
-
settings.hooks
|
|
237
|
+
settings.hooks[event] = others;
|
|
227
238
|
writeJsonWithBackup(settingsPath, settings);
|
|
228
239
|
return settingsPath;
|
|
229
240
|
}
|
|
230
241
|
else {
|
|
231
242
|
return settingsPath; // already correct — leave the file untouched
|
|
232
243
|
}
|
|
233
|
-
settings.hooks
|
|
244
|
+
settings.hooks[event] = entries;
|
|
234
245
|
writeJsonWithBackup(settingsPath, settings);
|
|
235
246
|
return settingsPath;
|
|
236
247
|
}
|
|
237
248
|
function uninstallHook() {
|
|
238
249
|
uninstallHookFrom(join(homedir(), ".claude", "settings.json"), "Claude Code every-prompt hook");
|
|
239
250
|
uninstallHookFrom(join(homedir(), ".codex", "hooks.json"), "Codex every-prompt hook");
|
|
251
|
+
uninstallHookFrom(geminiSettingsPath(), "Gemini CLI every-prompt hook", "BeforeAgent");
|
|
240
252
|
uninstallCursorHook();
|
|
241
253
|
}
|
|
242
254
|
/**
|
|
@@ -285,18 +297,18 @@ function uninstallCursorHook() {
|
|
|
285
297
|
writeJsonWithBackup(path, config);
|
|
286
298
|
console.log("✓ Cursor after-tool-call hook removed");
|
|
287
299
|
}
|
|
288
|
-
function uninstallHookFrom(settingsPath, label) {
|
|
300
|
+
function uninstallHookFrom(settingsPath, label, event = "UserPromptSubmit") {
|
|
289
301
|
if (!existsSync(settingsPath))
|
|
290
302
|
return;
|
|
291
303
|
const settings = readJson(settingsPath);
|
|
292
|
-
const entries = settings.hooks?.
|
|
304
|
+
const entries = settings.hooks?.[event];
|
|
293
305
|
if (!entries)
|
|
294
306
|
return;
|
|
295
307
|
const filtered = entries.filter((e) => !isOurHookEntry(e));
|
|
296
308
|
if (filtered.length !== entries.length) {
|
|
297
|
-
settings.hooks
|
|
309
|
+
settings.hooks[event] = filtered;
|
|
298
310
|
if (filtered.length === 0)
|
|
299
|
-
delete settings.hooks
|
|
311
|
+
delete settings.hooks[event];
|
|
300
312
|
writeJsonWithBackup(settingsPath, settings);
|
|
301
313
|
console.log(`✓ ${label} removed`);
|
|
302
314
|
}
|
|
@@ -441,7 +453,7 @@ export function runInstall(options = {}) {
|
|
|
441
453
|
const entry = serverEntry();
|
|
442
454
|
const found = targets().filter((t) => t.detect());
|
|
443
455
|
if (found.length === 0 && !existsSync(codexDir())) {
|
|
444
|
-
console.log("No supported AI apps detected (Claude Desktop, Claude Code, Cursor).");
|
|
456
|
+
console.log("No supported AI apps detected (Claude Desktop, Claude Code, Cursor, Codex, Gemini CLI).");
|
|
445
457
|
console.log("Manual setup — add to your app's MCP config:");
|
|
446
458
|
console.log(JSON.stringify({ mcpServers: { "context-doctor": entry } }, null, 2));
|
|
447
459
|
return { failures: [] };
|
|
@@ -497,6 +509,17 @@ export function runInstall(options = {}) {
|
|
|
497
509
|
failures.push("Cursor hook");
|
|
498
510
|
}
|
|
499
511
|
installCodex(entry, failures);
|
|
512
|
+
if (geminiCliPresent()) {
|
|
513
|
+
try {
|
|
514
|
+
const hook = installHookInto(geminiSettingsPath(), join(homedir(), ".gemini"), "BeforeAgent");
|
|
515
|
+
if (hook)
|
|
516
|
+
console.log(`✓ Gemini CLI every-prompt hook installed (${hook})`);
|
|
517
|
+
}
|
|
518
|
+
catch (e) {
|
|
519
|
+
console.error(`✗ Gemini CLI hook: ${e.message}`);
|
|
520
|
+
failures.push("Gemini CLI hook");
|
|
521
|
+
}
|
|
522
|
+
}
|
|
500
523
|
if (options.statusLine) {
|
|
501
524
|
try {
|
|
502
525
|
switch (installStatusLine()) {
|
package/dist/mcp.d.ts
CHANGED
|
@@ -11,5 +11,6 @@
|
|
|
11
11
|
* profile_context — analyze a conversation/prompt, report token breakdown + findings
|
|
12
12
|
* optimize_context — apply safe strategies, return the slimmed conversation
|
|
13
13
|
* context_best_practices — curated checklist for a given provider/use case
|
|
14
|
+
* pack_context — only the chunks of big files a question needs, within a token budget
|
|
14
15
|
*/
|
|
15
16
|
export {};
|
package/dist/mcp.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* profile_context — analyze a conversation/prompt, report token breakdown + findings
|
|
12
12
|
* optimize_context — apply safe strategies, return the slimmed conversation
|
|
13
13
|
* context_best_practices — curated checklist for a given provider/use case
|
|
14
|
+
* pack_context — only the chunks of big files a question needs, within a token budget
|
|
14
15
|
*/
|
|
15
16
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
16
17
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
@@ -22,6 +23,7 @@ import { renderProfile } from "./report.js";
|
|
|
22
23
|
import { formatTokens } from "./tokens.js";
|
|
23
24
|
import { recordLedger } from "./ledger.js";
|
|
24
25
|
import { runSketch } from "./sketch.js";
|
|
26
|
+
import { packContextSemantic, readSources, renderPack } from "./pack.js";
|
|
25
27
|
/**
|
|
26
28
|
* Server instructions are injected by MCP clients (Claude Desktop, Cursor, …)
|
|
27
29
|
* into the system context of EVERY conversation where this server is enabled.
|
|
@@ -38,18 +40,19 @@ import { runSketch } from "./sketch.js";
|
|
|
38
40
|
// thresholds keep it from firing on every turn, which would cost more context
|
|
39
41
|
// than it saves.
|
|
40
42
|
const SERVER_INSTRUCTIONS = `Context hygiene rules (always on):
|
|
41
|
-
1. Summarize any paste or tool result over ~2k tokens into the points you
|
|
42
|
-
2.
|
|
43
|
-
3.
|
|
44
|
-
4. If optimize_context returns a pruned-turns digest,
|
|
43
|
+
1. Summarize any paste or tool result over ~2k tokens into the points you need; never carry it verbatim.
|
|
44
|
+
2. Refer to earlier content by name; never re-quote it or inline base64.
|
|
45
|
+
3. Past ~30 turns or 3+ large pastes, or when asked about tokens, cost, speed or limits: call profile_context BEFORE answering and act on its top finding. In a chat app pass a \`sketch\` (turns + large/repeated blocks), not the conversation. Do not estimate token counts yourself.
|
|
46
|
+
4. If optimize_context returns a pruned-turns digest, write its ≤150-token summary.
|
|
47
|
+
5. To answer from a large file or folder you won't edit, call pack_context with the question; don't read it whole.`;
|
|
45
48
|
const STRATEGY_IDS = ["dedupe", "trim-tool-results", "trim-tool-calls", "strip-base64", "prune-history"];
|
|
46
49
|
/**
|
|
47
50
|
* Build a fully-configured server instance. A factory (not a singleton) so the
|
|
48
51
|
* stateless HTTP mode can hand every request its own server, per the MCP SDK's
|
|
49
52
|
* recommended pattern.
|
|
50
53
|
*/
|
|
51
|
-
function createServer() {
|
|
52
|
-
const server = new McpServer({ name: "context-doctor", version: "0.
|
|
54
|
+
function createServer({ fileAccess = true } = {}) {
|
|
55
|
+
const server = new McpServer({ name: "context-doctor", version: "0.27.0" }, { instructions: SERVER_INSTRUCTIONS });
|
|
53
56
|
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, ~120 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
57
|
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
58
|
sketch: z.object({
|
|
@@ -83,7 +86,7 @@ function createServer() {
|
|
|
83
86
|
const profile = profileConversation(parseConversation(conversation), model);
|
|
84
87
|
return { content: [{ type: "text", text: renderProfile(profile) }] };
|
|
85
88
|
});
|
|
86
|
-
server.tool("optimize_context", "Rewrite a conversation to reclaim tokens
|
|
89
|
+
server.tool("optimize_context", "Rewrite a conversation to reclaim tokens with deterministic strategies (dedupe, trim stale tool results, strip base64; prune-history is optional and lossy). Returns the slimmed JSON and a savings summary; no LLM calls. Use after profile_context finds recoverable waste the user wants fixed. Add prune-history only with the user's consent, then write the replacement summary as the result instructs.", {
|
|
87
90
|
conversation: z.string().describe("Conversation JSON (OpenAI or Anthropic format, or bare message array)"),
|
|
88
91
|
strategies: z.array(z.enum(STRATEGY_IDS)).optional()
|
|
89
92
|
.describe("Strategies to apply. Default: dedupe, trim-tool-results, strip-base64. Add trim-tool-calls to shrink big inline file writes, or prune-history for lossy compaction of old turns."),
|
|
@@ -133,6 +136,31 @@ function createServer() {
|
|
|
133
136
|
}
|
|
134
137
|
return { content };
|
|
135
138
|
});
|
|
139
|
+
server.tool("pack_context", "Read only the parts of large files a question needs: chunks files or folders along their structure, ranks the chunks against `query` and returns the best that fit `max_tokens`, each with an id and line range. No query: an outline; pick with `ids`. Use instead of reading a big doc, log or folder whole when not editing it. Text, code, PDF, Word, PowerPoint, ODT.", {
|
|
140
|
+
paths: z.array(z.string()).max(50).optional().describe("Files or folders (absolute, or relative to the server's cwd)"),
|
|
141
|
+
text: z.string().optional().describe("Raw text to pack instead of files"),
|
|
142
|
+
query: z.string().optional().describe("The question; omit for an outline"),
|
|
143
|
+
max_tokens: z.number().int().positive().max(200_000).optional().describe("Budget, default 4000"),
|
|
144
|
+
ids: z.array(z.string()).max(200).optional().describe("Chunk ids from an outline or result, e.g. docs/guide.md#4"),
|
|
145
|
+
model: z.string().optional().describe("Model the budget is counted for (default Claude)"),
|
|
146
|
+
semantic: z.boolean().optional().describe("Also rank by meaning (local Ollama embeddings)"),
|
|
147
|
+
},
|
|
148
|
+
// Reads files, never writes. Clients (Codex, ChatGPT) may skip approval for read-only tools.
|
|
149
|
+
{ title: "Pack context", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ paths, text, query, max_tokens, ids, model, semantic }) => {
|
|
150
|
+
const err = (t) => ({ content: [{ type: "text", text: t }], isError: true });
|
|
151
|
+
if (paths?.length && !fileAccess) {
|
|
152
|
+
return err("This server runs over HTTP and does not read files. Pass the document as `text`.");
|
|
153
|
+
}
|
|
154
|
+
const { sources, skipped } = paths?.length ? readSources(paths) : { sources: [], skipped: [] };
|
|
155
|
+
if (text)
|
|
156
|
+
sources.push({ name: "text", text });
|
|
157
|
+
if (sources.length === 0) {
|
|
158
|
+
return err(skipped.length ? `Nothing readable: ${skipped.join("; ")}` : "Pass `paths` (files or folders) or `text`.");
|
|
159
|
+
}
|
|
160
|
+
const result = await packContextSemantic(sources, { query, budget: max_tokens, ids, model, semantic });
|
|
161
|
+
const note = skipped.length ? `\nSkipped: ${skipped.slice(0, 10).join("; ")}${skipped.length > 10 ? ` (+${skipped.length - 10} more)` : ""}` : "";
|
|
162
|
+
return { content: [{ type: "text", text: renderPack(result) + note }] };
|
|
163
|
+
});
|
|
136
164
|
// A prompt shows up in Claude Desktop's "+" menu, so a user can run a checkup
|
|
137
165
|
// with one click instead of typing a request. It is the only proactive
|
|
138
166
|
// surface MCP offers besides `instructions`.
|
|
@@ -245,7 +273,7 @@ if (argv.includes("--http")) {
|
|
|
245
273
|
setHeader(req, "accept", "application/json, text/event-stream");
|
|
246
274
|
}
|
|
247
275
|
// Fresh server + transport per request (stateless — nothing shared).
|
|
248
|
-
const server = createServer();
|
|
276
|
+
const server = createServer({ fileAccess: false });
|
|
249
277
|
const transport = new StreamableHTTPServerTransport({
|
|
250
278
|
sessionIdGenerator: undefined,
|
|
251
279
|
// A client that never asked for a stream gets plain JSON back.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The MCP schema tax: what each MCP server's tool definitions add to every
|
|
3
|
+
* request, and whether the server is used at all.
|
|
4
|
+
*
|
|
5
|
+
* Tool definitions (name, description, JSON schema) ride in the request's
|
|
6
|
+
* `tools` array, so they are part of the fixed overhead `overhead` measures;
|
|
7
|
+
* transcripts never contain them. Two sources:
|
|
8
|
+
* - usage, from transcripts: every MCP call is a tool_use named
|
|
9
|
+
* `mcp__<server>__<tool>`, which also reveals servers the app provides
|
|
10
|
+
* (desktop browser, connectors) that no config file lists;
|
|
11
|
+
* - size, by asking each configured server for its tool list, exactly as
|
|
12
|
+
* Claude Code does at startup. That launches the servers, so it runs only
|
|
13
|
+
* when asked (`overhead --mcp`).
|
|
14
|
+
*
|
|
15
|
+
* Claude Code's tool search defers MCP definitions until the model looks them
|
|
16
|
+
* up; with it on, an unused server costs about its tool names, not its
|
|
17
|
+
* schemas. Usage of ToolSearch in the transcripts says whether that is so.
|
|
18
|
+
*/
|
|
19
|
+
export interface McpServerConfig {
|
|
20
|
+
name: string;
|
|
21
|
+
scope: "user" | "project" | ".mcp.json";
|
|
22
|
+
command?: string;
|
|
23
|
+
args?: string[];
|
|
24
|
+
env?: Record<string, string>;
|
|
25
|
+
url?: string;
|
|
26
|
+
type?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface McpServerSize {
|
|
29
|
+
name: string;
|
|
30
|
+
tools?: number;
|
|
31
|
+
/** Tokens of the full definitions: what an up-front load costs on every request. */
|
|
32
|
+
schemaTokens?: number;
|
|
33
|
+
/** Tokens of the tool names alone: roughly what a deferred load costs. */
|
|
34
|
+
nameTokens?: number;
|
|
35
|
+
error?: string;
|
|
36
|
+
}
|
|
37
|
+
export interface McpUsage {
|
|
38
|
+
/** Calls per server name (as it appears in tool names), main chain and subagents. */
|
|
39
|
+
calls: Map<string, number>;
|
|
40
|
+
sessions: number;
|
|
41
|
+
/** Sessions in which the model used ToolSearch, i.e. MCP definitions were deferred. */
|
|
42
|
+
toolSearchSessions: number;
|
|
43
|
+
}
|
|
44
|
+
/** Claude Code's spelling of a server name inside tool names. */
|
|
45
|
+
export declare function toolPrefixName(server: string): string;
|
|
46
|
+
/** MCP servers Claude Code starts in `cwd`, with their launch configs. */
|
|
47
|
+
export declare function claudeMcpConfigs(cwd?: string, home?: string): McpServerConfig[];
|
|
48
|
+
/** MCP calls per server over the last `days`, from Claude Code transcripts (a cheap line scan, no JSON parse per line). */
|
|
49
|
+
export declare function mcpUsage(days?: number, paths?: string[]): McpUsage;
|
|
50
|
+
/** Connect to one server the way a client does and size its tool list. */
|
|
51
|
+
export declare function measureServer(cfg: McpServerConfig, timeoutMs?: number): Promise<McpServerSize>;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The MCP schema tax: what each MCP server's tool definitions add to every
|
|
3
|
+
* request, and whether the server is used at all.
|
|
4
|
+
*
|
|
5
|
+
* Tool definitions (name, description, JSON schema) ride in the request's
|
|
6
|
+
* `tools` array, so they are part of the fixed overhead `overhead` measures;
|
|
7
|
+
* transcripts never contain them. Two sources:
|
|
8
|
+
* - usage, from transcripts: every MCP call is a tool_use named
|
|
9
|
+
* `mcp__<server>__<tool>`, which also reveals servers the app provides
|
|
10
|
+
* (desktop browser, connectors) that no config file lists;
|
|
11
|
+
* - size, by asking each configured server for its tool list, exactly as
|
|
12
|
+
* Claude Code does at startup. That launches the servers, so it runs only
|
|
13
|
+
* when asked (`overhead --mcp`).
|
|
14
|
+
*
|
|
15
|
+
* Claude Code's tool search defers MCP definitions until the model looks them
|
|
16
|
+
* up; with it on, an unused server costs about its tool names, not its
|
|
17
|
+
* schemas. Usage of ToolSearch in the transcripts says whether that is so.
|
|
18
|
+
*/
|
|
19
|
+
import { readFileSync } from "node:fs";
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
import { join, resolve } from "node:path";
|
|
22
|
+
import { forEachLine, listSessions } from "./session.js";
|
|
23
|
+
import { estimateTokens } from "./tokens.js";
|
|
24
|
+
const CLAUDE = "claude-opus-5";
|
|
25
|
+
/** Claude Code's spelling of a server name inside tool names. */
|
|
26
|
+
export function toolPrefixName(server) {
|
|
27
|
+
return server.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
28
|
+
}
|
|
29
|
+
/** MCP servers Claude Code starts in `cwd`, with their launch configs. */
|
|
30
|
+
export function claudeMcpConfigs(cwd = process.cwd(), home = homedir()) {
|
|
31
|
+
const out = new Map();
|
|
32
|
+
const addAll = (servers, scope) => {
|
|
33
|
+
for (const [name, c] of Object.entries(servers ?? {})) {
|
|
34
|
+
if (c && typeof c === "object")
|
|
35
|
+
out.set(name, { name, scope, command: c.command, args: c.args, env: c.env, url: c.url, type: c.type });
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
try {
|
|
39
|
+
const cfg = JSON.parse(readFileSync(join(home, ".claude.json"), "utf8"));
|
|
40
|
+
addAll(cfg.mcpServers, "user");
|
|
41
|
+
addAll(cfg.projects?.[resolve(cwd)]?.mcpServers, "project");
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
/* absent */
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
addAll(JSON.parse(readFileSync(join(cwd, ".mcp.json"), "utf8")).mcpServers, ".mcp.json");
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
/* absent */
|
|
51
|
+
}
|
|
52
|
+
return [...out.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
53
|
+
}
|
|
54
|
+
/** MCP calls per server over the last `days`, from Claude Code transcripts (a cheap line scan, no JSON parse per line). */
|
|
55
|
+
export function mcpUsage(days = 30, paths) {
|
|
56
|
+
const since = Date.now() - days * 86_400_000;
|
|
57
|
+
const files = paths ?? listSessions(10_000).filter((s) => s.path.includes(".claude") && s.modifiedAt.getTime() >= since).map((s) => s.path);
|
|
58
|
+
const calls = new Map();
|
|
59
|
+
let toolSearchSessions = 0;
|
|
60
|
+
for (const path of files) {
|
|
61
|
+
let searched = false;
|
|
62
|
+
forEachLine(path, (line) => {
|
|
63
|
+
if (!line.includes('"tool_use"'))
|
|
64
|
+
return;
|
|
65
|
+
for (const m of line.matchAll(/"name":"mcp__([^"]+?)__[^"]+"/g))
|
|
66
|
+
calls.set(m[1], (calls.get(m[1]) ?? 0) + 1);
|
|
67
|
+
if (!searched && line.includes('"name":"ToolSearch"'))
|
|
68
|
+
searched = true;
|
|
69
|
+
});
|
|
70
|
+
if (searched)
|
|
71
|
+
toolSearchSessions++;
|
|
72
|
+
}
|
|
73
|
+
return { calls, sessions: files.length, toolSearchSessions };
|
|
74
|
+
}
|
|
75
|
+
/** Connect to one server the way a client does and size its tool list. */
|
|
76
|
+
export async function measureServer(cfg, timeoutMs = 15_000) {
|
|
77
|
+
const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
|
|
78
|
+
const client = new Client({ name: "context-doctor-overhead", version: "1" });
|
|
79
|
+
let transport;
|
|
80
|
+
if (cfg.command) {
|
|
81
|
+
const { StdioClientTransport } = await import("@modelcontextprotocol/sdk/client/stdio.js");
|
|
82
|
+
transport = new StdioClientTransport({
|
|
83
|
+
command: cfg.command,
|
|
84
|
+
args: cfg.args ?? [],
|
|
85
|
+
env: { ...process.env, ...(cfg.env ?? {}) },
|
|
86
|
+
stderr: "ignore",
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
else if (cfg.url && cfg.type !== "sse") {
|
|
90
|
+
const { StreamableHTTPClientTransport } = await import("@modelcontextprotocol/sdk/client/streamableHttp.js");
|
|
91
|
+
transport = new StreamableHTTPClientTransport(new URL(cfg.url));
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
return { name: cfg.name, error: cfg.type === "sse" ? "SSE server: not measured" : "no command or url" };
|
|
95
|
+
}
|
|
96
|
+
let timer;
|
|
97
|
+
const work = (async () => {
|
|
98
|
+
await client.connect(transport);
|
|
99
|
+
const tools = [];
|
|
100
|
+
let cursor;
|
|
101
|
+
do {
|
|
102
|
+
const page = await client.listTools(cursor ? { cursor } : {});
|
|
103
|
+
tools.push(...page.tools);
|
|
104
|
+
cursor = page.nextCursor;
|
|
105
|
+
} while (cursor && tools.length < 5000);
|
|
106
|
+
const prefix = `mcp__${toolPrefixName(cfg.name)}__`;
|
|
107
|
+
const schemaTokens = tools.reduce((s, t) => s + estimateTokens(JSON.stringify({ name: prefix + t.name, description: t.description ?? "", input_schema: t.inputSchema ?? {} }), CLAUDE), 0);
|
|
108
|
+
const nameTokens = tools.reduce((s, t) => s + estimateTokens(prefix + t.name + "\n", CLAUDE), 0);
|
|
109
|
+
return { name: cfg.name, tools: tools.length, schemaTokens, nameTokens };
|
|
110
|
+
})();
|
|
111
|
+
const timeout = new Promise((res) => {
|
|
112
|
+
timer = setTimeout(() => res({ name: cfg.name, error: `no answer in ${timeoutMs / 1000}s` }), timeoutMs);
|
|
113
|
+
});
|
|
114
|
+
try {
|
|
115
|
+
return await Promise.race([work, timeout]);
|
|
116
|
+
}
|
|
117
|
+
catch (e) {
|
|
118
|
+
const msg = String(e?.message ?? e).split("\n")[0];
|
|
119
|
+
return { name: cfg.name, error: /401|403|unauthori[sz]ed/i.test(msg) ? "needs sign-in (not measured)" : msg.slice(0, 100) };
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
clearTimeout(timer);
|
|
123
|
+
await client.close().catch(() => { });
|
|
124
|
+
}
|
|
125
|
+
}
|