context-doctor 0.8.0 → 0.10.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 +38 -4
- package/dist/cache.d.ts +30 -0
- package/dist/cache.js +97 -0
- package/dist/cli.js +80 -7
- package/dist/cursor.d.ts +46 -0
- package/dist/cursor.js +203 -0
- package/dist/hook.js +16 -8
- package/dist/impact.js +1 -1
- package/dist/mcp.js +1 -1
- package/dist/profile.d.ts +1 -1
- package/dist/profile.js +87 -1
- package/dist/session.d.ts +15 -0
- package/dist/session.js +21 -2
- package/package.json +3 -2
- package/dist/test/chatgpt-export.test.d.ts +0 -2
- package/dist/test/chatgpt-export.test.js +0 -45
- package/dist/test/config.test.d.ts +0 -2
- package/dist/test/config.test.js +0 -64
- package/dist/test/dashboard.test.d.ts +0 -2
- package/dist/test/dashboard.test.js +0 -37
- package/dist/test/doctor.test.d.ts +0 -2
- package/dist/test/doctor.test.js +0 -19
- package/dist/test/hook.test.d.ts +0 -5
- package/dist/test/hook.test.js +0 -54
- package/dist/test/mcp-http.test.d.ts +0 -6
- package/dist/test/mcp-http.test.js +0 -63
- package/dist/test/proxy.test.d.ts +0 -6
- package/dist/test/proxy.test.js +0 -121
- package/dist/test/smoke.test.d.ts +0 -2
- package/dist/test/smoke.test.js +0 -111
- package/dist/test/watch.test.d.ts +0 -2
- package/dist/test/watch.test.js +0 -36
package/dist/impact.js
CHANGED
|
@@ -102,7 +102,7 @@ export async function buildImpactReport(proxyPort = 8787) {
|
|
|
102
102
|
}
|
|
103
103
|
lines.push("");
|
|
104
104
|
// -- Measured-now: recent session profiles ------------------------------------
|
|
105
|
-
lines.push("Your recent sessions — waste still recoverable
|
|
105
|
+
lines.push("Your recent sessions — live context and waste still recoverable");
|
|
106
106
|
lines.push("─".repeat(56));
|
|
107
107
|
const sessions = listSessions(8).filter((s) => s.sizeBytes <= MAX_SESSION_BYTES);
|
|
108
108
|
if (sessions.length === 0) {
|
package/dist/mcp.js
CHANGED
|
@@ -37,7 +37,7 @@ const STRATEGY_IDS = ["dedupe", "trim-tool-results", "strip-base64", "prune-hist
|
|
|
37
37
|
* recommended pattern.
|
|
38
38
|
*/
|
|
39
39
|
function createServer() {
|
|
40
|
-
const server = new McpServer({ name: "context-doctor", version: "0.
|
|
40
|
+
const server = new McpServer({ name: "context-doctor", version: "0.10.0" }, { instructions: SERVER_INSTRUCTIONS });
|
|
41
41
|
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.", {
|
|
42
42
|
conversation: z.string().describe("Conversation JSON (OpenAI or Anthropic format, or bare message array) or raw prompt text"),
|
|
43
43
|
model: z.string().optional().describe("Target model name for context-window math, e.g. claude-sonnet-5 or gpt-4o"),
|
package/dist/profile.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ export interface MessageProfile {
|
|
|
12
12
|
preview: string;
|
|
13
13
|
toolName?: string;
|
|
14
14
|
}
|
|
15
|
-
export type FindingId = "large_tool_result" | "duplicate_content" | "near_duplicate" | "repeated_tool_call" | "base64_blob" | "long_history" | "large_system_prompt" | "cache_ordering" | "near_window_limit";
|
|
15
|
+
export type FindingId = "large_tool_result" | "duplicate_content" | "near_duplicate" | "repeated_tool_call" | "repeated_file_read" | "retained_error_output" | "base64_blob" | "long_history" | "large_system_prompt" | "cache_ordering" | "near_window_limit";
|
|
16
16
|
export interface Finding {
|
|
17
17
|
id: FindingId;
|
|
18
18
|
severity: "info" | "warn" | "high";
|
package/dist/profile.js
CHANGED
|
@@ -15,6 +15,40 @@ function categoryOf(m) {
|
|
|
15
15
|
default: return "other";
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* Total recoverable tokens, counted as a UNION rather than a sum.
|
|
20
|
+
*
|
|
21
|
+
* Findings legitimately overlap: an oversized tool result can also be a
|
|
22
|
+
* near-duplicate, and a long history contains both. Adding their estimates
|
|
23
|
+
* would promise savings the same tokens can only deliver once. Each
|
|
24
|
+
* message-scoped finding is attributed to the message that would actually be
|
|
25
|
+
* removed (the LAST index — for a duplicate pair, the later copy), keeping the
|
|
26
|
+
* largest claim per message; whole-conversation findings then take only what
|
|
27
|
+
* is left unclaimed. The result is capped below the total, since no
|
|
28
|
+
* optimization reclaims an entire context.
|
|
29
|
+
*/
|
|
30
|
+
function unionSavings(findings, totalTokens) {
|
|
31
|
+
const perMessage = new Map();
|
|
32
|
+
let unscoped = 0;
|
|
33
|
+
for (const f of findings) {
|
|
34
|
+
if (f.estSavings <= 0)
|
|
35
|
+
continue;
|
|
36
|
+
if (f.messages.length === 0) {
|
|
37
|
+
unscoped += f.estSavings;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const target = f.messages[f.messages.length - 1];
|
|
41
|
+
perMessage.set(target, Math.max(perMessage.get(target) ?? 0, f.estSavings));
|
|
42
|
+
}
|
|
43
|
+
const scoped = [...perMessage.values()].reduce((a, b) => a + b, 0);
|
|
44
|
+
// Whole-conversation findings can only claim tokens no message-scoped
|
|
45
|
+
// finding already claimed.
|
|
46
|
+
const headroom = Math.max(0, totalTokens - scoped);
|
|
47
|
+
const total = scoped + Math.min(unscoped, headroom);
|
|
48
|
+
// A context can never be optimized away entirely; 90% is the ceiling any
|
|
49
|
+
// strategy set could plausibly reach.
|
|
50
|
+
return Math.min(total, Math.round(totalTokens * 0.9));
|
|
51
|
+
}
|
|
18
52
|
function preview(text, len = 90) {
|
|
19
53
|
const clean = text.replace(/\s+/g, " ").trim();
|
|
20
54
|
return clean.length > len ? clean.slice(0, len) + "…" : clean;
|
|
@@ -160,6 +194,58 @@ export function profileConversation(conv, model) {
|
|
|
160
194
|
});
|
|
161
195
|
}
|
|
162
196
|
}
|
|
197
|
+
// -- Same file read again and again -----------------------------------------
|
|
198
|
+
// The dominant waste in agent loops: a file re-read because its earlier
|
|
199
|
+
// contents scrolled out of attention, leaving several full copies in context.
|
|
200
|
+
{
|
|
201
|
+
const readsByPath = new Map();
|
|
202
|
+
for (const p of perMessage) {
|
|
203
|
+
if (p.msg.kind !== "tool_call" || !p.msg.toolCallText)
|
|
204
|
+
continue;
|
|
205
|
+
if (!/read|open|cat|view|get_file/i.test(p.msg.toolName ?? ""))
|
|
206
|
+
continue;
|
|
207
|
+
const match = /"(?:file_path|filePath|path|file)"\s*:\s*"([^"]{3,})"/.exec(p.msg.toolCallText);
|
|
208
|
+
if (!match)
|
|
209
|
+
continue;
|
|
210
|
+
const path = match[1];
|
|
211
|
+
readsByPath.set(path, [...(readsByPath.get(path) ?? []), p.msg.index]);
|
|
212
|
+
}
|
|
213
|
+
for (const [path, indexes] of readsByPath) {
|
|
214
|
+
if (indexes.length < 3)
|
|
215
|
+
continue;
|
|
216
|
+
// Each re-read pulls the file in again; all but the last are recoverable.
|
|
217
|
+
const resultTokens = perMessage
|
|
218
|
+
.filter((p) => p.msg.kind === "tool_result" && indexes.some((i) => p.msg.index === i + 1))
|
|
219
|
+
.reduce((sum, p) => sum + p.tokens, 0);
|
|
220
|
+
const savings = Math.round((resultTokens * (indexes.length - 1)) / indexes.length);
|
|
221
|
+
findings.push({
|
|
222
|
+
id: "repeated_file_read",
|
|
223
|
+
severity: savings > 4000 ? "high" : "warn",
|
|
224
|
+
estSavings: savings,
|
|
225
|
+
message: `${path.split("/").pop()} was read ${indexes.length} times (messages #${indexes.join(", #")}), keeping ${indexes.length} copies in context.`,
|
|
226
|
+
suggestion: "Re-read a file only after it changes; otherwise refer back to the copy already in context.",
|
|
227
|
+
messages: indexes,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
// -- Failed tool output kept verbatim ---------------------------------------
|
|
232
|
+
// Stack traces and command failures are read once and never again, yet they
|
|
233
|
+
// are often the largest blocks in an agent transcript.
|
|
234
|
+
for (const p of perMessage) {
|
|
235
|
+
if (p.msg.kind !== "tool_result" || p.tokens < 500)
|
|
236
|
+
continue;
|
|
237
|
+
const looksFailed = /Traceback \(most recent call last\)|command not found|npm error|ERR!|\bexit code [1-9]|Exception in thread|FAILED|error TS\d+/i.test(p.msg.text);
|
|
238
|
+
if (!looksFailed)
|
|
239
|
+
continue;
|
|
240
|
+
findings.push({
|
|
241
|
+
id: "retained_error_output",
|
|
242
|
+
severity: "warn",
|
|
243
|
+
estSavings: Math.round(p.tokens * 0.85),
|
|
244
|
+
message: `Message #${p.msg.index} holds ~${p.tokens} tokens of failed tool output (errors, stack trace or non-zero exit).`,
|
|
245
|
+
suggestion: "Keep the one line that identifies the failure and drop the rest — a full trace has no value once the fix is understood.",
|
|
246
|
+
messages: [p.msg.index],
|
|
247
|
+
});
|
|
248
|
+
}
|
|
163
249
|
// -- Base64 / binary blobs ---------------------------------------------------
|
|
164
250
|
for (const p of perMessage) {
|
|
165
251
|
if (BASE64_RE.test(p.msg.text)) {
|
|
@@ -235,7 +321,7 @@ export function profileConversation(conv, model) {
|
|
|
235
321
|
.slice(0, 5);
|
|
236
322
|
const severityRank = { high: 0, warn: 1, info: 2 };
|
|
237
323
|
findings.sort((a, b) => severityRank[a.severity] - severityRank[b.severity] || b.estSavings - a.estSavings);
|
|
238
|
-
const totalEstSavings = findings
|
|
324
|
+
const totalEstSavings = unionSavings(findings, totalTokens);
|
|
239
325
|
const pricing = pricingFor(model);
|
|
240
326
|
let cost;
|
|
241
327
|
if (pricing) {
|
package/dist/session.d.ts
CHANGED
|
@@ -15,6 +15,21 @@ export interface SessionInfo {
|
|
|
15
15
|
sizeBytes: number;
|
|
16
16
|
}
|
|
17
17
|
export interface ParsedSession {
|
|
18
|
+
/**
|
|
19
|
+
* The real input size of the most recent request, as reported by the API
|
|
20
|
+
* (input + cache-read + cache-creation tokens). Transcripts do not contain
|
|
21
|
+
* the harness's system prompt, tool schemas or skills, so an estimate over
|
|
22
|
+
* transcript messages alone undercounts badly — measured against these
|
|
23
|
+
* figures, by roughly 60%. When this is present, prefer it: it is ground
|
|
24
|
+
* truth rather than an estimate.
|
|
25
|
+
*/
|
|
26
|
+
reportedInputTokens?: number;
|
|
27
|
+
/**
|
|
28
|
+
* Messages dropped because a compaction replaced them. Reporting live
|
|
29
|
+
* context means counting only what the model still sees; this records what
|
|
30
|
+
* was compacted away so the difference can be shown rather than hidden.
|
|
31
|
+
*/
|
|
32
|
+
compactedAway?: number;
|
|
18
33
|
/** Conversation JSON string in Anthropic-ish format, ready for parseConversation(). */
|
|
19
34
|
conversationJson: string;
|
|
20
35
|
title?: string;
|
package/dist/session.js
CHANGED
|
@@ -89,6 +89,10 @@ export function parseSessionFile(path) {
|
|
|
89
89
|
const messages = [];
|
|
90
90
|
let title;
|
|
91
91
|
let model;
|
|
92
|
+
/** Index in `messages` of the newest compaction summary, or -1. */
|
|
93
|
+
let lastCompactIndex = -1;
|
|
94
|
+
/** Newest API-reported input size, if the transcript carries usage. */
|
|
95
|
+
let reportedInputTokens;
|
|
92
96
|
for (const line of raw.split("\n")) {
|
|
93
97
|
if (!line.trim())
|
|
94
98
|
continue;
|
|
@@ -113,13 +117,28 @@ export function parseSessionFile(path) {
|
|
|
113
117
|
continue;
|
|
114
118
|
if (typeof message.model === "string")
|
|
115
119
|
model = message.model;
|
|
120
|
+
const usage = message.usage;
|
|
121
|
+
if (entry.type === "assistant" && usage) {
|
|
122
|
+
const total = (usage.input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0);
|
|
123
|
+
if (total > 0)
|
|
124
|
+
reportedInputTokens = total;
|
|
125
|
+
}
|
|
126
|
+
if (entry.isCompactSummary)
|
|
127
|
+
lastCompactIndex = messages.length;
|
|
116
128
|
messages.push({ role: message.role, content: message.content });
|
|
117
129
|
}
|
|
130
|
+
// A compaction replaces everything before it: the summary entry IS the live
|
|
131
|
+
// history from that point on. Counting the pre-compaction turns would
|
|
132
|
+
// overstate context, cost per message and window fill — sometimes hugely.
|
|
133
|
+
const compactedAway = lastCompactIndex >= 0 ? lastCompactIndex : 0;
|
|
134
|
+
const live = lastCompactIndex >= 0 ? messages.slice(lastCompactIndex) : messages;
|
|
118
135
|
return {
|
|
119
|
-
conversationJson: JSON.stringify({ messages }),
|
|
136
|
+
conversationJson: JSON.stringify({ messages: live }),
|
|
120
137
|
title,
|
|
121
138
|
model,
|
|
122
|
-
messageCount:
|
|
139
|
+
messageCount: live.length,
|
|
140
|
+
compactedAway,
|
|
141
|
+
reportedInputTokens,
|
|
123
142
|
path,
|
|
124
143
|
};
|
|
125
144
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "context-doctor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Profile and optimize LLM context windows. See what's eating your tokens and fix it — works with Claude, GPT, Gemini, and any MCP-capable AI app.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"llm",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
},
|
|
31
31
|
"files": [
|
|
32
32
|
"dist",
|
|
33
|
+
"!dist/test",
|
|
33
34
|
"skills",
|
|
34
35
|
"README.md",
|
|
35
36
|
"LICENSE"
|
|
@@ -41,7 +42,7 @@
|
|
|
41
42
|
"build": "tsc && node -e \"const fs=require('fs');['dist/cli.js','dist/mcp.js'].forEach(f=>fs.chmodSync(f,0o755))\"",
|
|
42
43
|
"prepublishOnly": "npm run build",
|
|
43
44
|
"dev": "tsc --watch",
|
|
44
|
-
"test": "npm run build && node --test dist/test/smoke.test.js dist/test/proxy.test.js dist/test/hook.test.js dist/test/mcp-http.test.js dist/test/doctor.test.js dist/test/watch.test.js dist/test/chatgpt-export.test.js dist/test/config.test.js dist/test/dashboard.test.js"
|
|
45
|
+
"test": "npm run build && node --test dist/test/smoke.test.js dist/test/proxy.test.js dist/test/hook.test.js dist/test/mcp-http.test.js dist/test/doctor.test.js dist/test/watch.test.js dist/test/chatgpt-export.test.js dist/test/config.test.js dist/test/dashboard.test.js dist/test/cursor.test.js dist/test/cache.test.js"
|
|
45
46
|
},
|
|
46
47
|
"dependencies": {
|
|
47
48
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
/** session: ChatGPT data-export (conversations.json) parsing. */
|
|
2
|
-
import { test } from "node:test";
|
|
3
|
-
import assert from "node:assert/strict";
|
|
4
|
-
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
5
|
-
import { tmpdir } from "node:os";
|
|
6
|
-
import { join } from "node:path";
|
|
7
|
-
import { parseSessionFile } from "../session.js";
|
|
8
|
-
function node(id, role, text, t) {
|
|
9
|
-
return [id, { id, message: { author: { role }, content: { content_type: "text", parts: [text] }, create_time: t } }];
|
|
10
|
-
}
|
|
11
|
-
const older = {
|
|
12
|
-
title: "Older chat",
|
|
13
|
-
update_time: 100,
|
|
14
|
-
default_model_slug: "gpt-4o",
|
|
15
|
-
mapping: Object.fromEntries([node("a", "user", "old question", 1)]),
|
|
16
|
-
};
|
|
17
|
-
const newer = {
|
|
18
|
-
title: "Trip planning",
|
|
19
|
-
update_time: 200,
|
|
20
|
-
default_model_slug: "gpt-5",
|
|
21
|
-
mapping: Object.fromEntries([
|
|
22
|
-
node("r", "system", "You are helpful.", 1),
|
|
23
|
-
node("x", "user", "Plan me a trip to Japan with a detailed itinerary please.", 2),
|
|
24
|
-
node("y", "assistant", "Day 1: Tokyo. Day 2: Kyoto. Day 3: Osaka with food tour.", 3),
|
|
25
|
-
["tool-node", { id: "tool-node", message: { author: { role: "tool" }, content: { content_type: "text", parts: ["ignored"] }, create_time: 4 } }],
|
|
26
|
-
]),
|
|
27
|
-
};
|
|
28
|
-
test("parses a ChatGPT export: newest conversation, ordered messages, model detected", () => {
|
|
29
|
-
const dir = mkdtempSync(join(tmpdir(), "ctxdoc-gpt-"));
|
|
30
|
-
const file = join(dir, "conversations.json");
|
|
31
|
-
writeFileSync(file, JSON.stringify([older, newer]));
|
|
32
|
-
const parsed = parseSessionFile(file);
|
|
33
|
-
assert.equal(parsed.title, "Trip planning");
|
|
34
|
-
assert.equal(parsed.model, "gpt-5");
|
|
35
|
-
assert.equal(parsed.messageCount, 3); // tool node excluded
|
|
36
|
-
const conv = JSON.parse(parsed.conversationJson);
|
|
37
|
-
assert.equal(conv.messages[0].role, "system");
|
|
38
|
-
assert.equal(conv.messages[1].content.includes("Japan"), true);
|
|
39
|
-
});
|
|
40
|
-
test("JSONL transcripts still parse (no regression)", () => {
|
|
41
|
-
const dir = mkdtempSync(join(tmpdir(), "ctxdoc-jsonl-"));
|
|
42
|
-
const file = join(dir, "s.jsonl");
|
|
43
|
-
writeFileSync(file, JSON.stringify({ type: "user", message: { role: "user", content: "hi" } }) + "\n");
|
|
44
|
-
assert.equal(parseSessionFile(file).messageCount, 1);
|
|
45
|
-
});
|
package/dist/test/config.test.js
DELETED
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
/** Project config discovery + context budget verdicts. */
|
|
2
|
-
import { test } from "node:test";
|
|
3
|
-
import assert from "node:assert/strict";
|
|
4
|
-
import { execFile } from "node:child_process";
|
|
5
|
-
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
6
|
-
import { tmpdir } from "node:os";
|
|
7
|
-
import { join, dirname } from "node:path";
|
|
8
|
-
import { fileURLToPath } from "node:url";
|
|
9
|
-
import { checkBudget, loadConfig, RC_FILENAME } from "../config.js";
|
|
10
|
-
const cliPath = join(dirname(fileURLToPath(import.meta.url)), "..", "cli.js");
|
|
11
|
-
test("loadConfig walks up to the nearest .contextdoctorrc", () => {
|
|
12
|
-
const root = mkdtempSync(join(tmpdir(), "ctxdoc-cfg-"));
|
|
13
|
-
const nested = join(root, "packages", "app", "src");
|
|
14
|
-
mkdirSync(nested, { recursive: true });
|
|
15
|
-
writeFileSync(join(root, RC_FILENAME), JSON.stringify({ budget: { maxTokens: 1234 } }));
|
|
16
|
-
const loaded = loadConfig(nested);
|
|
17
|
-
assert.equal(loaded.config.budget?.maxTokens, 1234);
|
|
18
|
-
assert.equal(loaded.path, join(root, RC_FILENAME));
|
|
19
|
-
});
|
|
20
|
-
test("a malformed rc warns instead of throwing", () => {
|
|
21
|
-
const root = mkdtempSync(join(tmpdir(), "ctxdoc-cfg-bad-"));
|
|
22
|
-
writeFileSync(join(root, RC_FILENAME), "{ not json");
|
|
23
|
-
const warnings = [];
|
|
24
|
-
const loaded = loadConfig(root, (m) => warnings.push(m));
|
|
25
|
-
assert.deepEqual(loaded.config, {});
|
|
26
|
-
assert.equal(warnings.length, 1);
|
|
27
|
-
});
|
|
28
|
-
test("checkBudget flags each configured limit independently", () => {
|
|
29
|
-
const profile = { totalTokens: 200_000, usagePct: 65, cost: { perCallUsd: 1.2 } };
|
|
30
|
-
assert.equal(checkBudget(undefined, profile).overBudget, false);
|
|
31
|
-
assert.equal(checkBudget({}, profile).overBudget, false);
|
|
32
|
-
const tokens = checkBudget({ maxTokens: 100_000 }, profile);
|
|
33
|
-
assert.equal(tokens.overBudget, true);
|
|
34
|
-
assert.match(tokens.breaches[0], /over the 100000 budget/);
|
|
35
|
-
const cost = checkBudget({ maxCostPerMessageUsd: 0.5 }, profile);
|
|
36
|
-
assert.match(cost.breaches[0], /per message/);
|
|
37
|
-
const window = checkBudget({ maxWindowPct: 50 }, profile);
|
|
38
|
-
assert.match(window.breaches[0], /% of the window/);
|
|
39
|
-
const all = checkBudget({ maxTokens: 100_000, maxCostPerMessageUsd: 0.5, maxWindowPct: 50 }, profile);
|
|
40
|
-
assert.equal(all.breaches.length, 3);
|
|
41
|
-
const within = checkBudget({ maxTokens: 500_000, maxWindowPct: 90 }, profile);
|
|
42
|
-
assert.equal(within.overBudget, false);
|
|
43
|
-
});
|
|
44
|
-
test("analyze reports budget status from the project rc", async () => {
|
|
45
|
-
const root = mkdtempSync(join(tmpdir(), "ctxdoc-cfg-cli-"));
|
|
46
|
-
writeFileSync(join(root, RC_FILENAME), JSON.stringify({ budget: { maxTokens: 10 } }));
|
|
47
|
-
const chat = join(root, "chat.json");
|
|
48
|
-
writeFileSync(chat, JSON.stringify({ messages: [{ role: "user", content: "a fairly long message ".repeat(40) }] }));
|
|
49
|
-
const out = await new Promise((resolve, reject) => {
|
|
50
|
-
execFile(process.execPath, [cliPath, "analyze", chat, "--model", "claude-sonnet-5"], { cwd: root }, (err, stdout) => err ? reject(err) : resolve(stdout));
|
|
51
|
-
});
|
|
52
|
-
assert.ok(out.includes("OVER BUDGET"), `expected budget breach in output:\n${out}`);
|
|
53
|
-
assert.ok(out.includes(RC_FILENAME), "names the rc file responsible");
|
|
54
|
-
});
|
|
55
|
-
test("analyze stays quiet about budgets when no rc exists", async () => {
|
|
56
|
-
const root = mkdtempSync(join(tmpdir(), "ctxdoc-cfg-none-"));
|
|
57
|
-
const chat = join(root, "chat.json");
|
|
58
|
-
writeFileSync(chat, JSON.stringify({ messages: [{ role: "user", content: "hello" }] }));
|
|
59
|
-
const out = await new Promise((resolve, reject) => {
|
|
60
|
-
// HOME override keeps a real ~/.contextdoctorrc from leaking into the test.
|
|
61
|
-
execFile(process.execPath, [cliPath, "analyze", chat], { cwd: root, env: { ...process.env, HOME: root } }, (err, stdout) => err ? reject(err) : resolve(stdout));
|
|
62
|
-
});
|
|
63
|
-
assert.ok(!out.includes("BUDGET"), "no budget chatter without an rc");
|
|
64
|
-
});
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
/** Dashboard: local-only server, real data shape, self-contained page. */
|
|
2
|
-
import { test, after } from "node:test";
|
|
3
|
-
import assert from "node:assert/strict";
|
|
4
|
-
import { startDashboard, collectDashboardData } from "../dashboard.js";
|
|
5
|
-
const server = startDashboard({ port: 0 });
|
|
6
|
-
await new Promise((r) => server.once("listening", () => r()));
|
|
7
|
-
const port = server.address().port;
|
|
8
|
-
after(() => server.close());
|
|
9
|
-
test("binds loopback only", () => {
|
|
10
|
-
assert.equal(server.address().address, "127.0.0.1");
|
|
11
|
-
});
|
|
12
|
-
test("/api/data returns the documented shape", async () => {
|
|
13
|
-
const data = (await (await fetch(`http://127.0.0.1:${port}/api/data`)).json());
|
|
14
|
-
for (const key of ["generatedAt", "totals", "daily", "sessions", "proxy", "budget"]) {
|
|
15
|
-
assert.ok(key in data, `missing ${key}`);
|
|
16
|
-
}
|
|
17
|
-
for (const key of ["tokensSaved", "usdSaved", "checks", "warnings", "optimizeRuns"]) {
|
|
18
|
-
assert.equal(typeof data.totals[key], "number", `totals.${key} must be numeric`);
|
|
19
|
-
}
|
|
20
|
-
assert.ok(Array.isArray(data.daily) && Array.isArray(data.sessions));
|
|
21
|
-
});
|
|
22
|
-
test("page is self-contained: no external requests", async () => {
|
|
23
|
-
const html = await (await fetch(`http://127.0.0.1:${port}/`)).text();
|
|
24
|
-
assert.ok(html.startsWith("<!doctype html>"));
|
|
25
|
-
// Only same-origin data fetch; nothing pulled from the network.
|
|
26
|
-
assert.ok(!/https?:\/\//.test(html.replace(/http:\/\/127\.0\.0\.1/g, "")), "page must not reference remote origins");
|
|
27
|
-
assert.ok(html.includes("/api/data"));
|
|
28
|
-
// Accessibility affordances required by the house chart rules.
|
|
29
|
-
assert.ok(html.includes("Table view"), "table view present");
|
|
30
|
-
assert.ok(html.includes('role="img"') || html.includes("aria-label"), "charts labelled");
|
|
31
|
-
assert.ok(html.includes("prefers-color-scheme: dark"), "dark mode selected, not flipped");
|
|
32
|
-
});
|
|
33
|
-
test("collectDashboardData works without a proxy running", async () => {
|
|
34
|
-
const data = await collectDashboardData(59999); // nothing listens here
|
|
35
|
-
assert.equal(data.proxy, null);
|
|
36
|
-
assert.ok(data.totals.tokensSaved >= 0);
|
|
37
|
-
});
|
package/dist/test/doctor.test.js
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
/** doctor must always produce a diagnosis and exit 0, even on a bare machine. */
|
|
2
|
-
import { test } from "node:test";
|
|
3
|
-
import assert from "node:assert/strict";
|
|
4
|
-
import { execFile } from "node:child_process";
|
|
5
|
-
import { mkdtempSync } from "node:fs";
|
|
6
|
-
import { tmpdir } from "node:os";
|
|
7
|
-
import { join, dirname } from "node:path";
|
|
8
|
-
import { fileURLToPath } from "node:url";
|
|
9
|
-
const cliPath = join(dirname(fileURLToPath(import.meta.url)), "..", "cli.js");
|
|
10
|
-
test("doctor runs, checks the MCP handshake, and exits 0", async () => {
|
|
11
|
-
const stateDir = mkdtempSync(join(tmpdir(), "ctxdoc-doctor-"));
|
|
12
|
-
const out = await new Promise((resolve, reject) => {
|
|
13
|
-
execFile(process.execPath, [cliPath, "doctor"], { env: { ...process.env, CONTEXT_DOCTOR_HOOK_STATE: join(stateDir, "state.json") }, timeout: 20000 }, (err, stdout) => (err ? reject(err) : resolve(stdout)));
|
|
14
|
-
});
|
|
15
|
-
assert.ok(out.includes("CONTEXT DOCTOR — self-check"));
|
|
16
|
-
assert.ok(out.includes("MCP server handshake"));
|
|
17
|
-
assert.ok(/✓ MCP server handshake/.test(out), "our own server must pass its own handshake");
|
|
18
|
-
assert.ok(out.includes("Ledger"));
|
|
19
|
-
});
|
package/dist/test/hook.test.d.ts
DELETED
package/dist/test/hook.test.js
DELETED
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Hook tests: the every-prompt Claude Code hook must stay silent on lean
|
|
3
|
-
* sessions, fire with guidance on heavy ones, and rate-limit re-fires.
|
|
4
|
-
*/
|
|
5
|
-
import { test } from "node:test";
|
|
6
|
-
import assert from "node:assert/strict";
|
|
7
|
-
import { execFile } from "node:child_process";
|
|
8
|
-
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
9
|
-
import { tmpdir } from "node:os";
|
|
10
|
-
import { join, dirname } from "node:path";
|
|
11
|
-
import { fileURLToPath } from "node:url";
|
|
12
|
-
const cliPath = join(dirname(fileURLToPath(import.meta.url)), "..", "cli.js");
|
|
13
|
-
const dir = mkdtempSync(join(tmpdir(), "ctxdoc-hook-"));
|
|
14
|
-
const statePath = join(dir, "state.json");
|
|
15
|
-
function transcriptLine(role, content) {
|
|
16
|
-
return JSON.stringify({ type: role, message: { role, content } });
|
|
17
|
-
}
|
|
18
|
-
function runHook(transcriptPath, sessionId) {
|
|
19
|
-
return new Promise((resolve, reject) => {
|
|
20
|
-
const child = execFile(process.execPath, [cliPath, "hook"], { env: { ...process.env, CONTEXT_DOCTOR_HOOK_STATE: statePath } }, (err, stdout) => (err ? reject(err) : resolve(stdout)));
|
|
21
|
-
child.stdin.end(JSON.stringify({ session_id: sessionId, transcript_path: transcriptPath }));
|
|
22
|
-
});
|
|
23
|
-
}
|
|
24
|
-
// Lean session: a couple of small turns.
|
|
25
|
-
const leanPath = join(dir, "lean.jsonl");
|
|
26
|
-
writeFileSync(leanPath, [transcriptLine("user", "hi"), transcriptLine("assistant", "hello!")].join("\n"));
|
|
27
|
-
// Heavy session: ~100k tokens of transcript.
|
|
28
|
-
const heavyPath = join(dir, "heavy.jsonl");
|
|
29
|
-
const bigTurn = "We discussed the deployment pipeline and database migrations at length. ".repeat(80);
|
|
30
|
-
writeFileSync(heavyPath, Array.from({ length: 300 }, (_, i) => transcriptLine(i % 2 ? "assistant" : "user", bigTurn)).join("\n"));
|
|
31
|
-
test("hook stays silent on a lean session", async () => {
|
|
32
|
-
const out = await runHook(leanPath, "lean-session");
|
|
33
|
-
assert.equal(out.trim(), "");
|
|
34
|
-
});
|
|
35
|
-
test("hook fires with hygiene guidance on a heavy session", async () => {
|
|
36
|
-
const out = await runHook(heavyPath, "heavy-session");
|
|
37
|
-
const parsed = JSON.parse(out);
|
|
38
|
-
const ctx = parsed.hookSpecificOutput.additionalContext;
|
|
39
|
-
assert.equal(parsed.hookSpecificOutput.hookEventName, "UserPromptSubmit");
|
|
40
|
-
assert.ok(ctx.includes("<context-doctor>"));
|
|
41
|
-
assert.ok(/context is at ~\d/.test(ctx), "reports the measured size");
|
|
42
|
-
assert.ok(ctx.includes("context hygiene"));
|
|
43
|
-
});
|
|
44
|
-
test("hook rate-limits: second prompt in the same heavy session is silent", async () => {
|
|
45
|
-
const out = await runHook(heavyPath, "heavy-session");
|
|
46
|
-
assert.equal(out.trim(), "");
|
|
47
|
-
});
|
|
48
|
-
test("hook never errors on malformed input", async () => {
|
|
49
|
-
const out = await new Promise((resolve, reject) => {
|
|
50
|
-
const child = execFile(process.execPath, [cliPath, "hook"], (err, stdout) => err ? reject(err) : resolve(stdout));
|
|
51
|
-
child.stdin.end("this is not json");
|
|
52
|
-
});
|
|
53
|
-
assert.equal(out.trim(), "");
|
|
54
|
-
});
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* MCP streamable-HTTP transport: spawn `mcp.js --http`, run the initialize
|
|
3
|
-
* handshake and a tool call over plain HTTP, exactly as a URL-based client
|
|
4
|
-
* (e.g. a ChatGPT developer-mode connector) would.
|
|
5
|
-
*/
|
|
6
|
-
import { test, after } from "node:test";
|
|
7
|
-
import assert from "node:assert/strict";
|
|
8
|
-
import { spawn } from "node:child_process";
|
|
9
|
-
import { join, dirname } from "node:path";
|
|
10
|
-
import { fileURLToPath } from "node:url";
|
|
11
|
-
const mcpPath = join(dirname(fileURLToPath(import.meta.url)), "..", "mcp.js");
|
|
12
|
-
const PORT = 8898;
|
|
13
|
-
const child = spawn(process.execPath, [mcpPath, "--http", "--port", String(PORT)], { stdio: ["ignore", "ignore", "pipe"] });
|
|
14
|
-
await new Promise((resolve, reject) => {
|
|
15
|
-
const timer = setTimeout(() => reject(new Error("HTTP MCP server did not start")), 8000);
|
|
16
|
-
child.stderr.on("data", (d) => {
|
|
17
|
-
if (d.toString().includes("streamable HTTP")) {
|
|
18
|
-
clearTimeout(timer);
|
|
19
|
-
resolve();
|
|
20
|
-
}
|
|
21
|
-
});
|
|
22
|
-
});
|
|
23
|
-
after(() => child.kill());
|
|
24
|
-
async function rpc(body) {
|
|
25
|
-
const res = await fetch(`http://127.0.0.1:${PORT}/mcp`, {
|
|
26
|
-
method: "POST",
|
|
27
|
-
headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
|
|
28
|
-
body: JSON.stringify(body),
|
|
29
|
-
});
|
|
30
|
-
const text = await res.text();
|
|
31
|
-
// Streamable HTTP may answer as SSE ("data: {...}") or plain JSON.
|
|
32
|
-
const dataLine = text.split("\n").find((l) => l.startsWith("data: "));
|
|
33
|
-
return { status: res.status, json: JSON.parse(dataLine ? dataLine.slice(6) : text) };
|
|
34
|
-
}
|
|
35
|
-
test("initialize over HTTP returns server info + instructions", async () => {
|
|
36
|
-
const { status, json } = await rpc({
|
|
37
|
-
jsonrpc: "2.0",
|
|
38
|
-
id: 1,
|
|
39
|
-
method: "initialize",
|
|
40
|
-
params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "t", version: "1" } },
|
|
41
|
-
});
|
|
42
|
-
assert.equal(status, 200);
|
|
43
|
-
assert.equal(json.result.serverInfo.name, "context-doctor");
|
|
44
|
-
assert.ok(json.result.instructions.includes("Context hygiene"));
|
|
45
|
-
});
|
|
46
|
-
test("tools/call works statelessly over HTTP", async () => {
|
|
47
|
-
const { json } = await rpc({
|
|
48
|
-
jsonrpc: "2.0",
|
|
49
|
-
id: 2,
|
|
50
|
-
method: "tools/call",
|
|
51
|
-
params: {
|
|
52
|
-
name: "profile_context",
|
|
53
|
-
arguments: { conversation: JSON.stringify({ messages: [{ role: "user", content: "hello world" }] }) },
|
|
54
|
-
},
|
|
55
|
-
});
|
|
56
|
-
assert.ok(json.result.content[0].text.includes("CONTEXT DOCTOR"));
|
|
57
|
-
});
|
|
58
|
-
test("health endpoint responds; non-POST is rejected", async () => {
|
|
59
|
-
const health = (await (await fetch(`http://127.0.0.1:${PORT}/health`)).json());
|
|
60
|
-
assert.equal(health.ok, true);
|
|
61
|
-
const get = await fetch(`http://127.0.0.1:${PORT}/mcp`);
|
|
62
|
-
assert.equal(get.status, 405);
|
|
63
|
-
});
|