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/README.md +127 -18
- package/dist/autoclear.d.ts +9 -2
- package/dist/autoclear.js +45 -6
- package/dist/ci.d.ts +39 -0
- package/dist/ci.js +144 -0
- package/dist/cli.js +117 -0
- package/dist/doctor.js +45 -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.d.ts +2 -1
- package/dist/hook.js +45 -10
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/install.d.ts +12 -0
- package/dist/install.js +88 -9
- package/dist/mcp.d.ts +1 -0
- package/dist/mcp.js +45 -11
- 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/preferences.js +2 -2
- package/dist/proxy.d.ts +8 -1
- package/dist/proxy.js +26 -7
- package/dist/session.js +4 -0
- package/dist/split.d.ts +44 -0
- package/dist/split.js +165 -0
- package/dist/tokens.d.ts +2 -0
- package/dist/tokens.js +2 -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.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:
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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");
|
|
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
|
|
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?.
|
|
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
|
|
309
|
+
settings.hooks[event] = filtered;
|
|
251
310
|
if (filtered.length === 0)
|
|
252
|
-
delete settings.hooks
|
|
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 {};
|