context-doctor 0.3.4 → 0.3.5
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 +14 -0
- package/dist/hook.js +31 -10
- package/dist/mcp.js +4 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -246,6 +246,20 @@ Everything the optimizer does is inspectable: it prints exactly which messages c
|
|
|
246
246
|
|
|
247
247
|
`skills/context-doctor/SKILL.md` (installed by `npx context-doctor install`) teaches Claude to practice context hygiene proactively: summarize big tool results after consuming them, never re-paste duplicated content, keep stable content cache-friendly, and offer compaction when a session gets heavy — so sessions get inherently leaner without you asking.
|
|
248
248
|
|
|
249
|
+
## Performance: what context-doctor itself costs
|
|
250
|
+
|
|
251
|
+
A tool that promises speed must be near-free. Measured overhead per touchpoint:
|
|
252
|
+
|
|
253
|
+
| Touchpoint | When it runs | Overhead |
|
|
254
|
+
|---|---|---|
|
|
255
|
+
| Every-prompt hook (Claude Code) | Every prompt | **~80ms** (Node startup; logic ~1ms). Lean sessions exit on a single `stat()` — the transcript is never read. Full profiling (~200ms on a 4MB session) happens only when the transcript has grown ~40% since last checked |
|
|
256
|
+
| MCP server | Spawned once per app session | Tools run only when called; standing instructions cost **~110 tokens per conversation** — deliberately terse |
|
|
257
|
+
| Proxy | Per API request | ~1–3ms of CPU (parse → optimize → re-serialize) against typical model latencies of hundreds of ms; responses stream through chunk-by-chunk, never buffered |
|
|
258
|
+
| Skill | Loads only when relevant | ~1k tokens while active; its always-present description is ~60 tokens |
|
|
259
|
+
| CLI / library | Only when you run it | Not in any hot path |
|
|
260
|
+
|
|
261
|
+
Net effect is strongly negative overhead: the tokens these touchpoints save on every subsequent call dwarf what they cost.
|
|
262
|
+
|
|
249
263
|
## Why token counts are "~"
|
|
250
264
|
|
|
251
265
|
Exact counts require each provider's private tokenizer. `context-doctor` uses a calibrated chars-per-token heuristic (denser for code/JSON) that lands within ~10% — plenty accurate for finding what's heavy and measuring savings, and it keeps the tool fully offline with zero configuration.
|
package/dist/hook.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* Registered by `context-doctor install` under hooks.UserPromptSubmit in
|
|
11
11
|
* ~/.claude/settings.json; removed by `context-doctor uninstall`.
|
|
12
12
|
*/
|
|
13
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
14
14
|
import { homedir } from "node:os";
|
|
15
15
|
import { join } from "node:path";
|
|
16
16
|
import { parseConversation } from "./parse.js";
|
|
@@ -22,6 +22,13 @@ import { formatUsd } from "./pricing.js";
|
|
|
22
22
|
const WARN_TOKENS = 80_000;
|
|
23
23
|
/** Re-nudge only after the context grows another 40% — one reminder, not a nag. */
|
|
24
24
|
const REGROWTH_FACTOR = 1.4;
|
|
25
|
+
/**
|
|
26
|
+
* Fast-path gate: text tokens are at least ~4 bytes each and the transcript
|
|
27
|
+
* carries JSON overhead on top, so a file smaller than this cannot possibly
|
|
28
|
+
* hold WARN_TOKENS of context. Lean sessions cost one stat() call — the
|
|
29
|
+
* transcript is never even read.
|
|
30
|
+
*/
|
|
31
|
+
const MIN_BYTES_FOR_WARN = WARN_TOKENS * 4;
|
|
25
32
|
function statePath() {
|
|
26
33
|
return process.env.CONTEXT_DOCTOR_HOOK_STATE ?? join(homedir(), ".claude", ".context-doctor-hook-state.json");
|
|
27
34
|
}
|
|
@@ -38,13 +45,15 @@ export async function runHook() {
|
|
|
38
45
|
const transcriptPath = input.transcript_path;
|
|
39
46
|
if (!transcriptPath || !existsSync(transcriptPath))
|
|
40
47
|
return;
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
if (
|
|
48
|
+
// Fast path 1: a small transcript cannot exceed the threshold — exit on a
|
|
49
|
+
// single stat() without reading the file. This is the every-prompt cost
|
|
50
|
+
// for lean sessions: ~1ms.
|
|
51
|
+
const sizeBytes = statSync(transcriptPath).size;
|
|
52
|
+
if (sizeBytes < MIN_BYTES_FOR_WARN)
|
|
46
53
|
return;
|
|
47
|
-
//
|
|
54
|
+
// Fast path 2: growth gate BEFORE parsing. If the file hasn't grown ~40%
|
|
55
|
+
// since the last full parse, nothing new can trigger — exit without the
|
|
56
|
+
// expensive read. Heavy-but-quiet sessions cost one stat + tiny state read.
|
|
48
57
|
const sessionId = input.session_id ?? transcriptPath;
|
|
49
58
|
let state = {};
|
|
50
59
|
try {
|
|
@@ -53,11 +62,23 @@ export async function runHook() {
|
|
|
53
62
|
catch {
|
|
54
63
|
/* first run */
|
|
55
64
|
}
|
|
56
|
-
const
|
|
57
|
-
|
|
65
|
+
const rawPrev = state[sessionId];
|
|
66
|
+
// Migrate pre-0.3.5 numeric entries ({tokens only}) to the new shape.
|
|
67
|
+
const prev = typeof rawPrev === "number" ? { t: rawPrev, b: 0 } : rawPrev ?? { t: 0, b: 0 };
|
|
68
|
+
if (prev.b > 0 && sizeBytes < prev.b * REGROWTH_FACTOR)
|
|
58
69
|
return;
|
|
59
|
-
|
|
70
|
+
// Slow path (growth events only): full parse + profile.
|
|
71
|
+
const parsed = parseSessionFile(transcriptPath);
|
|
72
|
+
if (parsed.messageCount === 0)
|
|
73
|
+
return;
|
|
74
|
+
const profile = profileConversation(parseConversation(parsed.conversationJson), parsed.model);
|
|
75
|
+
// Record this parse so the next prompts take fast path 2.
|
|
76
|
+
const shouldWarn = profile.totalTokens >= WARN_TOKENS && profile.totalTokens >= prev.t * REGROWTH_FACTOR;
|
|
77
|
+
const nextState = { t: shouldWarn ? profile.totalTokens : prev.t, b: sizeBytes };
|
|
78
|
+
const entries = Object.entries({ ...state, [sessionId]: nextState });
|
|
60
79
|
writeFileSync(statePath(), JSON.stringify(Object.fromEntries(entries.slice(-100))));
|
|
80
|
+
if (!shouldWarn)
|
|
81
|
+
return;
|
|
61
82
|
const lines = [
|
|
62
83
|
`This session's context is at ~${formatTokens(profile.totalTokens)} tokens` +
|
|
63
84
|
(profile.usagePct ? ` (${profile.usagePct.toFixed(0)}% of the window)` : "") +
|
package/dist/mcp.js
CHANGED
|
@@ -26,12 +26,10 @@ import { formatTokens } from "./tokens.js";
|
|
|
26
26
|
* This is what upgrades plain-chat apps from "tools available on request" to
|
|
27
27
|
* "standing context-hygiene instructions in every chat" — no hook needed.
|
|
28
28
|
*/
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
4. When optimize_context returns a pruned-turns digest, YOU write the replacement summary (dense, factual, ≤150 tokens) as instructed in the result.`;
|
|
34
|
-
const server = new McpServer({ name: "context-doctor", version: "0.3.4" }, { instructions: SERVER_INSTRUCTIONS });
|
|
29
|
+
// Kept deliberately terse: these ride in EVERY conversation's context, and a
|
|
30
|
+
// context-saving tool must not itself be context overhead (~110 tokens).
|
|
31
|
+
const SERVER_INSTRUCTIONS = `Context hygiene, always: summarize large pastes/tool results instead of carrying them verbatim; reference earlier content, don't re-quote; never inline base64. Past ~30 turns or several large pastes, proactively offer to run profile_context. Any question about tokens, cost, or latency: call profile_context, don't estimate. If optimize_context returns a pruned-turns digest, you write the ≤150-token replacement summary.`;
|
|
32
|
+
const server = new McpServer({ name: "context-doctor", version: "0.3.5" }, { instructions: SERVER_INSTRUCTIONS });
|
|
35
33
|
const STRATEGY_IDS = ["dedupe", "trim-tool-results", "strip-base64", "prune-history"];
|
|
36
34
|
server.tool("profile_context", "Profile an LLM conversation or prompt: token breakdown by category, largest messages, and actionable findings about wasted context (duplicates, oversized tool results, base64 blobs, cache-unfriendly ordering). Accepts OpenAI/Anthropic conversation JSON or raw text. Call this immediately whenever the user asks about token usage, context size, LLM cost, or latency — and proactively offer it once a conversation grows long or accumulates large pasted content.", {
|
|
37
35
|
conversation: z.string().describe("Conversation JSON (OpenAI or Anthropic format, or bare message array) or raw prompt text"),
|
package/package.json
CHANGED