context-lens 0.2.0 → 0.2.1

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.
Files changed (79) hide show
  1. package/README.md +62 -17
  2. package/dist/cli-utils.d.ts +1 -1
  3. package/dist/cli-utils.d.ts.map +1 -1
  4. package/dist/cli-utils.js +28 -13
  5. package/dist/cli-utils.js.map +1 -1
  6. package/dist/cli.js +77 -65
  7. package/dist/cli.js.map +1 -1
  8. package/dist/core/conversation.d.ts +54 -0
  9. package/dist/core/conversation.d.ts.map +1 -0
  10. package/dist/core/conversation.js +188 -0
  11. package/dist/core/conversation.js.map +1 -0
  12. package/dist/core/models.d.ts +30 -0
  13. package/dist/core/models.d.ts.map +1 -0
  14. package/dist/core/models.js +96 -0
  15. package/dist/core/models.js.map +1 -0
  16. package/dist/core/parse.d.ts +17 -0
  17. package/dist/core/parse.d.ts.map +1 -0
  18. package/dist/core/parse.js +349 -0
  19. package/dist/core/parse.js.map +1 -0
  20. package/dist/core/routing.d.ts +47 -0
  21. package/dist/core/routing.d.ts.map +1 -0
  22. package/dist/core/routing.js +132 -0
  23. package/dist/core/routing.js.map +1 -0
  24. package/dist/core/source.d.ts +22 -0
  25. package/dist/core/source.d.ts.map +1 -0
  26. package/dist/core/source.js +56 -0
  27. package/dist/core/source.js.map +1 -0
  28. package/dist/core/tokens.d.ts +11 -0
  29. package/dist/core/tokens.d.ts.map +1 -0
  30. package/dist/core/tokens.js +16 -0
  31. package/dist/core/tokens.js.map +1 -0
  32. package/dist/core.d.ts +12 -22
  33. package/dist/core.d.ts.map +1 -1
  34. package/dist/core.js +12 -471
  35. package/dist/core.js.map +1 -1
  36. package/dist/http/headers.d.ts +25 -0
  37. package/dist/http/headers.d.ts.map +1 -0
  38. package/dist/http/headers.js +54 -0
  39. package/dist/http/headers.js.map +1 -0
  40. package/dist/lhar-types.generated.d.ts +1 -1
  41. package/dist/lhar.d.ts +4 -4
  42. package/dist/lhar.d.ts.map +1 -1
  43. package/dist/lhar.js +190 -106
  44. package/dist/lhar.js.map +1 -1
  45. package/dist/server/config.d.ts +12 -0
  46. package/dist/server/config.d.ts.map +1 -0
  47. package/dist/server/config.js +33 -0
  48. package/dist/server/config.js.map +1 -0
  49. package/dist/server/projection.d.ts +9 -0
  50. package/dist/server/projection.d.ts.map +1 -0
  51. package/dist/server/projection.js +39 -0
  52. package/dist/server/projection.js.map +1 -0
  53. package/dist/server/proxy.d.ts +13 -0
  54. package/dist/server/proxy.d.ts.map +1 -0
  55. package/dist/server/proxy.js +232 -0
  56. package/dist/server/proxy.js.map +1 -0
  57. package/dist/server/store.d.ts +33 -0
  58. package/dist/server/store.d.ts.map +1 -0
  59. package/dist/server/store.js +350 -0
  60. package/dist/server/store.js.map +1 -0
  61. package/dist/server/webui.d.ts +5 -0
  62. package/dist/server/webui.d.ts.map +1 -0
  63. package/dist/server/webui.js +170 -0
  64. package/dist/server/webui.js.map +1 -0
  65. package/dist/server-utils.d.ts +2 -2
  66. package/dist/server-utils.d.ts.map +1 -1
  67. package/dist/server-utils.js +12 -21
  68. package/dist/server-utils.js.map +1 -1
  69. package/dist/server.js +30 -697
  70. package/dist/server.js.map +1 -1
  71. package/dist/types.d.ts +50 -10
  72. package/dist/types.d.ts.map +1 -1
  73. package/dist/version.generated.d.ts +2 -0
  74. package/dist/version.generated.d.ts.map +1 -0
  75. package/dist/version.generated.js +2 -0
  76. package/dist/version.generated.js.map +1 -0
  77. package/package.json +18 -6
  78. package/public/index.html +39 -12
  79. package/schema/lhar.schema.json +1 -1
package/dist/server.js CHANGED
@@ -1,707 +1,40 @@
1
1
  #!/usr/bin/env node
2
- import http from 'node:http';
3
- import https from 'node:https';
4
- import url from 'node:url';
5
- import fs from 'node:fs';
6
- import path from 'node:path';
7
- import { fileURLToPath } from 'node:url';
8
- import { estimateTokens, detectApiFormat, parseContextInfo, getContextLimit, extractSource, resolveTargetUrl, extractWorkingDirectory, computeAgentKey, computeFingerprint, extractConversationLabel, detectSource, estimateCost, } from './core.js';
9
- import { analyzeComposition, parseResponseUsage, buildLharRecord, buildSessionLine, toLharJsonl, toLharJson } from './lhar.js';
10
- import { safeFilenamePart, headersForResolution, selectHeaders } from './server-utils.js';
2
+ import http from "node:http";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { loadServerConfig } from "./server/config.js";
6
+ import { createProxyHandler } from "./server/proxy.js";
7
+ import { Store } from "./server/store.js";
8
+ import { createWebUIHandler, loadHtmlUI } from "./server/webui.js";
11
9
  const __filename = fileURLToPath(import.meta.url);
12
10
  const __dirname = path.dirname(__filename);
13
- // Upstream targets — configurable via env vars
14
- const UPSTREAM_OPENAI_URL = process.env.UPSTREAM_OPENAI_URL || 'https://api.openai.com/v1';
15
- const UPSTREAM_ANTHROPIC_URL = process.env.UPSTREAM_ANTHROPIC_URL || 'https://api.anthropic.com';
16
- const UPSTREAM_CHATGPT_URL = process.env.UPSTREAM_CHATGPT_URL || 'https://chatgpt.com';
17
- // Safety defaults:
18
- // - Bind only to localhost unless explicitly overridden.
19
- // - Do not honor `x-target-url` unless explicitly enabled (prevents accidental open-proxy/SSRF).
20
- const BIND_HOST = process.env.CONTEXT_LENS_BIND_HOST || '127.0.0.1';
21
- const ALLOW_TARGET_OVERRIDE = process.env.CONTEXT_LENS_ALLOW_TARGET_OVERRIDE === '1';
22
- // In-memory storage for captured requests
23
- const capturedRequests = [];
24
- const MAX_SESSIONS = 10;
25
- let dataRevision = 0; // Monotonic counter, incremented on every store
26
- let nextEntryId = 1; // Integer counter for entry IDs (fix float collision)
27
- // Conversation threading — group requests by fingerprint
28
- const conversations = new Map(); // fingerprint -> { id, label, source, firstSeen }
29
- // Responses API chaining: response_id -> conversationId
30
- const responseIdToConvo = new Map();
31
- // Disk logging — one LHAR file per session/conversation
32
- const DATA_DIR = path.join(__dirname, '..', 'data');
33
- try {
34
- fs.mkdirSync(DATA_DIR, { recursive: true });
35
- }
36
- catch { }
37
- // Track which conversations already have a session preamble on disk
38
- const diskSessionsWritten = new Set();
39
- function logToDisk(entry) {
40
- const safeSource = safeFilenamePart(entry.source || 'unknown');
41
- const safeConvo = entry.conversationId ? safeFilenamePart(entry.conversationId) : null;
42
- const filename = safeConvo
43
- ? `${safeSource}-${safeConvo}.lhar`
44
- : 'ungrouped.lhar';
45
- const filePath = path.join(DATA_DIR, filename);
46
- let output = '';
47
- // Write session preamble on first entry for this conversation
48
- if (entry.conversationId && !diskSessionsWritten.has(entry.conversationId)) {
49
- diskSessionsWritten.add(entry.conversationId);
50
- const convo = conversations.get(entry.conversationId);
51
- if (convo) {
52
- const sessionLine = buildSessionLine(entry.conversationId, convo, entry.contextInfo.model);
53
- output += JSON.stringify(sessionLine) + '\n';
54
- }
55
- }
56
- const record = buildLharRecord(entry, capturedRequests);
57
- output += JSON.stringify(record) + '\n';
58
- fs.appendFile(filePath, output, err => { if (err)
59
- console.error('Log write error:', err.message); });
60
- }
61
- // Compact a content block — keep tool metadata, truncate text
62
- function compactBlock(b) {
63
- switch (b.type) {
64
- case 'tool_use':
65
- return { type: 'tool_use', id: b.id, name: b.name, input: {} };
66
- case 'tool_result': {
67
- const rc = typeof b.content === 'string'
68
- ? b.content.slice(0, 200)
69
- : Array.isArray(b.content)
70
- ? b.content.map(compactBlock)
71
- : '';
72
- return { type: 'tool_result', tool_use_id: b.tool_use_id, content: rc };
73
- }
74
- case 'text':
75
- return { type: 'text', text: b.text.slice(0, 200) };
76
- case 'input_text':
77
- return { type: 'input_text', text: b.text.slice(0, 200) };
78
- case 'image':
79
- return { type: 'image' };
80
- default: {
81
- // Handle thinking blocks and other unknown types — truncate text-like fields
82
- const any = b;
83
- if (any.thinking)
84
- return { ...any, thinking: any.thinking.slice(0, 200) };
85
- if (any.text)
86
- return { ...any, text: any.text.slice(0, 200) };
87
- return b;
88
- }
89
- }
90
- }
91
- // Compact contextInfo — keep metadata and token counts, drop large text payloads
92
- function compactContextInfo(ci) {
93
- return {
94
- provider: ci.provider,
95
- apiFormat: ci.apiFormat,
96
- model: ci.model,
97
- systemTokens: ci.systemTokens,
98
- toolsTokens: ci.toolsTokens,
99
- messagesTokens: ci.messagesTokens,
100
- totalTokens: ci.totalTokens,
101
- systemPrompts: [],
102
- tools: [],
103
- messages: ci.messages.map(m => ({
104
- role: m.role,
105
- content: typeof m.content === 'string' ? m.content.slice(0, 200) : '',
106
- tokens: m.tokens,
107
- contentBlocks: m.contentBlocks?.map(compactBlock) ?? null,
108
- })),
109
- };
110
- }
111
- // Release heavy data from an entry after it's been logged to disk
112
- function compactEntry(entry) {
113
- // Extract and preserve usage data from response before dropping it
114
- const usage = parseResponseUsage(entry.response);
115
- entry.response = {
116
- usage: {
117
- input_tokens: usage.inputTokens,
118
- output_tokens: usage.outputTokens,
119
- cache_read_input_tokens: usage.cacheReadTokens,
120
- cache_creation_input_tokens: usage.cacheWriteTokens,
121
- },
122
- model: usage.model,
123
- stop_reason: usage.finishReasons[0] || null,
124
- };
125
- entry.rawBody = undefined;
126
- entry.requestHeaders = {};
127
- entry.responseHeaders = {};
128
- // Compact contextInfo in-place
129
- entry.contextInfo.systemPrompts = [];
130
- entry.contextInfo.tools = [];
131
- entry.contextInfo.messages = entry.contextInfo.messages.map(m => ({
132
- role: m.role,
133
- content: typeof m.content === 'string' ? m.content.slice(0, 200) : '',
134
- tokens: m.tokens,
135
- contentBlocks: m.contentBlocks?.map(compactBlock) ?? null,
136
- }));
137
- }
138
- // Lightweight projection — strip rawBody, heavy headers; compact contextInfo
139
- // NOTE: response is included because compactEntry has already reduced it to just usage data
140
- function projectEntry(e) {
141
- const resp = e.response;
142
- const usage = resp?.usage;
143
- return {
144
- id: e.id,
145
- timestamp: e.timestamp,
146
- contextInfo: compactContextInfo(e.contextInfo),
147
- response: e.response,
148
- contextLimit: e.contextLimit,
149
- source: e.source,
150
- conversationId: e.conversationId,
151
- agentKey: e.agentKey,
152
- agentLabel: e.agentLabel,
153
- httpStatus: e.httpStatus,
154
- timings: e.timings,
155
- requestBytes: e.requestBytes,
156
- responseBytes: e.responseBytes,
157
- targetUrl: e.targetUrl,
158
- composition: e.composition,
159
- costUsd: e.costUsd,
160
- usage: usage ? {
161
- inputTokens: usage.input_tokens || 0,
162
- outputTokens: usage.output_tokens || 0,
163
- cacheReadTokens: usage.cache_read_input_tokens || 0,
164
- cacheWriteTokens: usage.cache_creation_input_tokens || 0,
165
- } : null,
166
- responseModel: resp?.model || null,
167
- stopReason: resp?.stop_reason || null,
168
- };
169
- }
170
- // State persistence — full rewrite to data/state.jsonl after every storeRequest()
171
- const STATE_FILE = path.join(DATA_DIR, 'state.jsonl');
172
- function saveState() {
173
- let lines = '';
174
- for (const [, convo] of conversations) {
175
- lines += JSON.stringify({ type: 'conversation', data: convo }) + '\n';
176
- }
177
- for (const entry of capturedRequests) {
178
- lines += JSON.stringify({ type: 'entry', data: projectEntry(entry) }) + '\n';
179
- }
180
- try {
181
- fs.writeFileSync(STATE_FILE, lines);
182
- }
183
- catch (err) {
184
- console.error('State save error:', err.message);
185
- }
186
- }
187
- function loadState() {
188
- let content;
189
- try {
190
- content = fs.readFileSync(STATE_FILE, 'utf8');
191
- }
192
- catch {
193
- return; // No state file — fresh start
194
- }
195
- const lines = content.split('\n').filter(l => l.length > 0);
196
- let loadedEntries = 0;
197
- let maxId = 0;
198
- for (const line of lines) {
199
- try {
200
- const record = JSON.parse(line);
201
- if (record.type === 'conversation') {
202
- const c = record.data;
203
- conversations.set(c.id, c);
204
- diskSessionsWritten.add(c.id);
205
- }
206
- else if (record.type === 'entry') {
207
- const projected = record.data;
208
- const entry = {
209
- ...projected,
210
- response: projected.response || { raw: true },
211
- requestHeaders: {},
212
- responseHeaders: {},
213
- rawBody: undefined,
214
- };
215
- capturedRequests.push(entry);
216
- if (entry.id > maxId)
217
- maxId = entry.id;
218
- loadedEntries++;
219
- }
220
- }
221
- catch (err) {
222
- console.error('State parse error:', err.message);
223
- }
224
- }
225
- if (loadedEntries > 0) {
226
- nextEntryId = maxId + 1;
227
- dataRevision = 1;
228
- // Loaded entries are already compact (projectEntry strips heavy data before saving).
229
- // Do NOT call compactEntry here — it would destroy the preserved response usage data.
230
- console.log(`Restored ${loadedEntries} entries from ${conversations.size} conversations`);
231
- }
232
- }
233
- // Store captured request
234
- function storeRequest(contextInfo, responseData, source, rawBody, meta, requestHeaders) {
235
- const resolvedSource = detectSource(contextInfo, source, requestHeaders);
236
- const fingerprint = computeFingerprint(contextInfo, rawBody ?? null, responseIdToConvo);
237
- // Register or look up conversation
238
- let conversationId = null;
239
- if (fingerprint) {
240
- if (!conversations.has(fingerprint)) {
241
- conversations.set(fingerprint, {
242
- id: fingerprint,
243
- label: extractConversationLabel(contextInfo),
244
- source: resolvedSource || 'unknown',
245
- workingDirectory: extractWorkingDirectory(contextInfo),
246
- firstSeen: new Date().toISOString(),
247
- });
248
- }
249
- else {
250
- const convo = conversations.get(fingerprint);
251
- // Backfill source if first request couldn't detect it
252
- if (convo.source === 'unknown' && resolvedSource && resolvedSource !== 'unknown') {
253
- convo.source = resolvedSource;
254
- }
255
- // Backfill working directory if first request didn't have it
256
- if (!convo.workingDirectory) {
257
- const wd = extractWorkingDirectory(contextInfo);
258
- if (wd)
259
- convo.workingDirectory = wd;
260
- }
261
- }
262
- conversationId = fingerprint;
263
- }
264
- // Agent key: distinguishes agents within a session (main vs subagents)
265
- const agentKey = computeAgentKey(contextInfo);
266
- const agentLabel = extractConversationLabel(contextInfo);
267
- // Compute composition and cost
268
- const composition = analyzeComposition(contextInfo, rawBody);
269
- const usage = parseResponseUsage(responseData);
270
- const inputTok = usage.inputTokens || contextInfo.totalTokens;
271
- const outputTok = usage.outputTokens;
272
- const costUsd = estimateCost(contextInfo.model, inputTok, outputTok);
273
- const entry = {
274
- id: nextEntryId++,
275
- timestamp: new Date().toISOString(),
276
- contextInfo,
277
- response: responseData,
278
- contextLimit: getContextLimit(contextInfo.model),
279
- source: resolvedSource || 'unknown',
280
- conversationId,
281
- agentKey,
282
- agentLabel,
283
- httpStatus: meta?.httpStatus ?? null,
284
- timings: meta?.timings ?? null,
285
- requestBytes: meta?.requestBytes ?? 0,
286
- responseBytes: meta?.responseBytes ?? 0,
287
- targetUrl: meta?.targetUrl ?? null,
288
- requestHeaders: meta?.requestHeaders ?? {},
289
- responseHeaders: meta?.responseHeaders ?? {},
290
- rawBody,
291
- composition,
292
- costUsd,
293
- };
294
- // Track response IDs for Responses API chaining
295
- const respId = responseData.id || responseData.response_id;
296
- if (respId && conversationId) {
297
- responseIdToConvo.set(respId, conversationId);
298
- }
299
- capturedRequests.unshift(entry);
300
- // Evict oldest sessions when we exceed the session limit
301
- if (conversations.size > MAX_SESSIONS) {
302
- // Find the oldest session by its most recent entry timestamp
303
- const sessionLatest = new Map();
304
- for (const r of capturedRequests) {
305
- if (r.conversationId) {
306
- const t = new Date(r.timestamp).getTime();
307
- const cur = sessionLatest.get(r.conversationId) || 0;
308
- if (t > cur)
309
- sessionLatest.set(r.conversationId, t);
310
- }
311
- }
312
- // Sort sessions oldest-first, evict until we're at the limit
313
- const sorted = [...sessionLatest.entries()].sort((a, b) => a[1] - b[1]);
314
- const toEvict = sorted.slice(0, sorted.length - MAX_SESSIONS).map(s => s[0]);
315
- const evictSet = new Set(toEvict);
316
- // Remove all entries belonging to evicted sessions
317
- for (let i = capturedRequests.length - 1; i >= 0; i--) {
318
- if (capturedRequests[i].conversationId && evictSet.has(capturedRequests[i].conversationId)) {
319
- capturedRequests.splice(i, 1);
320
- }
321
- }
322
- for (const cid of toEvict) {
323
- conversations.delete(cid);
324
- diskSessionsWritten.delete(cid);
325
- for (const [rid, rcid] of responseIdToConvo) {
326
- if (rcid === cid)
327
- responseIdToConvo.delete(rid);
328
- }
329
- }
330
- }
331
- dataRevision++;
332
- logToDisk(entry);
333
- compactEntry(entry);
334
- saveState();
335
- return entry;
336
- }
337
- // Upstream config for resolveTargetUrl
338
- const UPSTREAMS = {
339
- openai: UPSTREAM_OPENAI_URL,
340
- anthropic: UPSTREAM_ANTHROPIC_URL,
341
- chatgpt: UPSTREAM_CHATGPT_URL,
342
- };
343
- // Forward a request upstream (no body capture)
344
- function forwardRequest(req, res, parsedUrl, body) {
345
- const { targetUrl } = resolveTargetUrl({ pathname: parsedUrl.pathname, search: parsedUrl.search }, headersForResolution(req.headers, req.socket.remoteAddress, ALLOW_TARGET_OVERRIDE), UPSTREAMS);
346
- const targetParsed = url.parse(targetUrl);
347
- const forwardHeaders = { ...req.headers };
348
- delete forwardHeaders['x-target-url'];
349
- delete forwardHeaders['host'];
350
- forwardHeaders['host'] = targetParsed.host;
351
- // When we buffer the body, replace chunked encoding with exact content-length
352
- if (body) {
353
- delete forwardHeaders['transfer-encoding'];
354
- forwardHeaders['content-length'] = body.length;
355
- }
356
- const protocol = targetParsed.protocol === 'https:' ? https : http;
357
- const proxyReq = protocol.request({
358
- hostname: targetParsed.hostname,
359
- port: targetParsed.port,
360
- path: targetParsed.path,
361
- method: req.method,
362
- headers: forwardHeaders,
363
- }, (proxyRes) => {
364
- if (!res.headersSent)
365
- res.writeHead(proxyRes.statusCode, proxyRes.headers);
366
- proxyRes.pipe(res);
367
- proxyRes.on('error', (err) => {
368
- console.error('Upstream response error (forward):', err.message);
369
- if (!res.destroyed)
370
- res.end();
371
- });
372
- });
373
- // Abort upstream request if client disconnects
374
- res.on('close', () => { if (!proxyReq.destroyed)
375
- proxyReq.destroy(); });
376
- proxyReq.on('error', (err) => {
377
- // Suppress errors from client-initiated disconnects
378
- if (res.destroyed)
379
- return;
380
- console.error('Proxy error:', err.message || err.code || 'unknown');
381
- if (!res.headersSent) {
382
- res.writeHead(502, { 'Content-Type': 'application/json' });
383
- }
384
- if (!res.destroyed) {
385
- res.end(JSON.stringify({ error: 'Proxy error', details: err.message }));
386
- }
387
- });
388
- if (body)
389
- proxyReq.write(body);
390
- proxyReq.end();
391
- }
392
- // Proxy handler
393
- function handleProxy(req, res) {
394
- const parsedUrl = url.parse(req.url);
395
- const { source, cleanPath } = extractSource(parsedUrl.pathname);
396
- // Use clean path (without source prefix) for routing
397
- const cleanUrl = { ...parsedUrl, pathname: cleanPath };
398
- const { targetUrl, provider } = resolveTargetUrl({ pathname: cleanPath, search: parsedUrl.search }, headersForResolution(req.headers, req.socket.remoteAddress, ALLOW_TARGET_OVERRIDE), UPSTREAMS);
399
- const hasAuth = !!req.headers['authorization'];
400
- const sourceTag = source ? `[${source}]` : '';
401
- console.log(`${req.method} ${req.url} → ${targetUrl} [${provider}] ${sourceTag} auth=${hasAuth}`);
402
- // For non-POST requests (GET /v1/models, OPTIONS, etc.), pass through directly
403
- if (req.method !== 'POST') {
404
- return forwardRequest(req, res, cleanUrl, null);
405
- }
406
- // Collect body as raw Buffers to avoid corrupting multi-byte UTF-8 at chunk boundaries
407
- const chunks = [];
408
- let clientAborted = false;
409
- req.on('data', (chunk) => { chunks.push(chunk); });
410
- req.on('error', () => { clientAborted = true; });
411
- req.on('end', () => {
412
- if (clientAborted)
413
- return;
414
- const bodyBuffer = Buffer.concat(chunks);
415
- const body = bodyBuffer.toString('utf8');
416
- let bodyData;
417
- try {
418
- bodyData = JSON.parse(body);
419
- }
420
- catch (e) {
421
- console.log(` ⚠ Body is not JSON (${bodyBuffer.length} bytes), capturing raw`);
422
- // Still capture the raw request even if body isn't JSON
423
- const rawInfo = {
424
- provider,
425
- apiFormat: 'raw',
426
- model: 'unknown',
427
- systemTokens: 0, toolsTokens: 0,
428
- messagesTokens: estimateTokens(body),
429
- totalTokens: estimateTokens(body),
430
- systemPrompts: [],
431
- tools: [],
432
- messages: [{ role: 'raw', content: body.substring(0, 2000), tokens: estimateTokens(body) }],
433
- };
434
- storeRequest(rawInfo, { raw: true }, source, undefined, undefined, selectHeaders(req.headers));
435
- return forwardRequest(req, res, cleanUrl, bodyBuffer);
436
- }
437
- console.log(` ✓ Parsed JSON body (${Object.keys(bodyData).join(', ')})`);
438
- const apiFormat = detectApiFormat(cleanPath);
439
- const contextInfo = parseContextInfo(provider, bodyData, apiFormat);
440
- const targetParsed = url.parse(targetUrl);
441
- // Forward headers (remove proxy-specific ones)
442
- const forwardHeaders = { ...req.headers };
443
- delete forwardHeaders['x-target-url'];
444
- delete forwardHeaders['host'];
445
- delete forwardHeaders['transfer-encoding'];
446
- forwardHeaders['host'] = targetParsed.host;
447
- // Ensure content-length matches the exact bytes we forward
448
- forwardHeaders['content-length'] = bodyBuffer.length;
449
- // Make request to actual API
450
- const protocol = targetParsed.protocol === 'https:' ? https : http;
451
- const startTime = performance.now();
452
- let firstByteTime = 0;
453
- const reqBytes = bodyBuffer.length;
454
- const proxyReq = protocol.request({
455
- hostname: targetParsed.hostname,
456
- port: targetParsed.port,
457
- path: targetParsed.path,
458
- method: req.method,
459
- headers: forwardHeaders,
460
- }, (proxyRes) => {
461
- console.log(` ← ${proxyRes.statusCode} ${proxyRes.statusMessage}`);
462
- // Forward response headers
463
- res.writeHead(proxyRes.statusCode, proxyRes.headers);
464
- const httpStatus = proxyRes.statusCode || 0;
465
- // Handle streaming vs non-streaming
466
- const isStreaming = proxyRes.headers['content-type']?.includes('text/event-stream');
467
- let respBytes = 0;
468
- const capturedReqHeaders = selectHeaders(req.headers);
469
- const capturedResHeaders = selectHeaders(proxyRes.headers);
470
- // Collect response as Buffer[] to avoid corrupting multi-byte UTF-8 at chunk boundaries
471
- const respChunks = [];
472
- proxyRes.on('data', (chunk) => {
473
- if (!firstByteTime)
474
- firstByteTime = performance.now();
475
- respBytes += chunk.length;
476
- respChunks.push(chunk);
477
- if (!res.destroyed)
478
- res.write(chunk);
479
- });
480
- proxyRes.on('end', () => {
481
- const endTime = performance.now();
482
- if (!firstByteTime)
483
- firstByteTime = endTime;
484
- const meta = {
485
- httpStatus,
486
- timings: {
487
- send_ms: Math.round(firstByteTime - startTime),
488
- wait_ms: Math.round(firstByteTime - startTime),
489
- receive_ms: Math.round(endTime - firstByteTime),
490
- total_ms: Math.round(endTime - startTime),
491
- tokens_per_second: null,
492
- },
493
- requestBytes: reqBytes,
494
- responseBytes: respBytes,
495
- targetUrl,
496
- requestHeaders: capturedReqHeaders,
497
- responseHeaders: capturedResHeaders,
498
- };
499
- const respBody = Buffer.concat(respChunks).toString('utf8');
500
- if (isStreaming) {
501
- storeRequest(contextInfo, { streaming: true, chunks: respBody }, source, bodyData, meta, capturedReqHeaders);
502
- }
503
- else {
504
- try {
505
- const responseData = JSON.parse(respBody);
506
- storeRequest(contextInfo, responseData, source, bodyData, meta, capturedReqHeaders);
507
- }
508
- catch (e) {
509
- storeRequest(contextInfo, { raw: respBody }, source, bodyData, meta, capturedReqHeaders);
510
- }
511
- }
512
- if (!res.destroyed)
513
- res.end();
514
- });
515
- proxyRes.on('error', (err) => {
516
- console.error('Upstream response error:', err.message);
517
- if (!res.destroyed)
518
- res.end();
519
- });
520
- });
521
- // Abort upstream request if client disconnects
522
- res.on('close', () => { if (!proxyReq.destroyed)
523
- proxyReq.destroy(); });
524
- proxyReq.on('error', (err) => {
525
- // Suppress errors from client-initiated disconnects
526
- if (res.destroyed)
527
- return;
528
- console.error('Proxy error:', err.message || err.code || 'unknown');
529
- if (!res.headersSent) {
530
- res.writeHead(502, { 'Content-Type': 'application/json' });
531
- }
532
- if (!res.destroyed) {
533
- res.end(JSON.stringify({ error: 'Proxy error', details: err.message }));
534
- }
535
- });
536
- proxyReq.write(bodyBuffer);
537
- proxyReq.end();
538
- });
539
- }
540
- // Ingest endpoint — accepts captured data from external tools (e.g. mitmproxy addon)
541
- function handleIngest(req, res) {
542
- if (req.method !== 'POST') {
543
- res.writeHead(405);
544
- res.end();
545
- return;
546
- }
547
- const bodyChunks = [];
548
- req.on('data', (chunk) => { bodyChunks.push(chunk); });
549
- req.on('end', () => {
550
- try {
551
- const data = JSON.parse(Buffer.concat(bodyChunks).toString('utf8'));
552
- const provider = data.provider || 'unknown';
553
- const apiFormat = data.apiFormat || 'unknown';
554
- const source = data.source || 'unknown';
555
- const contextInfo = parseContextInfo(provider, data.body || {}, apiFormat);
556
- storeRequest(contextInfo, data.response || {}, source, data.body || {});
557
- console.log(` 📥 Ingested: [${provider}] ${contextInfo.model} from ${source}`);
558
- res.writeHead(200, { 'Content-Type': 'application/json' });
559
- res.end(JSON.stringify({ ok: true }));
560
- }
561
- catch (e) {
562
- console.error('Ingest error:', e.message);
563
- res.writeHead(400, { 'Content-Type': 'application/json' });
564
- res.end(JSON.stringify({ error: e.message }));
565
- }
566
- });
567
- }
568
- // Load HTML UI from file at startup
569
- const htmlUI = fs.readFileSync(path.join(__dirname, '..', 'public', 'index.html'), 'utf-8');
570
- // Web UI handler
571
- function handleWebUI(req, res) {
572
- const parsedUrl = url.parse(req.url, true);
573
- if (parsedUrl.pathname === '/api/ingest' && req.method === 'POST') {
574
- return handleIngest(req, res);
575
- }
576
- // DELETE /api/conversations/:id — delete one session
577
- const convoDeleteMatch = parsedUrl.pathname?.match(/^\/api\/conversations\/(.+)$/);
578
- if (convoDeleteMatch && req.method === 'DELETE') {
579
- const convoId = decodeURIComponent(convoDeleteMatch[1]);
580
- conversations.delete(convoId);
581
- // Remove entries belonging to this conversation
582
- for (let i = capturedRequests.length - 1; i >= 0; i--) {
583
- if (capturedRequests[i].conversationId === convoId)
584
- capturedRequests.splice(i, 1);
585
- }
586
- diskSessionsWritten.delete(convoId);
587
- for (const [rid, cid] of responseIdToConvo) {
588
- if (cid === convoId)
589
- responseIdToConvo.delete(rid);
590
- }
591
- dataRevision++;
592
- saveState();
593
- res.writeHead(200, { 'Content-Type': 'application/json' });
594
- res.end(JSON.stringify({ ok: true }));
595
- return;
596
- }
597
- // POST /api/reset — reset all data
598
- if (parsedUrl.pathname === '/api/reset' && req.method === 'POST') {
599
- capturedRequests.length = 0;
600
- conversations.clear();
601
- diskSessionsWritten.clear();
602
- responseIdToConvo.clear();
603
- nextEntryId = 1;
604
- dataRevision++;
605
- saveState();
606
- res.writeHead(200, { 'Content-Type': 'application/json' });
607
- res.end(JSON.stringify({ ok: true }));
608
- return;
609
- }
610
- if (parsedUrl.pathname === '/api/requests') {
611
- // API endpoint: group requests by conversation
612
- const grouped = new Map(); // conversationId -> entries[]
613
- const ungrouped = [];
614
- for (const entry of capturedRequests) {
615
- if (entry.conversationId) {
616
- if (!grouped.has(entry.conversationId))
617
- grouped.set(entry.conversationId, []);
618
- grouped.get(entry.conversationId).push(entry);
619
- }
620
- else {
621
- ungrouped.push(entry);
622
- }
623
- }
624
- const convos = [];
625
- for (const [id, entries] of grouped) {
626
- const meta = conversations.get(id) || { id, label: 'Unknown', source: 'unknown', firstSeen: entries[entries.length - 1].timestamp };
627
- // Sub-group entries by agentKey
628
- const agentMap = new Map();
629
- for (const e of entries) {
630
- const ak = e.agentKey || '_default';
631
- if (!agentMap.has(ak))
632
- agentMap.set(ak, []);
633
- agentMap.get(ak).push(e);
634
- }
635
- const agents = [];
636
- for (const [ak, agentEntries] of agentMap) {
637
- agents.push({
638
- key: ak,
639
- label: agentEntries[agentEntries.length - 1].agentLabel || 'Unnamed',
640
- model: agentEntries[0].contextInfo.model,
641
- entries: agentEntries.map(projectEntry), // newest-first (inherited from capturedRequests order)
642
- });
643
- }
644
- // Sort agents: most recent activity first
645
- agents.sort((a, b) => new Date(b.entries[0].timestamp).getTime() - new Date(a.entries[0].timestamp).getTime());
646
- convos.push({ ...meta, agents, entries: entries.map(projectEntry) });
647
- }
648
- // Sort conversations newest-first (by most recent entry)
649
- convos.sort((a, b) => new Date(b.entries[0].timestamp).getTime() - new Date(a.entries[0].timestamp).getTime());
650
- res.writeHead(200, { 'Content-Type': 'application/json' });
651
- res.end(JSON.stringify({ revision: dataRevision, conversations: convos, ungrouped: ungrouped.map(projectEntry) }));
652
- }
653
- else if (parsedUrl.pathname === '/api/export/lhar') {
654
- // Export as JSONL (.lhar)
655
- const convoFilter = parsedUrl.query.conversation;
656
- const entries = convoFilter
657
- ? capturedRequests.filter(e => e.conversationId === convoFilter)
658
- : capturedRequests;
659
- const jsonl = toLharJsonl(entries, conversations);
660
- res.writeHead(200, {
661
- 'Content-Type': 'application/x-ndjson',
662
- 'Content-Disposition': 'attachment; filename="context-lens-export.lhar"',
663
- });
664
- res.end(jsonl);
665
- }
666
- else if (parsedUrl.pathname === '/api/export/lhar.json') {
667
- // Export as wrapped JSON (.lhar.json)
668
- const convoFilter = parsedUrl.query.conversation;
669
- const entries = convoFilter
670
- ? capturedRequests.filter(e => e.conversationId === convoFilter)
671
- : capturedRequests;
672
- const wrapped = toLharJson(entries, conversations);
673
- res.writeHead(200, {
674
- 'Content-Type': 'application/json',
675
- 'Content-Disposition': 'attachment; filename="context-lens-export.lhar.json"',
676
- });
677
- res.end(JSON.stringify(wrapped, null, 2));
678
- }
679
- else if (parsedUrl.pathname === '/') {
680
- // Serve HTML UI
681
- res.writeHead(200, { 'Content-Type': 'text/html' });
682
- res.end(htmlUI);
683
- }
684
- else {
685
- res.writeHead(404, { 'Content-Type': 'text/plain' });
686
- res.end('Not Found');
687
- }
688
- }
11
+ const config = loadServerConfig(__dirname);
12
+ const store = new Store({
13
+ dataDir: config.dataDir,
14
+ stateFile: config.stateFile,
15
+ maxSessions: config.maxSessions,
16
+ maxCompactMessages: config.maxCompactMessages,
17
+ });
689
18
  // Start servers
690
- loadState();
691
- const proxyServer = http.createServer(handleProxy);
692
- const webUIServer = http.createServer(handleWebUI);
693
- proxyServer.listen(4040, BIND_HOST, () => {
694
- console.log(`🔍 Context Lens Proxy running on http://${BIND_HOST}:4040`);
19
+ store.loadState();
20
+ const proxyServer = http.createServer(createProxyHandler(store, {
21
+ upstreams: config.upstreams,
22
+ allowTargetOverride: config.allowTargetOverride,
23
+ }));
24
+ const htmlUI = loadHtmlUI(__dirname);
25
+ const webUIServer = http.createServer(createWebUIHandler(store, htmlUI));
26
+ proxyServer.listen(4040, config.bindHost, () => {
27
+ console.log(`🔍 Context Lens Proxy running on http://${config.bindHost}:4040`);
695
28
  });
696
- webUIServer.listen(4041, BIND_HOST, () => {
697
- console.log(`🌐 Context Lens Web UI running on http://${BIND_HOST}:4041`);
29
+ webUIServer.listen(4041, config.bindHost, () => {
30
+ console.log(`🌐 Context Lens Web UI running on http://${config.bindHost}:4041`);
698
31
  // Only show verbose help when running standalone (not spawned by cli.js)
699
32
  if (!process.env.CONTEXT_LENS_CLI) {
700
- console.log(`\nUpstream: OpenAI → ${UPSTREAM_OPENAI_URL}`);
701
- console.log(` Anthropic → ${UPSTREAM_ANTHROPIC_URL}`);
702
- console.log('\nUsage:');
703
- console.log(' Codex (subscription): UPSTREAM_OPENAI_URL=https://chatgpt.com/backend-api/codex node server.js');
704
- console.log(' Codex (API key): node server.js');
33
+ console.log(`\nUpstream: OpenAI → ${config.upstreams.openai}`);
34
+ console.log(` Anthropic → ${config.upstreams.anthropic}`);
35
+ console.log("\nUsage:");
36
+ console.log(" Codex (subscription): UPSTREAM_OPENAI_URL=https://chatgpt.com/backend-api/codex node server.js");
37
+ console.log(" Codex (API key): node server.js");
705
38
  console.log(' Then: OPENAI_BASE_URL=http://localhost:4040 codex "your prompt"');
706
39
  console.log(' Claude: ANTHROPIC_BASE_URL=http://localhost:4040/claude claude "your prompt"');
707
40
  }