context-doctor 0.25.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/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.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Claude Code UserPromptSubmit hook: runs on EVERY query in Claude Code.
2
+ * Claude Code UserPromptSubmit hook: runs on EVERY query in Claude Code (and
3
+ * Codex, same format), and after every tool call in Cursor (postToolUse).
3
4
  *
4
5
  * Claude Code pipes hook input as JSON on stdin ({session_id, transcript_path,
5
6
  * prompt, ...}). We profile the session transcript; when the context is lean
package/dist/hook.js CHANGED
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Claude Code UserPromptSubmit hook: runs on EVERY query in Claude Code.
2
+ * Claude Code UserPromptSubmit hook: runs on EVERY query in Claude Code (and
3
+ * Codex, same format), and after every tool call in Cursor (postToolUse).
3
4
  *
4
5
  * Claude Code pipes hook input as JSON on stdin ({session_id, transcript_path,
5
6
  * prompt, ...}). We profile the session transcript; when the context is lean
@@ -18,7 +19,7 @@ import { recordLedger, statePath } from "./ledger.js";
18
19
  import { parseConversation } from "./parse.js";
19
20
  import { profileConversation } from "./profile.js";
20
21
  import { parseSessionFile } from "./session.js";
21
- import { formatTokens, CHARS_PER_TOKEN } from "./tokens.js";
22
+ import { formatTokens, CHARS_PER_TOKEN, LARGEST_WINDOW } from "./tokens.js";
22
23
  import { ensureProxyUp } from "./autopilot.js";
23
24
  import { detectColdResume, renderColdResume, renderColdResumeNotice } from "./coldresume.js";
24
25
  import { formatUsd, pricingFor } from "./pricing.js";
@@ -145,13 +146,23 @@ export async function runHook() {
145
146
  }
146
147
  const input = JSON.parse(await readStdin());
147
148
  await heal;
149
+ // Cursor runs this hook twice over: Claude Code's UserPromptSubmit as its
150
+ // beforeSubmitPrompt (which cannot add context, so there is nothing to do),
151
+ // and natively after each tool call, where `additional_context` reaches
152
+ // the model. Codex and Claude Code send UserPromptSubmit.
153
+ // Cursor marks every hook input with cursor_version; any of its events but
154
+ // postToolUse would only spend the once-per-growth warning on output Cursor
155
+ // drops.
156
+ const cursor = input.hook_event_name === "postToolUse";
157
+ if (input.hook_event_name === "beforeSubmitPrompt" || (input.cursor_version && !cursor))
158
+ return;
148
159
  const transcriptPath = input.transcript_path;
149
160
  if (!transcriptPath || !existsSync(transcriptPath))
150
161
  return;
151
162
  // Fast path 1: a small transcript cannot exceed the threshold — exit on a
152
163
  // single stat() without reading the file. This is the every-prompt cost
153
164
  // for lean sessions: ~1ms.
154
- const { config } = loadConfig(input.cwd ?? process.cwd());
165
+ const { config } = loadConfig(input.cwd ?? input.workspace_roots?.[0] ?? process.cwd());
155
166
  const threshold = warnThreshold(config.budget?.maxTokens);
156
167
  const sizeBytes = statSync(transcriptPath).size;
157
168
  if (sizeBytes < minBytesForWarn(threshold))
@@ -159,7 +170,7 @@ export async function runHook() {
159
170
  // Fast path 2: growth gate BEFORE parsing. If the file hasn't grown ~40%
160
171
  // since the last full parse, nothing new can trigger — exit without the
161
172
  // expensive read. Heavy-but-quiet sessions cost one stat + tiny state read.
162
- const sessionId = input.session_id ?? transcriptPath;
173
+ const sessionId = input.session_id ?? input.conversation_id ?? transcriptPath;
163
174
  const prev = readSessionState(sessionId);
164
175
  // Cold resume: back on a large session after the cache expired. Checked
165
176
  // before the growth gate, because a return after idle involves no growth.
@@ -177,12 +188,21 @@ export async function runHook() {
177
188
  const emit = (lines) => {
178
189
  if (lines.length === 0 && !notice)
179
190
  return;
191
+ const block = `<context-doctor>\n${lines.join("\n")}\n</context-doctor>`;
192
+ if (cursor) {
193
+ // Cursor's postToolUse has no user-facing field, so only the model's note goes out.
194
+ if (lines.length > 0)
195
+ console.log(JSON.stringify({ additional_context: block }));
196
+ return;
197
+ }
180
198
  const out = {};
181
199
  // systemMessage is shown to the user in the app; additionalContext goes to the model.
182
200
  if (notice)
183
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";
184
204
  if (lines.length > 0)
185
- out.hookSpecificOutput = { hookEventName: "UserPromptSubmit", additionalContext: `<context-doctor>\n${lines.join("\n")}\n</context-doctor>` };
205
+ out.hookSpecificOutput = { hookEventName: event, additionalContext: block };
186
206
  console.log(JSON.stringify(out));
187
207
  };
188
208
  if (prev.b > 0 && sizeBytes < prev.b * REGROWTH_FACTOR) {
@@ -195,7 +215,8 @@ export async function runHook() {
195
215
  const parsed = parseSessionFile(transcriptPath);
196
216
  if (parsed.messageCount === 0)
197
217
  return;
198
- const profile = profileConversation(parseConversation(parsed.conversationJson), parsed.model);
218
+ const model = parsed.model ?? input.model;
219
+ const profile = profileConversation(parseConversation(parsed.conversationJson), model);
199
220
  // Prefer the API's own figure when the transcript carries it: it includes
200
221
  // the system prompt and tool schemas the transcript omits, so it is the
201
222
  // real context size rather than a message-only estimate.
@@ -208,15 +229,27 @@ export async function runHook() {
208
229
  emit(notes);
209
230
  return;
210
231
  }
211
- const windowPct = profile.contextWindow ? (liveTokens / profile.contextWindow) * 100 : undefined;
232
+ // An estimate past the window (no API figure, e.g. a history imported into
233
+ // Codex) is not the live context: the app must already be trimming it.
234
+ const overWindow = !cursor && parsed.reportedInputTokens === undefined && liveTokens > (profile.contextWindow ?? LARGEST_WINDOW);
235
+ const windowPct = !cursor && !overWindow && profile.contextWindow ? (liveTokens / profile.contextWindow) * 100 : undefined;
212
236
  // Agent sessions run on the prompt cache: a message normally re-reads the
213
237
  // context at the cached rate (0.1x) and pays the full rate only when the
214
238
  // cache has expired. Quoting the uncached figure alone overstated the
215
239
  // per-message cost tenfold (fixed in 0.22).
216
- const pricing = pricingFor(parsed.model);
240
+ const pricing = pricingFor(model);
217
241
  const cachedUsd = pricing ? (liveTokens * pricing.cacheReadPerM) / 1e6 : undefined;
218
242
  const coldUsd = pricing ? (liveTokens * pricing.inputPerM * 1.25) / 1e6 : undefined;
219
- const lines = [
243
+ // Cursor's agent transcript records tool calls but not their output, and
244
+ // keeps turns Cursor has since summarized, so it measures how much the chat
245
+ // has accumulated, not the live context: no window share or price for it.
246
+ const lines = cursor ? [
247
+ `This chat has accumulated ~${formatTokens(liveTokens)} tokens of messages and tool calls (tool output not counted; Cursor summarizes older turns itself, so the live context may differ).`,
248
+ "Practice context hygiene from here on: summarize large tool results instead of keeping them verbatim, reference earlier content rather than re-reading or re-quoting it, and keep responses lean.",
249
+ ] : overWindow ? [
250
+ `This session's transcript holds ~${formatTokens(liveTokens)} tokens, more than ${profile.contextWindow ? `the model's ~${formatTokens(profile.contextWindow)}` : "any current model's"} window, so the app is already summarizing or dropping older turns; what the model sees is a part of it.`,
251
+ "Practice context hygiene from here on: summarize large tool results instead of keeping them verbatim, reference earlier content rather than re-reading or re-quoting it, and keep responses lean.",
252
+ ] : [
220
253
  `This session's context is at ~${formatTokens(liveTokens)} tokens` +
221
254
  (windowPct !== undefined ? ` (${windowPct.toFixed(0)}% of the window)` : "") +
222
255
  (cachedUsd !== undefined ? `: each message re-reads it for ~${formatUsd(cachedUsd)} from the prompt cache, ~${formatUsd(coldUsd)} when the cache has expired` : "") +
@@ -233,7 +266,9 @@ export async function runHook() {
233
266
  lines.push(`Largest recoverable waste: ${topFinding.message} (${topFinding.suggestion})`);
234
267
  }
235
268
  if (liveTokens > threshold * 2) {
236
- lines.push("If it fits the flow, offer the user a compaction of the older history.");
269
+ lines.push(cursor
270
+ ? "If the task changes, suggest the user start a new chat from a short summary of this one."
271
+ : "If it fits the flow, offer the user a compaction of the older history.");
237
272
  }
238
273
  emit([...notes, ...lines]);
239
274
  }
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
  *
@@ -31,6 +33,16 @@ export declare function isEphemeralPath(path: string): boolean;
31
33
  * ours. Returns what happened so install can print the truth.
32
34
  */
33
35
  export declare function installStatusLine(): "installed" | "already" | "kept-foreign" | "no-claude-code";
36
+ /**
37
+ * Cursor runs Claude Code's UserPromptSubmit hook as its own
38
+ * beforeSubmitPrompt, which can only allow or block a prompt: what the hook
39
+ * says never reaches Cursor's model. Cursor's postToolUse can add context
40
+ * (`additional_context`), and its input carries the agent transcript, so the
41
+ * check runs there, in Cursor's own ~/.cursor/hooks.json. It fires after every
42
+ * tool call, which the hook's stat-only fast path and regrowth gate make cheap.
43
+ */
44
+ export declare function cursorHooksPath(): string;
45
+ export declare function installCursorHook(): string | null;
34
46
  export declare function codexDir(): string;
35
47
  /**
36
48
  * Add or replace our [mcp_servers.context-doctor] table in config.toml without
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.UserPromptSubmit ?? [];
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,33 +234,81 @@ 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.UserPromptSubmit = others;
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.UserPromptSubmit = entries;
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");
252
+ uninstallCursorHook();
253
+ }
254
+ /**
255
+ * Cursor runs Claude Code's UserPromptSubmit hook as its own
256
+ * beforeSubmitPrompt, which can only allow or block a prompt: what the hook
257
+ * says never reaches Cursor's model. Cursor's postToolUse can add context
258
+ * (`additional_context`), and its input carries the agent transcript, so the
259
+ * check runs there, in Cursor's own ~/.cursor/hooks.json. It fires after every
260
+ * tool call, which the hook's stat-only fast path and regrowth gate make cheap.
261
+ */
262
+ export function cursorHooksPath() {
263
+ return join(homedir(), ".cursor", "hooks.json");
264
+ }
265
+ const isOurCursorEntry = (e) => /(cli\.js|context-doctor(\.cmd|\.exe|\.bat)?)"?\s+hook\s*$/.test(String(e?.command ?? ""));
266
+ export function installCursorHook() {
267
+ if (!existsSync(join(homedir(), ".cursor")))
268
+ return null;
269
+ const path = cursorHooksPath();
270
+ const config = readJson(path);
271
+ config.version = config.version ?? 1;
272
+ config.hooks = config.hooks ?? {};
273
+ const entries = config.hooks.postToolUse ?? [];
274
+ const want = hookCommand();
275
+ const ours = entries.filter(isOurCursorEntry);
276
+ if (ours.length === 1 && ours[0].command === want)
277
+ return path; // already correct
278
+ config.hooks.postToolUse = [...entries.filter((e) => !isOurCursorEntry(e)), { command: want, timeout: 10 }];
279
+ writeJsonWithBackup(path, config);
280
+ return path;
240
281
  }
241
- function uninstallHookFrom(settingsPath, label) {
282
+ function uninstallCursorHook() {
283
+ const path = cursorHooksPath();
284
+ if (!existsSync(path))
285
+ return;
286
+ const config = readJson(path);
287
+ const entries = config.hooks?.postToolUse;
288
+ if (!entries)
289
+ return;
290
+ const filtered = entries.filter((e) => !isOurCursorEntry(e));
291
+ if (filtered.length === entries.length)
292
+ return;
293
+ if (filtered.length === 0)
294
+ delete config.hooks.postToolUse;
295
+ else
296
+ config.hooks.postToolUse = filtered;
297
+ writeJsonWithBackup(path, config);
298
+ console.log("✓ Cursor after-tool-call hook removed");
299
+ }
300
+ function uninstallHookFrom(settingsPath, label, event = "UserPromptSubmit") {
242
301
  if (!existsSync(settingsPath))
243
302
  return;
244
303
  const settings = readJson(settingsPath);
245
- const entries = settings.hooks?.UserPromptSubmit;
304
+ const entries = settings.hooks?.[event];
246
305
  if (!entries)
247
306
  return;
248
307
  const filtered = entries.filter((e) => !isOurHookEntry(e));
249
308
  if (filtered.length !== entries.length) {
250
- settings.hooks.UserPromptSubmit = filtered;
309
+ settings.hooks[event] = filtered;
251
310
  if (filtered.length === 0)
252
- delete settings.hooks.UserPromptSubmit;
311
+ delete settings.hooks[event];
253
312
  writeJsonWithBackup(settingsPath, settings);
254
313
  console.log(`✓ ${label} removed`);
255
314
  }
@@ -394,7 +453,7 @@ export function runInstall(options = {}) {
394
453
  const entry = serverEntry();
395
454
  const found = targets().filter((t) => t.detect());
396
455
  if (found.length === 0 && !existsSync(codexDir())) {
397
- 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).");
398
457
  console.log("Manual setup — add to your app's MCP config:");
399
458
  console.log(JSON.stringify({ mcpServers: { "context-doctor": entry } }, null, 2));
400
459
  return { failures: [] };
@@ -440,7 +499,27 @@ export function runInstall(options = {}) {
440
499
  console.error(`✗ Claude Code every-prompt hook: ${e.message}`);
441
500
  failures.push("Claude Code hook");
442
501
  }
502
+ try {
503
+ const cursorHook = installCursorHook();
504
+ if (cursorHook)
505
+ console.log(`✓ Cursor after-tool-call hook installed (${cursorHook}) — Cursor's agent gets the same guidance in heavy sessions`);
506
+ }
507
+ catch (e) {
508
+ console.error(`✗ Cursor hook: ${e.message}`);
509
+ failures.push("Cursor hook");
510
+ }
443
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
+ }
444
523
  if (options.statusLine) {
445
524
  try {
446
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 {};