@buzzie-ai/jannal 0.3.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 +194 -0
- package/bin/jannal.js +3 -0
- package/lib/plugins.js +67 -0
- package/lib/tokens.js +176 -0
- package/package.json +52 -0
- package/public/assets/index-B8dfyj9-.css +1 -0
- package/public/assets/index-CzXZ1AkJ.js +23 -0
- package/public/favicon.png +0 -0
- package/public/index.html +144 -0
- package/public/jannal-1.png +0 -0
- package/public/logo.png +0 -0
- package/server.js +1142 -0
package/server.js
ADDED
|
@@ -0,0 +1,1142 @@
|
|
|
1
|
+
const http = require("http");
|
|
2
|
+
const https = require("https");
|
|
3
|
+
const { WebSocketServer } = require("ws");
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
|
|
7
|
+
const { PluginHost } = require("./lib/plugins");
|
|
8
|
+
|
|
9
|
+
const ANTHROPIC_HOST = "api.anthropic.com";
|
|
10
|
+
|
|
11
|
+
const {
|
|
12
|
+
estimateTokens,
|
|
13
|
+
getBudget,
|
|
14
|
+
inferBudget,
|
|
15
|
+
analyzeSegments,
|
|
16
|
+
} = require("./lib/tokens");
|
|
17
|
+
|
|
18
|
+
// ─── Model pricing ($ per 1M tokens) ────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
// Source: https://docs.anthropic.com/en/docs/about-claude/pricing
|
|
21
|
+
// Prices are $ per 1M tokens. Ordered most-specific first for substring matching.
|
|
22
|
+
const MODEL_PRICING = {
|
|
23
|
+
"claude-opus-4-6": { input: 5, output: 25 },
|
|
24
|
+
"claude-opus-4.6": { input: 5, output: 25 },
|
|
25
|
+
"claude-opus-4-5": { input: 5, output: 25 },
|
|
26
|
+
"claude-opus-4.5": { input: 5, output: 25 },
|
|
27
|
+
"claude-opus-4-1": { input: 15, output: 75 },
|
|
28
|
+
"claude-opus-4.1": { input: 15, output: 75 },
|
|
29
|
+
"claude-opus-4": { input: 15, output: 75 },
|
|
30
|
+
"claude-3-opus": { input: 15, output: 75 },
|
|
31
|
+
"claude-opus": { input: 5, output: 25 }, // default opus = latest (4.5+)
|
|
32
|
+
"claude-sonnet-4": { input: 3, output: 15 },
|
|
33
|
+
"claude-sonnet-3": { input: 3, output: 15 },
|
|
34
|
+
"claude-3-5-sonnet":{ input: 3, output: 15 },
|
|
35
|
+
"claude-sonnet": { input: 3, output: 15 },
|
|
36
|
+
"claude-haiku-4": { input: 1, output: 5 },
|
|
37
|
+
"claude-3-5-haiku": { input: 0.80, output: 4 },
|
|
38
|
+
"claude-haiku-3": { input: 0.25, output: 1.25 },
|
|
39
|
+
"claude-3-haiku": { input: 0.25, output: 1.25 },
|
|
40
|
+
"claude-haiku": { input: 1, output: 5 }, // default haiku = latest (4.5)
|
|
41
|
+
"claude-4": { input: 3, output: 15 },
|
|
42
|
+
"claude-3": { input: 3, output: 15 },
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function getModelPricing(model) {
|
|
46
|
+
if (!model) return { input: 3, output: 15 };
|
|
47
|
+
for (const [key, pricing] of Object.entries(MODEL_PRICING)) {
|
|
48
|
+
if (model.includes(key)) return pricing;
|
|
49
|
+
}
|
|
50
|
+
return { input: 3, output: 15 }; // default to sonnet pricing
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function calculateCost(inputTokens, outputTokens, model, cacheCreationTokens = 0, cacheReadTokens = 0) {
|
|
54
|
+
const pricing = getModelPricing(model);
|
|
55
|
+
// Non-cached input tokens = total - cache_creation - cache_read
|
|
56
|
+
const baseInputTokens = Math.max(0, inputTokens - cacheCreationTokens - cacheReadTokens);
|
|
57
|
+
const baseCost = (baseInputTokens / 1_000_000) * pricing.input;
|
|
58
|
+
// Cache writes cost 25% more than base input
|
|
59
|
+
const cacheWriteCost = (cacheCreationTokens / 1_000_000) * pricing.input * 1.25;
|
|
60
|
+
// Cache reads cost 10% of base input
|
|
61
|
+
const cacheReadCost = (cacheReadTokens / 1_000_000) * pricing.input * 0.10;
|
|
62
|
+
const inputCost = baseCost + cacheWriteCost + cacheReadCost;
|
|
63
|
+
const outputCost = (outputTokens / 1_000_000) * pricing.output;
|
|
64
|
+
return { inputCost, outputCost, totalCost: inputCost + outputCost };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ─── Streaming response parser ───────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
function parseStreamedResponse(data) {
|
|
70
|
+
const lines = data.split("\n");
|
|
71
|
+
let inputTokens = 0;
|
|
72
|
+
let cacheCreationTokens = 0;
|
|
73
|
+
let cacheReadTokens = 0;
|
|
74
|
+
let outputTokens = 0;
|
|
75
|
+
let stopReason = null;
|
|
76
|
+
const toolsUsed = [];
|
|
77
|
+
|
|
78
|
+
for (const line of lines) {
|
|
79
|
+
if (!line.startsWith("data: ")) continue;
|
|
80
|
+
try {
|
|
81
|
+
const event = JSON.parse(line.slice(6));
|
|
82
|
+
if (event.type === "message_start" && event.message?.usage) {
|
|
83
|
+
const u = event.message.usage;
|
|
84
|
+
inputTokens = u.input_tokens || 0;
|
|
85
|
+
cacheCreationTokens = u.cache_creation_input_tokens || 0;
|
|
86
|
+
cacheReadTokens = u.cache_read_input_tokens || 0;
|
|
87
|
+
}
|
|
88
|
+
if (event.type === "message_delta") {
|
|
89
|
+
if (event.usage) outputTokens = event.usage.output_tokens || 0;
|
|
90
|
+
if (event.delta?.stop_reason) stopReason = event.delta.stop_reason;
|
|
91
|
+
}
|
|
92
|
+
if (event.type === "content_block_start" &&
|
|
93
|
+
event.content_block?.type === "tool_use" &&
|
|
94
|
+
event.content_block?.name) {
|
|
95
|
+
toolsUsed.push(event.content_block.name);
|
|
96
|
+
}
|
|
97
|
+
} catch (e) { /* skip */ }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Total input = non-cached + cache-created + cache-read
|
|
101
|
+
const totalInputTokens = inputTokens + cacheCreationTokens + cacheReadTokens;
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
inputTokens: totalInputTokens,
|
|
105
|
+
cacheCreationTokens,
|
|
106
|
+
cacheReadTokens,
|
|
107
|
+
outputTokens,
|
|
108
|
+
stopReason,
|
|
109
|
+
toolsUsed,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ─── Accurate token counting via Anthropic API ──────────────────────────────
|
|
114
|
+
|
|
115
|
+
function countTokensViaAPI(body, apiKey) {
|
|
116
|
+
return new Promise((resolve) => {
|
|
117
|
+
const payload = JSON.stringify({
|
|
118
|
+
model: body.model,
|
|
119
|
+
system: body.system,
|
|
120
|
+
tools: body.tools,
|
|
121
|
+
messages: body.messages,
|
|
122
|
+
});
|
|
123
|
+
const payloadBuffer = Buffer.from(payload, "utf-8");
|
|
124
|
+
|
|
125
|
+
const req = https.request(
|
|
126
|
+
{
|
|
127
|
+
hostname: ANTHROPIC_HOST,
|
|
128
|
+
port: 443,
|
|
129
|
+
path: "/v1/messages/count_tokens",
|
|
130
|
+
method: "POST",
|
|
131
|
+
headers: {
|
|
132
|
+
"content-type": "application/json",
|
|
133
|
+
"x-api-key": apiKey,
|
|
134
|
+
"anthropic-version": "2023-06-01",
|
|
135
|
+
"content-length": payloadBuffer.length,
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
(res) => {
|
|
139
|
+
let data = "";
|
|
140
|
+
res.on("data", (chunk) => (data += chunk));
|
|
141
|
+
res.on("end", () => {
|
|
142
|
+
try {
|
|
143
|
+
const result = JSON.parse(data);
|
|
144
|
+
resolve(result.input_tokens || null);
|
|
145
|
+
} catch (e) {
|
|
146
|
+
resolve(null);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
);
|
|
151
|
+
req.on("error", () => resolve(null));
|
|
152
|
+
req.setTimeout(5000, () => { req.destroy(); resolve(null); });
|
|
153
|
+
req.write(payloadBuffer);
|
|
154
|
+
req.end();
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ─── Helper: read POST body ─────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
function readBody(req) {
|
|
161
|
+
return new Promise((resolve) => {
|
|
162
|
+
const chunks = [];
|
|
163
|
+
req.on("data", (c) => chunks.push(c));
|
|
164
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ─── Helper: JSON response ──────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
function jsonResponse(res, status, data) {
|
|
171
|
+
res.writeHead(status, {
|
|
172
|
+
"Content-Type": "application/json",
|
|
173
|
+
"Access-Control-Allow-Origin": "*",
|
|
174
|
+
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
|
|
175
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
176
|
+
});
|
|
177
|
+
res.end(JSON.stringify(data));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ─── Hash helpers ────────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
function simpleHash(str) {
|
|
183
|
+
let hash = 5381;
|
|
184
|
+
for (let i = 0; i < str.length; i++) {
|
|
185
|
+
hash = ((hash << 5) + hash + str.charCodeAt(i)) & 0xffffffff;
|
|
186
|
+
}
|
|
187
|
+
return hash.toString(36);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Strip Claude Code infrastructure tags from user message text.
|
|
192
|
+
* Used by grouping helpers and router intent extraction for consistent text cleaning.
|
|
193
|
+
*/
|
|
194
|
+
function stripInfrastructureTags(text) {
|
|
195
|
+
return text
|
|
196
|
+
.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, "")
|
|
197
|
+
.replace(/<command-message>[\s\S]*?<\/command-message>/g, "")
|
|
198
|
+
.replace(/<command-name>[\s\S]*?<\/command-name>/g, "")
|
|
199
|
+
.replace(/<command-args>[\s\S]*?<\/command-args>/g, "")
|
|
200
|
+
.replace(/<local-command-stdout>[\s\S]*?<\/local-command-stdout>/g, "")
|
|
201
|
+
.replace(/<local-command-caveat>[\s\S]*?<\/local-command-caveat>/g, "")
|
|
202
|
+
.trim();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ─── createServer factory ────────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
function createServer(opts = {}) {
|
|
208
|
+
const PORT = process.env.JANNAL_PORT || 4455;
|
|
209
|
+
const pluginHost = new PluginHost();
|
|
210
|
+
|
|
211
|
+
// Register plugins
|
|
212
|
+
if (opts.plugins) {
|
|
213
|
+
for (const p of opts.plugins) pluginHost.register(p);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ─── WebSocket clients ─────────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
const wsClients = new Set();
|
|
219
|
+
|
|
220
|
+
function broadcast(data) {
|
|
221
|
+
const msg = JSON.stringify(data);
|
|
222
|
+
for (const client of wsClients) {
|
|
223
|
+
if (client.readyState === 1) client.send(msg);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ─── Request storage (full content kept server-side) ───────────────────────
|
|
228
|
+
|
|
229
|
+
const reqStore = new Map(); // reqId -> { fullContents, model }
|
|
230
|
+
const MAX_STORED_REQS = 200;
|
|
231
|
+
|
|
232
|
+
// ─── Profile management ────────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
const PROFILES_FILE = path.join(__dirname, "profiles.json");
|
|
235
|
+
|
|
236
|
+
let profiles = {};
|
|
237
|
+
let activeProfile = "All Tools";
|
|
238
|
+
|
|
239
|
+
function loadProfiles() {
|
|
240
|
+
try {
|
|
241
|
+
if (fs.existsSync(PROFILES_FILE)) {
|
|
242
|
+
const data = JSON.parse(fs.readFileSync(PROFILES_FILE, "utf-8"));
|
|
243
|
+
profiles = data.profiles || {};
|
|
244
|
+
activeProfile = data.activeProfile || "All Tools";
|
|
245
|
+
}
|
|
246
|
+
} catch (err) {
|
|
247
|
+
console.error("Failed to load profiles:", err.message);
|
|
248
|
+
}
|
|
249
|
+
// Ensure default profile always exists
|
|
250
|
+
if (!profiles["All Tools"]) {
|
|
251
|
+
profiles["All Tools"] = { name: "All Tools", mode: "allowlist", tools: [] };
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function saveProfiles() {
|
|
256
|
+
try {
|
|
257
|
+
fs.writeFileSync(PROFILES_FILE, JSON.stringify({ profiles, activeProfile }, null, 2));
|
|
258
|
+
} catch (err) {
|
|
259
|
+
console.error("Failed to save profiles:", err.message);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function applyToolFilter(tools, profileName) {
|
|
264
|
+
if (!tools || !profileName || profileName === "All Tools") {
|
|
265
|
+
return { filtered: tools || [], removed: [] };
|
|
266
|
+
}
|
|
267
|
+
const profile = profiles[profileName];
|
|
268
|
+
if (!profile || !profile.tools || profile.tools.length === 0) {
|
|
269
|
+
return { filtered: tools, removed: [] };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const removed = [];
|
|
273
|
+
let filtered;
|
|
274
|
+
|
|
275
|
+
if (profile.mode === "blocklist") {
|
|
276
|
+
// Remove tools that are in the blocklist
|
|
277
|
+
filtered = tools.filter((t) => {
|
|
278
|
+
if (profile.tools.includes(t.name)) {
|
|
279
|
+
removed.push(t.name);
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
return true;
|
|
283
|
+
});
|
|
284
|
+
} else {
|
|
285
|
+
// allowlist: keep only tools in the list
|
|
286
|
+
filtered = tools.filter((t) => {
|
|
287
|
+
if (profile.tools.includes(t.name)) return true;
|
|
288
|
+
removed.push(t.name);
|
|
289
|
+
return false;
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return { filtered, removed };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
loadProfiles();
|
|
297
|
+
|
|
298
|
+
// ─── Group tracking ────────────────────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
let groupCounter = 0;
|
|
301
|
+
let lastRequestTime = 0;
|
|
302
|
+
let lastMainSessionKey = null;
|
|
303
|
+
const GAP_THRESHOLD = 45000;
|
|
304
|
+
const NEW_MAIN_MSG_THRESHOLD = 20;
|
|
305
|
+
const MAX_TRACKED_CONVERSATIONS = 10;
|
|
306
|
+
const conversationGroups = new Map();
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Compute a stable session hash from model + system prompt text content.
|
|
310
|
+
*/
|
|
311
|
+
function getSessionHash(body) {
|
|
312
|
+
if (!body.system) return "no-system";
|
|
313
|
+
let text;
|
|
314
|
+
if (typeof body.system === "string") {
|
|
315
|
+
text = body.system.trim();
|
|
316
|
+
} else if (Array.isArray(body.system)) {
|
|
317
|
+
text = body.system
|
|
318
|
+
.filter((b) => b.type === "text" && b.text)
|
|
319
|
+
.map((b) => b.text.trim())
|
|
320
|
+
.join("\n")
|
|
321
|
+
.trim();
|
|
322
|
+
} else {
|
|
323
|
+
return "no-system";
|
|
324
|
+
}
|
|
325
|
+
if (!text) return "no-system";
|
|
326
|
+
text = text.replace(/^x-anthropic-billing-header:[^\n]*\n?/, "");
|
|
327
|
+
const model = body.model || "unknown";
|
|
328
|
+
return simpleHash(model + "|" + text.slice(0, 5000));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Walk body.messages backwards to find the most recent human-authored text.
|
|
333
|
+
*/
|
|
334
|
+
function extractLastHumanText(body) {
|
|
335
|
+
if (!body.messages) return null;
|
|
336
|
+
for (let i = body.messages.length - 1; i >= 0; i--) {
|
|
337
|
+
const msg = body.messages[i];
|
|
338
|
+
if (msg.role !== "user") continue;
|
|
339
|
+
if (typeof msg.content === "string") {
|
|
340
|
+
const cleaned = stripInfrastructureTags(msg.content);
|
|
341
|
+
if (cleaned.length > 0) return cleaned.slice(0, 200);
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
if (Array.isArray(msg.content)) {
|
|
345
|
+
const textParts = msg.content
|
|
346
|
+
.filter((b) => b.type === "text" && b.text)
|
|
347
|
+
.map((b) => b.text);
|
|
348
|
+
if (textParts.length === 0) continue;
|
|
349
|
+
const combined = stripInfrastructureTags(textParts.join("\n"));
|
|
350
|
+
if (combined.length > 0) return combined.slice(0, 200);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Walk body.messages forwards to find the FIRST human-authored text.
|
|
358
|
+
*/
|
|
359
|
+
function extractFirstHumanText(body) {
|
|
360
|
+
if (!body.messages) return null;
|
|
361
|
+
for (let i = 0; i < body.messages.length; i++) {
|
|
362
|
+
const msg = body.messages[i];
|
|
363
|
+
if (msg.role !== "user") continue;
|
|
364
|
+
if (typeof msg.content === "string") {
|
|
365
|
+
const cleaned = stripInfrastructureTags(msg.content);
|
|
366
|
+
if (cleaned.length > 0) return cleaned.slice(0, 200);
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
if (Array.isArray(msg.content)) {
|
|
370
|
+
const textParts = msg.content
|
|
371
|
+
.filter((b) => b.type === "text" && b.text)
|
|
372
|
+
.map((b) => b.text);
|
|
373
|
+
if (textParts.length === 0) continue;
|
|
374
|
+
const combined = stripInfrastructureTags(textParts.join("\n"));
|
|
375
|
+
if (combined.length > 0) return combined.slice(0, 200);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Assign a group (turn) ID to a request.
|
|
383
|
+
*/
|
|
384
|
+
function assignGroup(body) {
|
|
385
|
+
const now = Date.now();
|
|
386
|
+
const msgCount = (body.messages || []).length;
|
|
387
|
+
const model = body.model || "unknown";
|
|
388
|
+
const gap = now - lastRequestTime;
|
|
389
|
+
const sessionHash = getSessionHash(body);
|
|
390
|
+
const currentLastText = extractLastHumanText(body);
|
|
391
|
+
|
|
392
|
+
lastRequestTime = now;
|
|
393
|
+
|
|
394
|
+
// Time gap: clear all tracked conversations, start fresh
|
|
395
|
+
if (gap > GAP_THRESHOLD || conversationGroups.size === 0) {
|
|
396
|
+
conversationGroups.clear();
|
|
397
|
+
const groupId = groupCounter++;
|
|
398
|
+
conversationGroups.set(sessionHash, {
|
|
399
|
+
groupId,
|
|
400
|
+
lastHumanText: currentLastText,
|
|
401
|
+
lastSeen: now,
|
|
402
|
+
lastStopReason: null,
|
|
403
|
+
});
|
|
404
|
+
if (msgCount > NEW_MAIN_MSG_THRESHOLD) {
|
|
405
|
+
lastMainSessionKey = sessionHash;
|
|
406
|
+
}
|
|
407
|
+
console.log(` [group] NEW group=${groupId} reason=gap(${gap}ms) model=${model} msgs=${msgCount}`);
|
|
408
|
+
return groupId;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Known session: look up by sessionHash
|
|
412
|
+
if (conversationGroups.has(sessionHash)) {
|
|
413
|
+
const conv = conversationGroups.get(sessionHash);
|
|
414
|
+
conv.lastSeen = now;
|
|
415
|
+
|
|
416
|
+
const lastTextChanged = currentLastText !== null
|
|
417
|
+
&& conv.lastHumanText !== null
|
|
418
|
+
&& currentLastText !== conv.lastHumanText;
|
|
419
|
+
|
|
420
|
+
if (lastTextChanged) {
|
|
421
|
+
conv.lastHumanText = currentLastText;
|
|
422
|
+
|
|
423
|
+
if (conv.lastStopReason === "end_turn") {
|
|
424
|
+
const groupId = groupCounter++;
|
|
425
|
+
conv.groupId = groupId;
|
|
426
|
+
if (msgCount > NEW_MAIN_MSG_THRESHOLD) {
|
|
427
|
+
lastMainSessionKey = sessionHash;
|
|
428
|
+
}
|
|
429
|
+
console.log(` [group] NEW group=${groupId} reason=new_user_msg model=${model} msgs=${msgCount} prevStop=end_turn`);
|
|
430
|
+
return groupId;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (conv.lastStopReason === "tool_use") {
|
|
434
|
+
if (msgCount > NEW_MAIN_MSG_THRESHOLD) {
|
|
435
|
+
lastMainSessionKey = sessionHash;
|
|
436
|
+
}
|
|
437
|
+
console.log(` [group] SAME group=${conv.groupId} reason=continuation_after_tool_use model=${model} msgs=${msgCount}`);
|
|
438
|
+
return conv.groupId;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const groupId = groupCounter++;
|
|
442
|
+
conv.groupId = groupId;
|
|
443
|
+
if (msgCount > NEW_MAIN_MSG_THRESHOLD) {
|
|
444
|
+
lastMainSessionKey = sessionHash;
|
|
445
|
+
}
|
|
446
|
+
console.log(` [group] NEW group=${groupId} reason=new_user_msg model=${model} msgs=${msgCount} prevStop=${conv.lastStopReason || "none"}`);
|
|
447
|
+
return groupId;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (currentLastText !== null) conv.lastHumanText = currentLastText;
|
|
451
|
+
if (msgCount > NEW_MAIN_MSG_THRESHOLD) {
|
|
452
|
+
lastMainSessionKey = sessionHash;
|
|
453
|
+
}
|
|
454
|
+
console.log(` [group] SAME group=${conv.groupId} reason=same_conv model=${model} msgs=${msgCount}`);
|
|
455
|
+
return conv.groupId;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// New sessionHash, high msg count → new main conversation
|
|
459
|
+
if (msgCount > NEW_MAIN_MSG_THRESHOLD) {
|
|
460
|
+
const groupId = groupCounter++;
|
|
461
|
+
conversationGroups.set(sessionHash, {
|
|
462
|
+
groupId,
|
|
463
|
+
lastHumanText: currentLastText,
|
|
464
|
+
lastSeen: now,
|
|
465
|
+
lastStopReason: null,
|
|
466
|
+
});
|
|
467
|
+
lastMainSessionKey = sessionHash;
|
|
468
|
+
|
|
469
|
+
// Evict oldest if map too large
|
|
470
|
+
if (conversationGroups.size > MAX_TRACKED_CONVERSATIONS) {
|
|
471
|
+
let oldestKey = null, oldestTime = Infinity;
|
|
472
|
+
for (const [key, val] of conversationGroups) {
|
|
473
|
+
if (val.lastSeen < oldestTime) { oldestTime = val.lastSeen; oldestKey = key; }
|
|
474
|
+
}
|
|
475
|
+
if (oldestKey) conversationGroups.delete(oldestKey);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
console.log(` [group] NEW group=${groupId} reason=new_conv model=${model} msgs=${msgCount}`);
|
|
479
|
+
return groupId;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Subagent: attach to the most recently active MAIN session's group
|
|
483
|
+
if (lastMainSessionKey && conversationGroups.has(lastMainSessionKey)) {
|
|
484
|
+
const parentGroup = conversationGroups.get(lastMainSessionKey).groupId;
|
|
485
|
+
console.log(` [group] SAME group=${parentGroup} reason=subagent(msgs=${msgCount}) model=${model}`);
|
|
486
|
+
return parentGroup;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Fallback: no tracked main session yet
|
|
490
|
+
const latest = [...conversationGroups.values()].sort((a, b) => b.lastSeen - a.lastSeen)[0];
|
|
491
|
+
const fallbackGroup = latest ? latest.groupId : groupCounter++;
|
|
492
|
+
console.log(` [group] SAME group=${fallbackGroup} reason=subagent_fallback(msgs=${msgCount}) model=${model}`);
|
|
493
|
+
return fallbackGroup;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// ─── Request analysis ──────────────────────────────────────────────────────
|
|
497
|
+
|
|
498
|
+
let reqCounter = 0;
|
|
499
|
+
|
|
500
|
+
function analyzeRequest(body) {
|
|
501
|
+
const segments = [];
|
|
502
|
+
const fullContents = [];
|
|
503
|
+
|
|
504
|
+
// System prompt
|
|
505
|
+
if (body.system) {
|
|
506
|
+
const text = typeof body.system === "string" ? body.system : JSON.stringify(body.system, null, 2);
|
|
507
|
+
segments.push({
|
|
508
|
+
type: "system",
|
|
509
|
+
name: "System Prompt",
|
|
510
|
+
tokens: estimateTokens(text),
|
|
511
|
+
charLength: text.length,
|
|
512
|
+
preview: text.slice(0, 200),
|
|
513
|
+
});
|
|
514
|
+
fullContents.push(text);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Tools — one aggregate segment
|
|
518
|
+
if (body.tools && body.tools.length > 0) {
|
|
519
|
+
const toolsJson = JSON.stringify(body.tools);
|
|
520
|
+
const toolsSummary = body.tools.map((t) => t.name).join(", ");
|
|
521
|
+
|
|
522
|
+
const toolsFormatted = JSON.stringify(body.tools, null, 2);
|
|
523
|
+
segments.push({
|
|
524
|
+
type: "tools",
|
|
525
|
+
name: `Tools (${body.tools.length})`,
|
|
526
|
+
tokens: estimateTokens(toolsJson),
|
|
527
|
+
charLength: toolsFormatted.length,
|
|
528
|
+
count: body.tools.length,
|
|
529
|
+
toolNames: body.tools.map((t) => t.name),
|
|
530
|
+
preview: toolsSummary.slice(0, 200),
|
|
531
|
+
});
|
|
532
|
+
fullContents.push(toolsFormatted);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Messages + extract tools used
|
|
536
|
+
const toolsUsed = new Set();
|
|
537
|
+
if (body.messages) {
|
|
538
|
+
for (let i = 0; i < body.messages.length; i++) {
|
|
539
|
+
const msg = body.messages[i];
|
|
540
|
+
const content = typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content);
|
|
541
|
+
|
|
542
|
+
if (Array.isArray(msg.content)) {
|
|
543
|
+
for (const block of msg.content) {
|
|
544
|
+
if (block && block.type === "tool_use" && block.name) {
|
|
545
|
+
toolsUsed.add(block.name);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const isToolResult =
|
|
551
|
+
Array.isArray(msg.content) && msg.content.some((c) => c.type === "tool_result");
|
|
552
|
+
const isToolUse =
|
|
553
|
+
Array.isArray(msg.content) && msg.content.some((c) => c.type === "tool_use");
|
|
554
|
+
|
|
555
|
+
let type = "message";
|
|
556
|
+
let name = `${msg.role} message`;
|
|
557
|
+
if (isToolResult) { type = "tool_result"; name = "Tool Result"; }
|
|
558
|
+
else if (isToolUse) { type = "tool_use"; name = "Tool Use (assistant)"; }
|
|
559
|
+
|
|
560
|
+
let fullContent = content;
|
|
561
|
+
try {
|
|
562
|
+
const parsed = JSON.parse(content);
|
|
563
|
+
fullContent = JSON.stringify(parsed, null, 2);
|
|
564
|
+
} catch (e) { /* not JSON, keep as-is */ }
|
|
565
|
+
|
|
566
|
+
segments.push({
|
|
567
|
+
type,
|
|
568
|
+
role: msg.role,
|
|
569
|
+
name,
|
|
570
|
+
tokens: estimateTokens(content),
|
|
571
|
+
charLength: content.length,
|
|
572
|
+
preview: content.slice(0, 200),
|
|
573
|
+
index: i,
|
|
574
|
+
});
|
|
575
|
+
fullContents.push(fullContent);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const reqId = reqCounter++;
|
|
580
|
+
const model = body.model || "unknown";
|
|
581
|
+
const sessionHash = getSessionHash(body);
|
|
582
|
+
const groupId = assignGroup(body);
|
|
583
|
+
|
|
584
|
+
const toolsSeg = segments.find((s) => s.type === "tools");
|
|
585
|
+
const userMessages = (body.messages || [])
|
|
586
|
+
.filter((m) => m.role === "user")
|
|
587
|
+
.map((m) => {
|
|
588
|
+
if (typeof m.content === "string") return m.content;
|
|
589
|
+
if (Array.isArray(m.content)) {
|
|
590
|
+
const textParts = m.content
|
|
591
|
+
.filter((block) => block.type === "text" && block.text)
|
|
592
|
+
.map((block) => block.text);
|
|
593
|
+
return textParts.join("\n");
|
|
594
|
+
}
|
|
595
|
+
return "";
|
|
596
|
+
})
|
|
597
|
+
.filter((text) => text.length > 0)
|
|
598
|
+
.map((text) => stripInfrastructureTags(text))
|
|
599
|
+
.filter((text) => text.length > 0)
|
|
600
|
+
.map((text) => text.slice(0, 500))
|
|
601
|
+
.slice(-3);
|
|
602
|
+
|
|
603
|
+
// Store full content + model + telemetry fields server-side
|
|
604
|
+
reqStore.set(reqId, {
|
|
605
|
+
fullContents,
|
|
606
|
+
model,
|
|
607
|
+
sessionHash,
|
|
608
|
+
groupId,
|
|
609
|
+
stream: !!body.stream,
|
|
610
|
+
toolNames: toolsSeg?.toolNames || [],
|
|
611
|
+
toolCount: toolsSeg?.count || 0,
|
|
612
|
+
estimatedToolTokens: toolsSeg?.tokens || 0,
|
|
613
|
+
userMessages,
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
// Evict old requests if over limit
|
|
617
|
+
if (reqStore.size > MAX_STORED_REQS) {
|
|
618
|
+
const oldest = reqStore.keys().next().value;
|
|
619
|
+
reqStore.delete(oldest);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Calculate estimated cost
|
|
623
|
+
const totalEstimatedTokens = segments.reduce((s, seg) => s + seg.tokens, 0);
|
|
624
|
+
const estimatedCost = calculateCost(totalEstimatedTokens, 0, model);
|
|
625
|
+
|
|
626
|
+
return {
|
|
627
|
+
turn: reqId,
|
|
628
|
+
model,
|
|
629
|
+
budget: inferBudget(model, totalEstimatedTokens),
|
|
630
|
+
maxTokens: body.max_tokens,
|
|
631
|
+
stream: !!body.stream,
|
|
632
|
+
segments,
|
|
633
|
+
totalEstimatedTokens,
|
|
634
|
+
estimatedCost,
|
|
635
|
+
timestamp: Date.now(),
|
|
636
|
+
messageCount: (body.messages || []).length,
|
|
637
|
+
toolsUsed: [...toolsUsed],
|
|
638
|
+
groupId,
|
|
639
|
+
sessionHash,
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// ─── HTTP Server ───────────────────────────────────────────────────────────
|
|
644
|
+
|
|
645
|
+
const server = http.createServer((req, res) => {
|
|
646
|
+
// ── CORS preflight ──
|
|
647
|
+
if (req.method === "OPTIONS") {
|
|
648
|
+
res.writeHead(204, {
|
|
649
|
+
"Access-Control-Allow-Origin": "*",
|
|
650
|
+
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
|
|
651
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
652
|
+
});
|
|
653
|
+
res.end();
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// ── Let plugins handle custom routes first ──
|
|
658
|
+
const pluginHelpers = { jsonResponse, readBody, broadcast };
|
|
659
|
+
if (pluginHost.onRoute(req, res, pluginHelpers)) return;
|
|
660
|
+
|
|
661
|
+
// ── Serve Inspector UI (static files from public/) ──
|
|
662
|
+
if (req.method === "GET") {
|
|
663
|
+
const MIME_TYPES = {
|
|
664
|
+
".html": "text/html", ".js": "application/javascript", ".css": "text/css",
|
|
665
|
+
".json": "application/json", ".svg": "image/svg+xml", ".png": "image/png",
|
|
666
|
+
".ico": "image/x-icon", ".woff": "font/woff", ".woff2": "font/woff2",
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
if (req.url === "/" || req.url === "/index.html") {
|
|
670
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
671
|
+
fs.createReadStream(path.join(__dirname, "public", "index.html")).pipe(res);
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Serve other static files from public/ (JS, CSS, assets)
|
|
676
|
+
const urlPath = req.url.split("?")[0];
|
|
677
|
+
if (urlPath.startsWith("/assets/") || urlPath.endsWith(".js") || urlPath.endsWith(".css") || urlPath.endsWith(".svg") || urlPath.endsWith(".ico") || urlPath.endsWith(".png")) {
|
|
678
|
+
const filePath = path.join(__dirname, "public", urlPath);
|
|
679
|
+
const safePath = path.resolve(filePath);
|
|
680
|
+
if (!safePath.startsWith(path.join(__dirname, "public"))) {
|
|
681
|
+
res.writeHead(403); res.end("Forbidden"); return;
|
|
682
|
+
}
|
|
683
|
+
if (fs.existsSync(safePath)) {
|
|
684
|
+
const ext = path.extname(safePath);
|
|
685
|
+
res.writeHead(200, { "Content-Type": MIME_TYPES[ext] || "application/octet-stream" });
|
|
686
|
+
fs.createReadStream(safePath).pipe(res);
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
if (req.url === "/health") {
|
|
692
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
693
|
+
res.end(JSON.stringify({ status: "ok", requests: reqCounter, clients: wsClients.size }));
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// ── API: Fetch full content for a segment ──
|
|
698
|
+
const contentMatch = req.url.match(/^\/api\/content\/(\d+)\/(\d+)$/);
|
|
699
|
+
if (contentMatch) {
|
|
700
|
+
const reqId = parseInt(contentMatch[1]);
|
|
701
|
+
const segIndex = parseInt(contentMatch[2]);
|
|
702
|
+
const stored = reqStore.get(reqId);
|
|
703
|
+
|
|
704
|
+
if (!stored || segIndex >= stored.fullContents.length) {
|
|
705
|
+
jsonResponse(res, 200, { error: "Not found", content: null });
|
|
706
|
+
} else {
|
|
707
|
+
jsonResponse(res, 200, {
|
|
708
|
+
content: stored.fullContents[segIndex],
|
|
709
|
+
charLength: stored.fullContents[segIndex].length,
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// ── API: Search across all requests ──
|
|
716
|
+
const searchMatch = req.url.match(/^\/api\/search\?q=(.+)$/);
|
|
717
|
+
if (searchMatch) {
|
|
718
|
+
const query = decodeURIComponent(searchMatch[1]).toLowerCase();
|
|
719
|
+
const results = [];
|
|
720
|
+
for (const [reqId, stored] of reqStore) {
|
|
721
|
+
for (let i = 0; i < stored.fullContents.length; i++) {
|
|
722
|
+
const content = stored.fullContents[i].toLowerCase();
|
|
723
|
+
const idx = content.indexOf(query);
|
|
724
|
+
if (idx !== -1) {
|
|
725
|
+
const start = Math.max(0, idx - 60);
|
|
726
|
+
const end = Math.min(content.length, idx + query.length + 60);
|
|
727
|
+
results.push({
|
|
728
|
+
turnId: reqId,
|
|
729
|
+
segIndex: i,
|
|
730
|
+
snippet: stored.fullContents[i].slice(start, end),
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
jsonResponse(res, 200, { results });
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// ── API: List profiles ──
|
|
740
|
+
if (req.url === "/api/profiles") {
|
|
741
|
+
jsonResponse(res, 200, { profiles, active: activeProfile });
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// ── API: Get active profile ──
|
|
746
|
+
if (req.url === "/api/active-profile") {
|
|
747
|
+
jsonResponse(res, 200, { active: activeProfile, profile: profiles[activeProfile] || null });
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// ── API: Router config (default — no plugin) ──
|
|
752
|
+
if (req.url === "/api/router/config") {
|
|
753
|
+
jsonResponse(res, 200, {
|
|
754
|
+
schema_version: 1,
|
|
755
|
+
mode: "off",
|
|
756
|
+
premium: false,
|
|
757
|
+
available: false,
|
|
758
|
+
effective_mode: "off",
|
|
759
|
+
locked_reason: "requires_pro",
|
|
760
|
+
});
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// ── API: Router status (default — no plugin) ──
|
|
765
|
+
if (req.url === "/api/router/status") {
|
|
766
|
+
jsonResponse(res, 200, {
|
|
767
|
+
schema_version: 1,
|
|
768
|
+
premium: false,
|
|
769
|
+
available: false,
|
|
770
|
+
effective_mode: "off",
|
|
771
|
+
locked_reason: "requires_pro",
|
|
772
|
+
mode: "off",
|
|
773
|
+
runtime: null,
|
|
774
|
+
capabilities: null,
|
|
775
|
+
model: null,
|
|
776
|
+
metrics: null,
|
|
777
|
+
});
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
res.writeHead(404);
|
|
782
|
+
res.end("Not found");
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// ── API: Profile management (POST / DELETE) ──
|
|
787
|
+
if (req.method === "POST" && req.url === "/api/profiles") {
|
|
788
|
+
readBody(req).then((buf) => {
|
|
789
|
+
try {
|
|
790
|
+
const data = JSON.parse(buf.toString());
|
|
791
|
+
const { name, mode, tools } = data;
|
|
792
|
+
if (!name || name === "All Tools") {
|
|
793
|
+
jsonResponse(res, 400, { error: "Invalid profile name" });
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
profiles[name] = { name, mode: mode || "blocklist", tools: tools || [] };
|
|
797
|
+
saveProfiles();
|
|
798
|
+
broadcast({ type: "profiles_updated", profiles, active: activeProfile });
|
|
799
|
+
jsonResponse(res, 200, { success: true, profile: profiles[name] });
|
|
800
|
+
} catch (err) {
|
|
801
|
+
jsonResponse(res, 400, { error: err.message });
|
|
802
|
+
}
|
|
803
|
+
});
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
if (req.method === "POST" && req.url === "/api/active-profile") {
|
|
808
|
+
readBody(req).then((buf) => {
|
|
809
|
+
try {
|
|
810
|
+
const data = JSON.parse(buf.toString());
|
|
811
|
+
if (profiles[data.name]) {
|
|
812
|
+
activeProfile = data.name;
|
|
813
|
+
saveProfiles();
|
|
814
|
+
broadcast({ type: "active_profile_changed", active: activeProfile, profile: profiles[activeProfile] });
|
|
815
|
+
jsonResponse(res, 200, { success: true, active: activeProfile });
|
|
816
|
+
} else {
|
|
817
|
+
jsonResponse(res, 404, { error: "Profile not found" });
|
|
818
|
+
}
|
|
819
|
+
} catch (err) {
|
|
820
|
+
jsonResponse(res, 400, { error: err.message });
|
|
821
|
+
}
|
|
822
|
+
});
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
if (req.method === "DELETE") {
|
|
827
|
+
const deleteMatch = req.url.match(/^\/api\/profiles\/(.+)$/);
|
|
828
|
+
if (deleteMatch) {
|
|
829
|
+
const name = decodeURIComponent(deleteMatch[1]);
|
|
830
|
+
if (name === "All Tools") {
|
|
831
|
+
jsonResponse(res, 400, { error: "Cannot delete default profile" });
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
delete profiles[name];
|
|
835
|
+
if (activeProfile === name) activeProfile = "All Tools";
|
|
836
|
+
saveProfiles();
|
|
837
|
+
broadcast({ type: "profiles_updated", profiles, active: activeProfile });
|
|
838
|
+
jsonResponse(res, 200, { success: true });
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// ── Proxy API requests to Anthropic ──
|
|
844
|
+
let bodyChunks = [];
|
|
845
|
+
req.on("data", (chunk) => bodyChunks.push(chunk));
|
|
846
|
+
req.on("end", async () => {
|
|
847
|
+
const bodyBuffer = Buffer.concat(bodyChunks);
|
|
848
|
+
const bodyStr = bodyBuffer.toString("utf-8");
|
|
849
|
+
|
|
850
|
+
let forwardBuffer = bodyBuffer;
|
|
851
|
+
let filteringInfo = null;
|
|
852
|
+
let requestTurn = null;
|
|
853
|
+
|
|
854
|
+
// Analyze + filter if it's a messages endpoint
|
|
855
|
+
if (req.url.includes("/messages")) {
|
|
856
|
+
try {
|
|
857
|
+
const parsed = JSON.parse(bodyStr);
|
|
858
|
+
|
|
859
|
+
// Apply tool filtering
|
|
860
|
+
const originalToolCount = (parsed.tools || []).length;
|
|
861
|
+
const { filtered, removed } = applyToolFilter(parsed.tools, activeProfile);
|
|
862
|
+
|
|
863
|
+
if (removed.length > 0) {
|
|
864
|
+
parsed.tools = filtered;
|
|
865
|
+
const modifiedStr = JSON.stringify(parsed);
|
|
866
|
+
forwardBuffer = Buffer.from(modifiedStr, "utf-8");
|
|
867
|
+
filteringInfo = {
|
|
868
|
+
originalToolCount,
|
|
869
|
+
filteredToolCount: filtered.length,
|
|
870
|
+
removedTools: removed,
|
|
871
|
+
tokensSaved: estimateTokens(JSON.stringify(removed.map(name =>
|
|
872
|
+
(JSON.parse(bodyStr).tools || []).find(t => t.name === name)
|
|
873
|
+
).filter(Boolean))),
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// Analyze the FILTERED request (what actually gets sent)
|
|
878
|
+
const analysis = analyzeRequest(parsed);
|
|
879
|
+
|
|
880
|
+
// Attach filtering info
|
|
881
|
+
if (filteringInfo) {
|
|
882
|
+
analysis.filteringActive = true;
|
|
883
|
+
analysis.originalToolCount = filteringInfo.originalToolCount;
|
|
884
|
+
analysis.filteredToolCount = filteringInfo.filteredToolCount;
|
|
885
|
+
analysis.removedTools = filteringInfo.removedTools;
|
|
886
|
+
analysis.tokensSaved = filteringInfo.tokensSaved;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
requestTurn = analysis.turn;
|
|
890
|
+
|
|
891
|
+
broadcast({ type: "request", ...analysis });
|
|
892
|
+
console.log(
|
|
893
|
+
`[R${analysis.turn}] ${analysis.model} | ${analysis.segments.length} segs | ~${analysis.totalEstimatedTokens} tokens | $${analysis.estimatedCost.totalCost.toFixed(4)}${filteringInfo ? ` | FILTERED: ${filteringInfo.originalToolCount}→${filteringInfo.filteredToolCount} tools (-${filteringInfo.removedTools.length})` : ""}`
|
|
894
|
+
);
|
|
895
|
+
|
|
896
|
+
// Let plugins analyze the request (router prediction, etc.)
|
|
897
|
+
await pluginHost.onRequestAnalyzed(analysis, {
|
|
898
|
+
reqStore,
|
|
899
|
+
activeProfile,
|
|
900
|
+
profiles,
|
|
901
|
+
parsedBody: parsed,
|
|
902
|
+
broadcast,
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
// Fire count_tokens in parallel for accurate count (non-blocking)
|
|
906
|
+
const apiKey = req.headers["x-api-key"];
|
|
907
|
+
if (apiKey) {
|
|
908
|
+
countTokensViaAPI(parsed, apiKey).then((exactTokens) => {
|
|
909
|
+
if (exactTokens && exactTokens > 0) {
|
|
910
|
+
const scaleFactor = exactTokens / analysis.totalEstimatedTokens;
|
|
911
|
+
const scaledSegments = analysis.segments.map(seg => ({
|
|
912
|
+
...seg,
|
|
913
|
+
tokens: Math.round(seg.tokens * scaleFactor),
|
|
914
|
+
}));
|
|
915
|
+
const exactCost = calculateCost(exactTokens, 0, analysis.model);
|
|
916
|
+
broadcast({
|
|
917
|
+
type: "token_count_update",
|
|
918
|
+
turn: analysis.turn,
|
|
919
|
+
exactInputTokens: exactTokens,
|
|
920
|
+
scaleFactor: scaleFactor.toFixed(3),
|
|
921
|
+
segments: scaledSegments,
|
|
922
|
+
estimatedCost: exactCost,
|
|
923
|
+
});
|
|
924
|
+
console.log(
|
|
925
|
+
` → count_tokens: ${exactTokens.toLocaleString()} exact (was ~${analysis.totalEstimatedTokens.toLocaleString()}, scale ${scaleFactor.toFixed(2)}x)`
|
|
926
|
+
);
|
|
927
|
+
}
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
} catch (e) {
|
|
931
|
+
console.error("Failed to parse request body:", e.message);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// Forward to Anthropic
|
|
936
|
+
const fwdHeaders = {};
|
|
937
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
938
|
+
if (key === "host" || key === "connection" || key === "accept-encoding") continue;
|
|
939
|
+
fwdHeaders[key] = value;
|
|
940
|
+
}
|
|
941
|
+
fwdHeaders["host"] = ANTHROPIC_HOST;
|
|
942
|
+
fwdHeaders["content-length"] = forwardBuffer.length;
|
|
943
|
+
|
|
944
|
+
const proxyReq = https.request(
|
|
945
|
+
{
|
|
946
|
+
hostname: ANTHROPIC_HOST,
|
|
947
|
+
port: 443,
|
|
948
|
+
path: req.url,
|
|
949
|
+
method: req.method,
|
|
950
|
+
headers: fwdHeaders,
|
|
951
|
+
},
|
|
952
|
+
(proxyRes) => {
|
|
953
|
+
const resHeaders = {};
|
|
954
|
+
for (const [key, value] of Object.entries(proxyRes.headers)) {
|
|
955
|
+
resHeaders[key] = value;
|
|
956
|
+
}
|
|
957
|
+
res.writeHead(proxyRes.statusCode, resHeaders);
|
|
958
|
+
|
|
959
|
+
let responseData = "";
|
|
960
|
+
proxyRes.on("data", (chunk) => {
|
|
961
|
+
res.write(chunk);
|
|
962
|
+
responseData += chunk.toString();
|
|
963
|
+
});
|
|
964
|
+
proxyRes.on("end", () => {
|
|
965
|
+
res.end();
|
|
966
|
+
|
|
967
|
+
if (req.url.includes("/messages")) {
|
|
968
|
+
const parsed = parseStreamedResponse(responseData);
|
|
969
|
+
let { inputTokens: actualInput, outputTokens: actualOutput, cacheCreationTokens, cacheReadTokens, stopReason, toolsUsed } = parsed;
|
|
970
|
+
|
|
971
|
+
if (!actualInput) {
|
|
972
|
+
try {
|
|
973
|
+
const jsonRes = JSON.parse(responseData);
|
|
974
|
+
const u = jsonRes.usage || {};
|
|
975
|
+
const nonCached = u.input_tokens || 0;
|
|
976
|
+
cacheCreationTokens = u.cache_creation_input_tokens || 0;
|
|
977
|
+
cacheReadTokens = u.cache_read_input_tokens || 0;
|
|
978
|
+
actualInput = nonCached + cacheCreationTokens + cacheReadTokens;
|
|
979
|
+
actualOutput = u.output_tokens || 0;
|
|
980
|
+
if (Array.isArray(jsonRes.content)) {
|
|
981
|
+
for (const block of jsonRes.content) {
|
|
982
|
+
if (block.type === "tool_use" && block.name) {
|
|
983
|
+
toolsUsed.push(block.name);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
} catch (e) { /* streaming response, already parsed above */ }
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// Correlate response to originating request
|
|
991
|
+
const reqId = requestTurn ?? reqCounter - 1;
|
|
992
|
+
const stored = reqStore.get(reqId);
|
|
993
|
+
if (stored) stored.toolsUsed = toolsUsed;
|
|
994
|
+
const model = stored?.model || "unknown";
|
|
995
|
+
const cost = (actualInput || actualOutput)
|
|
996
|
+
? calculateCost(actualInput, actualOutput, model, cacheCreationTokens, cacheReadTokens)
|
|
997
|
+
: null;
|
|
998
|
+
|
|
999
|
+
if (actualInput || actualOutput) {
|
|
1000
|
+
broadcast({
|
|
1001
|
+
type: "response_complete",
|
|
1002
|
+
turn: reqId,
|
|
1003
|
+
usage: {
|
|
1004
|
+
input_tokens: actualInput,
|
|
1005
|
+
output_tokens: actualOutput,
|
|
1006
|
+
cache_creation_input_tokens: cacheCreationTokens || 0,
|
|
1007
|
+
cache_read_input_tokens: cacheReadTokens || 0,
|
|
1008
|
+
},
|
|
1009
|
+
cost,
|
|
1010
|
+
stopReason,
|
|
1011
|
+
toolsUsed: toolsUsed.length > 0 ? toolsUsed : undefined,
|
|
1012
|
+
timestamp: Date.now(),
|
|
1013
|
+
});
|
|
1014
|
+
const cacheInfo = (cacheCreationTokens || cacheReadTokens)
|
|
1015
|
+
? ` (cache: ${cacheReadTokens} read, ${cacheCreationTokens} created)`
|
|
1016
|
+
: "";
|
|
1017
|
+
const toolsInfo = toolsUsed.length > 0 ? ` | tools: ${toolsUsed.join(", ")}` : "";
|
|
1018
|
+
console.log(
|
|
1019
|
+
` → [R${reqId}] Response: ${actualInput} in / ${actualOutput} out [${stopReason}] | $${cost.totalCost.toFixed(4)}${cacheInfo}${toolsInfo}`
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
// Update per-session stop reason for turn-boundary detection
|
|
1024
|
+
if (stored?.sessionHash && conversationGroups.has(stored.sessionHash)) {
|
|
1025
|
+
conversationGroups.get(stored.sessionHash).lastStopReason = stopReason;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// Let plugins handle response completion (eval logging, etc.)
|
|
1029
|
+
pluginHost.onResponseComplete(reqId, {
|
|
1030
|
+
stored,
|
|
1031
|
+
activeProfile,
|
|
1032
|
+
profiles,
|
|
1033
|
+
stopReason,
|
|
1034
|
+
actualInput,
|
|
1035
|
+
actualOutput,
|
|
1036
|
+
cost,
|
|
1037
|
+
toolsUsed,
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
);
|
|
1043
|
+
|
|
1044
|
+
proxyReq.on("error", (err) => {
|
|
1045
|
+
console.error("Proxy error:", err.message);
|
|
1046
|
+
res.writeHead(502, { "Content-Type": "application/json" });
|
|
1047
|
+
res.end(JSON.stringify({ error: "Proxy error", message: err.message }));
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
proxyReq.write(forwardBuffer);
|
|
1051
|
+
proxyReq.end();
|
|
1052
|
+
});
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
// ─── WebSocket Server ──────────────────────────────────────────────────────
|
|
1056
|
+
|
|
1057
|
+
const wss = new WebSocketServer({ server });
|
|
1058
|
+
|
|
1059
|
+
wss.on("connection", (ws) => {
|
|
1060
|
+
wsClients.add(ws);
|
|
1061
|
+
console.log(`Inspector connected (${wsClients.size} clients)`);
|
|
1062
|
+
ws.send(JSON.stringify({
|
|
1063
|
+
type: "connected",
|
|
1064
|
+
requests: reqCounter,
|
|
1065
|
+
profiles,
|
|
1066
|
+
activeProfile,
|
|
1067
|
+
premium: false,
|
|
1068
|
+
routerMode: "off",
|
|
1069
|
+
...pluginHost.getConnectPayload(),
|
|
1070
|
+
}));
|
|
1071
|
+
|
|
1072
|
+
ws.on("message", (data) => {
|
|
1073
|
+
try {
|
|
1074
|
+
const msg = JSON.parse(data.toString());
|
|
1075
|
+
if (msg.type === "set_active_profile") {
|
|
1076
|
+
if (profiles[msg.profile]) {
|
|
1077
|
+
activeProfile = msg.profile;
|
|
1078
|
+
saveProfiles();
|
|
1079
|
+
broadcast({ type: "active_profile_changed", active: activeProfile, profile: profiles[activeProfile] });
|
|
1080
|
+
console.log(` Profile changed → ${activeProfile}`);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
} catch (err) {
|
|
1084
|
+
console.error("Failed to parse WS message:", err.message);
|
|
1085
|
+
}
|
|
1086
|
+
});
|
|
1087
|
+
|
|
1088
|
+
ws.on("close", () => {
|
|
1089
|
+
wsClients.delete(ws);
|
|
1090
|
+
console.log(`Inspector disconnected (${wsClients.size} clients)`);
|
|
1091
|
+
});
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
// ─── Return server control object ──────────────────────────────────────────
|
|
1095
|
+
|
|
1096
|
+
return {
|
|
1097
|
+
server,
|
|
1098
|
+
wss,
|
|
1099
|
+
broadcast,
|
|
1100
|
+
pluginHost,
|
|
1101
|
+
|
|
1102
|
+
async start() {
|
|
1103
|
+
// Run plugin init (load config, init data dirs, etc.)
|
|
1104
|
+
await pluginHost.onInit({ broadcast });
|
|
1105
|
+
|
|
1106
|
+
return new Promise((resolve) => {
|
|
1107
|
+
server.listen(PORT, async () => {
|
|
1108
|
+
console.log("");
|
|
1109
|
+
console.log(" ┌─────────────────────────────────────────────┐");
|
|
1110
|
+
console.log(" │ Jannal — Inspector Proxy │");
|
|
1111
|
+
console.log(" └─────────────────────────────────────────────┘");
|
|
1112
|
+
console.log("");
|
|
1113
|
+
console.log(` Inspector UI: http://localhost:${PORT}`);
|
|
1114
|
+
console.log(` Proxy target: https://${ANTHROPIC_HOST}`);
|
|
1115
|
+
console.log(` Active profile: ${activeProfile}`);
|
|
1116
|
+
console.log(` Profiles loaded: ${Object.keys(profiles).length}`);
|
|
1117
|
+
console.log("");
|
|
1118
|
+
console.log(" To use with Claude Code:");
|
|
1119
|
+
console.log(` ANTHROPIC_BASE_URL=http://localhost:${PORT} claude`);
|
|
1120
|
+
console.log("");
|
|
1121
|
+
|
|
1122
|
+
// Run plugin server-start hooks (warm up models, etc.)
|
|
1123
|
+
await pluginHost.onServerStart({ broadcast });
|
|
1124
|
+
|
|
1125
|
+
console.log("");
|
|
1126
|
+
console.log(" Waiting for requests...");
|
|
1127
|
+
console.log("");
|
|
1128
|
+
|
|
1129
|
+
resolve({ server, port: PORT });
|
|
1130
|
+
});
|
|
1131
|
+
});
|
|
1132
|
+
},
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// ─── Direct execution: core-only mode ────────────────────────────────────────
|
|
1137
|
+
|
|
1138
|
+
if (require.main === module) {
|
|
1139
|
+
createServer().start();
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
module.exports = { createServer };
|