cto-ai-cli 5.2.0 → 7.1.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.
@@ -1,2932 +0,0 @@
1
- // src/gateway/server.ts
2
- import { createServer, Agent as HttpAgent } from "http";
3
- import { request as httpsRequest, Agent as HttpsAgent } from "https";
4
- import { request as httpRequest } from "http";
5
- import { URL } from "url";
6
- import { lookup } from "dns/promises";
7
-
8
- // src/gateway/types.ts
9
- var DEFAULT_GATEWAY_CONFIG = {
10
- port: 8787,
11
- host: "127.0.0.1",
12
- optimize: true,
13
- projectPath: ".",
14
- budget: 5e4,
15
- redactSecrets: true,
16
- blockOnSecrets: false,
17
- apiKey: "",
18
- allowedTargetDomains: [],
19
- // Empty = default LLM provider allowlist
20
- maxBodyBytes: 10 * 1024 * 1024,
21
- // 10MB
22
- upstreamTimeoutMs: 12e4,
23
- // 2 minutes (streaming can be slow)
24
- costTracking: true,
25
- budgetDaily: 0,
26
- budgetMonthly: 0,
27
- alertThreshold: 0.8,
28
- auditLog: true,
29
- logDir: ".cto/gateway",
30
- dashboard: true,
31
- dashboardPath: "/__cto"
32
- };
33
- var DEFAULT_ALLOWED_DOMAINS = /* @__PURE__ */ new Set([
34
- "api.openai.com",
35
- "api.anthropic.com",
36
- "generativelanguage.googleapis.com",
37
- "aiplatform.googleapis.com"
38
- // Azure uses custom subdomains: *.openai.azure.com
39
- ]);
40
- var PRIVATE_IP_PATTERNS = [
41
- /^127\./,
42
- // Loopback
43
- /^10\./,
44
- // Class A private
45
- /^172\.(1[6-9]|2\d|3[01])\./,
46
- // Class B private
47
- /^192\.168\./,
48
- // Class C private
49
- /^169\.254\./,
50
- // Link-local (AWS metadata!)
51
- /^0\./,
52
- // Current network
53
- /^::1$/,
54
- // IPv6 loopback
55
- /^f[cd]/i,
56
- // IPv6 private
57
- /^fe80:/i
58
- // IPv6 link-local
59
- ];
60
- function isPrivateIP(ip) {
61
- return PRIVATE_IP_PATTERNS.some((p) => p.test(ip));
62
- }
63
- function isAllowedTarget(hostname, config) {
64
- if (config.allowedTargetDomains.length > 0) {
65
- return config.allowedTargetDomains.some(
66
- (d) => hostname === d || hostname.endsWith("." + d)
67
- );
68
- }
69
- if (DEFAULT_ALLOWED_DOMAINS.has(hostname)) return true;
70
- if (hostname.endsWith(".openai.azure.com")) return true;
71
- return false;
72
- }
73
-
74
- // src/gateway/providers.ts
75
- var OPENAI_MODELS = [
76
- { id: "gpt-4o", contextWindow: 128e3, costPerMInput: 2.5, costPerMOutput: 10, maxOutput: 16384 },
77
- { id: "gpt-4o-mini", contextWindow: 128e3, costPerMInput: 0.15, costPerMOutput: 0.6, maxOutput: 16384 },
78
- { id: "gpt-4-turbo", contextWindow: 128e3, costPerMInput: 10, costPerMOutput: 30, maxOutput: 4096 },
79
- { id: "gpt-3.5-turbo", contextWindow: 16385, costPerMInput: 0.5, costPerMOutput: 1.5, maxOutput: 4096 },
80
- { id: "o1", contextWindow: 2e5, costPerMInput: 15, costPerMOutput: 60, maxOutput: 1e5 },
81
- { id: "o1-mini", contextWindow: 128e3, costPerMInput: 3, costPerMOutput: 12, maxOutput: 65536 },
82
- { id: "o3-mini", contextWindow: 2e5, costPerMInput: 1.1, costPerMOutput: 4.4, maxOutput: 1e5 }
83
- ];
84
- var ANTHROPIC_MODELS = [
85
- { id: "claude-sonnet-4-20250514", contextWindow: 2e5, costPerMInput: 3, costPerMOutput: 15, maxOutput: 64e3 },
86
- { id: "claude-3-5-haiku-20241022", contextWindow: 2e5, costPerMInput: 0.8, costPerMOutput: 4, maxOutput: 8192 },
87
- { id: "claude-3-opus-20240229", contextWindow: 2e5, costPerMInput: 15, costPerMOutput: 75, maxOutput: 4096 }
88
- ];
89
- var GOOGLE_MODELS = [
90
- { id: "gemini-2.5-pro", contextWindow: 1e6, costPerMInput: 1.25, costPerMOutput: 10, maxOutput: 65536 },
91
- { id: "gemini-2.0-flash", contextWindow: 1e6, costPerMInput: 0.1, costPerMOutput: 0.4, maxOutput: 8192 },
92
- { id: "gemini-1.5-pro", contextWindow: 2e6, costPerMInput: 1.25, costPerMOutput: 5, maxOutput: 8192 }
93
- ];
94
- function parseOpenAIRequest(body) {
95
- const rawMessages = Array.isArray(body.messages) ? body.messages : [];
96
- const messages = rawMessages.map((m) => ({
97
- role: m.role || "user",
98
- content: typeof m.content === "string" ? m.content : JSON.stringify(m.content)
99
- }));
100
- return {
101
- model: body.model || "unknown",
102
- messages,
103
- stream: body.stream === true,
104
- maxTokens: body.max_tokens ?? body.max_completion_tokens,
105
- temperature: body.temperature
106
- };
107
- }
108
- function parseOpenAIResponse(body, _streaming) {
109
- const usage = body.usage;
110
- const choices = Array.isArray(body.choices) ? body.choices : [];
111
- const first = choices[0];
112
- const msg = first?.message;
113
- return {
114
- model: body.model || "unknown",
115
- inputTokens: usage?.prompt_tokens || 0,
116
- outputTokens: usage?.completion_tokens || 0,
117
- content: msg?.content || "",
118
- finishReason: first?.finish_reason || "stop"
119
- };
120
- }
121
- function parseAnthropicRequest(body) {
122
- const messages = [];
123
- if (body.system) {
124
- messages.push({ role: "system", content: String(body.system) });
125
- }
126
- const rawMessages = Array.isArray(body.messages) ? body.messages : [];
127
- for (const m of rawMessages) {
128
- const content = typeof m.content === "string" ? m.content : Array.isArray(m.content) ? m.content.map((b) => String(b.text ?? "")).join("\n") : "";
129
- const role = m.role || "user";
130
- messages.push({ role, content });
131
- }
132
- return {
133
- model: body.model || "unknown",
134
- messages,
135
- stream: body.stream === true,
136
- maxTokens: body.max_tokens,
137
- temperature: body.temperature
138
- };
139
- }
140
- function parseAnthropicResponse(body, _streaming) {
141
- const usage = body.usage;
142
- const contentBlocks = Array.isArray(body.content) ? body.content : [];
143
- return {
144
- model: body.model || "unknown",
145
- inputTokens: usage?.input_tokens || 0,
146
- outputTokens: usage?.output_tokens || 0,
147
- content: contentBlocks.map((b) => String(b.text ?? "")).join("\n"),
148
- finishReason: body.stop_reason || "end_turn"
149
- };
150
- }
151
- function parseGoogleRequest(body) {
152
- const messages = [];
153
- const sysInst = body.systemInstruction;
154
- if (sysInst && Array.isArray(sysInst.parts)) {
155
- messages.push({
156
- role: "system",
157
- content: sysInst.parts.map((p) => String(p.text ?? "")).join("\n")
158
- });
159
- }
160
- const contents = Array.isArray(body.contents) ? body.contents : [];
161
- for (const item of contents) {
162
- const role = item.role === "model" ? "assistant" : "user";
163
- const parts = Array.isArray(item.parts) ? item.parts : [];
164
- const content = parts.map((p) => String(p.text ?? "")).join("\n");
165
- messages.push({ role, content });
166
- }
167
- const model = body.model || body.modelId || "gemini-2.0-flash";
168
- const genConfig = body.generationConfig;
169
- return {
170
- model,
171
- messages,
172
- stream: body.stream === true,
173
- maxTokens: genConfig?.maxOutputTokens,
174
- temperature: genConfig?.temperature
175
- };
176
- }
177
- function parseGoogleResponse(body, _streaming) {
178
- const candidates = Array.isArray(body.candidates) ? body.candidates : [];
179
- const candidate = candidates[0];
180
- const usage = body.usageMetadata;
181
- const candidateContent = candidate?.content;
182
- const parts = Array.isArray(candidateContent?.parts) ? candidateContent.parts : [];
183
- return {
184
- model: body.modelVersion || body.model || "gemini-2.0-flash",
185
- inputTokens: usage?.promptTokenCount || 0,
186
- outputTokens: usage?.candidatesTokenCount || 0,
187
- content: parts.map((p) => String(p.text ?? "")).join("\n"),
188
- finishReason: candidate?.finishReason || "STOP"
189
- };
190
- }
191
- var PROVIDERS = {
192
- openai: {
193
- name: "openai",
194
- displayName: "OpenAI",
195
- baseUrl: "https://api.openai.com",
196
- authHeader: "Authorization",
197
- chatPath: "/v1/chat/completions",
198
- models: OPENAI_MODELS,
199
- parseRequest: parseOpenAIRequest,
200
- parseResponse: parseOpenAIResponse,
201
- detectProvider: (url, _headers) => url.includes("api.openai.com") || url.includes("/v1/chat/completions")
202
- },
203
- anthropic: {
204
- name: "anthropic",
205
- displayName: "Anthropic",
206
- baseUrl: "https://api.anthropic.com",
207
- authHeader: "x-api-key",
208
- chatPath: "/v1/messages",
209
- models: ANTHROPIC_MODELS,
210
- parseRequest: parseAnthropicRequest,
211
- parseResponse: parseAnthropicResponse,
212
- detectProvider: (url, headers) => url.includes("api.anthropic.com") || url.includes("/v1/messages") || !!headers["x-api-key"] || !!headers["anthropic-version"]
213
- },
214
- google: {
215
- name: "google",
216
- displayName: "Google AI",
217
- baseUrl: "https://generativelanguage.googleapis.com",
218
- authHeader: "x-goog-api-key",
219
- chatPath: "/v1beta/models",
220
- models: GOOGLE_MODELS,
221
- parseRequest: parseGoogleRequest,
222
- parseResponse: parseGoogleResponse,
223
- detectProvider: (url, _headers) => url.includes("generativelanguage.googleapis.com") || url.includes("aiplatform.googleapis.com")
224
- },
225
- "azure-openai": {
226
- name: "azure-openai",
227
- displayName: "Azure OpenAI",
228
- baseUrl: "",
229
- authHeader: "api-key",
230
- chatPath: "/openai/deployments",
231
- models: OPENAI_MODELS,
232
- // Same models, different hosting
233
- parseRequest: parseOpenAIRequest,
234
- parseResponse: parseOpenAIResponse,
235
- detectProvider: (url, headers) => url.includes(".openai.azure.com") || !!headers["api-key"]
236
- },
237
- custom: {
238
- name: "custom",
239
- displayName: "Custom (OpenAI-compatible)",
240
- baseUrl: "",
241
- authHeader: "Authorization",
242
- chatPath: "/v1/chat/completions",
243
- models: [],
244
- parseRequest: parseOpenAIRequest,
245
- parseResponse: parseOpenAIResponse,
246
- detectProvider: () => false
247
- // Fallback only
248
- }
249
- };
250
- function detectProvider(url, headers) {
251
- for (const provider of Object.values(PROVIDERS)) {
252
- if (provider.name === "custom") continue;
253
- if (provider.detectProvider(url, headers)) return provider;
254
- }
255
- return PROVIDERS.custom;
256
- }
257
- function getModelConfig(provider, modelId) {
258
- const exact = provider.models.find((m) => m.id === modelId);
259
- if (exact) return exact;
260
- return provider.models.find((m) => modelId.startsWith(m.id) || m.id.startsWith(modelId));
261
- }
262
- function estimateCost(provider, modelId, inputTokens, outputTokens) {
263
- const model = getModelConfig(provider, modelId);
264
- if (!model) return 0;
265
- const inputCost = inputTokens / 1e6 * model.costPerMInput;
266
- const outputCost = outputTokens / 1e6 * model.costPerMOutput;
267
- return Math.round((inputCost + outputCost) * 1e6) / 1e6;
268
- }
269
-
270
- // src/govern/secrets.ts
271
- import { readFile } from "fs/promises";
272
- import { readFileSync, existsSync, mkdirSync, writeFileSync } from "fs";
273
- import { resolve, relative, join, dirname } from "path";
274
- import { createHash } from "crypto";
275
- var BUILTIN_PATTERNS = [
276
- // API Keys
277
- { type: "api-key", source: `(?:api[_-]?key|apikey)\\s*[:=]\\s*['"]?([a-zA-Z0-9_\\-]{20,})['"]?`, flags: "gi", severity: "critical", description: "API Key" },
278
- { type: "api-key", source: "sk-[a-zA-Z0-9]{20,}", flags: "g", severity: "critical", description: "OpenAI/Anthropic API Key" },
279
- { type: "api-key", source: "sk-ant-[a-zA-Z0-9\\-]{20,}", flags: "g", severity: "critical", description: "Anthropic API Key" },
280
- // AWS
281
- { type: "aws-key", source: "AKIA[0-9A-Z]{16}", flags: "g", severity: "critical", description: "AWS Access Key ID" },
282
- { type: "aws-key", source: `(?:aws_secret_access_key|aws_secret)\\s*[:=]\\s*['"]?([a-zA-Z0-9/+=]{40})['"]?`, flags: "gi", severity: "critical", description: "AWS Secret Key" },
283
- // Private Keys
284
- { type: "private-key", source: "-----BEGIN (?:RSA |EC |DSA )?PRIVATE KEY-----", flags: "g", severity: "critical", description: "Private Key" },
285
- { type: "private-key", source: "-----BEGIN OPENSSH PRIVATE KEY-----", flags: "g", severity: "critical", description: "SSH Private Key" },
286
- // Passwords
287
- { type: "password", source: `(?:password|passwd|pwd)\\s*[:=]\\s*['"]([^'"]{8,})['"](?!\\s*\\{)`, flags: "gi", severity: "high", description: "Hardcoded Password" },
288
- { type: "password", source: `(?:DB_PASSWORD|DATABASE_PASSWORD|MYSQL_PASSWORD|POSTGRES_PASSWORD)\\s*[:=]\\s*['"]?([^'"{}\\s]{4,})['"]?`, flags: "gi", severity: "high", description: "Database Password" },
289
- // Tokens
290
- { type: "token", source: `(?:bearer|token|auth_token|access_token|refresh_token)\\s*[:=]\\s*['"]([a-zA-Z0-9_\\-.]{20,})['"](?!\\s*\\{)`, flags: "gi", severity: "high", description: "Auth Token" },
291
- { type: "token", source: "ghp_[a-zA-Z0-9]{36}", flags: "g", severity: "critical", description: "GitHub Personal Access Token" },
292
- { type: "token", source: "gho_[a-zA-Z0-9]{36}", flags: "g", severity: "critical", description: "GitHub OAuth Token" },
293
- { type: "token", source: "glpat-[a-zA-Z0-9\\-]{20,}", flags: "g", severity: "critical", description: "GitLab Personal Access Token" },
294
- { type: "token", source: "npm_[a-zA-Z0-9]{36}", flags: "g", severity: "high", description: "npm Token" },
295
- // Connection strings
296
- { type: "connection-string", source: `(?:mongodb(?:\\+srv)?|postgres(?:ql)?|mysql|redis|amqp):\\/\\/[^\\s'"]+:[^\\s'"]+@[^\\s'"]+`, flags: "gi", severity: "critical", description: "Database Connection String" },
297
- { type: "connection-string", source: `(?:DATABASE_URL|REDIS_URL|MONGODB_URI)\\s*[:=]\\s*['"]?([^\\s'"]{10,})['"]?`, flags: "gi", severity: "high", description: "Database URL" },
298
- // Environment variables with secrets
299
- { type: "env-variable", source: `(?:SECRET|PRIVATE|ENCRYPTION)[_-]?(?:KEY|TOKEN|PASS)\\s*[:=]\\s*['"]?([^\\s'"]{8,})['"]?`, flags: "gi", severity: "high", description: "Secret Environment Variable" },
300
- // Stripe
301
- { type: "api-key", source: "sk_live_[a-zA-Z0-9]{24,}", flags: "g", severity: "critical", description: "Stripe Live Secret Key" },
302
- { type: "api-key", source: "pk_live_[a-zA-Z0-9]{24,}", flags: "g", severity: "high", description: "Stripe Live Publishable Key" },
303
- { type: "api-key", source: "rk_live_[a-zA-Z0-9]{24,}", flags: "g", severity: "critical", description: "Stripe Restricted Key" },
304
- // Slack
305
- { type: "token", source: "xoxb-[0-9]{10,}-[0-9]{10,}-[a-zA-Z0-9]{24,}", flags: "g", severity: "critical", description: "Slack Bot Token" },
306
- { type: "token", source: "xoxp-[0-9]{10,}-[0-9]{10,}-[a-zA-Z0-9]{24,}", flags: "g", severity: "critical", description: "Slack User Token" },
307
- { type: "api-key", source: "https://hooks\\.slack\\.com/services/T[a-zA-Z0-9_]+/B[a-zA-Z0-9_]+/[a-zA-Z0-9_]+", flags: "g", severity: "high", description: "Slack Webhook URL" },
308
- // Google
309
- { type: "api-key", source: "AIza[0-9A-Za-z_-]{35}", flags: "g", severity: "high", description: "Google API Key" },
310
- { type: "token", source: "ya29\\.[0-9A-Za-z_-]+", flags: "g", severity: "high", description: "Google OAuth Token" },
311
- // Azure
312
- { type: "api-key", source: "(?:AccountKey|SharedAccessKey)\\s*=\\s*[a-zA-Z0-9+/=]{40,}", flags: "g", severity: "critical", description: "Azure Storage Key" },
313
- // Twilio
314
- { type: "api-key", source: "AC[a-f0-9]{32}", flags: "g", severity: "high", description: "Twilio Account SID" },
315
- // SendGrid
316
- { type: "api-key", source: "SG\\.[a-zA-Z0-9_-]{22}\\.[a-zA-Z0-9_-]{43}", flags: "g", severity: "critical", description: "SendGrid API Key" },
317
- // JWT
318
- { type: "token", source: "eyJ[a-zA-Z0-9_-]{10,}\\.eyJ[a-zA-Z0-9_-]{10,}\\.[a-zA-Z0-9_-]{10,}", flags: "g", severity: "high", description: "JSON Web Token" },
319
- // Datadog
320
- { type: "api-key", source: `(?:DD_API_KEY|DATADOG_API_KEY)\\s*[:=]\\s*['"]?([a-f0-9]{32})['"]?`, flags: "gi", severity: "critical", description: "Datadog API Key" },
321
- { type: "api-key", source: `(?:DD_APP_KEY|DATADOG_APP_KEY)\\s*[:=]\\s*['"]?([a-f0-9]{40})['"]?`, flags: "gi", severity: "critical", description: "Datadog App Key" },
322
- // Sentry
323
- { type: "connection-string", source: "https://[a-f0-9]{32}@[a-z0-9]+\\.ingest\\.sentry\\.io/[0-9]+", flags: "g", severity: "high", description: "Sentry DSN" },
324
- // Firebase
325
- { type: "api-key", source: `(?:FIREBASE_API_KEY|FIREBASE_KEY)\\s*[:=]\\s*['"]?([a-zA-Z0-9_\\-]{30,})['"]?`, flags: "gi", severity: "high", description: "Firebase API Key" },
326
- { type: "connection-string", source: `firebase[a-z]*:\\/\\/[^\\s'"]+`, flags: "gi", severity: "high", description: "Firebase URL" },
327
- // Supabase
328
- { type: "api-key", source: "sbp_[a-f0-9]{40}", flags: "g", severity: "critical", description: "Supabase Service Key" },
329
- { type: "token", source: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\\.[a-zA-Z0-9_-]{20,}\\.[a-zA-Z0-9_-]{20,}", flags: "g", severity: "high", description: "Supabase Anon/Service JWT" },
330
- // Vercel
331
- { type: "token", source: `(?:VERCEL_TOKEN|VERCEL_API_TOKEN)\\s*[:=]\\s*['"]?([a-zA-Z0-9]{24,})['"]?`, flags: "gi", severity: "critical", description: "Vercel Token" },
332
- // Heroku
333
- { type: "api-key", source: `(?:HEROKU_API_KEY|HEROKU_TOKEN)\\s*[:=]\\s*['"]?([a-f0-9\\-]{36,})['"]?`, flags: "gi", severity: "critical", description: "Heroku API Key" },
334
- // DigitalOcean
335
- { type: "token", source: "dop_v1_[a-f0-9]{64}", flags: "g", severity: "critical", description: "DigitalOcean Personal Access Token" },
336
- { type: "token", source: "doo_v1_[a-f0-9]{64}", flags: "g", severity: "critical", description: "DigitalOcean OAuth Token" },
337
- // Mailgun
338
- { type: "api-key", source: "key-[a-zA-Z0-9]{32}", flags: "g", severity: "high", description: "Mailgun API Key" },
339
- // PII
340
- { type: "pii", source: "\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Z|a-z]{2,}\\b", flags: "g", severity: "medium", description: "Email Address (PII)" },
341
- { type: "pii", source: "\\b(?!000|666|9\\d{2})(\\d{3})[-.]?(?!00)(\\d{2})[-.]?(?!0000)(\\d{4})\\b", flags: "g", severity: "high", description: "Possible SSN (PII)" }
342
- ];
343
- var _cachedBuiltinPatterns = null;
344
- function getBuiltinPatterns() {
345
- if (!_cachedBuiltinPatterns) {
346
- _cachedBuiltinPatterns = BUILTIN_PATTERNS.map((def) => ({
347
- type: def.type,
348
- pattern: new RegExp(def.source, def.flags),
349
- severity: def.severity,
350
- description: def.description
351
- }));
352
- }
353
- return _cachedBuiltinPatterns;
354
- }
355
- function buildPatterns(customPatterns = []) {
356
- const builtins = getBuiltinPatterns();
357
- if (customPatterns.length === 0) return builtins;
358
- const patterns = [...builtins];
359
- for (const custom of customPatterns) {
360
- try {
361
- patterns.push({
362
- type: "custom",
363
- pattern: new RegExp(custom, "gi"),
364
- severity: "medium",
365
- description: `Custom pattern: ${custom}`
366
- });
367
- } catch {
368
- }
369
- }
370
- return patterns;
371
- }
372
- function scanContentForSecrets(content, filePath, customPatterns = [], extraPiiSafeDomains) {
373
- const findings = [];
374
- const lines = content.split("\n");
375
- const allPatterns = buildPatterns(customPatterns);
376
- for (const secretPattern of allPatterns) {
377
- for (let i = 0; i < lines.length; i++) {
378
- const line = lines[i];
379
- secretPattern.pattern.lastIndex = 0;
380
- let match;
381
- while ((match = secretPattern.pattern.exec(line)) !== null) {
382
- const matchText = match[0];
383
- if (isTemplateOrPlaceholder(matchText)) continue;
384
- if (secretPattern.type === "pii" && isSafeEmail(matchText, extraPiiSafeDomains)) continue;
385
- findings.push({
386
- type: secretPattern.type,
387
- file: filePath,
388
- line: i + 1,
389
- match: matchText,
390
- redacted: redactSecret(matchText),
391
- severity: secretPattern.severity
392
- });
393
- }
394
- }
395
- }
396
- return deduplicateFindings(findings);
397
- }
398
- async function scanFileForSecrets(filePath, projectPath, customPatterns = []) {
399
- try {
400
- const content = await readFile(filePath, "utf-8");
401
- const relPath = relative(resolve(projectPath), resolve(filePath));
402
- return scanContentForSecrets(content, relPath, customPatterns);
403
- } catch {
404
- return [];
405
- }
406
- }
407
- function sanitizeContent(content, customPatterns = []) {
408
- let sanitized = content;
409
- const allPatterns = buildPatterns(customPatterns);
410
- for (const secretPattern of allPatterns) {
411
- sanitized = sanitized.replace(secretPattern.pattern, (match) => {
412
- if (isTemplateOrPlaceholder(match)) return match;
413
- return redactSecret(match);
414
- });
415
- }
416
- return sanitized;
417
- }
418
- function redactSecret(value) {
419
- if (value.length <= 8) return "***REDACTED***";
420
- const prefix = value.substring(0, 4);
421
- const suffix = value.substring(value.length - 2);
422
- return `${prefix}${"*".repeat(Math.min(value.length - 6, 20))}${suffix}`;
423
- }
424
- function isTemplateOrPlaceholder(value) {
425
- const placeholders = [
426
- /\$\{.*\}/,
427
- /\{\{.*\}\}/,
428
- /%[sd]/,
429
- /<[A-Z_]+>/,
430
- /YOUR_.*_HERE/i,
431
- /\bCHANGE_ME\b/i,
432
- /\bPLACEHOLDER\b/i,
433
- /\bexample\b/i,
434
- /\bTODO\b/i,
435
- /xxx+/i,
436
- /\breplace.?me\b/i,
437
- /\bdummy\b/i,
438
- /\btest_?key\b/i,
439
- /\bsample\b/i
440
- ];
441
- return placeholders.some((p) => p.test(value));
442
- }
443
- var PII_SAFE_EMAIL_DOMAINS = /* @__PURE__ */ new Set([
444
- "example.com",
445
- "example.org",
446
- "example.net",
447
- "test.com",
448
- "test.org",
449
- "test.net",
450
- "localhost",
451
- "localhost.localdomain",
452
- "email.com",
453
- "mail.com",
454
- "foo.com",
455
- "bar.com",
456
- "baz.com",
457
- "acme.com",
458
- "company.com",
459
- "corp.com",
460
- "noreply.com",
461
- "no-reply.com",
462
- "users.noreply.github.com",
463
- "placeholder.com"
464
- ]);
465
- function isSafeEmail(value, extraDomains) {
466
- const match = value.match(/@([a-zA-Z0-9.-]+)$/);
467
- if (!match) return false;
468
- const domain = match[1].toLowerCase();
469
- if (PII_SAFE_EMAIL_DOMAINS.has(domain)) return true;
470
- if (extraDomains && extraDomains.has(domain)) return true;
471
- return false;
472
- }
473
- function deduplicateFindings(findings) {
474
- const seen = /* @__PURE__ */ new Set();
475
- return findings.filter((f) => {
476
- const key = `${f.file}:${f.line}:${f.type}:${f.match}`;
477
- if (seen.has(key)) return false;
478
- seen.add(key);
479
- return true;
480
- });
481
- }
482
-
483
- // src/engine/selector.ts
484
- import { createHash as createHash2 } from "crypto";
485
-
486
- // src/engine/pruner.ts
487
- import { readFile as readFile3 } from "fs/promises";
488
-
489
- // src/engine/tokenizer.ts
490
- import { encodingForModel } from "js-tiktoken";
491
- import { readFile as readFile2, stat } from "fs/promises";
492
- var CHARS_PER_TOKEN = 4;
493
- var encoder = null;
494
- function getEncoder() {
495
- if (!encoder) {
496
- encoder = encodingForModel("claude-3-5-sonnet-20241022");
497
- }
498
- return encoder;
499
- }
500
- function countTokensTiktoken(text) {
501
- try {
502
- const enc = getEncoder();
503
- const tokens = enc.encode(text);
504
- return tokens.length;
505
- } catch {
506
- return Math.ceil(text.length / CHARS_PER_TOKEN);
507
- }
508
- }
509
- function countTokensChars4(sizeInBytes) {
510
- return Math.ceil(sizeInBytes / CHARS_PER_TOKEN);
511
- }
512
- function estimateTokens(content, sizeInBytes, method = "chars4") {
513
- if (method === "tiktoken") {
514
- return countTokensTiktoken(content);
515
- }
516
- return countTokensChars4(sizeInBytes);
517
- }
518
-
519
- // src/engine/pruner.ts
520
- var TS_EXTENSIONS = /* @__PURE__ */ new Set(["ts", "tsx", "js", "jsx", "mts", "mjs"]);
521
- async function pruneFile(file, level) {
522
- if (level === "excluded") {
523
- return emptyResult(file, "excluded");
524
- }
525
- if (level === "full") {
526
- return fullContent(file);
527
- }
528
- const ext = file.extension.toLowerCase();
529
- const isTS = TS_EXTENSIONS.has(ext);
530
- if (isTS) {
531
- return pruneTypeScript(file, level);
532
- }
533
- return pruneGeneric(file, level);
534
- }
535
- async function pruneTypeScript(file, level) {
536
- let content;
537
- try {
538
- content = await readFile3(file.path, "utf-8");
539
- } catch {
540
- return emptyResult(file, level);
541
- }
542
- const prunedContent = level === "signatures" ? extractSignaturesRegex(content) : extractSkeletonRegex(content);
543
- const prunedTokens = countTokensChars4(Buffer.byteLength(prunedContent, "utf-8"));
544
- const savingsPercent = file.tokens > 0 ? (file.tokens - prunedTokens) / file.tokens * 100 : 0;
545
- return {
546
- relativePath: file.relativePath,
547
- originalTokens: file.tokens,
548
- prunedTokens,
549
- pruneLevel: level,
550
- content: prunedContent,
551
- savingsPercent: Math.max(0, savingsPercent)
552
- };
553
- }
554
- function extractSignaturesRegex(content) {
555
- const lines = content.split("\n");
556
- const parts = [];
557
- let i = 0;
558
- while (i < lines.length) {
559
- const line = lines[i];
560
- const trimmed = line.trim();
561
- if (trimmed === "") {
562
- i++;
563
- continue;
564
- }
565
- if (trimmed.startsWith("/**")) {
566
- const docLines = [];
567
- while (i < lines.length) {
568
- docLines.push(lines[i]);
569
- if (lines[i].includes("*/")) {
570
- i++;
571
- break;
572
- }
573
- i++;
574
- }
575
- parts.push(docLines.join("\n"));
576
- continue;
577
- }
578
- if (trimmed.startsWith("//")) {
579
- parts.push(line);
580
- i++;
581
- continue;
582
- }
583
- if (/^\s*(import|export)\s/.test(line) && (trimmed.includes(" from ") || trimmed.startsWith("import "))) {
584
- const block = collectBracedLine(lines, i);
585
- parts.push(block.text);
586
- i = block.nextIndex;
587
- continue;
588
- }
589
- if (/^\s*export\s*(\{|\*)/.test(trimmed)) {
590
- const block = collectBracedLine(lines, i);
591
- parts.push(block.text);
592
- i = block.nextIndex;
593
- continue;
594
- }
595
- if (/^\s*(export\s+)?type\s+\w/.test(trimmed) && !trimmed.startsWith("typeof")) {
596
- const block = collectBalanced(lines, i);
597
- parts.push(block.text);
598
- i = block.nextIndex;
599
- continue;
600
- }
601
- if (/^\s*(export\s+)?interface\s+\w/.test(trimmed)) {
602
- const block = collectBalanced(lines, i);
603
- parts.push(block.text);
604
- i = block.nextIndex;
605
- continue;
606
- }
607
- if (/^\s*(export\s+)?(const\s+)?enum\s+\w/.test(trimmed)) {
608
- const block = collectBalanced(lines, i);
609
- parts.push(block.text);
610
- i = block.nextIndex;
611
- continue;
612
- }
613
- const fnMatch = trimmed.match(/^(export\s+)?(async\s+)?function\s+(\w+)/);
614
- if (fnMatch) {
615
- const sig = extractFnSignature(lines, i);
616
- parts.push(`${sig} { /* ... */ }`);
617
- i = skipBlock(lines, i);
618
- continue;
619
- }
620
- const arrowMatch = trimmed.match(/^(export\s+)?(const|let|var)\s+(\w+)/);
621
- if (arrowMatch && looksLikeFunctionDecl(lines, i)) {
622
- const prefix = trimmed.match(/^((?:export\s+)?(?:const|let|var)\s+\w+[^=]*=)/)?.[1];
623
- if (prefix) {
624
- parts.push(`${prefix} /* ... */;`);
625
- }
626
- i = skipBlock(lines, i);
627
- continue;
628
- }
629
- if (arrowMatch) {
630
- const block = collectStatement(lines, i);
631
- parts.push(block.text);
632
- i = block.nextIndex;
633
- continue;
634
- }
635
- if (/^\s*(export\s+)?(abstract\s+)?class\s+\w/.test(trimmed)) {
636
- const classOutline = extractClassOutline(lines, i);
637
- parts.push(classOutline.text);
638
- i = classOutline.nextIndex;
639
- continue;
640
- }
641
- i++;
642
- }
643
- return parts.join("\n");
644
- }
645
- function extractSkeletonRegex(content) {
646
- const lines = content.split("\n");
647
- const parts = [];
648
- let i = 0;
649
- while (i < lines.length) {
650
- const trimmed = lines[i].trim();
651
- if (/^import\s/.test(trimmed)) {
652
- const block = collectBracedLine(lines, i);
653
- parts.push(block.text);
654
- i = block.nextIndex;
655
- continue;
656
- }
657
- if (/^export\s+(type|interface)\s+\w/.test(trimmed)) {
658
- const block = collectBalanced(lines, i);
659
- parts.push(block.text);
660
- i = block.nextIndex;
661
- continue;
662
- }
663
- if (/^export\s+(const\s+)?enum\s+\w/.test(trimmed)) {
664
- const block = collectBalanced(lines, i);
665
- parts.push(block.text);
666
- i = block.nextIndex;
667
- continue;
668
- }
669
- if (/^export\s+(async\s+)?function\s+\w/.test(trimmed)) {
670
- const sig = extractFnSignature(lines, i);
671
- parts.push(`${sig};`);
672
- i = skipBlock(lines, i);
673
- continue;
674
- }
675
- if (/^export\s+(abstract\s+)?class\s+/.test(trimmed)) {
676
- const nameMatch = trimmed.match(/class\s+(\w+)/);
677
- const name = nameMatch?.[1] ?? "Unknown";
678
- const end = skipBlock(lines, i);
679
- const methods = [];
680
- for (let j = i + 1; j < end; j++) {
681
- const mt = lines[j].trim();
682
- const mm = mt.match(/^(?:static\s+)?(?:async\s+)?(\w+)\s*\(/);
683
- if (mm && mm[1] !== "constructor") methods.push(mm[1]);
684
- }
685
- parts.push(`export class ${name} { /* methods: ${methods.join(", ")} */ }`);
686
- i = end;
687
- continue;
688
- }
689
- if (/^export\s*(\{|\*)/.test(trimmed)) {
690
- const block = collectBracedLine(lines, i);
691
- parts.push(block.text);
692
- i = block.nextIndex;
693
- continue;
694
- }
695
- i++;
696
- }
697
- return parts.join("\n");
698
- }
699
- function collectBracedLine(lines, start) {
700
- let text = lines[start];
701
- let i = start + 1;
702
- while (i < lines.length && !text.includes(";") && !text.trimEnd().endsWith("'") && !text.trimEnd().endsWith('"')) {
703
- text += "\n" + lines[i];
704
- i++;
705
- }
706
- return { text, nextIndex: i };
707
- }
708
- function collectBalanced(lines, start) {
709
- let depth = 0;
710
- let text = "";
711
- let i = start;
712
- let started = false;
713
- while (i < lines.length) {
714
- const line = lines[i];
715
- text += (text ? "\n" : "") + line;
716
- for (const ch of line) {
717
- if (ch === "{" || ch === "(") {
718
- depth++;
719
- started = true;
720
- }
721
- if (ch === "}" || ch === ")") depth--;
722
- }
723
- i++;
724
- if (started && depth <= 0) break;
725
- if (!started && line.includes(";")) break;
726
- }
727
- return { text, nextIndex: i };
728
- }
729
- function collectStatement(lines, start) {
730
- let text = lines[start];
731
- let i = start + 1;
732
- if (text.includes(";")) return { text, nextIndex: i };
733
- let depth = 0;
734
- for (const ch of text) {
735
- if (ch === "{" || ch === "(" || ch === "[") depth++;
736
- if (ch === "}" || ch === ")" || ch === "]") depth--;
737
- }
738
- while (i < lines.length && depth > 0) {
739
- text += "\n" + lines[i];
740
- for (const ch of lines[i]) {
741
- if (ch === "{" || ch === "(" || ch === "[") depth++;
742
- if (ch === "}" || ch === ")" || ch === "]") depth--;
743
- }
744
- i++;
745
- }
746
- return { text, nextIndex: i };
747
- }
748
- function extractFnSignature(lines, start) {
749
- let sig = "";
750
- let i = start;
751
- while (i < lines.length) {
752
- const line = lines[i].trim();
753
- sig += (sig ? " " : "") + line;
754
- if (line.includes("{")) {
755
- sig = sig.replace(/\s*\{[^]*$/, "").trim();
756
- break;
757
- }
758
- i++;
759
- }
760
- return sig;
761
- }
762
- function skipBlock(lines, start) {
763
- let depth = 0;
764
- let i = start;
765
- let foundBrace = false;
766
- while (i < lines.length) {
767
- for (const ch of lines[i]) {
768
- if (ch === "{") {
769
- depth++;
770
- foundBrace = true;
771
- }
772
- if (ch === "}") depth--;
773
- }
774
- i++;
775
- if (foundBrace && depth <= 0) break;
776
- if (!foundBrace && lines[i - 1].includes(";")) break;
777
- }
778
- return i;
779
- }
780
- function looksLikeFunctionDecl(lines, start) {
781
- const chunk = lines.slice(start, Math.min(start + 5, lines.length)).join(" ");
782
- return /=>/.test(chunk) || /=\s*function/.test(chunk);
783
- }
784
- function extractClassOutline(lines, start) {
785
- const header = lines[start].trim();
786
- let headerText = header;
787
- let i = start + 1;
788
- if (!header.includes("{")) {
789
- while (i < lines.length) {
790
- headerText += " " + lines[i].trim();
791
- if (lines[i].includes("{")) {
792
- i++;
793
- break;
794
- }
795
- i++;
796
- }
797
- } else {
798
- i = start + 1;
799
- }
800
- const bodyParts = [headerText.replace(/\{[^]*$/, "{").trim()];
801
- let depth = 1;
802
- while (i < lines.length && depth > 0) {
803
- const line = lines[i];
804
- const trimmed = line.trim();
805
- for (const ch of line) {
806
- if (ch === "{") depth++;
807
- if (ch === "}") depth--;
808
- }
809
- if (depth <= 0) {
810
- i++;
811
- break;
812
- }
813
- if (depth === 1) {
814
- if (/^(private|protected|public|readonly|static|#)/.test(trimmed) && !trimmed.includes("(")) {
815
- bodyParts.push(` ${trimmed}`);
816
- } else if (/^constructor\s*\(/.test(trimmed)) {
817
- const sig = extractFnSignature(lines, i);
818
- bodyParts.push(` ${sig} { /* ... */ }`);
819
- } else if (/^(?:static\s+)?(?:async\s+)?(?:get\s+|set\s+)?\w+\s*[(<]/.test(trimmed) && !trimmed.startsWith("//")) {
820
- const sig = extractFnSignature(lines, i);
821
- bodyParts.push(` ${sig} { /* ... */ }`);
822
- }
823
- }
824
- i++;
825
- }
826
- bodyParts.push("}");
827
- return { text: bodyParts.join("\n"), nextIndex: i };
828
- }
829
- async function pruneGeneric(file, level) {
830
- let content;
831
- try {
832
- content = await readFile3(file.path, "utf-8");
833
- } catch {
834
- return emptyResult(file, level);
835
- }
836
- return pruneGenericFromContent(file, content, level);
837
- }
838
- function pruneGenericFromContent(file, content, level) {
839
- const lines = content.split("\n");
840
- let result;
841
- if (level === "signatures") {
842
- result = lines.filter((line) => {
843
- const t = line.trim();
844
- return t === "" || t.startsWith("#") || t.startsWith("//") || t.startsWith("import ") || t.startsWith("from ") || t.startsWith("export ") || t.startsWith("def ") || t.startsWith("async def ") || t.startsWith("class ") || t.startsWith("function ") || t.startsWith("const ") || t.startsWith("let ") || t.startsWith("var ") || /^(pub |fn |struct |enum |impl |mod |use )/.test(t);
845
- });
846
- } else {
847
- result = lines.filter((line) => {
848
- const t = line.trim();
849
- return t.startsWith("import ") || t.startsWith("from ") || t.startsWith("export ") || t.startsWith("def ") || t.startsWith("class ") || t.startsWith("function ") || /^(pub |fn |struct |enum |mod |use )/.test(t);
850
- });
851
- }
852
- const prunedContent = result.join("\n");
853
- const prunedTokens = countTokensChars4(Buffer.byteLength(prunedContent, "utf-8"));
854
- const savingsPercent = file.tokens > 0 ? (file.tokens - prunedTokens) / file.tokens * 100 : 0;
855
- return {
856
- relativePath: file.relativePath,
857
- originalTokens: file.tokens,
858
- prunedTokens,
859
- pruneLevel: level,
860
- content: prunedContent,
861
- savingsPercent: Math.max(0, savingsPercent)
862
- };
863
- }
864
- async function fullContent(file) {
865
- let content = "";
866
- try {
867
- content = await readFile3(file.path, "utf-8");
868
- } catch {
869
- }
870
- return {
871
- relativePath: file.relativePath,
872
- originalTokens: file.tokens,
873
- prunedTokens: file.tokens,
874
- pruneLevel: "full",
875
- content,
876
- savingsPercent: 0
877
- };
878
- }
879
- function emptyResult(file, level) {
880
- return {
881
- relativePath: file.relativePath,
882
- originalTokens: file.tokens,
883
- prunedTokens: 0,
884
- pruneLevel: level,
885
- content: "",
886
- savingsPercent: 100
887
- };
888
- }
889
-
890
- // src/engine/graph-utils.ts
891
- function buildAdjacencyList(edges) {
892
- const forward = /* @__PURE__ */ new Map();
893
- const reverse = /* @__PURE__ */ new Map();
894
- for (const edge of edges) {
895
- if (!forward.has(edge.from)) forward.set(edge.from, []);
896
- forward.get(edge.from).push(edge.to);
897
- if (!reverse.has(edge.to)) reverse.set(edge.to, []);
898
- reverse.get(edge.to).push(edge.from);
899
- }
900
- return { forward, reverse };
901
- }
902
- function bfsBidirectional(seeds, adj, depth) {
903
- const result = new Set(seeds);
904
- let frontier = [...seeds];
905
- const visited = /* @__PURE__ */ new Set();
906
- for (let d = 0; d < depth; d++) {
907
- const nextFrontier = [];
908
- for (const node of frontier) {
909
- if (visited.has(node)) continue;
910
- visited.add(node);
911
- const fwd = adj.forward.get(node);
912
- if (fwd) {
913
- for (const neighbor of fwd) {
914
- if (!visited.has(neighbor)) {
915
- result.add(neighbor);
916
- nextFrontier.push(neighbor);
917
- }
918
- }
919
- }
920
- const rev = adj.reverse.get(node);
921
- if (rev) {
922
- for (const neighbor of rev) {
923
- if (!visited.has(neighbor)) {
924
- result.add(neighbor);
925
- nextFrontier.push(neighbor);
926
- }
927
- }
928
- }
929
- }
930
- frontier = nextFrontier;
931
- }
932
- return result;
933
- }
934
- function matchGlob(path, pattern) {
935
- const regexStr = pattern.replace(/\./g, "\\.").replace(/\*\*/g, "\xA7\xA7").replace(/\*/g, "[^/]*").replace(/§§/g, ".*").replace(/\?/g, ".");
936
- try {
937
- return new RegExp(`^${regexStr}$`).test(path);
938
- } catch {
939
- return false;
940
- }
941
- }
942
-
943
- // src/engine/coverage.ts
944
- function calculateCoverage(targetPaths, includedPaths, allFiles, graph, depth = 2) {
945
- const adj = buildAdjacencyList(graph.edges);
946
- const relevantSet = targetPaths.length > 0 ? bfsBidirectional(targetPaths, adj, depth) : /* @__PURE__ */ new Set();
947
- const includedSet = new Set(includedPaths);
948
- const tempFileMap = new Map(allFiles.map((f) => [f.relativePath, f]));
949
- for (const path of includedPaths) {
950
- const file = tempFileMap.get(path);
951
- if (!file) continue;
952
- for (const imp of file.imports) {
953
- const impFile = tempFileMap.get(imp);
954
- if (impFile && impFile.kind === "type") {
955
- relevantSet.add(imp);
956
- }
957
- }
958
- }
959
- const relevantFiles = Array.from(relevantSet);
960
- const includedRelevant = relevantFiles.filter((f) => includedSet.has(f));
961
- const missingRelevant = relevantFiles.filter((f) => !includedSet.has(f));
962
- const missingCritical = missingRelevant.filter((f) => {
963
- const file = tempFileMap.get(f);
964
- return file && (file.exclusionImpact === "critical" || file.exclusionImpact === "high");
965
- });
966
- const fileMap = new Map(allFiles.map((f) => [f.relativePath, f]));
967
- let totalRelevantRisk = 0;
968
- let includedRelevantRisk = 0;
969
- for (const f of relevantFiles) {
970
- const risk = fileMap.get(f)?.riskScore ?? 1;
971
- totalRelevantRisk += risk;
972
- if (includedSet.has(f)) {
973
- includedRelevantRisk += risk;
974
- }
975
- }
976
- const score = totalRelevantRisk > 0 ? Math.round(includedRelevantRisk / totalRelevantRisk * 100) : relevantFiles.length > 0 ? Math.round(includedRelevant.length / relevantFiles.length * 100) : 100;
977
- let explanation;
978
- if (score >= 90) {
979
- explanation = `Excellent coverage (${score}%): AI has nearly all relevant context.`;
980
- } else if (score >= 70) {
981
- explanation = `Good coverage (${score}%): Most relevant files included.`;
982
- if (missingCritical.length > 0) {
983
- explanation += ` Warning: ${missingCritical.length} critical file(s) missing.`;
984
- }
985
- } else if (score >= 50) {
986
- explanation = `Partial coverage (${score}%): Significant context is missing.`;
987
- if (missingCritical.length > 0) {
988
- explanation += ` ${missingCritical.length} critical file(s) not included \u2014 AI quality will degrade.`;
989
- }
990
- } else {
991
- explanation = `Low coverage (${score}%): Most relevant files are excluded. AI response quality will be poor.`;
992
- }
993
- return {
994
- score,
995
- relevantFiles,
996
- includedRelevant,
997
- missingRelevant,
998
- missingCritical,
999
- explanation
1000
- };
1001
- }
1002
-
1003
- // src/engine/budget.ts
1004
- function getPruneLevelForRisk(riskScore) {
1005
- if (riskScore >= 80) return "full";
1006
- if (riskScore >= 60) return "full";
1007
- if (riskScore >= 30) return "signatures";
1008
- return "skeleton";
1009
- }
1010
-
1011
- // src/engine/selector.ts
1012
- async function selectContext(input) {
1013
- const { task, analysis, budget, policies, depth = 2 } = input;
1014
- const decisions = [];
1015
- const targetPaths = identifyTargetFiles(task, analysis.files);
1016
- if (targetPaths.length > 0) {
1017
- decisions.push({
1018
- file: targetPaths.join(", "),
1019
- action: "include-full",
1020
- reason: `Target file(s) identified from task description`
1021
- });
1022
- }
1023
- const adj = buildAdjacencyList(analysis.graph.edges);
1024
- const expandedPaths = targetPaths.length > 0 ? Array.from(bfsBidirectional(targetPaths, adj, depth)) : [];
1025
- const expansionCount = expandedPaths.length - targetPaths.length;
1026
- if (expansionCount > 0) {
1027
- decisions.push({
1028
- file: `${expansionCount} dependencies`,
1029
- action: "include-full",
1030
- reason: `Expanded ${targetPaths.length} target(s) to ${expandedPaths.length} files via dependency graph (depth ${depth})`
1031
- });
1032
- }
1033
- const allFileMap = new Map(analysis.files.map((f) => [f.relativePath, f]));
1034
- if (targetPaths.length > 0) {
1035
- for (const path of expandedPaths) {
1036
- const file = allFileMap.get(path);
1037
- if (!file) continue;
1038
- for (const imp of file.imports) {
1039
- const impFile = allFileMap.get(imp);
1040
- if (impFile && impFile.kind === "type") {
1041
- expandedPaths.push(imp);
1042
- }
1043
- }
1044
- }
1045
- }
1046
- const { mustInclude, mustExclude } = applyPolicies(analysis.files, policies);
1047
- const candidateSet = /* @__PURE__ */ new Set([...expandedPaths, ...mustInclude]);
1048
- if (targetPaths.length === 0) {
1049
- for (const f of analysis.files) {
1050
- candidateSet.add(f.relativePath);
1051
- }
1052
- }
1053
- for (const ex of mustExclude) {
1054
- candidateSet.delete(ex);
1055
- decisions.push({
1056
- file: ex,
1057
- action: "exclude",
1058
- reason: "Excluded by policy"
1059
- });
1060
- }
1061
- const hasSecretBlock = policies?.rules.some(
1062
- (r) => r.type === "secret-block" && r.enabled
1063
- );
1064
- if (hasSecretBlock) {
1065
- for (const path of Array.from(candidateSet)) {
1066
- const file = allFileMap.get(path);
1067
- if (!file) continue;
1068
- const findings = await scanFileForSecrets(
1069
- file.path,
1070
- analysis.projectPath
1071
- );
1072
- if (findings.length > 0) {
1073
- candidateSet.delete(path);
1074
- decisions.push({
1075
- file: path,
1076
- action: "exclude",
1077
- reason: `Blocked: ${findings.length} secret(s) detected (${findings.map((f) => f.type).join(", ")})`
1078
- });
1079
- }
1080
- }
1081
- }
1082
- const candidates = Array.from(candidateSet).map((p) => allFileMap.get(p)).filter((f) => f !== void 0).sort((a, b) => {
1083
- const aIsTarget = targetPaths.includes(a.relativePath) ? 0 : 1;
1084
- const bIsTarget = targetPaths.includes(b.relativePath) ? 0 : 1;
1085
- if (aIsTarget !== bIsTarget) return aIsTarget - bIsTarget;
1086
- const aIsMust = mustInclude.has(a.relativePath) ? 0 : 1;
1087
- const bIsMust = mustInclude.has(b.relativePath) ? 0 : 1;
1088
- if (aIsMust !== bIsMust) return aIsMust - bIsMust;
1089
- return b.riskScore - a.riskScore;
1090
- });
1091
- const selectedFiles = [];
1092
- let usedTokens = 0;
1093
- for (const file of candidates) {
1094
- const isTarget = targetPaths.includes(file.relativePath);
1095
- const isMustInclude = mustInclude.has(file.relativePath);
1096
- const defaultLevel = isTarget ? "full" : getPruneLevelForRisk(file.riskScore);
1097
- const levels = getCascadeLevels(defaultLevel);
1098
- let included = false;
1099
- for (const level of levels) {
1100
- if (level === "excluded") break;
1101
- let tokens;
1102
- if (level === "full") {
1103
- tokens = file.tokens;
1104
- } else {
1105
- const pruned = await pruneFile(file, level);
1106
- tokens = pruned.prunedTokens;
1107
- }
1108
- if (usedTokens + tokens <= budget) {
1109
- usedTokens += tokens;
1110
- selectedFiles.push({
1111
- relativePath: file.relativePath,
1112
- tokens,
1113
- originalTokens: file.tokens,
1114
- pruneLevel: level,
1115
- riskScore: file.riskScore,
1116
- reason: buildReason(file, level, isTarget, isMustInclude)
1117
- });
1118
- if (level !== defaultLevel) {
1119
- decisions.push({
1120
- file: file.relativePath,
1121
- action: `include-${level}`,
1122
- reason: `Downgraded from ${defaultLevel} to ${level} due to budget constraint`,
1123
- alternatives: `Would need ${file.tokens - tokens} more tokens for ${defaultLevel}`
1124
- });
1125
- }
1126
- included = true;
1127
- break;
1128
- }
1129
- }
1130
- if (!included) {
1131
- decisions.push({
1132
- file: file.relativePath,
1133
- action: "exclude",
1134
- reason: `Budget exhausted (risk: ${file.riskScore}, needs ${file.tokens} tokens)`
1135
- });
1136
- }
1137
- }
1138
- const includedPaths = selectedFiles.map((f) => f.relativePath);
1139
- const coverage = calculateCoverage(
1140
- targetPaths,
1141
- includedPaths,
1142
- analysis.files,
1143
- analysis.graph,
1144
- depth
1145
- );
1146
- const includedSet = new Set(includedPaths);
1147
- const excludedFiles = analysis.files.filter(
1148
- (f) => !includedSet.has(f.relativePath)
1149
- );
1150
- const excludedRisk = excludedFiles.length > 0 ? Math.round(excludedFiles.reduce((s, f) => s + f.riskScore, 0) / excludedFiles.length) : 0;
1151
- const hashInput = selectedFiles.map((f) => `${f.relativePath}:${f.pruneLevel}`).sort().join("|") + `|budget:${budget}`;
1152
- const hash = createHash2("sha256").update(hashInput).digest("hex").substring(0, 16);
1153
- return {
1154
- files: selectedFiles,
1155
- totalTokens: usedTokens,
1156
- budget,
1157
- usedPercent: budget > 0 ? Math.round(usedTokens / budget * 100 * 10) / 10 : 0,
1158
- coverage,
1159
- riskScore: excludedRisk,
1160
- deterministic: true,
1161
- hash,
1162
- decisions
1163
- };
1164
- }
1165
- function identifyTargetFiles(task, files) {
1166
- const targets = [];
1167
- const pathPattern = /(?:^|\s|["'`])([.\w/-]+\.[a-zA-Z]{1,4})(?:\s|$|["'`]|,|:)/g;
1168
- let match;
1169
- while ((match = pathPattern.exec(task)) !== null) {
1170
- const candidate = match[1];
1171
- const found = files.find(
1172
- (f) => f.relativePath === candidate || f.relativePath.endsWith(candidate)
1173
- );
1174
- if (found && !targets.includes(found.relativePath)) {
1175
- targets.push(found.relativePath);
1176
- }
1177
- }
1178
- return targets;
1179
- }
1180
- function applyPolicies(files, policies) {
1181
- const mustInclude = /* @__PURE__ */ new Set();
1182
- const mustExclude = /* @__PURE__ */ new Set();
1183
- if (!policies) return { mustInclude, mustExclude };
1184
- for (const rule of policies.rules) {
1185
- if (!rule.enabled) continue;
1186
- if (rule.type === "include-always" && rule.pattern) {
1187
- for (const file of files) {
1188
- if (matchGlob(file.relativePath, rule.pattern)) {
1189
- mustInclude.add(file.relativePath);
1190
- }
1191
- }
1192
- }
1193
- if (rule.type === "exclude-always" && rule.pattern) {
1194
- for (const file of files) {
1195
- if (matchGlob(file.relativePath, rule.pattern)) {
1196
- mustExclude.add(file.relativePath);
1197
- }
1198
- }
1199
- }
1200
- }
1201
- return { mustInclude, mustExclude };
1202
- }
1203
- function getCascadeLevels(startLevel) {
1204
- const all = ["full", "signatures", "skeleton", "excluded"];
1205
- const startIdx = all.indexOf(startLevel);
1206
- return all.slice(startIdx);
1207
- }
1208
- function buildReason(file, level, isTarget, isMustInclude) {
1209
- if (isTarget) return "Target file";
1210
- if (isMustInclude) return "Required by policy";
1211
- const impact = file.exclusionImpact;
1212
- const levelStr = level === "full" ? "full content" : level;
1213
- if (impact === "critical") return `Critical dependency (risk ${file.riskScore}) \u2014 ${levelStr}`;
1214
- if (impact === "high") return `High-risk dependency (risk ${file.riskScore}) \u2014 ${levelStr}`;
1215
- if (impact === "medium") return `Medium relevance (risk ${file.riskScore}) \u2014 ${levelStr}`;
1216
- return `Low relevance (risk ${file.riskScore}) \u2014 ${levelStr}`;
1217
- }
1218
-
1219
- // src/gateway/interceptor.ts
1220
- import { readFileSync as readFileSync2 } from "fs";
1221
- import { resolve as resolve2 } from "path";
1222
- function estimateTokensFromString(s) {
1223
- return Math.ceil(Buffer.byteLength(s, "utf-8") / 4);
1224
- }
1225
- async function interceptRequest(messages, config, analysis) {
1226
- const decisions = [];
1227
- let secretsRedacted = 0;
1228
- let secretsBlocked = false;
1229
- let contextInjected = false;
1230
- const originalTokens = messages.reduce((sum, m) => sum + estimateTokensFromString(m.content), 0);
1231
- let processedMessages = messages;
1232
- if (config.redactSecrets || config.blockOnSecrets) {
1233
- const { messages: scannedMessages, redactedCount, blocked, scanDecisions } = scanMessages(messages, config);
1234
- processedMessages = scannedMessages;
1235
- secretsRedacted = redactedCount;
1236
- secretsBlocked = blocked;
1237
- decisions.push(...scanDecisions);
1238
- if (blocked) {
1239
- return {
1240
- modified: true,
1241
- messages: processedMessages,
1242
- originalTokens,
1243
- optimizedTokens: 0,
1244
- secretsRedacted,
1245
- secretsBlocked: true,
1246
- contextInjected: false,
1247
- decisions
1248
- };
1249
- }
1250
- }
1251
- if (config.optimize && analysis) {
1252
- const { messages: optimizedMessages, injected, optimizeDecisions } = await optimizeContext(processedMessages, analysis, config);
1253
- processedMessages = optimizedMessages;
1254
- contextInjected = injected;
1255
- decisions.push(...optimizeDecisions);
1256
- }
1257
- const optimizedTokens = processedMessages.reduce((sum, m) => sum + estimateTokensFromString(m.content), 0);
1258
- return {
1259
- modified: secretsRedacted > 0 || contextInjected,
1260
- messages: processedMessages,
1261
- originalTokens,
1262
- optimizedTokens,
1263
- secretsRedacted,
1264
- secretsBlocked,
1265
- contextInjected,
1266
- decisions
1267
- };
1268
- }
1269
- function scanMessages(messages, config) {
1270
- const scanDecisions = [];
1271
- let redactedCount = 0;
1272
- let blocked = false;
1273
- const scannedMessages = messages.map((msg) => {
1274
- const findings = scanContentForSecrets(msg.content, `message:${msg.role}`);
1275
- if (findings.length === 0) return msg;
1276
- const criticalCount = findings.filter((f) => f.severity === "critical").length;
1277
- if (config.blockOnSecrets && criticalCount > 0) {
1278
- blocked = true;
1279
- scanDecisions.push(
1280
- `BLOCKED: ${criticalCount} critical secret(s) in ${msg.role} message. Types: ${[...new Set(findings.map((f) => f.type))].join(", ")}`
1281
- );
1282
- return msg;
1283
- }
1284
- const sanitized = sanitizeContent(msg.content);
1285
- redactedCount += findings.length;
1286
- scanDecisions.push(
1287
- `Redacted ${findings.length} secret(s) in ${msg.role} message: ${[...new Set(findings.map((f) => f.type))].join(", ")}`
1288
- );
1289
- return { ...msg, content: sanitized };
1290
- });
1291
- return { messages: scannedMessages, redactedCount, blocked, scanDecisions };
1292
- }
1293
- async function optimizeContext(messages, analysis, config) {
1294
- const optimizeDecisions = [];
1295
- const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
1296
- if (!lastUserMsg) {
1297
- optimizeDecisions.push("No user message found \u2014 skipping optimization");
1298
- return { messages, injected: false, optimizeDecisions };
1299
- }
1300
- const hasCtxContext = messages.some(
1301
- (m) => m.role === "system" && m.content.includes("[CTO Context]")
1302
- );
1303
- if (hasCtxContext) {
1304
- optimizeDecisions.push("CTO context already present \u2014 skipping injection");
1305
- return { messages, injected: false, optimizeDecisions };
1306
- }
1307
- try {
1308
- const selection = await selectContext({
1309
- task: lastUserMsg.content.slice(0, 500),
1310
- analysis,
1311
- budget: config.budget
1312
- });
1313
- if (selection.files.length === 0) {
1314
- optimizeDecisions.push("No relevant files found for task \u2014 skipping injection");
1315
- return { messages, injected: false, optimizeDecisions };
1316
- }
1317
- const contentBudget = Math.floor(config.budget * 0.6);
1318
- let usedTokens = 0;
1319
- const contextLines = [
1320
- "[CTO Context] Optimized project context (auto-injected by CTO Gateway)",
1321
- "",
1322
- `Project: ${analysis.projectName} (${analysis.totalFiles} files, ${Math.round(analysis.totalTokens / 1e3)}K tokens)`,
1323
- `Selected: ${selection.files.length} files, ${selection.totalTokens.toLocaleString()} tokens (${selection.coverage.score}% coverage)`,
1324
- ""
1325
- ];
1326
- const topFiles = selection.files.sort((a, b) => b.riskScore - a.riskScore);
1327
- const injectedFiles = [];
1328
- const skippedFiles = [];
1329
- for (const f of topFiles) {
1330
- if (usedTokens >= contentBudget) {
1331
- skippedFiles.push(f.relativePath);
1332
- continue;
1333
- }
1334
- try {
1335
- const fullPath = resolve2(config.projectPath, f.relativePath);
1336
- const content = readFileSync2(fullPath, "utf-8");
1337
- const fileTokens = estimateTokensFromString(content);
1338
- const remainingBudget = contentBudget - usedTokens;
1339
- let fileContent;
1340
- let truncated = false;
1341
- if (fileTokens > remainingBudget) {
1342
- const charLimit = remainingBudget * 4;
1343
- fileContent = content.slice(0, charLimit);
1344
- truncated = true;
1345
- } else {
1346
- fileContent = content;
1347
- }
1348
- const ext = f.relativePath.split(".").pop() || "";
1349
- contextLines.push(`### ${f.relativePath}${truncated ? " [truncated]" : ""}`);
1350
- contextLines.push("```" + ext);
1351
- contextLines.push(fileContent);
1352
- contextLines.push("```");
1353
- contextLines.push("");
1354
- usedTokens += estimateTokensFromString(fileContent);
1355
- injectedFiles.push(f.relativePath);
1356
- } catch {
1357
- skippedFiles.push(f.relativePath);
1358
- }
1359
- }
1360
- const typeFiles = analysis.files.filter((f) => f.kind === "type").map((f) => f.relativePath);
1361
- if (typeFiles.length > 0) {
1362
- contextLines.push("Type definitions (always import from these):");
1363
- for (const tf of typeFiles.slice(0, 10)) {
1364
- contextLines.push(` - ${tf}`);
1365
- }
1366
- contextLines.push("");
1367
- }
1368
- if (analysis.graph.hubs.length > 0) {
1369
- contextLines.push("Hub files (central modules with many dependents):");
1370
- for (const hub of analysis.graph.hubs.slice(0, 5)) {
1371
- contextLines.push(` - ${hub.relativePath} (${hub.dependents} dependents)`);
1372
- }
1373
- contextLines.push("");
1374
- }
1375
- if (skippedFiles.length > 0) {
1376
- contextLines.push(`Additional relevant files (not included due to token budget):`);
1377
- for (const sf of skippedFiles.slice(0, 15)) {
1378
- contextLines.push(` - ${sf}`);
1379
- }
1380
- if (skippedFiles.length > 15) {
1381
- contextLines.push(` ... and ${skippedFiles.length - 15} more`);
1382
- }
1383
- }
1384
- const contextBlock = contextLines.join("\n");
1385
- const systemMsg = {
1386
- role: "system",
1387
- content: contextBlock
1388
- };
1389
- const existingSystemIdx = messages.findIndex((m) => m.role === "system");
1390
- let optimizedMessages;
1391
- if (existingSystemIdx >= 0) {
1392
- optimizedMessages = [...messages];
1393
- optimizedMessages[existingSystemIdx] = {
1394
- ...optimizedMessages[existingSystemIdx],
1395
- content: optimizedMessages[existingSystemIdx].content + "\n\n" + contextBlock
1396
- };
1397
- } else {
1398
- optimizedMessages = [systemMsg, ...messages];
1399
- }
1400
- optimizeDecisions.push(
1401
- `Injected CTO context: ${injectedFiles.length} files with contents (${usedTokens.toLocaleString()} tokens), ${skippedFiles.length} listed without contents, ${selection.coverage.score}% coverage`
1402
- );
1403
- return { messages: optimizedMessages, injected: true, optimizeDecisions };
1404
- } catch (err) {
1405
- const errMsg = err instanceof Error ? err.message : String(err);
1406
- optimizeDecisions.push(`Context optimization failed: ${errMsg}`);
1407
- return { messages, injected: false, optimizeDecisions };
1408
- }
1409
- }
1410
-
1411
- // src/gateway/tracker.ts
1412
- import { mkdirSync as mkdirSync2, appendFileSync, readFileSync as readFileSync3, readdirSync, existsSync as existsSync2 } from "fs";
1413
- import { join as join2 } from "path";
1414
- import { randomUUID } from "crypto";
1415
- var UsageTracker = class {
1416
- logDir;
1417
- config;
1418
- eventHandlers = [];
1419
- cache = null;
1420
- cacheMonth = null;
1421
- // In-memory cost accumulators — survive async disk writes
1422
- memRecords = [];
1423
- constructor(config) {
1424
- this.config = config;
1425
- this.logDir = join2(config.logDir, "usage");
1426
- mkdirSync2(this.logDir, { recursive: true });
1427
- }
1428
- // ===== EVENT SYSTEM =====
1429
- onEvent(handler) {
1430
- this.eventHandlers.push(handler);
1431
- }
1432
- emit(event) {
1433
- for (const handler of this.eventHandlers) {
1434
- try {
1435
- handler(event);
1436
- } catch {
1437
- }
1438
- }
1439
- }
1440
- // ===== RECORD =====
1441
- record(params) {
1442
- const record = {
1443
- id: randomUUID().slice(0, 8),
1444
- timestamp: /* @__PURE__ */ new Date(),
1445
- ...params
1446
- };
1447
- const monthKey = this.getMonthKey(record.timestamp);
1448
- const logFile = join2(this.logDir, `${monthKey}.jsonl`);
1449
- const line = JSON.stringify({
1450
- ...record,
1451
- timestamp: record.timestamp.toISOString()
1452
- });
1453
- appendFileSync(logFile, line + "\n");
1454
- this.memRecords.push(record);
1455
- this.cache = null;
1456
- this.emit({ type: "request", record });
1457
- this.checkBudget(record.timestamp);
1458
- return record;
1459
- }
1460
- // ===== BUDGET CHECKS =====
1461
- checkBudget(now) {
1462
- if (this.config.budgetDaily > 0) {
1463
- const dailyCost = this.getDailyCost(now);
1464
- const threshold = this.config.budgetDaily * this.config.alertThreshold;
1465
- if (dailyCost >= this.config.budgetDaily) {
1466
- this.emit({
1467
- type: "budget-exceeded",
1468
- current: dailyCost,
1469
- limit: this.config.budgetDaily,
1470
- period: "daily"
1471
- });
1472
- } else if (dailyCost >= threshold) {
1473
- this.emit({
1474
- type: "budget-alert",
1475
- current: dailyCost,
1476
- limit: this.config.budgetDaily,
1477
- period: "daily"
1478
- });
1479
- }
1480
- }
1481
- if (this.config.budgetMonthly > 0) {
1482
- const monthlyCost = this.getMonthlyCost(now);
1483
- const threshold = this.config.budgetMonthly * this.config.alertThreshold;
1484
- if (monthlyCost >= this.config.budgetMonthly) {
1485
- this.emit({
1486
- type: "budget-exceeded",
1487
- current: monthlyCost,
1488
- limit: this.config.budgetMonthly,
1489
- period: "monthly"
1490
- });
1491
- } else if (monthlyCost >= threshold) {
1492
- this.emit({
1493
- type: "budget-alert",
1494
- current: monthlyCost,
1495
- limit: this.config.budgetMonthly,
1496
- period: "monthly"
1497
- });
1498
- }
1499
- }
1500
- }
1501
- isDailyBudgetExceeded(now = /* @__PURE__ */ new Date()) {
1502
- if (this.config.budgetDaily <= 0) return false;
1503
- return this.getDailyCost(now) >= this.config.budgetDaily;
1504
- }
1505
- isMonthlyBudgetExceeded(now = /* @__PURE__ */ new Date()) {
1506
- if (this.config.budgetMonthly <= 0) return false;
1507
- return this.getMonthlyCost(now) >= this.config.budgetMonthly;
1508
- }
1509
- // ===== QUERIES =====
1510
- getDailyCost(date = /* @__PURE__ */ new Date()) {
1511
- const dayStr = date.toISOString().split("T")[0];
1512
- const diskRecords = this.getMonthRecords(date);
1513
- const allRecords = this.mergeWithMemRecords(diskRecords);
1514
- return allRecords.filter((r) => r.timestamp.toISOString().startsWith(dayStr)).reduce((sum, r) => sum + r.costUSD, 0);
1515
- }
1516
- getMonthlyCost(date = /* @__PURE__ */ new Date()) {
1517
- const diskRecords = this.getMonthRecords(date);
1518
- const allRecords = this.mergeWithMemRecords(diskRecords);
1519
- return allRecords.reduce((sum, r) => sum + r.costUSD, 0);
1520
- }
1521
- mergeWithMemRecords(diskRecords) {
1522
- if (this.memRecords.length === 0) return diskRecords;
1523
- const diskIds = new Set(diskRecords.map((r) => r.id));
1524
- const newRecords = this.memRecords.filter((r) => !diskIds.has(r.id));
1525
- return [...diskRecords, ...newRecords];
1526
- }
1527
- getSummary(period = "month") {
1528
- const now = /* @__PURE__ */ new Date();
1529
- let records;
1530
- switch (period) {
1531
- case "day": {
1532
- const dayStr = now.toISOString().split("T")[0];
1533
- records = this.mergeWithMemRecords(this.getMonthRecords(now)).filter(
1534
- (r) => r.timestamp.toISOString().startsWith(dayStr)
1535
- );
1536
- break;
1537
- }
1538
- case "week": {
1539
- const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1e3);
1540
- const weekAgoKey = this.getMonthKey(weekAgo);
1541
- const nowKey = this.getMonthKey(now);
1542
- const baseRecs = weekAgoKey !== nowKey ? [...this.getMonthRecordsByKey(weekAgoKey), ...this.getMonthRecords(now)] : this.getMonthRecords(now);
1543
- records = this.mergeWithMemRecords(baseRecs).filter((r) => r.timestamp >= weekAgo);
1544
- break;
1545
- }
1546
- case "month":
1547
- records = this.mergeWithMemRecords(this.getMonthRecords(now));
1548
- break;
1549
- case "all":
1550
- records = this.mergeWithMemRecords(this.getAllRecords());
1551
- break;
1552
- }
1553
- const byModel = {};
1554
- const byProvider = {};
1555
- for (const r of records) {
1556
- if (!byModel[r.model]) byModel[r.model] = { requests: 0, costUSD: 0, tokens: 0 };
1557
- byModel[r.model].requests++;
1558
- byModel[r.model].costUSD += r.costUSD;
1559
- byModel[r.model].tokens += r.inputTokens + r.outputTokens;
1560
- if (!byProvider[r.provider]) byProvider[r.provider] = { requests: 0, costUSD: 0 };
1561
- byProvider[r.provider].requests++;
1562
- byProvider[r.provider].costUSD += r.costUSD;
1563
- }
1564
- return {
1565
- period,
1566
- totalRequests: records.length,
1567
- totalInputTokens: records.reduce((s, r) => s + r.inputTokens, 0),
1568
- totalOutputTokens: records.reduce((s, r) => s + r.outputTokens, 0),
1569
- totalCostUSD: records.reduce((s, r) => s + r.costUSD, 0),
1570
- totalSavedTokens: records.reduce((s, r) => s + r.savedTokens, 0),
1571
- totalSavedUSD: records.reduce((s, r) => s + r.savedUSD, 0),
1572
- totalSecretsRedacted: records.reduce((s, r) => s + r.secretsRedacted, 0),
1573
- byModel,
1574
- byProvider
1575
- };
1576
- }
1577
- // ===== STORAGE =====
1578
- getMonthKey(date) {
1579
- return date.toISOString().slice(0, 7);
1580
- }
1581
- getMonthRecordsByKey(monthKey) {
1582
- const filePath = join2(this.logDir, `${monthKey}.jsonl`);
1583
- if (!existsSync2(filePath)) return [];
1584
- return readFileSync3(filePath, "utf-8").split("\n").filter((line) => line.trim()).map((line) => {
1585
- try {
1586
- const parsed = JSON.parse(line);
1587
- parsed.timestamp = new Date(parsed.timestamp);
1588
- return parsed;
1589
- } catch {
1590
- return null;
1591
- }
1592
- }).filter((r) => r !== null);
1593
- }
1594
- getMonthRecords(date) {
1595
- const monthKey = this.getMonthKey(date);
1596
- if (this.cache && this.cacheMonth === monthKey) return this.cache;
1597
- const filePath = join2(this.logDir, `${monthKey}.jsonl`);
1598
- if (!existsSync2(filePath)) return [];
1599
- const records = readFileSync3(filePath, "utf-8").split("\n").filter((line) => line.trim()).map((line) => {
1600
- try {
1601
- const parsed = JSON.parse(line);
1602
- parsed.timestamp = new Date(parsed.timestamp);
1603
- return parsed;
1604
- } catch {
1605
- return null;
1606
- }
1607
- }).filter((r) => r !== null);
1608
- this.cache = records;
1609
- this.cacheMonth = monthKey;
1610
- return records;
1611
- }
1612
- getAllRecords() {
1613
- if (!existsSync2(this.logDir)) return [];
1614
- const files = readdirSync(this.logDir).filter((f) => f.endsWith(".jsonl")).sort();
1615
- const allRecords = [];
1616
- for (const file of files) {
1617
- const content = readFileSync3(join2(this.logDir, file), "utf-8");
1618
- const records = content.split("\n").filter((line) => line.trim()).map((line) => {
1619
- try {
1620
- const parsed = JSON.parse(line);
1621
- parsed.timestamp = new Date(parsed.timestamp);
1622
- return parsed;
1623
- } catch {
1624
- return null;
1625
- }
1626
- }).filter((r) => r !== null);
1627
- allRecords.push(...records);
1628
- }
1629
- return allRecords;
1630
- }
1631
- };
1632
-
1633
- // src/engine/analyzer.ts
1634
- import { readFile as readFile4, readdir, stat as stat2 } from "fs/promises";
1635
- import { join as join4, extname, relative as relative3, resolve as resolve4, basename as basename2 } from "path";
1636
- import { createHash as createHash3 } from "crypto";
1637
-
1638
- // src/types/engine.ts
1639
- var DEFAULT_RISK_WEIGHTS = {
1640
- hub: 30,
1641
- typeProvider: 25,
1642
- complexity: 15,
1643
- recency: 15,
1644
- config: 10,
1645
- churn: 5
1646
- };
1647
-
1648
- // src/types/config.ts
1649
- var DEFAULT_CONFIG = {
1650
- version: "2.0",
1651
- analysis: {
1652
- extensions: {
1653
- code: ["ts", "tsx", "js", "jsx", "py", "go", "rs", "java", "kt", "rb", "php", "c", "cpp", "h", "hpp", "cs"],
1654
- config: ["json", "yml", "yaml", "toml"],
1655
- docs: ["md", "txt", "rst"]
1656
- },
1657
- ignore: {
1658
- dirs: ["node_modules", "dist", "build", ".git", "coverage", "__pycache__", ".next", "vendor", ".cto"],
1659
- patterns: ["*.min.js", "*.map", "*.lock", "*.generated.*"]
1660
- },
1661
- maxDepth: 20
1662
- },
1663
- risk: {
1664
- weights: {
1665
- hub: 30,
1666
- typeProvider: 25,
1667
- complexity: 15,
1668
- recency: 15,
1669
- config: 10,
1670
- churn: 5
1671
- }
1672
- },
1673
- interaction: {
1674
- defaultBudget: 5e4,
1675
- defaultModel: "claude-sonnet-4"
1676
- },
1677
- tokens: {
1678
- method: "chars4"
1679
- },
1680
- governance: {
1681
- auditEnabled: true,
1682
- secretDetection: true,
1683
- retentionDays: 90
1684
- }
1685
- };
1686
-
1687
- // src/engine/graph.ts
1688
- import { Project, SyntaxKind } from "ts-morph";
1689
- import { resolve as resolve3, relative as relative2, dirname as dirname2, join as join3 } from "path";
1690
- import { existsSync as existsSync3 } from "fs";
1691
- var TS_EXTENSIONS2 = /* @__PURE__ */ new Set(["ts", "tsx", "js", "jsx", "mts", "mjs", "cts", "cjs"]);
1692
- function createProject(projectPath, filePaths) {
1693
- const tsConfigPath = join3(projectPath, "tsconfig.json");
1694
- const hasTsConfig = existsSync3(tsConfigPath);
1695
- const project = new Project({
1696
- tsConfigFilePath: hasTsConfig ? tsConfigPath : void 0,
1697
- skipAddingFilesFromTsConfig: true,
1698
- compilerOptions: hasTsConfig ? void 0 : {
1699
- allowJs: true,
1700
- jsx: 4,
1701
- // JsxEmit.ReactJSX
1702
- esModuleInterop: true,
1703
- moduleResolution: 100
1704
- // Bundler
1705
- }
1706
- });
1707
- const tsFiles = filePaths.filter((f) => {
1708
- const ext = f.split(".").pop()?.toLowerCase() ?? "";
1709
- return TS_EXTENSIONS2.has(ext);
1710
- });
1711
- for (const filePath of tsFiles) {
1712
- try {
1713
- project.addSourceFileAtPath(filePath);
1714
- } catch {
1715
- }
1716
- }
1717
- return project;
1718
- }
1719
- function buildProjectGraph(projectPath, files) {
1720
- const absPath = resolve3(projectPath);
1721
- const tsFiles = files.filter((f) => TS_EXTENSIONS2.has(f.extension)).map((f) => f.path);
1722
- if (tsFiles.length === 0) {
1723
- return emptyGraph(files);
1724
- }
1725
- let project;
1726
- try {
1727
- project = createProject(projectPath, tsFiles);
1728
- } catch {
1729
- return emptyGraph(files);
1730
- }
1731
- const edges = [];
1732
- const nodeSet = /* @__PURE__ */ new Set();
1733
- for (const sourceFile of project.getSourceFiles()) {
1734
- const fromRel = relative2(absPath, sourceFile.getFilePath());
1735
- if (fromRel.startsWith("..") || fromRel.includes("node_modules")) continue;
1736
- nodeSet.add(fromRel);
1737
- for (const imp of sourceFile.getImportDeclarations()) {
1738
- const moduleSpecifier = imp.getModuleSpecifierValue();
1739
- const resolved = resolveImport(sourceFile, moduleSpecifier, absPath);
1740
- if (resolved) {
1741
- nodeSet.add(resolved);
1742
- edges.push({ from: fromRel, to: resolved, type: "import" });
1743
- }
1744
- }
1745
- for (const exp of sourceFile.getExportDeclarations()) {
1746
- const moduleSpecifier = exp.getModuleSpecifierValue();
1747
- if (moduleSpecifier) {
1748
- const resolved = resolveImport(sourceFile, moduleSpecifier, absPath);
1749
- if (resolved) {
1750
- nodeSet.add(resolved);
1751
- edges.push({ from: fromRel, to: resolved, type: "re-export" });
1752
- }
1753
- }
1754
- }
1755
- }
1756
- const nodes = Array.from(nodeSet);
1757
- const importedByCount = /* @__PURE__ */ new Map();
1758
- const importCount = /* @__PURE__ */ new Map();
1759
- for (const edge of edges) {
1760
- importedByCount.set(edge.to, (importedByCount.get(edge.to) ?? 0) + 1);
1761
- importCount.set(edge.from, (importCount.get(edge.from) ?? 0) + 1);
1762
- }
1763
- const N = Math.max(nodes.length, 1);
1764
- const hubs = nodes.map((node) => {
1765
- const inDeg = importedByCount.get(node) ?? 0;
1766
- const outDeg = importCount.get(node) ?? 0;
1767
- const centrality = N > 1 ? inDeg / (N - 1) * 100 : 0;
1768
- const score = Math.round(centrality + outDeg * (100 / (2 * N)));
1769
- return {
1770
- relativePath: node,
1771
- dependents: inDeg,
1772
- dependencies: outDeg,
1773
- score: Math.min(100, score)
1774
- };
1775
- }).filter((h) => h.dependents >= 3 || h.score >= 15).sort((a, b) => b.score - a.score);
1776
- const leaves = nodes.filter(
1777
- (node) => (importedByCount.get(node) ?? 0) === 0 && (importCount.get(node) ?? 0) > 0
1778
- );
1779
- const connectedNodes = /* @__PURE__ */ new Set();
1780
- for (const edge of edges) {
1781
- connectedNodes.add(edge.from);
1782
- connectedNodes.add(edge.to);
1783
- }
1784
- const allFileNodes = new Set(files.map((f) => f.relativePath));
1785
- const orphans = Array.from(allFileNodes).filter((n) => !connectedNodes.has(n));
1786
- const clusters = detectClusters(nodes, edges, files);
1787
- enrichComplexity(project, absPath, files);
1788
- return { nodes, edges, hubs, leaves, orphans, clusters };
1789
- }
1790
- var UnionFind = class {
1791
- parent;
1792
- rank;
1793
- constructor(nodes) {
1794
- this.parent = /* @__PURE__ */ new Map();
1795
- this.rank = /* @__PURE__ */ new Map();
1796
- for (const n of nodes) {
1797
- this.parent.set(n, n);
1798
- this.rank.set(n, 0);
1799
- }
1800
- }
1801
- find(x) {
1802
- const p = this.parent.get(x);
1803
- if (p === void 0) return x;
1804
- if (p !== x) {
1805
- this.parent.set(x, this.find(p));
1806
- }
1807
- return this.parent.get(x);
1808
- }
1809
- union(a, b) {
1810
- const ra = this.find(a);
1811
- const rb = this.find(b);
1812
- if (ra === rb) return;
1813
- const rankA = this.rank.get(ra) ?? 0;
1814
- const rankB = this.rank.get(rb) ?? 0;
1815
- if (rankA < rankB) {
1816
- this.parent.set(ra, rb);
1817
- } else if (rankA > rankB) {
1818
- this.parent.set(rb, ra);
1819
- } else {
1820
- this.parent.set(rb, ra);
1821
- this.rank.set(ra, rankA + 1);
1822
- }
1823
- }
1824
- };
1825
- function detectClusters(nodes, edges, files) {
1826
- const uf = new UnionFind(nodes);
1827
- for (const edge of edges) {
1828
- uf.union(edge.from, edge.to);
1829
- }
1830
- const components = /* @__PURE__ */ new Map();
1831
- for (const node of nodes) {
1832
- const root = uf.find(node);
1833
- if (!components.has(root)) components.set(root, []);
1834
- components.get(root).push(node);
1835
- }
1836
- const tokenMap = new Map(files.map((f) => [f.relativePath, f.tokens]));
1837
- const clusters = [];
1838
- for (const [, groupFiles] of components) {
1839
- if (groupFiles.length < 2) continue;
1840
- const name = commonPrefix(groupFiles);
1841
- const fileSet = new Set(groupFiles);
1842
- let internalEdges = 0;
1843
- let externalEdges = 0;
1844
- for (const edge of edges) {
1845
- const fromIn = fileSet.has(edge.from);
1846
- const toIn = fileSet.has(edge.to);
1847
- if (fromIn && toIn) internalEdges++;
1848
- else if (fromIn || toIn) externalEdges++;
1849
- }
1850
- const totalEdges = internalEdges + externalEdges;
1851
- const cohesion = totalEdges > 0 ? internalEdges / totalEdges : 0;
1852
- const totalTokens = groupFiles.reduce((s, f) => s + (tokenMap.get(f) ?? 0), 0);
1853
- clusters.push({
1854
- id: name.replace(/[^a-zA-Z0-9]/g, "-") || `cluster-${clusters.length}`,
1855
- name: name || `cluster-${clusters.length}`,
1856
- files: groupFiles,
1857
- totalTokens,
1858
- internalEdges,
1859
- externalEdges,
1860
- cohesion: Math.round(cohesion * 100) / 100
1861
- });
1862
- }
1863
- return clusters.sort((a, b) => b.files.length - a.files.length);
1864
- }
1865
- function commonPrefix(paths) {
1866
- if (paths.length === 0) return "";
1867
- const parts = paths.map((p) => p.split("/"));
1868
- const prefix = [];
1869
- for (let i = 0; i < parts[0].length - 1; i++) {
1870
- const segment = parts[0][i];
1871
- if (parts.every((p) => p[i] === segment)) {
1872
- prefix.push(segment);
1873
- } else break;
1874
- }
1875
- return prefix.join("/") || parts[0][0];
1876
- }
1877
- function enrichComplexity(project, absPath, files) {
1878
- const fileMap = new Map(files.map((f) => [f.relativePath, f]));
1879
- for (const sourceFile of project.getSourceFiles()) {
1880
- const relPath = relative2(absPath, sourceFile.getFilePath());
1881
- if (relPath.startsWith("..") || relPath.includes("node_modules")) continue;
1882
- const file = fileMap.get(relPath);
1883
- if (!file) continue;
1884
- let totalComplexity = 0;
1885
- for (const func of sourceFile.getFunctions()) {
1886
- totalComplexity += calculateCyclomaticComplexity(func);
1887
- }
1888
- for (const cls of sourceFile.getClasses()) {
1889
- for (const method of cls.getMethods()) {
1890
- totalComplexity += calculateCyclomaticComplexity(method);
1891
- }
1892
- }
1893
- for (const varDecl of sourceFile.getVariableDeclarations()) {
1894
- const init = varDecl.getInitializer();
1895
- if (init && (init.getKind() === SyntaxKind.ArrowFunction || init.getKind() === SyntaxKind.FunctionExpression)) {
1896
- totalComplexity += calculateCyclomaticComplexity(init);
1897
- }
1898
- }
1899
- file.complexity = Math.max(1, totalComplexity);
1900
- }
1901
- }
1902
- function calculateCyclomaticComplexity(node) {
1903
- let complexity = 1;
1904
- node.forEachDescendant((descendant) => {
1905
- switch (descendant.getKind()) {
1906
- case SyntaxKind.IfStatement:
1907
- case SyntaxKind.ConditionalExpression:
1908
- case SyntaxKind.ForStatement:
1909
- case SyntaxKind.ForInStatement:
1910
- case SyntaxKind.ForOfStatement:
1911
- case SyntaxKind.WhileStatement:
1912
- case SyntaxKind.DoStatement:
1913
- case SyntaxKind.CaseClause:
1914
- case SyntaxKind.CatchClause:
1915
- complexity++;
1916
- break;
1917
- case SyntaxKind.BinaryExpression: {
1918
- const opToken = descendant.getOperatorToken?.();
1919
- if (opToken) {
1920
- const kind = opToken.getKind();
1921
- if (kind === SyntaxKind.AmpersandAmpersandToken || kind === SyntaxKind.BarBarToken || kind === SyntaxKind.QuestionQuestionToken) {
1922
- complexity++;
1923
- }
1924
- }
1925
- break;
1926
- }
1927
- }
1928
- });
1929
- return complexity;
1930
- }
1931
- function resolveImport(sourceFile, moduleSpecifier, projectRoot) {
1932
- if (!moduleSpecifier.startsWith(".")) return null;
1933
- const sourceDir = dirname2(sourceFile.getFilePath());
1934
- const basePath = resolve3(sourceDir, moduleSpecifier);
1935
- const extensions = [".ts", ".tsx", ".js", ".jsx", "/index.ts", "/index.tsx", "/index.js", "/index.jsx"];
1936
- for (const ext of extensions) {
1937
- const candidate = basePath.endsWith(ext) ? basePath : basePath + ext;
1938
- if (existsSync3(candidate)) {
1939
- const rel = relative2(projectRoot, candidate);
1940
- if (!rel.startsWith("..")) return rel;
1941
- }
1942
- }
1943
- if (moduleSpecifier.endsWith(".js")) {
1944
- const tsPath = basePath.replace(/\.js$/, ".ts");
1945
- if (existsSync3(tsPath)) {
1946
- const rel = relative2(projectRoot, tsPath);
1947
- if (!rel.startsWith("..")) return rel;
1948
- }
1949
- }
1950
- return null;
1951
- }
1952
- function emptyGraph(files) {
1953
- return {
1954
- nodes: files.map((f) => f.relativePath),
1955
- edges: [],
1956
- hubs: [],
1957
- leaves: [],
1958
- orphans: files.map((f) => f.relativePath),
1959
- clusters: []
1960
- };
1961
- }
1962
-
1963
- // src/engine/risk.ts
1964
- function scoreAllFiles(files, graph, weights = DEFAULT_RISK_WEIGHTS) {
1965
- const typeProviderUsage = computeTypeProviderUsage(files, graph);
1966
- for (const file of files) {
1967
- const factors = computeRiskFactors(file, graph, typeProviderUsage, weights);
1968
- file.riskFactors = factors;
1969
- file.riskScore = computeWeightedScore(factors);
1970
- file.exclusionImpact = scoreToImpact(file.riskScore);
1971
- }
1972
- }
1973
- function computeRiskFactors(file, graph, typeProviderUsage, weights) {
1974
- const factors = [];
1975
- factors.push(computeHubFactor(file, weights.hub));
1976
- factors.push(computeTypeProviderFactor(file, typeProviderUsage, weights.typeProvider));
1977
- factors.push(computeComplexityFactor(file, weights.complexity));
1978
- factors.push(computeRecencyFactor(file, weights.recency));
1979
- factors.push(computeConfigFactor(file, weights.config));
1980
- factors.push(computeChurnFactor(file, weights.churn));
1981
- return factors;
1982
- }
1983
- function computeHubFactor(file, weight) {
1984
- const dependents = file.importedBy.length;
1985
- const K = 12;
1986
- const score = dependents === 0 ? 0 : Math.min(100, Math.round(100 * Math.log2(1 + dependents) / Math.log2(1 + K)));
1987
- const detail = dependents === 0 ? "No dependents" : `Hub: ${dependents} file(s) depend on this (score ${score}/100)`;
1988
- return { type: "hub", score, weight, detail };
1989
- }
1990
- function computeTypeProviderFactor(file, usage, weight) {
1991
- const isTypeFile = file.kind === "type";
1992
- const consumers = usage.get(file.relativePath) ?? 0;
1993
- let score;
1994
- let detail;
1995
- if (isTypeFile && consumers >= 4) {
1996
- score = 100;
1997
- detail = `Type provider: used by ${consumers} files (critical type source)`;
1998
- } else if (isTypeFile && consumers >= 1) {
1999
- score = 50;
2000
- detail = `Type provider: used by ${consumers} files`;
2001
- } else if (isTypeFile) {
2002
- score = 30;
2003
- detail = "Type file (no detected consumers)";
2004
- } else {
2005
- score = 0;
2006
- detail = "Not a type provider";
2007
- }
2008
- return { type: "type-provider", score, weight, detail };
2009
- }
2010
- function computeComplexityFactor(file, weight) {
2011
- const c = file.complexity;
2012
- const K = 30;
2013
- const score = Math.min(100, Math.round(100 * Math.log(1 + c) / Math.log(1 + K)));
2014
- const detail = c >= 30 ? `Very high complexity: ${c} (AI needs full context)` : c >= 10 ? `High complexity: ${c}` : `Complexity: ${c}`;
2015
- return { type: "complexity", score, weight, detail };
2016
- }
2017
- function computeRecencyFactor(file, weight) {
2018
- const now = Date.now();
2019
- const modified = new Date(file.lastModified).getTime();
2020
- const daysAgo = (now - modified) / (1e3 * 60 * 60 * 24);
2021
- const HALF_LIFE = 7;
2022
- const score = Math.round(100 * Math.pow(2, -daysAgo / HALF_LIFE));
2023
- const detail = daysAgo <= 1 ? "Modified today" : `Modified ${Math.round(daysAgo)} days ago (decay score ${score})`;
2024
- return { type: "recency", score, weight, detail };
2025
- }
2026
- function computeConfigFactor(file, weight) {
2027
- let score;
2028
- let detail;
2029
- if (file.kind === "entry") {
2030
- score = 90;
2031
- detail = "Entry point \u2014 critical for understanding app structure";
2032
- } else if (file.kind === "config") {
2033
- score = 80;
2034
- detail = "Configuration file \u2014 affects runtime behavior";
2035
- } else {
2036
- score = 0;
2037
- detail = "Regular source file";
2038
- }
2039
- return { type: "config", score, weight, detail };
2040
- }
2041
- function computeChurnFactor(file, weight) {
2042
- const complexitySignal = Math.min(file.complexity / 20, 1);
2043
- const now = Date.now();
2044
- const daysAgo = (now - new Date(file.lastModified).getTime()) / (1e3 * 60 * 60 * 24);
2045
- const recencySignal = Math.pow(2, -daysAgo / 7);
2046
- const score = Math.round(Math.sqrt(complexitySignal * recencySignal) * 100);
2047
- const detail = score >= 50 ? "Likely under active development (complex + recent)" : score >= 20 ? "Some recent activity" : "Stable \u2014 low churn (proxy estimate)";
2048
- return { type: "churn", score, weight, detail };
2049
- }
2050
- function computeWeightedScore(factors) {
2051
- let totalWeightedScore = 0;
2052
- let totalWeight = 0;
2053
- for (const factor of factors) {
2054
- totalWeightedScore += factor.score * factor.weight;
2055
- totalWeight += factor.weight;
2056
- }
2057
- if (totalWeight === 0) return 0;
2058
- return Math.round(totalWeightedScore / totalWeight);
2059
- }
2060
- function scoreToImpact(score) {
2061
- if (score >= 80) return "critical";
2062
- if (score >= 60) return "high";
2063
- if (score >= 30) return "medium";
2064
- if (score > 0) return "low";
2065
- return "none";
2066
- }
2067
- function computeTypeProviderUsage(files, graph) {
2068
- const usage = /* @__PURE__ */ new Map();
2069
- const typeFiles = new Set(
2070
- files.filter((f) => f.kind === "type").map((f) => f.relativePath)
2071
- );
2072
- for (const edge of graph.edges) {
2073
- if (typeFiles.has(edge.to)) {
2074
- usage.set(edge.to, (usage.get(edge.to) ?? 0) + 1);
2075
- }
2076
- }
2077
- return usage;
2078
- }
2079
-
2080
- // src/engine/analyzer.ts
2081
- function matchesPattern(filename, patterns) {
2082
- for (const pattern of patterns) {
2083
- if (pattern.startsWith("*.")) {
2084
- const ext = pattern.slice(1);
2085
- if (filename.endsWith(ext)) return true;
2086
- } else if (filename === pattern) {
2087
- return true;
2088
- }
2089
- }
2090
- return false;
2091
- }
2092
- async function walkProject(rootPath, options) {
2093
- const results = [];
2094
- const { ignoreDirs, ignorePatterns, extensions, maxDepth = 20 } = options;
2095
- const ignoreDirSet = new Set(ignoreDirs);
2096
- async function walk(dir, depth) {
2097
- if (depth > maxDepth) return;
2098
- let entries;
2099
- try {
2100
- entries = await readdir(dir, { withFileTypes: true });
2101
- } catch {
2102
- return;
2103
- }
2104
- const promises = [];
2105
- for (const entry of entries) {
2106
- const fullPath = join4(dir, entry.name);
2107
- if (entry.isDirectory()) {
2108
- if (!ignoreDirSet.has(entry.name) && !entry.name.startsWith(".")) {
2109
- promises.push(walk(fullPath, depth + 1));
2110
- }
2111
- } else if (entry.isFile()) {
2112
- const ext = extname(entry.name).slice(1).toLowerCase();
2113
- if (ext && extensions.includes(ext) && !matchesPattern(entry.name, ignorePatterns)) {
2114
- promises.push(
2115
- (async () => {
2116
- const fileStat = await stat2(fullPath).catch(() => null);
2117
- if (!fileStat) return;
2118
- let lines = 0;
2119
- try {
2120
- const content = await readFile4(fullPath, "utf-8");
2121
- lines = content.split("\n").length;
2122
- } catch {
2123
- lines = 0;
2124
- }
2125
- results.push({
2126
- path: fullPath,
2127
- relativePath: relative3(rootPath, fullPath),
2128
- extension: ext,
2129
- size: fileStat.size,
2130
- lastModified: fileStat.mtime,
2131
- lines
2132
- });
2133
- })()
2134
- );
2135
- }
2136
- }
2137
- }
2138
- await Promise.all(promises);
2139
- }
2140
- await walk(rootPath, 0);
2141
- return results;
2142
- }
2143
- var TYPE_PATTERNS = [/types?\//i, /\.d\.ts$/, /interfaces?\//i];
2144
- var TEST_PATTERNS = [/\.test\.[jt]sx?$/, /\.spec\.[jt]sx?$/, /\/__tests__\//, /\/tests?\//];
2145
- var CONFIG_PATTERNS = [/\.config\.[jt]s$/, /rc\.[jt]s$/, /\.env/, /tsconfig/, /package\.json$/, /\.yml$/, /\.yaml$/, /\.toml$/];
2146
- var ENTRY_PATTERNS = [/^index\.[jt]sx?$/, /^main\.[jt]sx?$/, /^app\.[jt]sx?$/, /^server\.[jt]sx?$/];
2147
- function classifyFileKind(relativePath) {
2148
- const filename = basename2(relativePath);
2149
- if (TYPE_PATTERNS.some((p) => p.test(relativePath))) return "type";
2150
- if (TEST_PATTERNS.some((p) => p.test(relativePath))) return "test";
2151
- if (CONFIG_PATTERNS.some((p) => p.test(relativePath) || p.test(filename))) return "config";
2152
- if (ENTRY_PATTERNS.some((p) => p.test(filename))) return "entry";
2153
- return "source";
2154
- }
2155
- function detectStack(files) {
2156
- const stack = [];
2157
- const extensions = new Set(files.map((f) => f.extension));
2158
- const paths = files.map((f) => f.relativePath.toLowerCase());
2159
- if (extensions.has("ts") || extensions.has("tsx")) stack.push("TypeScript");
2160
- else if (extensions.has("js") || extensions.has("jsx")) stack.push("JavaScript");
2161
- if (extensions.has("py")) stack.push("Python");
2162
- if (extensions.has("go")) stack.push("Go");
2163
- if (extensions.has("rs")) stack.push("Rust");
2164
- if (extensions.has("java")) stack.push("Java");
2165
- if (extensions.has("kt")) stack.push("Kotlin");
2166
- if (extensions.has("rb")) stack.push("Ruby");
2167
- if (extensions.has("php")) stack.push("PHP");
2168
- if (extensions.has("cs")) stack.push("C#");
2169
- if (extensions.has("c") || extensions.has("cpp")) stack.push("C/C++");
2170
- if (paths.some((p) => p.includes("next.config"))) stack.push("Next.js");
2171
- if (paths.some((p) => p.includes("nuxt.config"))) stack.push("Nuxt");
2172
- if (paths.some((p) => p.includes("angular.json"))) stack.push("Angular");
2173
- return stack;
2174
- }
2175
- async function analyzeProject(projectPath, config) {
2176
- const absPath = resolve4(projectPath);
2177
- const projectName = basename2(absPath);
2178
- const mergedConfig = mergeConfig(DEFAULT_CONFIG, config);
2179
- const allExtensions = [
2180
- ...mergedConfig.analysis.extensions.code,
2181
- ...mergedConfig.analysis.extensions.config,
2182
- ...mergedConfig.analysis.extensions.docs
2183
- ];
2184
- const walkEntries = await walkProject(absPath, {
2185
- ignoreDirs: mergedConfig.analysis.ignore.dirs,
2186
- ignorePatterns: mergedConfig.analysis.ignore.patterns,
2187
- extensions: allExtensions,
2188
- maxDepth: mergedConfig.analysis.maxDepth
2189
- });
2190
- const tokenMethod = mergedConfig.tokens.method;
2191
- const BATCH_SIZE = 50;
2192
- async function estimateFileTokens(entry) {
2193
- let tokens;
2194
- if (tokenMethod === "tiktoken") {
2195
- try {
2196
- const content = await readFile4(entry.path, "utf-8");
2197
- tokens = estimateTokens(content, entry.size, "tiktoken");
2198
- } catch {
2199
- tokens = countTokensChars4(entry.size);
2200
- }
2201
- } else {
2202
- tokens = countTokensChars4(entry.size);
2203
- }
2204
- return {
2205
- path: entry.path,
2206
- relativePath: entry.relativePath,
2207
- extension: entry.extension,
2208
- size: entry.size,
2209
- tokens,
2210
- lines: entry.lines,
2211
- lastModified: entry.lastModified,
2212
- kind: classifyFileKind(entry.relativePath),
2213
- imports: [],
2214
- importedBy: [],
2215
- isHub: false,
2216
- complexity: 0,
2217
- riskScore: 0,
2218
- riskFactors: [],
2219
- exclusionImpact: "none"
2220
- };
2221
- }
2222
- const files = [];
2223
- for (let i = 0; i < walkEntries.length; i += BATCH_SIZE) {
2224
- const batch = walkEntries.slice(i, i + BATCH_SIZE);
2225
- const results = await Promise.all(batch.map(estimateFileTokens));
2226
- files.push(...results);
2227
- }
2228
- const graph = buildProjectGraph(absPath, files);
2229
- for (const file of files) {
2230
- const nodeImports = [];
2231
- const nodeImportedBy = [];
2232
- for (const edge of graph.edges) {
2233
- if (edge.from === file.relativePath) nodeImports.push(edge.to);
2234
- if (edge.to === file.relativePath) nodeImportedBy.push(edge.from);
2235
- }
2236
- file.imports = nodeImports;
2237
- file.importedBy = nodeImportedBy;
2238
- file.isHub = graph.hubs.some((h) => h.relativePath === file.relativePath);
2239
- }
2240
- const riskWeights = mergedConfig.risk.weights;
2241
- scoreAllFiles(files, graph, riskWeights);
2242
- const riskProfile = {
2243
- distribution: {
2244
- critical: files.filter((f) => f.riskScore >= 80).length,
2245
- high: files.filter((f) => f.riskScore >= 60 && f.riskScore < 80).length,
2246
- medium: files.filter((f) => f.riskScore >= 30 && f.riskScore < 60).length,
2247
- low: files.filter((f) => f.riskScore < 30).length
2248
- },
2249
- topRiskFiles: [...files].sort((a, b) => b.riskScore - a.riskScore).slice(0, 10),
2250
- overallComplexity: files.length > 0 ? files.reduce((s, f) => s + f.complexity, 0) / files.length : 0
2251
- };
2252
- const totalTokens = files.reduce((s, f) => s + f.tokens, 0);
2253
- const hashInput = files.map((f) => `${f.relativePath}:${f.tokens}:${f.riskScore}`).sort().join("|");
2254
- const hash = createHash3("sha256").update(hashInput).digest("hex").substring(0, 16);
2255
- const stack = detectStack(walkEntries);
2256
- return {
2257
- projectPath: absPath,
2258
- projectName,
2259
- analyzedAt: /* @__PURE__ */ new Date(),
2260
- hash,
2261
- files,
2262
- totalFiles: files.length,
2263
- totalTokens,
2264
- graph,
2265
- riskProfile,
2266
- stack,
2267
- tokenMethod
2268
- };
2269
- }
2270
- function mergeConfig(base, overrides) {
2271
- if (!overrides) return base;
2272
- return {
2273
- ...base,
2274
- ...overrides,
2275
- analysis: {
2276
- ...base.analysis,
2277
- ...overrides.analysis,
2278
- extensions: {
2279
- ...base.analysis.extensions,
2280
- ...overrides.analysis?.extensions
2281
- },
2282
- ignore: {
2283
- ...base.analysis.ignore,
2284
- ...overrides.analysis?.ignore
2285
- }
2286
- },
2287
- risk: {
2288
- ...base.risk,
2289
- ...overrides.risk,
2290
- weights: {
2291
- ...base.risk.weights,
2292
- ...overrides.risk?.weights
2293
- }
2294
- },
2295
- interaction: {
2296
- ...base.interaction,
2297
- ...overrides.interaction
2298
- },
2299
- tokens: {
2300
- ...base.tokens,
2301
- ...overrides.tokens
2302
- },
2303
- governance: {
2304
- ...base.governance,
2305
- ...overrides.governance
2306
- }
2307
- };
2308
- }
2309
-
2310
- // src/gateway/server.ts
2311
- var ContextGateway = class {
2312
- config;
2313
- tracker;
2314
- analysis = null;
2315
- analysisPromise = null;
2316
- eventHandlers = [];
2317
- server = null;
2318
- httpAgent;
2319
- httpsAgent;
2320
- budgetLock = false;
2321
- // Simple lock for budget reservation
2322
- constructor(config = {}) {
2323
- this.config = { ...DEFAULT_GATEWAY_CONFIG, ...config };
2324
- this.tracker = new UsageTracker(this.config);
2325
- this.httpAgent = new HttpAgent({ keepAlive: true, maxSockets: 50 });
2326
- this.httpsAgent = new HttpsAgent({ keepAlive: true, maxSockets: 50 });
2327
- this.tracker.onEvent((event) => this.emit(event));
2328
- }
2329
- // ===== EVENTS =====
2330
- onEvent(handler) {
2331
- this.eventHandlers.push(handler);
2332
- }
2333
- emit(event) {
2334
- for (const handler of this.eventHandlers) {
2335
- try {
2336
- handler(event);
2337
- } catch {
2338
- }
2339
- }
2340
- }
2341
- // ===== LIFECYCLE =====
2342
- async start() {
2343
- if (this.config.optimize) {
2344
- this.analysisPromise = this.refreshAnalysis();
2345
- }
2346
- this.server = createServer((req, res) => this.handleRequest(req, res));
2347
- return new Promise((resolve5) => {
2348
- this.server.listen(this.config.port, this.config.host, () => {
2349
- resolve5();
2350
- });
2351
- });
2352
- }
2353
- async stop() {
2354
- return new Promise((resolve5) => {
2355
- if (this.server) {
2356
- this.server.close(() => resolve5());
2357
- } else {
2358
- resolve5();
2359
- }
2360
- });
2361
- }
2362
- getTracker() {
2363
- return this.tracker;
2364
- }
2365
- // ===== ANALYSIS =====
2366
- async refreshAnalysis() {
2367
- try {
2368
- const analysis = await analyzeProject(this.config.projectPath);
2369
- this.analysis = analysis;
2370
- return analysis;
2371
- } catch (err) {
2372
- const message = err instanceof Error ? err.message : String(err);
2373
- this.emit({ type: "error", message: `Analysis failed: ${message}`, error: err instanceof Error ? err : void 0 });
2374
- throw err;
2375
- }
2376
- }
2377
- // ===== REQUEST HANDLER =====
2378
- async handleRequest(req, res) {
2379
- const startTime = Date.now();
2380
- if (this.config.dashboard && req.url?.startsWith(this.config.dashboardPath)) {
2381
- return this.serveDashboard(req, res);
2382
- }
2383
- if (req.url === "/health" || req.url === "/__cto/health") {
2384
- res.writeHead(200, { "Content-Type": "application/json" });
2385
- res.end(JSON.stringify({
2386
- status: "ok",
2387
- version: "4.0.0",
2388
- uptime: process.uptime(),
2389
- analysis: this.analysis ? "ready" : "loading"
2390
- }));
2391
- return;
2392
- }
2393
- if (this.config.apiKey) {
2394
- const authHeader = req.headers["x-cto-key"] || req.headers["authorization"]?.replace(/^Bearer\s+/i, "") || "";
2395
- if (authHeader !== this.config.apiKey) {
2396
- res.writeHead(401, { "Content-Type": "application/json" });
2397
- res.end(JSON.stringify({ error: "Unauthorized. Set x-cto-key header or Authorization: Bearer <key>" }));
2398
- return;
2399
- }
2400
- }
2401
- if (req.method !== "POST") {
2402
- res.writeHead(405, { "Content-Type": "application/json" });
2403
- res.end(JSON.stringify({ error: "Method not allowed. Gateway only proxies POST requests." }));
2404
- return;
2405
- }
2406
- let body;
2407
- try {
2408
- body = await readBody(req, this.config.maxBodyBytes);
2409
- } catch (err) {
2410
- const errMsg = err instanceof Error ? err.message : String(err);
2411
- const status = errMsg === "body-too-large" ? 413 : 400;
2412
- res.writeHead(status, { "Content-Type": "application/json" });
2413
- res.end(JSON.stringify({ error: status === 413 ? `Request body too large. Max: ${Math.round(this.config.maxBodyBytes / 1024 / 1024)}MB` : "Failed to read request body" }));
2414
- return;
2415
- }
2416
- let parsedBody;
2417
- try {
2418
- parsedBody = JSON.parse(body);
2419
- } catch {
2420
- res.writeHead(400, { "Content-Type": "application/json" });
2421
- res.end(JSON.stringify({ error: "Invalid JSON in request body" }));
2422
- return;
2423
- }
2424
- const targetUrl = req.headers["x-cto-target"] || req.headers["x-target-url"] || "";
2425
- if (!targetUrl) {
2426
- res.writeHead(400, { "Content-Type": "application/json" });
2427
- res.end(JSON.stringify({
2428
- error: "Missing target URL. Set x-cto-target header to the provider API URL.",
2429
- example: "x-cto-target: https://api.openai.com/v1/chat/completions"
2430
- }));
2431
- return;
2432
- }
2433
- let targetUrlParsed;
2434
- try {
2435
- targetUrlParsed = new URL(targetUrl);
2436
- } catch {
2437
- res.writeHead(400, { "Content-Type": "application/json" });
2438
- res.end(JSON.stringify({ error: "Invalid target URL" }));
2439
- return;
2440
- }
2441
- if (targetUrlParsed.protocol !== "https:" && targetUrlParsed.hostname !== "localhost") {
2442
- res.writeHead(403, { "Content-Type": "application/json" });
2443
- res.end(JSON.stringify({ error: "Only HTTPS targets allowed (SSRF protection)" }));
2444
- return;
2445
- }
2446
- if (!isAllowedTarget(targetUrlParsed.hostname, this.config)) {
2447
- res.writeHead(403, { "Content-Type": "application/json" });
2448
- res.end(JSON.stringify({
2449
- error: `Target domain not allowed: ${targetUrlParsed.hostname}`,
2450
- allowed: this.config.allowedTargetDomains.length > 0 ? this.config.allowedTargetDomains : ["api.openai.com", "api.anthropic.com", "*.googleapis.com", "*.openai.azure.com"]
2451
- }));
2452
- return;
2453
- }
2454
- try {
2455
- const resolved = await lookup(targetUrlParsed.hostname);
2456
- if (isPrivateIP(resolved.address)) {
2457
- res.writeHead(403, { "Content-Type": "application/json" });
2458
- res.end(JSON.stringify({ error: "Target resolves to private IP (SSRF protection)" }));
2459
- return;
2460
- }
2461
- } catch {
2462
- }
2463
- const headers = flattenHeaders(req.headers);
2464
- const provider = detectProvider(targetUrl || req.url || "", headers);
2465
- const parsed = provider.parseRequest(parsedBody);
2466
- const now = /* @__PURE__ */ new Date();
2467
- if (this.tracker.isDailyBudgetExceeded(now)) {
2468
- res.writeHead(429, { "Content-Type": "application/json" });
2469
- res.end(JSON.stringify({
2470
- error: "Daily budget exceeded",
2471
- budget: this.config.budgetDaily,
2472
- current: this.tracker.getDailyCost(now)
2473
- }));
2474
- return;
2475
- }
2476
- if (this.tracker.isMonthlyBudgetExceeded(now)) {
2477
- res.writeHead(429, { "Content-Type": "application/json" });
2478
- res.end(JSON.stringify({
2479
- error: "Monthly budget exceeded",
2480
- budget: this.config.budgetMonthly,
2481
- current: this.tracker.getMonthlyCost(now)
2482
- }));
2483
- return;
2484
- }
2485
- if (this.analysisPromise && !this.analysis) {
2486
- try {
2487
- await this.analysisPromise;
2488
- } catch {
2489
- }
2490
- }
2491
- const interceptResult = await interceptRequest(parsed.messages, this.config, this.analysis);
2492
- if (interceptResult.secretsBlocked) {
2493
- res.writeHead(403, { "Content-Type": "application/json" });
2494
- res.end(JSON.stringify({
2495
- error: "Request blocked: secrets detected in message content",
2496
- decisions: interceptResult.decisions,
2497
- secretsRedacted: interceptResult.secretsRedacted
2498
- }));
2499
- this.tracker.record({
2500
- provider: provider.name,
2501
- model: parsed.model,
2502
- inputTokens: 0,
2503
- outputTokens: 0,
2504
- costUSD: 0,
2505
- originalTokens: interceptResult.originalTokens,
2506
- optimizedTokens: 0,
2507
- savedTokens: 0,
2508
- savedUSD: 0,
2509
- secretsRedacted: interceptResult.secretsRedacted,
2510
- secretsBlocked: true,
2511
- projectPath: this.config.projectPath,
2512
- latencyMs: Date.now() - startTime,
2513
- stream: parsed.stream,
2514
- error: "blocked:secrets"
2515
- });
2516
- return;
2517
- }
2518
- const modifiedBody = rebuildRequestBody(parsedBody, interceptResult.messages, provider.name);
2519
- try {
2520
- await this.proxyRequest(targetUrl, req, res, modifiedBody, provider, parsed, interceptResult, startTime);
2521
- } catch (err) {
2522
- const errMsg = err instanceof Error ? err.message : String(err);
2523
- if (!res.headersSent) {
2524
- const status = errMsg === "upstream-timeout" ? 504 : 502;
2525
- res.writeHead(status, { "Content-Type": "application/json" });
2526
- res.end(JSON.stringify({ error: status === 504 ? "Upstream provider timeout" : `Proxy error: ${errMsg}` }));
2527
- }
2528
- this.emit({ type: "error", message: `Proxy error: ${errMsg}`, error: err instanceof Error ? err : void 0 });
2529
- }
2530
- }
2531
- // ===== PROXY =====
2532
- async proxyRequest(targetUrl, clientReq, clientRes, body, provider, parsed, interceptResult, startTime) {
2533
- const url = new URL(targetUrl);
2534
- const isHttps = url.protocol === "https:";
2535
- const requester = isHttps ? httpsRequest : httpRequest;
2536
- const forwardHeaders = {};
2537
- const stripHeaders = /* @__PURE__ */ new Set(["host", "content-length", "x-cto-target", "x-target-url", "x-cto-key"]);
2538
- for (const [key, value] of Object.entries(clientReq.headers)) {
2539
- if (stripHeaders.has(key)) continue;
2540
- if (value) forwardHeaders[key] = Array.isArray(value) ? value[0] : value;
2541
- }
2542
- forwardHeaders["content-length"] = Buffer.byteLength(body).toString();
2543
- return new Promise((resolve5, reject) => {
2544
- const proxyReq = requester(
2545
- {
2546
- hostname: url.hostname,
2547
- port: url.port || (isHttps ? 443 : 80),
2548
- path: url.pathname + url.search,
2549
- method: "POST",
2550
- headers: forwardHeaders,
2551
- agent: isHttps ? this.httpsAgent : this.httpAgent,
2552
- // Connection pooling
2553
- timeout: this.config.upstreamTimeoutMs
2554
- },
2555
- (proxyRes) => {
2556
- if (parsed.stream && proxyRes.headers["content-type"]?.includes("text/event-stream")) {
2557
- this.handleStreamResponse(
2558
- proxyRes,
2559
- clientRes,
2560
- provider,
2561
- parsed,
2562
- interceptResult,
2563
- startTime
2564
- ).then(resolve5).catch(reject);
2565
- } else {
2566
- this.handleBufferedResponse(
2567
- proxyRes,
2568
- clientRes,
2569
- provider,
2570
- parsed,
2571
- interceptResult,
2572
- startTime
2573
- ).then(resolve5).catch(reject);
2574
- }
2575
- }
2576
- );
2577
- proxyReq.on("timeout", () => {
2578
- proxyReq.destroy();
2579
- reject(new Error("upstream-timeout"));
2580
- });
2581
- proxyReq.on("error", reject);
2582
- proxyReq.write(body);
2583
- proxyReq.end();
2584
- });
2585
- }
2586
- // ===== STREAM HANDLER =====
2587
- async handleStreamResponse(proxyRes, clientRes, provider, parsed, interceptResult, startTime) {
2588
- clientRes.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
2589
- let fullContent2 = "";
2590
- let inputTokens = 0;
2591
- let outputTokens = 0;
2592
- let sseBuffer = "";
2593
- return new Promise((resolve5) => {
2594
- proxyRes.on("data", (chunk) => {
2595
- clientRes.write(chunk);
2596
- sseBuffer += chunk.toString();
2597
- const events = sseBuffer.split("\n\n");
2598
- sseBuffer = events.pop() || "";
2599
- for (const event of events) {
2600
- for (const line of event.split("\n")) {
2601
- if (!line.startsWith("data: ")) continue;
2602
- const data = line.slice(6).trim();
2603
- if (data === "[DONE]") continue;
2604
- try {
2605
- const obj = JSON.parse(data);
2606
- const delta = obj.choices?.[0]?.delta?.content || obj.delta?.text || "";
2607
- if (delta) fullContent2 += delta;
2608
- if (obj.usage) {
2609
- inputTokens = obj.usage.prompt_tokens || obj.usage.input_tokens || 0;
2610
- outputTokens = obj.usage.completion_tokens || obj.usage.output_tokens || 0;
2611
- }
2612
- } catch {
2613
- }
2614
- }
2615
- }
2616
- });
2617
- proxyRes.on("end", () => {
2618
- if (sseBuffer.trim()) {
2619
- for (const line of sseBuffer.split("\n")) {
2620
- if (!line.startsWith("data: ")) continue;
2621
- const data = line.slice(6).trim();
2622
- if (data === "[DONE]") continue;
2623
- try {
2624
- const obj = JSON.parse(data);
2625
- if (obj.usage) {
2626
- inputTokens = obj.usage.prompt_tokens || obj.usage.input_tokens || 0;
2627
- outputTokens = obj.usage.completion_tokens || obj.usage.output_tokens || 0;
2628
- }
2629
- } catch {
2630
- }
2631
- }
2632
- }
2633
- clientRes.end();
2634
- if (inputTokens === 0) inputTokens = interceptResult.optimizedTokens;
2635
- if (outputTokens === 0) outputTokens = Math.ceil(fullContent2.length / 4);
2636
- const costUSD = estimateCost(provider, parsed.model, inputTokens, outputTokens);
2637
- const originalCost = estimateCost(provider, parsed.model, interceptResult.originalTokens, outputTokens);
2638
- this.tracker.record({
2639
- provider: provider.name,
2640
- model: parsed.model,
2641
- inputTokens,
2642
- outputTokens,
2643
- costUSD,
2644
- originalTokens: interceptResult.originalTokens,
2645
- optimizedTokens: interceptResult.optimizedTokens,
2646
- savedTokens: interceptResult.originalTokens - interceptResult.optimizedTokens,
2647
- savedUSD: Math.max(0, originalCost - costUSD),
2648
- secretsRedacted: interceptResult.secretsRedacted,
2649
- secretsBlocked: false,
2650
- projectPath: this.config.projectPath,
2651
- latencyMs: Date.now() - startTime,
2652
- stream: true
2653
- });
2654
- resolve5();
2655
- });
2656
- proxyRes.on("error", () => {
2657
- clientRes.end();
2658
- resolve5();
2659
- });
2660
- });
2661
- }
2662
- // ===== BUFFERED HANDLER =====
2663
- async handleBufferedResponse(proxyRes, clientRes, provider, parsed, interceptResult, startTime) {
2664
- return new Promise((resolve5) => {
2665
- const chunks = [];
2666
- proxyRes.on("data", (chunk) => chunks.push(chunk));
2667
- proxyRes.on("end", () => {
2668
- const responseBody = Buffer.concat(chunks).toString();
2669
- clientRes.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
2670
- clientRes.end(responseBody);
2671
- try {
2672
- const responseJson = JSON.parse(responseBody);
2673
- const parsedResponse = provider.parseResponse(responseJson, false);
2674
- const costUSD = estimateCost(
2675
- provider,
2676
- parsedResponse.model || parsed.model,
2677
- parsedResponse.inputTokens,
2678
- parsedResponse.outputTokens
2679
- );
2680
- const originalCost = estimateCost(
2681
- provider,
2682
- parsed.model,
2683
- interceptResult.originalTokens,
2684
- parsedResponse.outputTokens
2685
- );
2686
- this.tracker.record({
2687
- provider: provider.name,
2688
- model: parsedResponse.model || parsed.model,
2689
- inputTokens: parsedResponse.inputTokens,
2690
- outputTokens: parsedResponse.outputTokens,
2691
- costUSD,
2692
- originalTokens: interceptResult.originalTokens,
2693
- optimizedTokens: interceptResult.optimizedTokens,
2694
- savedTokens: interceptResult.originalTokens - interceptResult.optimizedTokens,
2695
- savedUSD: Math.max(0, originalCost - costUSD),
2696
- secretsRedacted: interceptResult.secretsRedacted,
2697
- secretsBlocked: false,
2698
- projectPath: this.config.projectPath,
2699
- latencyMs: Date.now() - startTime,
2700
- stream: false
2701
- });
2702
- } catch {
2703
- this.tracker.record({
2704
- provider: provider.name,
2705
- model: parsed.model,
2706
- inputTokens: interceptResult.optimizedTokens,
2707
- outputTokens: 0,
2708
- costUSD: 0,
2709
- originalTokens: interceptResult.originalTokens,
2710
- optimizedTokens: interceptResult.optimizedTokens,
2711
- savedTokens: interceptResult.originalTokens - interceptResult.optimizedTokens,
2712
- savedUSD: 0,
2713
- secretsRedacted: interceptResult.secretsRedacted,
2714
- secretsBlocked: false,
2715
- projectPath: this.config.projectPath,
2716
- latencyMs: Date.now() - startTime,
2717
- stream: false,
2718
- error: "response-parse-failed"
2719
- });
2720
- }
2721
- resolve5();
2722
- });
2723
- proxyRes.on("error", () => {
2724
- clientRes.end();
2725
- resolve5();
2726
- });
2727
- });
2728
- }
2729
- // ===== DASHBOARD =====
2730
- serveDashboard(_req, res) {
2731
- const summary = this.tracker.getSummary("month");
2732
- const dailySummary = this.tracker.getSummary("day");
2733
- const html = generateDashboardHTML(summary, dailySummary, this.config, this.analysis);
2734
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
2735
- res.end(html);
2736
- }
2737
- };
2738
- function readBody(req, maxBytes = 0) {
2739
- return new Promise((resolve5, reject) => {
2740
- const chunks = [];
2741
- let totalBytes = 0;
2742
- req.on("data", (chunk) => {
2743
- totalBytes += chunk.length;
2744
- if (maxBytes > 0 && totalBytes > maxBytes) {
2745
- req.destroy();
2746
- reject(new Error("body-too-large"));
2747
- return;
2748
- }
2749
- chunks.push(chunk);
2750
- });
2751
- req.on("end", () => resolve5(Buffer.concat(chunks).toString()));
2752
- req.on("error", reject);
2753
- });
2754
- }
2755
- function flattenHeaders(headers) {
2756
- const flat = {};
2757
- for (const [key, value] of Object.entries(headers)) {
2758
- if (value) flat[key] = Array.isArray(value) ? value[0] : value;
2759
- }
2760
- return flat;
2761
- }
2762
- function rebuildRequestBody(original, messages, provider) {
2763
- const body = { ...original };
2764
- if (provider === "anthropic") {
2765
- const systemMsg = messages.find((m) => m.role === "system");
2766
- const otherMsgs = messages.filter((m) => m.role !== "system");
2767
- if (systemMsg) body.system = systemMsg.content;
2768
- body.messages = otherMsgs;
2769
- } else if (provider === "google") {
2770
- const systemMsg = messages.find((m) => m.role === "system");
2771
- const otherMsgs = messages.filter((m) => m.role !== "system");
2772
- if (systemMsg) {
2773
- body.systemInstruction = { parts: [{ text: systemMsg.content }] };
2774
- }
2775
- body.contents = otherMsgs.map((m) => ({
2776
- role: m.role === "assistant" ? "model" : "user",
2777
- parts: [{ text: m.content }]
2778
- }));
2779
- } else {
2780
- body.messages = messages;
2781
- }
2782
- return JSON.stringify(body);
2783
- }
2784
- function generateDashboardHTML(monthly, daily, config, analysis) {
2785
- const modelRows = Object.entries(monthly.byModel).sort(([, a], [, b]) => b.costUSD - a.costUSD).map(
2786
- ([model, data]) => `<tr><td>${model}</td><td>${data.requests}</td><td>${(data.tokens / 1e3).toFixed(1)}K</td><td>$${data.costUSD.toFixed(4)}</td></tr>`
2787
- ).join("");
2788
- const providerRows = Object.entries(monthly.byProvider).sort(([, a], [, b]) => b.costUSD - a.costUSD).map(
2789
- ([provider, data]) => `<tr><td>${provider}</td><td>${data.requests}</td><td>$${data.costUSD.toFixed(4)}</td></tr>`
2790
- ).join("");
2791
- return `<!DOCTYPE html>
2792
- <html lang="en">
2793
- <head>
2794
- <meta charset="utf-8">
2795
- <meta name="viewport" content="width=device-width, initial-scale=1">
2796
- <title>CTO Gateway Dashboard</title>
2797
- <style>
2798
- * { margin: 0; padding: 0; box-sizing: border-box; }
2799
- body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0a0a0f; color: #e0e0e0; padding: 2rem; }
2800
- h1 { font-size: 1.8rem; margin-bottom: 0.5rem; color: #fff; }
2801
- h2 { font-size: 1.2rem; margin: 2rem 0 1rem; color: #8b8bff; }
2802
- .subtitle { color: #666; margin-bottom: 2rem; }
2803
- .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 2rem; }
2804
- .card { background: #14141f; border: 1px solid #2a2a3a; border-radius: 12px; padding: 1.5rem; }
2805
- .card .label { font-size: 0.75rem; text-transform: uppercase; color: #666; letter-spacing: 0.05em; }
2806
- .card .value { font-size: 2rem; font-weight: 700; color: #fff; margin-top: 0.25rem; }
2807
- .card .detail { font-size: 0.85rem; color: #888; margin-top: 0.25rem; }
2808
- .card.green .value { color: #4ade80; }
2809
- .card.red .value { color: #f87171; }
2810
- .card.blue .value { color: #60a5fa; }
2811
- .card.purple .value { color: #a78bfa; }
2812
- table { width: 100%; border-collapse: collapse; margin-top: 0.5rem; }
2813
- th { text-align: left; padding: 0.5rem; color: #666; font-size: 0.75rem; text-transform: uppercase; border-bottom: 1px solid #2a2a3a; }
2814
- td { padding: 0.5rem; border-bottom: 1px solid #1a1a2a; font-size: 0.9rem; }
2815
- .status { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 8px; }
2816
- .status.on { background: #4ade80; }
2817
- .status.off { background: #666; }
2818
- .footer { margin-top: 3rem; color: #444; font-size: 0.75rem; }
2819
- .badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; }
2820
- .badge.critical { background: #7f1d1d; color: #fca5a5; }
2821
- .badge.ok { background: #14532d; color: #86efac; }
2822
- </style>
2823
- </head>
2824
- <body>
2825
- <h1>\u26A1 CTO Context Gateway</h1>
2826
- <p class="subtitle">Real-time AI proxy with context optimization, secret redaction, and cost tracking</p>
2827
-
2828
- <h2>Today</h2>
2829
- <div class="grid">
2830
- <div class="card blue">
2831
- <div class="label">Requests</div>
2832
- <div class="value">${daily.totalRequests}</div>
2833
- </div>
2834
- <div class="card">
2835
- <div class="label">Cost</div>
2836
- <div class="value">$${daily.totalCostUSD.toFixed(2)}</div>
2837
- ${config.budgetDaily > 0 ? `<div class="detail">Budget: $${config.budgetDaily}/day</div>` : ""}
2838
- </div>
2839
- <div class="card green">
2840
- <div class="label">Tokens Saved</div>
2841
- <div class="value">${(daily.totalSavedTokens / 1e3).toFixed(1)}K</div>
2842
- <div class="detail">$${daily.totalSavedUSD.toFixed(2)} saved</div>
2843
- </div>
2844
- <div class="card ${daily.totalSecretsRedacted > 0 ? "red" : ""}">
2845
- <div class="label">Secrets Redacted</div>
2846
- <div class="value">${daily.totalSecretsRedacted}</div>
2847
- </div>
2848
- </div>
2849
-
2850
- <h2>This Month</h2>
2851
- <div class="grid">
2852
- <div class="card blue">
2853
- <div class="label">Total Requests</div>
2854
- <div class="value">${monthly.totalRequests}</div>
2855
- </div>
2856
- <div class="card">
2857
- <div class="label">Total Cost</div>
2858
- <div class="value">$${monthly.totalCostUSD.toFixed(2)}</div>
2859
- ${config.budgetMonthly > 0 ? `<div class="detail">Budget: $${config.budgetMonthly}/month</div>` : ""}
2860
- </div>
2861
- <div class="card green">
2862
- <div class="label">Total Saved</div>
2863
- <div class="value">$${monthly.totalSavedUSD.toFixed(2)}</div>
2864
- <div class="detail">${(monthly.totalSavedTokens / 1e3).toFixed(0)}K tokens</div>
2865
- </div>
2866
- <div class="card purple">
2867
- <div class="label">Tokens Processed</div>
2868
- <div class="value">${((monthly.totalInputTokens + monthly.totalOutputTokens) / 1e3).toFixed(0)}K</div>
2869
- <div class="detail">${(monthly.totalInputTokens / 1e3).toFixed(0)}K in / ${(monthly.totalOutputTokens / 1e3).toFixed(0)}K out</div>
2870
- </div>
2871
- </div>
2872
-
2873
- <h2>Features</h2>
2874
- <div class="grid">
2875
- <div class="card">
2876
- <div class="label">Context Optimization</div>
2877
- <div class="value"><span class="status ${config.optimize ? "on" : "off"}"></span>${config.optimize ? "ON" : "OFF"}</div>
2878
- ${analysis ? `<div class="detail">${analysis.totalFiles} files, ${(analysis.totalTokens / 1e3).toFixed(0)}K tokens</div>` : '<div class="detail">Loading analysis...</div>'}
2879
- </div>
2880
- <div class="card">
2881
- <div class="label">Secret Redaction</div>
2882
- <div class="value"><span class="status ${config.redactSecrets ? "on" : "off"}"></span>${config.redactSecrets ? "ON" : "OFF"}</div>
2883
- ${config.blockOnSecrets ? '<span class="badge critical">BLOCKING</span>' : ""}
2884
- </div>
2885
- <div class="card">
2886
- <div class="label">Cost Tracking</div>
2887
- <div class="value"><span class="status ${config.costTracking ? "on" : "off"}"></span>${config.costTracking ? "ON" : "OFF"}</div>
2888
- </div>
2889
- <div class="card">
2890
- <div class="label">Audit Log</div>
2891
- <div class="value"><span class="status ${config.auditLog ? "on" : "off"}"></span>${config.auditLog ? "ON" : "OFF"}</div>
2892
- <div class="detail">${config.logDir}</div>
2893
- </div>
2894
- </div>
2895
-
2896
- ${modelRows ? `
2897
- <h2>By Model</h2>
2898
- <div class="card">
2899
- <table>
2900
- <thead><tr><th>Model</th><th>Requests</th><th>Tokens</th><th>Cost</th></tr></thead>
2901
- <tbody>${modelRows}</tbody>
2902
- </table>
2903
- </div>` : ""}
2904
-
2905
- ${providerRows ? `
2906
- <h2>By Provider</h2>
2907
- <div class="card">
2908
- <table>
2909
- <thead><tr><th>Provider</th><th>Requests</th><th>Cost</th></tr></thead>
2910
- <tbody>${providerRows}</tbody>
2911
- </table>
2912
- </div>` : ""}
2913
-
2914
- <div class="footer">
2915
- CTO Context Gateway v4.0.0 \xB7 Listening on ${config.host}:${config.port} \xB7 <a href="/health" style="color:#666">Health</a>
2916
- </div>
2917
-
2918
- <script>setTimeout(() => location.reload(), 30000);</script>
2919
- </body>
2920
- </html>`;
2921
- }
2922
- export {
2923
- ContextGateway,
2924
- DEFAULT_GATEWAY_CONFIG,
2925
- PROVIDERS,
2926
- UsageTracker,
2927
- detectProvider,
2928
- estimateCost,
2929
- getModelConfig,
2930
- interceptRequest
2931
- };
2932
- //# sourceMappingURL=index.js.map