context-doctor 0.2.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/LICENSE +21 -0
- package/README.md +177 -0
- package/dist/cli.d.ts +10 -0
- package/dist/cli.js +216 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +12 -0
- package/dist/install.d.ts +10 -0
- package/dist/install.js +120 -0
- package/dist/mcp.d.ts +15 -0
- package/dist/mcp.js +92 -0
- package/dist/optimize.d.ts +36 -0
- package/dist/optimize.js +189 -0
- package/dist/parse.d.ts +31 -0
- package/dist/parse.js +118 -0
- package/dist/pricing.d.ts +26 -0
- package/dist/pricing.js +48 -0
- package/dist/profile.d.ts +55 -0
- package/dist/profile.js +197 -0
- package/dist/proxy.d.ts +31 -0
- package/dist/proxy.js +137 -0
- package/dist/report.d.ts +7 -0
- package/dist/report.js +82 -0
- package/dist/session.d.ts +27 -0
- package/dist/session.js +80 -0
- package/dist/test/proxy.test.d.ts +6 -0
- package/dist/test/proxy.test.js +77 -0
- package/dist/test/smoke.test.d.ts +2 -0
- package/dist/test/smoke.test.js +73 -0
- package/dist/tokens.d.ts +17 -0
- package/dist/tokens.js +69 -0
- package/package.json +54 -0
- package/skills/context-doctor/SKILL.md +39 -0
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* context-doctor MCP server (stdio).
|
|
4
|
+
*
|
|
5
|
+
* Plug into Claude Desktop, ChatGPT desktop (developer mode), Cursor, or any
|
|
6
|
+
* MCP client:
|
|
7
|
+
*
|
|
8
|
+
* { "mcpServers": { "context-doctor": { "command": "npx", "args": ["-y", "context-doctor-mcp"] } } }
|
|
9
|
+
*
|
|
10
|
+
* Tools:
|
|
11
|
+
* profile_context — analyze a conversation/prompt, report token breakdown + findings
|
|
12
|
+
* optimize_context — apply safe strategies, return the slimmed conversation
|
|
13
|
+
* context_best_practices — curated checklist for a given provider/use case
|
|
14
|
+
*/
|
|
15
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
16
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
17
|
+
import { z } from "zod";
|
|
18
|
+
import { parseConversation } from "./parse.js";
|
|
19
|
+
import { profileConversation } from "./profile.js";
|
|
20
|
+
import { optimizeConversation } from "./optimize.js";
|
|
21
|
+
import { renderProfile } from "./report.js";
|
|
22
|
+
import { formatTokens } from "./tokens.js";
|
|
23
|
+
const server = new McpServer({ name: "context-doctor", version: "0.2.0" });
|
|
24
|
+
const STRATEGY_IDS = ["dedupe", "trim-tool-results", "strip-base64", "prune-history"];
|
|
25
|
+
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.", {
|
|
26
|
+
conversation: z.string().describe("Conversation JSON (OpenAI or Anthropic format, or bare message array) or raw prompt text"),
|
|
27
|
+
model: z.string().optional().describe("Target model name for context-window math, e.g. claude-sonnet-5 or gpt-4o"),
|
|
28
|
+
}, async ({ conversation, model }) => {
|
|
29
|
+
const profile = profileConversation(parseConversation(conversation), model);
|
|
30
|
+
return { content: [{ type: "text", text: renderProfile(profile) }] };
|
|
31
|
+
});
|
|
32
|
+
server.tool("optimize_context", "Rewrite a conversation to reclaim tokens using deterministic strategies: dedupe repeated content, trim stale tool results, strip base64 blobs, optionally prune old history. Returns the slimmed conversation JSON plus a savings summary. No LLM calls — safe and inspectable.", {
|
|
33
|
+
conversation: z.string().describe("Conversation JSON (OpenAI or Anthropic format, or bare message array)"),
|
|
34
|
+
strategies: z.array(z.enum(STRATEGY_IDS)).optional()
|
|
35
|
+
.describe("Strategies to apply. Default: dedupe, trim-tool-results, strip-base64. Add prune-history for lossy compaction of old turns."),
|
|
36
|
+
keep_recent: z.number().int().positive().optional().describe("Messages at the tail to leave untouched (default 6)"),
|
|
37
|
+
max_tool_result_tokens: z.number().int().positive().optional().describe("Token budget for trimmed tool results (default 300)"),
|
|
38
|
+
}, async ({ conversation, strategies, keep_recent, max_tool_result_tokens }) => {
|
|
39
|
+
const result = optimizeConversation(conversation, {
|
|
40
|
+
strategies: strategies,
|
|
41
|
+
keepRecent: keep_recent,
|
|
42
|
+
maxToolResultTokens: max_tool_result_tokens,
|
|
43
|
+
});
|
|
44
|
+
const saved = result.tokensBefore - result.tokensAfter;
|
|
45
|
+
const summary = `Saved ~${formatTokens(saved)} tokens (${formatTokens(result.tokensBefore)} → ${formatTokens(result.tokensAfter)}) ` +
|
|
46
|
+
`via ${result.applied.length} change(s):\n` +
|
|
47
|
+
result.applied.map((c) => `- [${c.strategy}] message #${c.messageIndex}: ${c.note} (~${formatTokens(c.tokensSaved)})`).join("\n");
|
|
48
|
+
const content = [
|
|
49
|
+
{ type: "text", text: summary },
|
|
50
|
+
{ type: "text", text: JSON.stringify(result.conversation, null, 2) },
|
|
51
|
+
];
|
|
52
|
+
// Host-model summarization: instead of calling an LLM ourselves (which would
|
|
53
|
+
// need an API key), hand the pruned material to the model that invoked this
|
|
54
|
+
// tool and ask IT to write the summary.
|
|
55
|
+
if (result.prunedDigest) {
|
|
56
|
+
content.push({
|
|
57
|
+
type: "text",
|
|
58
|
+
text: "ACTION REQUIRED (you, the assistant calling this tool): the pruned turns are digested below. " +
|
|
59
|
+
"Write a dense factual summary of them (≤150 tokens: decisions, current state, open items, key identifiers) " +
|
|
60
|
+
"and replace the '[context-doctor: ... pruned]' stub message in the conversation above with your summary " +
|
|
61
|
+
"before presenting the result.\n\nPRUNED TURNS DIGEST:\n" + result.prunedDigest,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return { content };
|
|
65
|
+
});
|
|
66
|
+
const BEST_PRACTICES = {
|
|
67
|
+
general: [
|
|
68
|
+
"Put stable content first (system prompt, tool definitions, reference docs) and volatile content last — prompt caches match byte-identical prefixes only.",
|
|
69
|
+
"Truncate or summarize tool results before they enter history; keep only what the model actually needs downstream.",
|
|
70
|
+
"Never inline base64/binary data in text content — use file/image APIs.",
|
|
71
|
+
"Summarize and drop conversation history past ~30-40 turns; models lose the middle of long contexts well before the hard limit.",
|
|
72
|
+
"Deduplicate: if the same document/result appears twice, replace later copies with a reference.",
|
|
73
|
+
"Measure before optimizing — profile the conversation to find the actual heavy hitters.",
|
|
74
|
+
],
|
|
75
|
+
anthropic: [
|
|
76
|
+
"Use prompt caching with cache_control breakpoints after your stable prefix — cached reads cost ~10% of base input price.",
|
|
77
|
+
"Claude models have a 200k window, but quality degrades under heavy fill; aim to stay under ~70%.",
|
|
78
|
+
"For agents: prefer compact tool-result summaries in history and re-fetch details on demand.",
|
|
79
|
+
],
|
|
80
|
+
openai: [
|
|
81
|
+
"Prefix caching is automatic for prompts >1024 tokens — but only on byte-identical prefixes, so keep the front of your prompt stable.",
|
|
82
|
+
"Use max_completion_tokens headroom math: input + output must fit the window together.",
|
|
83
|
+
],
|
|
84
|
+
};
|
|
85
|
+
server.tool("context_best_practices", "Get a curated checklist of context-management best practices, optionally specialized for a provider (anthropic, openai).", {
|
|
86
|
+
provider: z.enum(["general", "anthropic", "openai"]).optional().describe("Provider to specialize tips for (default: general)"),
|
|
87
|
+
}, async ({ provider }) => {
|
|
88
|
+
const tips = [...BEST_PRACTICES.general, ...(provider && provider !== "general" ? BEST_PRACTICES[provider] : [])];
|
|
89
|
+
return { content: [{ type: "text", text: tips.map((t, i) => `${i + 1}. ${t}`).join("\n") }] };
|
|
90
|
+
});
|
|
91
|
+
const transport = new StdioServerTransport();
|
|
92
|
+
await server.connect(transport);
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The optimizer: deterministic, lossless-first strategies that rewrite a
|
|
3
|
+
* conversation's message array to reclaim tokens. No LLM calls — everything
|
|
4
|
+
* here is safe to run offline and inspect before use.
|
|
5
|
+
*
|
|
6
|
+
* Strategies operate on the ORIGINAL JSON structure (not the normalized view)
|
|
7
|
+
* so the output is a drop-in replacement for the input conversation.
|
|
8
|
+
*/
|
|
9
|
+
export type StrategyId = "dedupe" | "trim-tool-results" | "prune-history" | "strip-base64";
|
|
10
|
+
export interface OptimizeOptions {
|
|
11
|
+
strategies?: StrategyId[];
|
|
12
|
+
/** Tool results older than this many messages from the end get trimmed. */
|
|
13
|
+
keepRecent?: number;
|
|
14
|
+
/** Max tokens a trimmed tool result keeps. */
|
|
15
|
+
maxToolResultTokens?: number;
|
|
16
|
+
}
|
|
17
|
+
export interface AppliedChange {
|
|
18
|
+
strategy: StrategyId;
|
|
19
|
+
messageIndex: number;
|
|
20
|
+
tokensSaved: number;
|
|
21
|
+
note: string;
|
|
22
|
+
}
|
|
23
|
+
export interface OptimizeResult {
|
|
24
|
+
/** The rewritten conversation, same shape as the input. */
|
|
25
|
+
conversation: unknown;
|
|
26
|
+
tokensBefore: number;
|
|
27
|
+
tokensAfter: number;
|
|
28
|
+
applied: AppliedChange[];
|
|
29
|
+
/**
|
|
30
|
+
* When prune-history ran: a compact digest of the pruned turns. A host LLM
|
|
31
|
+
* (e.g. the model running in Claude Desktop via MCP) can summarize this and
|
|
32
|
+
* replace the stub message — summarization without any API key.
|
|
33
|
+
*/
|
|
34
|
+
prunedDigest?: string;
|
|
35
|
+
}
|
|
36
|
+
export declare function optimizeConversation(input: string, options?: OptimizeOptions): OptimizeResult;
|
package/dist/optimize.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The optimizer: deterministic, lossless-first strategies that rewrite a
|
|
3
|
+
* conversation's message array to reclaim tokens. No LLM calls — everything
|
|
4
|
+
* here is safe to run offline and inspect before use.
|
|
5
|
+
*
|
|
6
|
+
* Strategies operate on the ORIGINAL JSON structure (not the normalized view)
|
|
7
|
+
* so the output is a drop-in replacement for the input conversation.
|
|
8
|
+
*/
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { estimateTokens } from "./tokens.js";
|
|
11
|
+
const DEFAULTS = {
|
|
12
|
+
strategies: ["dedupe", "trim-tool-results", "strip-base64"],
|
|
13
|
+
keepRecent: 6,
|
|
14
|
+
maxToolResultTokens: 300,
|
|
15
|
+
};
|
|
16
|
+
const BASE64_RE = /(?:data:[\w/+.-]+;base64,)?[A-Za-z0-9+/]{500,}={0,2}/g;
|
|
17
|
+
function hash(text) {
|
|
18
|
+
return createHash("sha1").update(text.replace(/\s+/g, " ").trim()).digest("hex");
|
|
19
|
+
}
|
|
20
|
+
/** Extract all text from a message content value (string or block array). */
|
|
21
|
+
function textOf(content) {
|
|
22
|
+
if (typeof content === "string")
|
|
23
|
+
return content;
|
|
24
|
+
if (!Array.isArray(content))
|
|
25
|
+
return JSON.stringify(content ?? "");
|
|
26
|
+
return content
|
|
27
|
+
.map((b) => {
|
|
28
|
+
if (typeof b === "string")
|
|
29
|
+
return b;
|
|
30
|
+
if (b?.type === "text")
|
|
31
|
+
return b.text ?? "";
|
|
32
|
+
if (b?.type === "tool_result")
|
|
33
|
+
return textOf(b.content);
|
|
34
|
+
if (b?.type === "tool_use")
|
|
35
|
+
return JSON.stringify(b.input ?? {});
|
|
36
|
+
return "";
|
|
37
|
+
})
|
|
38
|
+
.join("\n");
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Replace the text of a message content value, preserving block structure.
|
|
42
|
+
* tool_result blocks must keep their type and tool_use_id — the Anthropic API
|
|
43
|
+
* rejects conversations where a tool_use has no matching tool_result — so the
|
|
44
|
+
* replacement text goes INSIDE the first tool_result/text block rather than
|
|
45
|
+
* replacing the block itself. Later text blocks are dropped; other block types
|
|
46
|
+
* (tool_use, image) pass through untouched.
|
|
47
|
+
*/
|
|
48
|
+
function replaceText(content, newText) {
|
|
49
|
+
if (typeof content === "string" || !Array.isArray(content))
|
|
50
|
+
return newText;
|
|
51
|
+
let placed = false;
|
|
52
|
+
const out = content
|
|
53
|
+
.map((b) => {
|
|
54
|
+
if (b?.type === "tool_result") {
|
|
55
|
+
const replaced = { ...b, content: placed ? "[removed]" : newText };
|
|
56
|
+
placed = true;
|
|
57
|
+
return replaced;
|
|
58
|
+
}
|
|
59
|
+
if (b?.type === "text") {
|
|
60
|
+
if (placed)
|
|
61
|
+
return null;
|
|
62
|
+
placed = true;
|
|
63
|
+
return { ...b, text: newText };
|
|
64
|
+
}
|
|
65
|
+
return b;
|
|
66
|
+
})
|
|
67
|
+
.filter(Boolean);
|
|
68
|
+
if (!placed)
|
|
69
|
+
out.push({ type: "text", text: newText });
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
function truncateToTokens(text, maxTokens) {
|
|
73
|
+
const approxChars = maxTokens * 4;
|
|
74
|
+
if (text.length <= approxChars)
|
|
75
|
+
return text;
|
|
76
|
+
const head = text.slice(0, approxChars);
|
|
77
|
+
const omitted = text.length - approxChars;
|
|
78
|
+
return `${head}\n…[context-doctor: trimmed ${omitted} chars of stale tool output]`;
|
|
79
|
+
}
|
|
80
|
+
function isToolResultMessage(m) {
|
|
81
|
+
if (m?.role === "tool")
|
|
82
|
+
return true;
|
|
83
|
+
if (Array.isArray(m?.content))
|
|
84
|
+
return m.content.some((b) => b?.type === "tool_result");
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
export function optimizeConversation(input, options = {}) {
|
|
88
|
+
// ?? per field (not object spread) so an explicit `undefined` from a caller
|
|
89
|
+
// still falls back to the default.
|
|
90
|
+
const opts = {
|
|
91
|
+
strategies: options.strategies ?? DEFAULTS.strategies,
|
|
92
|
+
keepRecent: options.keepRecent ?? DEFAULTS.keepRecent,
|
|
93
|
+
maxToolResultTokens: options.maxToolResultTokens ?? DEFAULTS.maxToolResultTokens,
|
|
94
|
+
};
|
|
95
|
+
let data;
|
|
96
|
+
try {
|
|
97
|
+
data = JSON.parse(input);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
throw new Error("optimize requires a JSON conversation (message array, or object with a `messages` field)");
|
|
101
|
+
}
|
|
102
|
+
const messages = Array.isArray(data) ? data : data.messages;
|
|
103
|
+
if (!Array.isArray(messages)) {
|
|
104
|
+
throw new Error("No `messages` array found in input");
|
|
105
|
+
}
|
|
106
|
+
const tokensBefore = messages.reduce((s, m) => s + estimateTokens(textOf(m.content)), 0);
|
|
107
|
+
const applied = [];
|
|
108
|
+
// -- strip-base64: replace inline blobs with a placeholder --------------------
|
|
109
|
+
if (opts.strategies.includes("strip-base64")) {
|
|
110
|
+
messages.forEach((m, i) => {
|
|
111
|
+
const text = textOf(m.content);
|
|
112
|
+
if (!BASE64_RE.test(text))
|
|
113
|
+
return;
|
|
114
|
+
BASE64_RE.lastIndex = 0;
|
|
115
|
+
const before = estimateTokens(text);
|
|
116
|
+
const cleaned = text.replace(BASE64_RE, "[context-doctor: base64 blob removed — use file/image APIs instead]");
|
|
117
|
+
const saved = before - estimateTokens(cleaned);
|
|
118
|
+
if (saved > 50) {
|
|
119
|
+
m.content = replaceText(m.content, cleaned);
|
|
120
|
+
applied.push({ strategy: "strip-base64", messageIndex: i, tokensSaved: saved, note: "Removed inline base64 data" });
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
// -- dedupe: identical content beyond the first occurrence --------------------
|
|
125
|
+
if (opts.strategies.includes("dedupe")) {
|
|
126
|
+
const seen = new Map();
|
|
127
|
+
messages.forEach((m, i) => {
|
|
128
|
+
const text = textOf(m.content);
|
|
129
|
+
if (text.length < 300)
|
|
130
|
+
return;
|
|
131
|
+
const h = hash(text);
|
|
132
|
+
const first = seen.get(h);
|
|
133
|
+
if (first === undefined) {
|
|
134
|
+
seen.set(h, i);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const saved = estimateTokens(text);
|
|
138
|
+
m.content = replaceText(m.content, `[context-doctor: identical to message #${first} — content removed]`);
|
|
139
|
+
applied.push({ strategy: "dedupe", messageIndex: i, tokensSaved: saved, note: `Duplicate of message #${first}` });
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
// -- trim-tool-results: shrink stale tool output ------------------------------
|
|
143
|
+
if (opts.strategies.includes("trim-tool-results")) {
|
|
144
|
+
const cutoff = messages.length - opts.keepRecent;
|
|
145
|
+
messages.forEach((m, i) => {
|
|
146
|
+
if (i >= cutoff || !isToolResultMessage(m))
|
|
147
|
+
return;
|
|
148
|
+
const text = textOf(m.content);
|
|
149
|
+
const before = estimateTokens(text);
|
|
150
|
+
if (before <= opts.maxToolResultTokens)
|
|
151
|
+
return;
|
|
152
|
+
const trimmed = truncateToTokens(text, opts.maxToolResultTokens);
|
|
153
|
+
m.content = replaceText(m.content, trimmed);
|
|
154
|
+
applied.push({
|
|
155
|
+
strategy: "trim-tool-results",
|
|
156
|
+
messageIndex: i,
|
|
157
|
+
tokensSaved: before - estimateTokens(trimmed),
|
|
158
|
+
note: "Stale tool result truncated",
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
// -- prune-history: replace the older half with a stub ------------------------
|
|
163
|
+
// Opt-in only: it is lossy, so it is not in the default strategy set.
|
|
164
|
+
let prunedDigest;
|
|
165
|
+
if (opts.strategies.includes("prune-history") && messages.length > opts.keepRecent * 2) {
|
|
166
|
+
const keepFrom = messages.length - opts.keepRecent;
|
|
167
|
+
const pruned = messages.slice(0, keepFrom);
|
|
168
|
+
const prunedTokens = pruned.reduce((s, m) => s + estimateTokens(textOf(m.content)), 0);
|
|
169
|
+
// Digest: first ~200 chars of each pruned turn — enough for a host LLM to
|
|
170
|
+
// write a faithful summary, small enough not to defeat the pruning.
|
|
171
|
+
prunedDigest = pruned
|
|
172
|
+
.map((m, i) => `[${i}:${m.role}] ${textOf(m.content).replace(/\s+/g, " ").slice(0, 200)}`)
|
|
173
|
+
.join("\n");
|
|
174
|
+
const stub = {
|
|
175
|
+
role: "user",
|
|
176
|
+
content: `[context-doctor: ${pruned.length} earlier messages (~${prunedTokens} tokens) pruned. ` +
|
|
177
|
+
`Replace this stub with an LLM-written summary of those turns for best results.]`,
|
|
178
|
+
};
|
|
179
|
+
messages.splice(0, keepFrom, stub);
|
|
180
|
+
applied.push({
|
|
181
|
+
strategy: "prune-history",
|
|
182
|
+
messageIndex: 0,
|
|
183
|
+
tokensSaved: prunedTokens - estimateTokens(stub.content),
|
|
184
|
+
note: `Pruned ${pruned.length} old messages`,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
const tokensAfter = messages.reduce((s, m) => s + estimateTokens(textOf(m.content)), 0);
|
|
188
|
+
return { conversation: data, tokensBefore, tokensAfter, applied, prunedDigest };
|
|
189
|
+
}
|
package/dist/parse.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize conversations from different providers into one shape.
|
|
3
|
+
*
|
|
4
|
+
* Accepted inputs:
|
|
5
|
+
* - OpenAI chat format: { messages: [{ role, content, tool_calls?, tool_call_id? }] }
|
|
6
|
+
* - Anthropic format: { system?, messages: [{ role, content: string | Block[] }] }
|
|
7
|
+
* where Block = { type: "text" | "tool_use" | "tool_result" | "image", ... }
|
|
8
|
+
* - Bare message array: [{ role, content }, ...]
|
|
9
|
+
* - Raw text: treated as a single user message (last-resort fallback)
|
|
10
|
+
*/
|
|
11
|
+
export type MessageKind = "system" | "user" | "assistant" | "tool_call" | "tool_result" | "image" | "other";
|
|
12
|
+
export interface NormalizedMessage {
|
|
13
|
+
/** Index in the original message array (-1 for extracted system prompt). */
|
|
14
|
+
index: number;
|
|
15
|
+
role: string;
|
|
16
|
+
kind: MessageKind;
|
|
17
|
+
/** Flattened text content used for token estimation and analysis. */
|
|
18
|
+
text: string;
|
|
19
|
+
/** Tool name when kind is tool_call/tool_result and it is known. */
|
|
20
|
+
toolName?: string;
|
|
21
|
+
/** Just the tool-call portion (name + args), for repeat-call detection. */
|
|
22
|
+
toolCallText?: string;
|
|
23
|
+
/** True when the content contained non-text blocks (images, documents). */
|
|
24
|
+
hasBinary: boolean;
|
|
25
|
+
}
|
|
26
|
+
export interface NormalizedConversation {
|
|
27
|
+
messages: NormalizedMessage[];
|
|
28
|
+
/** Format detected, for reporting. */
|
|
29
|
+
sourceFormat: "openai" | "anthropic" | "array" | "text";
|
|
30
|
+
}
|
|
31
|
+
export declare function parseConversation(input: string): NormalizedConversation;
|
package/dist/parse.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize conversations from different providers into one shape.
|
|
3
|
+
*
|
|
4
|
+
* Accepted inputs:
|
|
5
|
+
* - OpenAI chat format: { messages: [{ role, content, tool_calls?, tool_call_id? }] }
|
|
6
|
+
* - Anthropic format: { system?, messages: [{ role, content: string | Block[] }] }
|
|
7
|
+
* where Block = { type: "text" | "tool_use" | "tool_result" | "image", ... }
|
|
8
|
+
* - Bare message array: [{ role, content }, ...]
|
|
9
|
+
* - Raw text: treated as a single user message (last-resort fallback)
|
|
10
|
+
*/
|
|
11
|
+
function flattenContent(content) {
|
|
12
|
+
if (typeof content === "string")
|
|
13
|
+
return { text: content, hasBinary: false };
|
|
14
|
+
if (!Array.isArray(content))
|
|
15
|
+
return { text: JSON.stringify(content ?? ""), hasBinary: false };
|
|
16
|
+
let text = "";
|
|
17
|
+
let hasBinary = false;
|
|
18
|
+
let toolName;
|
|
19
|
+
let kind;
|
|
20
|
+
let toolCallText = "";
|
|
21
|
+
for (const block of content) {
|
|
22
|
+
if (block == null || typeof block !== "object") {
|
|
23
|
+
text += String(block ?? "");
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
const b = block;
|
|
27
|
+
switch (b.type) {
|
|
28
|
+
case "text":
|
|
29
|
+
text += b.text ?? "";
|
|
30
|
+
break;
|
|
31
|
+
case "tool_use": {
|
|
32
|
+
kind = "tool_call";
|
|
33
|
+
toolName = b.name ?? toolName;
|
|
34
|
+
const call = `[tool call: ${b.name}] ${JSON.stringify(b.input ?? {})}`;
|
|
35
|
+
text += call;
|
|
36
|
+
toolCallText += call;
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
case "tool_result": {
|
|
40
|
+
kind = "tool_result";
|
|
41
|
+
const inner = flattenContent(b.content);
|
|
42
|
+
hasBinary = hasBinary || inner.hasBinary;
|
|
43
|
+
text += inner.text;
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
case "image":
|
|
47
|
+
case "document":
|
|
48
|
+
hasBinary = true;
|
|
49
|
+
text += `[${b.type}]`;
|
|
50
|
+
break;
|
|
51
|
+
case "thinking":
|
|
52
|
+
// Count the thinking text but NOT the signature — it is an opaque
|
|
53
|
+
// base64 blob the API requires, not something the user can trim.
|
|
54
|
+
text += b.thinking ?? "";
|
|
55
|
+
break;
|
|
56
|
+
case "redacted_thinking":
|
|
57
|
+
text += "[redacted thinking]";
|
|
58
|
+
break;
|
|
59
|
+
default:
|
|
60
|
+
text += JSON.stringify(b);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return { text, hasBinary, toolName, kind, toolCallText: toolCallText || undefined };
|
|
64
|
+
}
|
|
65
|
+
function normalizeMessage(raw, index) {
|
|
66
|
+
const role = String(raw.role ?? "user");
|
|
67
|
+
const flat = flattenContent(raw.content);
|
|
68
|
+
let kind = flat.kind ?? (["system", "user", "assistant"].includes(role) ? role : "other");
|
|
69
|
+
let toolName = flat.toolName;
|
|
70
|
+
let text = flat.text;
|
|
71
|
+
let toolCallText = flat.toolCallText;
|
|
72
|
+
// OpenAI-style tool plumbing lives outside `content`.
|
|
73
|
+
if (role === "tool") {
|
|
74
|
+
kind = "tool_result";
|
|
75
|
+
}
|
|
76
|
+
const toolCalls = raw.tool_calls;
|
|
77
|
+
if (Array.isArray(toolCalls) && toolCalls.length > 0) {
|
|
78
|
+
kind = "tool_call";
|
|
79
|
+
toolName = toolCalls[0]?.function?.name ?? toolCalls[0]?.name;
|
|
80
|
+
const calls = toolCalls
|
|
81
|
+
.map((tc) => `[tool call: ${tc.function?.name ?? tc.name}] ${tc.function?.arguments ?? JSON.stringify(tc.input ?? {})}`)
|
|
82
|
+
.join("\n");
|
|
83
|
+
text += calls;
|
|
84
|
+
toolCallText = (toolCallText ?? "") + calls;
|
|
85
|
+
}
|
|
86
|
+
return { index, role, kind, text, toolName, toolCallText, hasBinary: flat.hasBinary };
|
|
87
|
+
}
|
|
88
|
+
export function parseConversation(input) {
|
|
89
|
+
let data;
|
|
90
|
+
try {
|
|
91
|
+
data = JSON.parse(input);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// Not JSON — treat the whole thing as one user message so profiling still works.
|
|
95
|
+
return {
|
|
96
|
+
sourceFormat: "text",
|
|
97
|
+
messages: [{ index: 0, role: "user", kind: "user", text: input, hasBinary: false }],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
if (Array.isArray(data)) {
|
|
101
|
+
return {
|
|
102
|
+
sourceFormat: "array",
|
|
103
|
+
messages: data.map((m, i) => normalizeMessage(m, i)),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
const obj = data;
|
|
107
|
+
const rawMessages = obj.messages ?? [];
|
|
108
|
+
const messages = [];
|
|
109
|
+
// Anthropic keeps the system prompt outside the messages array.
|
|
110
|
+
if (obj.system != null) {
|
|
111
|
+
const flat = flattenContent(obj.system);
|
|
112
|
+
messages.push({ index: -1, role: "system", kind: "system", text: flat.text, hasBinary: flat.hasBinary });
|
|
113
|
+
}
|
|
114
|
+
messages.push(...rawMessages.map((m, i) => normalizeMessage(m, i)));
|
|
115
|
+
const isAnthropic = obj.system != null ||
|
|
116
|
+
rawMessages.some((m) => Array.isArray(m.content) && m.content.some((b) => b?.type === "tool_use" || b?.type === "tool_result"));
|
|
117
|
+
return { sourceFormat: isAnthropic ? "anthropic" : "openai", messages };
|
|
118
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-model pricing for cost estimation, USD per million tokens.
|
|
3
|
+
*
|
|
4
|
+
* Anthropic prices are current as of mid-2026 (first-party API list rates).
|
|
5
|
+
* OpenAI/Google figures are approximate public list prices and drift over
|
|
6
|
+
* time — all cost output is labeled as an estimate. One table, one file,
|
|
7
|
+
* easy to update: PRs welcome when prices change.
|
|
8
|
+
*/
|
|
9
|
+
export interface ModelPricing {
|
|
10
|
+
/** USD per 1M input tokens. */
|
|
11
|
+
inputPerM: number;
|
|
12
|
+
/** USD per 1M output tokens (unused by the profiler, kept for reference). */
|
|
13
|
+
outputPerM: number;
|
|
14
|
+
/** USD per 1M cached input tokens read (≈10% of input for Claude/OpenAI). */
|
|
15
|
+
cacheReadPerM: number;
|
|
16
|
+
}
|
|
17
|
+
export declare function pricingFor(model?: string): ModelPricing | undefined;
|
|
18
|
+
export declare function inputCostUsd(tokens: number, pricing: ModelPricing): number;
|
|
19
|
+
/**
|
|
20
|
+
* Rough time-to-first-token impact of input size. Prompt processing on major
|
|
21
|
+
* providers runs on the order of tens of thousands of tokens per second;
|
|
22
|
+
* ~25k tok/s is a serviceable cross-provider planning number, so every 10k
|
|
23
|
+
* input tokens costs roughly 0.4s of TTFT (much less on cache hits).
|
|
24
|
+
*/
|
|
25
|
+
export declare function estimatedTtftSeconds(inputTokens: number): number;
|
|
26
|
+
export declare function formatUsd(amount: number): string;
|
package/dist/pricing.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-model pricing for cost estimation, USD per million tokens.
|
|
3
|
+
*
|
|
4
|
+
* Anthropic prices are current as of mid-2026 (first-party API list rates).
|
|
5
|
+
* OpenAI/Google figures are approximate public list prices and drift over
|
|
6
|
+
* time — all cost output is labeled as an estimate. One table, one file,
|
|
7
|
+
* easy to update: PRs welcome when prices change.
|
|
8
|
+
*/
|
|
9
|
+
const PRICING = [
|
|
10
|
+
[/claude.*fable|claude.*mythos/i, { inputPerM: 10, outputPerM: 50, cacheReadPerM: 1 }],
|
|
11
|
+
[/claude.*opus/i, { inputPerM: 5, outputPerM: 25, cacheReadPerM: 0.5 }],
|
|
12
|
+
[/claude.*sonnet/i, { inputPerM: 3, outputPerM: 15, cacheReadPerM: 0.3 }],
|
|
13
|
+
[/claude.*haiku/i, { inputPerM: 1, outputPerM: 5, cacheReadPerM: 0.1 }],
|
|
14
|
+
[/gpt-4o-mini/i, { inputPerM: 0.15, outputPerM: 0.6, cacheReadPerM: 0.075 }],
|
|
15
|
+
[/gpt-4o|gpt-4\.1/i, { inputPerM: 2.5, outputPerM: 10, cacheReadPerM: 1.25 }],
|
|
16
|
+
[/gpt-4-turbo/i, { inputPerM: 10, outputPerM: 30, cacheReadPerM: 10 }],
|
|
17
|
+
[/o[13](-|$)/i, { inputPerM: 2, outputPerM: 8, cacheReadPerM: 1 }],
|
|
18
|
+
[/gemini.*flash/i, { inputPerM: 0.1, outputPerM: 0.4, cacheReadPerM: 0.025 }],
|
|
19
|
+
[/gemini.*pro/i, { inputPerM: 1.25, outputPerM: 5, cacheReadPerM: 0.31 }],
|
|
20
|
+
];
|
|
21
|
+
export function pricingFor(model) {
|
|
22
|
+
if (!model)
|
|
23
|
+
return undefined;
|
|
24
|
+
for (const [pattern, pricing] of PRICING) {
|
|
25
|
+
if (pattern.test(model))
|
|
26
|
+
return pricing;
|
|
27
|
+
}
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
export function inputCostUsd(tokens, pricing) {
|
|
31
|
+
return (tokens / 1_000_000) * pricing.inputPerM;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Rough time-to-first-token impact of input size. Prompt processing on major
|
|
35
|
+
* providers runs on the order of tens of thousands of tokens per second;
|
|
36
|
+
* ~25k tok/s is a serviceable cross-provider planning number, so every 10k
|
|
37
|
+
* input tokens costs roughly 0.4s of TTFT (much less on cache hits).
|
|
38
|
+
*/
|
|
39
|
+
export function estimatedTtftSeconds(inputTokens) {
|
|
40
|
+
return inputTokens / 25_000;
|
|
41
|
+
}
|
|
42
|
+
export function formatUsd(amount) {
|
|
43
|
+
if (amount >= 1)
|
|
44
|
+
return `$${amount.toFixed(2)}`;
|
|
45
|
+
if (amount >= 0.01)
|
|
46
|
+
return `$${amount.toFixed(3)}`;
|
|
47
|
+
return `$${amount.toFixed(4)}`;
|
|
48
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The profiler: turns a normalized conversation into a breakdown of where
|
|
3
|
+
* tokens go, plus a list of actionable findings ("what's eating my window").
|
|
4
|
+
*/
|
|
5
|
+
import { NormalizedConversation } from "./parse.js";
|
|
6
|
+
export type Category = "system" | "user" | "assistant" | "tool_calls" | "tool_results" | "other";
|
|
7
|
+
export interface MessageProfile {
|
|
8
|
+
index: number;
|
|
9
|
+
role: string;
|
|
10
|
+
kind: string;
|
|
11
|
+
tokens: number;
|
|
12
|
+
preview: string;
|
|
13
|
+
toolName?: string;
|
|
14
|
+
}
|
|
15
|
+
export type FindingId = "large_tool_result" | "duplicate_content" | "repeated_tool_call" | "base64_blob" | "long_history" | "large_system_prompt" | "cache_ordering" | "near_window_limit";
|
|
16
|
+
export interface Finding {
|
|
17
|
+
id: FindingId;
|
|
18
|
+
severity: "info" | "warn" | "high";
|
|
19
|
+
/** Estimated tokens recoverable by acting on this finding (0 = advisory). */
|
|
20
|
+
estSavings: number;
|
|
21
|
+
message: string;
|
|
22
|
+
suggestion: string;
|
|
23
|
+
/** Message indexes involved. */
|
|
24
|
+
messages: number[];
|
|
25
|
+
}
|
|
26
|
+
export interface CostEstimate {
|
|
27
|
+
/** Input cost of sending this context once, USD (estimate). */
|
|
28
|
+
perCallUsd: number;
|
|
29
|
+
/** Input cost over 1,000 calls — the number that makes people act. */
|
|
30
|
+
per1kCallsUsd: number;
|
|
31
|
+
/** USD recoverable per call if all savings findings are applied. */
|
|
32
|
+
savingsPerCallUsd: number;
|
|
33
|
+
/** Same, over 1,000 calls. */
|
|
34
|
+
savingsPer1kCallsUsd: number;
|
|
35
|
+
/** Estimated seconds of time-to-first-token attributable to this input size. */
|
|
36
|
+
ttftSeconds: number;
|
|
37
|
+
/** TTFT seconds saved per call if savings are applied. */
|
|
38
|
+
ttftSavedSeconds: number;
|
|
39
|
+
}
|
|
40
|
+
export interface ContextProfile {
|
|
41
|
+
totalTokens: number;
|
|
42
|
+
model?: string;
|
|
43
|
+
contextWindow?: number;
|
|
44
|
+
usagePct?: number;
|
|
45
|
+
messageCount: number;
|
|
46
|
+
categories: Record<Category, number>;
|
|
47
|
+
largestMessages: MessageProfile[];
|
|
48
|
+
findings: Finding[];
|
|
49
|
+
/** Total estimated savings if all findings with savings are acted on. */
|
|
50
|
+
totalEstSavings: number;
|
|
51
|
+
/** Present when the model has a known price. All figures are estimates. */
|
|
52
|
+
cost?: CostEstimate;
|
|
53
|
+
sourceFormat: string;
|
|
54
|
+
}
|
|
55
|
+
export declare function profileConversation(conv: NormalizedConversation, model?: string): ContextProfile;
|