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.
- package/README.md +62 -17
- package/dist/cli-utils.d.ts +1 -1
- package/dist/cli-utils.d.ts.map +1 -1
- package/dist/cli-utils.js +28 -13
- package/dist/cli-utils.js.map +1 -1
- package/dist/cli.js +77 -65
- package/dist/cli.js.map +1 -1
- package/dist/core/conversation.d.ts +54 -0
- package/dist/core/conversation.d.ts.map +1 -0
- package/dist/core/conversation.js +188 -0
- package/dist/core/conversation.js.map +1 -0
- package/dist/core/models.d.ts +30 -0
- package/dist/core/models.d.ts.map +1 -0
- package/dist/core/models.js +96 -0
- package/dist/core/models.js.map +1 -0
- package/dist/core/parse.d.ts +17 -0
- package/dist/core/parse.d.ts.map +1 -0
- package/dist/core/parse.js +349 -0
- package/dist/core/parse.js.map +1 -0
- package/dist/core/routing.d.ts +47 -0
- package/dist/core/routing.d.ts.map +1 -0
- package/dist/core/routing.js +132 -0
- package/dist/core/routing.js.map +1 -0
- package/dist/core/source.d.ts +22 -0
- package/dist/core/source.d.ts.map +1 -0
- package/dist/core/source.js +56 -0
- package/dist/core/source.js.map +1 -0
- package/dist/core/tokens.d.ts +11 -0
- package/dist/core/tokens.d.ts.map +1 -0
- package/dist/core/tokens.js +16 -0
- package/dist/core/tokens.js.map +1 -0
- package/dist/core.d.ts +12 -22
- package/dist/core.d.ts.map +1 -1
- package/dist/core.js +12 -471
- package/dist/core.js.map +1 -1
- package/dist/http/headers.d.ts +25 -0
- package/dist/http/headers.d.ts.map +1 -0
- package/dist/http/headers.js +54 -0
- package/dist/http/headers.js.map +1 -0
- package/dist/lhar-types.generated.d.ts +1 -1
- package/dist/lhar.d.ts +4 -4
- package/dist/lhar.d.ts.map +1 -1
- package/dist/lhar.js +190 -106
- package/dist/lhar.js.map +1 -1
- package/dist/server/config.d.ts +12 -0
- package/dist/server/config.d.ts.map +1 -0
- package/dist/server/config.js +33 -0
- package/dist/server/config.js.map +1 -0
- package/dist/server/projection.d.ts +9 -0
- package/dist/server/projection.d.ts.map +1 -0
- package/dist/server/projection.js +39 -0
- package/dist/server/projection.js.map +1 -0
- package/dist/server/proxy.d.ts +13 -0
- package/dist/server/proxy.d.ts.map +1 -0
- package/dist/server/proxy.js +232 -0
- package/dist/server/proxy.js.map +1 -0
- package/dist/server/store.d.ts +33 -0
- package/dist/server/store.d.ts.map +1 -0
- package/dist/server/store.js +350 -0
- package/dist/server/store.js.map +1 -0
- package/dist/server/webui.d.ts +5 -0
- package/dist/server/webui.d.ts.map +1 -0
- package/dist/server/webui.js +170 -0
- package/dist/server/webui.js.map +1 -0
- package/dist/server-utils.d.ts +2 -2
- package/dist/server-utils.d.ts.map +1 -1
- package/dist/server-utils.js +12 -21
- package/dist/server-utils.js.map +1 -1
- package/dist/server.js +30 -697
- package/dist/server.js.map +1 -1
- package/dist/types.d.ts +50 -10
- package/dist/types.d.ts.map +1 -1
- package/dist/version.generated.d.ts +2 -0
- package/dist/version.generated.d.ts.map +1 -0
- package/dist/version.generated.js +2 -0
- package/dist/version.generated.js.map +1 -0
- package/package.json +18 -6
- package/public/index.html +39 -12
- 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
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
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
|
-
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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(
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
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,
|
|
697
|
-
console.log(`🌐 Context Lens Web UI running on http://${
|
|
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 → ${
|
|
701
|
-
console.log(` Anthropic → ${
|
|
702
|
-
console.log(
|
|
703
|
-
console.log(
|
|
704
|
-
console.log(
|
|
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
|
}
|