@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/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 };