@dropout-ai/runtime 0.3.2 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.js +318 -179
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dropout-ai/runtime",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "Invisible Node.js runtime for capturing AI interactions.",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
package/src/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * @dropout-ai/runtime
3
3
  * Universal AI Interaction Capture for Node.js & Next.js
4
- * Patches: fetch, http.request, https.request
4
+ * Role: Passive observer.
5
+ * Behavior: Fire-and-forget raw JSON to Supabase.
5
6
  * Capability: Captures Genkit, OpenAI, LangChain, Axios, and standard fetch.
6
7
  */
7
8
 
@@ -9,20 +10,39 @@ const https = require('https');
9
10
  const http = require('http');
10
11
  const crypto = require('crypto');
11
12
 
12
- // --- CONFIGURATION ---
13
+ // --- DEFAULT CONFIGURATION ---
14
+ const SUPABASE_FUNCTION_URL = "https://hipughmjlwmwjxzyxfzs.supabase.co/functions/v1/capture-sealed";
15
+
16
+ // Known AI Domains for Auto-Detection
13
17
  const KNOWN_AI_DOMAINS = [
14
- 'api.openai.com', // OpenAI
15
- 'api.anthropic.com', // Claude
16
- 'generativelanguage.googleapis.com', // Gemini
17
- 'aiplatform.googleapis.com', // Vertex AI (Genkit often uses this)
18
- 'api.groq.com', // Groq
19
- 'api.mistral.ai', // Mistral
20
- 'api.cohere.ai' // Cohere
18
+ 'api.openai.com', 'api.anthropic.com', 'generativelanguage.googleapis.com',
19
+ 'aiplatform.googleapis.com', 'api.groq.com', 'api.mistral.ai', 'api.cohere.ai'
21
20
  ];
22
21
 
23
- const DROPOUT_INGEST_URL = "https://hipughmjlwmwjxzyxfzs.supabase.co/functions/v1/capture-sealed";
22
+ // Global State
23
+ const GLOBAL_OBJ = typeof window !== 'undefined' ? window : global;
24
+ let activeConfig = {
25
+ projectId: null,
26
+ apiKey: null,
27
+ captureEndpoint: SUPABASE_FUNCTION_URL,
28
+ maxOutputBytes: 32768,
29
+ privacyMode: (typeof process !== 'undefined' && process.env.DROPOUT_PRIVACY_MODE) || 'full',
30
+ debug: false // Added debug flag
31
+ };
32
+
33
+ let isPatched = false;
34
+ let turnIndex = 0;
35
+ let lastTurnConfirmed = true;
36
+ let lastPromptHash = null;
37
+ let lastResponseHash = null;
38
+ let instance = null;
24
39
 
25
40
  // --- UTILS ---
41
+
42
+ function log(msg, ...args) {
43
+ if (activeConfig.debug) console.log(`[Dropout] ${msg}`, ...args);
44
+ }
45
+
26
46
  function generateSessionId() {
27
47
  try {
28
48
  return crypto.randomUUID();
@@ -31,210 +51,329 @@ function generateSessionId() {
31
51
  }
32
52
  }
33
53
 
34
- class Dropout {
35
- constructor(config = {}) {
36
- // 1. Validation
37
- if (!config.apiKey || !config.projectId) {
38
- console.warn("[Dropout] ⚠️ Initialization Skipped: Missing apiKey or projectId.");
39
- return;
40
- }
54
+ function hash(text) {
55
+ if (!text) return null;
56
+ try {
57
+ return crypto.createHash('sha256').update(text.toLowerCase().trim()).digest('hex');
58
+ } catch (e) { return 'hash_err'; }
59
+ }
41
60
 
42
- // 2. Config Setup
43
- this.config = config;
44
- this.projectId = config.projectId;
45
- this.apiKey = config.apiKey;
46
- this.debug = config.debug || false; // Toggle this to see logs
47
- this.privacy = config.privacy || 'full';
61
+ function normalize(url, body) {
62
+ let provider = 'custom';
63
+ let model = 'unknown';
64
+
65
+ if (url) {
66
+ const u = url.toLowerCase();
67
+ if (u.includes('openai')) provider = 'openai';
68
+ else if (u.includes('anthropic')) provider = 'anthropic';
69
+ else if (u.includes('google') || u.includes('generative') || u.includes('aiplatform')) provider = 'google';
70
+ else if (u.includes('groq')) provider = 'groq';
71
+ else if (u.includes('mistral')) provider = 'mistral';
72
+ else if (u.includes('cohere')) provider = 'cohere';
73
+ else if (u.includes('localhost') || u.includes('127.0.0.1')) provider = 'local';
74
+ }
48
75
 
49
- // 3. Singleton Guard (Prevent double-patching)
50
- if (global.__dropout_initialized__) {
51
- if (this.debug) console.log("[Dropout] ℹ️ Already initialized. Skipping patch.");
52
- return;
53
- }
54
- global.__dropout_initialized__ = true;
76
+ if (body) {
77
+ try {
78
+ const parsed = typeof body === 'string' ? JSON.parse(body) : body;
79
+ if (parsed.model) model = parsed.model;
80
+ } catch (e) { }
81
+ }
82
+ return { provider, model };
83
+ }
55
84
 
56
- // 4. Initialize Identity
57
- if (!global.__dropout_session_id__) {
58
- global.__dropout_session_id__ = generateSessionId();
59
- }
85
+ function isAiRequest(url, bodyString) {
86
+ if (!url) return false;
87
+ // Guard: Infinite Loop Prevention
88
+ if (url.includes(activeConfig.captureEndpoint)) return false;
60
89
 
61
- // 5. Start the Wiretap
62
- if (this.debug) console.log(`[Dropout] 🟢 Initialized for Project: ${this.projectId}`);
63
- this.patchNetwork();
64
- }
90
+ // 1. Check Known Domains
91
+ if (KNOWN_AI_DOMAINS.some(d => url.includes(d))) return true;
65
92
 
66
- log(msg, ...args) {
67
- if (this.debug) console.log(`[Dropout] ${msg}`, ...args);
93
+ // 2. Heuristic Check (for custom endpoints)
94
+ if (bodyString) {
95
+ return (bodyString.includes('"model"') || bodyString.includes('"messages"')) &&
96
+ (bodyString.includes('"user"') || bodyString.includes('"prompt"'));
68
97
  }
98
+ return false;
99
+ }
69
100
 
70
- isAiRequest(url) {
71
- if (!url) return false;
72
- // Guard: Infinite Loop Prevention (Don't capture our own calls)
73
- if (url.includes(DROPOUT_INGEST_URL)) return false;
101
+ // --- EMITTER (Authenticated) ---
74
102
 
75
- // Check against known AI domains
76
- return KNOWN_AI_DOMAINS.some(domain => url.includes(domain));
77
- }
103
+ function emit(payload) {
104
+ // 1. Guard: Do not emit if not initialized
105
+ if (!activeConfig.apiKey || !activeConfig.projectId) return;
78
106
 
79
- // --- THE CORE: UNIVERSAL PATCHING ---
80
- patchNetwork() {
81
- const _this = this;
107
+ // 2. Construct Final Payload
108
+ const finalPayload = {
109
+ ...payload,
110
+ project_id: activeConfig.projectId,
82
111
 
83
- // A. Patch Global Fetch (Used by OpenAI v4+, Vercel AI SDK, Next.js)
84
- if (global.fetch) {
85
- const originalFetch = global.fetch;
86
- global.fetch = async function (input, init) {
87
- const url = typeof input === 'string' ? input : input?.url;
112
+ // Ensure these columns are always populated for the DB
113
+ content_blob: payload.content, // Map content to blob
114
+ received_at: new Date().toISOString()
115
+ };
88
116
 
89
- // PASSTHROUGH: If not AI, ignore
90
- if (!_this.isAiRequest(url)) {
91
- return originalFetch.apply(this, arguments);
92
- }
117
+ log(`🚀 Sending Capture (${payload.turn_role})`);
93
118
 
94
- _this.log(`⚡ [FETCH] Intercepting request to: ${url}`);
119
+ // 3. Fire and Forget using HTTPS (Bypassing our own patches)
120
+ const req = https.request(activeConfig.captureEndpoint, {
121
+ method: 'POST',
122
+ headers: {
123
+ 'Content-Type': 'application/json',
124
+ 'x-dropout-key': activeConfig.apiKey
125
+ }
126
+ });
95
127
 
96
- // Capture Request Body
97
- let reqBody = "";
98
- try { if (init && init.body) reqBody = init.body; } catch (e) { }
128
+ req.on('error', (e) => log("❌ Upload Failed", e.message));
129
+ req.write(JSON.stringify(finalPayload));
130
+ req.end();
131
+ }
132
+
133
+ // --- MONKEY PATCH ---
99
134
 
100
- const startTime = Date.now();
101
- let response;
135
+ function applyPatch() {
136
+ if (isPatched) return;
102
137
 
103
- // Execute Original Call
104
- try {
105
- response = await originalFetch.apply(this, arguments);
106
- } catch (e) { throw e; }
138
+ // --- A. PATCH FETCH (Next.js / OpenAI) ---
139
+ if (GLOBAL_OBJ.fetch) {
140
+ GLOBAL_OBJ.__dropout_original_fetch__ = GLOBAL_OBJ.fetch;
141
+ GLOBAL_OBJ.fetch = async function (input, init) {
142
+ const url = typeof input === 'string' ? input : (input && input.url);
107
143
 
108
- // Capture Response (Clone response stream)
109
- try {
110
- const cloned = response.clone();
111
- const resBody = await cloned.text();
144
+ // Check body string safely
145
+ let bodyStr = "";
146
+ if (init && init.body) {
147
+ try { bodyStr = typeof init.body === 'string' ? init.body : JSON.stringify(init.body); } catch (e) { }
148
+ }
112
149
 
113
- _this.emit({
114
- url,
115
- method: 'FETCH',
116
- request: reqBody,
117
- response: resBody,
118
- latency: Date.now() - startTime,
150
+ // Guard
151
+ if (!isAiRequest(url, bodyStr)) {
152
+ return GLOBAL_OBJ.__dropout_original_fetch__(input, init);
153
+ }
154
+
155
+ log(`⚡ [FETCH] Intercepting: ${url}`);
156
+
157
+ const start = Date.now();
158
+
159
+ // Calculate Turn
160
+ let activeTurn;
161
+ if (lastTurnConfirmed) {
162
+ activeTurn = turnIndex++;
163
+ lastTurnConfirmed = false;
164
+ } else {
165
+ activeTurn = turnIndex > 0 ? turnIndex - 1 : 0;
166
+ }
167
+
168
+ const { provider, model } = normalize(url, bodyStr);
169
+ const pHash = hash(bodyStr);
170
+
171
+ // Emit Request (User)
172
+ emit({
173
+ session_id: GLOBAL_OBJ.__dropout_session_id__,
174
+ turn_index: activeTurn,
175
+ direction: 'user_to_ai',
176
+ turn_role: 'user',
177
+ provider,
178
+ model,
179
+ content: activeConfig.privacyMode === 'full' ? bodyStr : null,
180
+ content_hash: pHash,
181
+ metadata_flags: {
182
+ retry_like: pHash === lastPromptHash ? 1 : 0,
183
+ method: 'FETCH',
184
+ url: url
185
+ }
186
+ });
187
+ lastPromptHash = pHash;
188
+
189
+ // Actual Call
190
+ let response;
191
+ try {
192
+ response = await GLOBAL_OBJ.__dropout_original_fetch__(input, init);
193
+ } catch (err) { throw err; }
194
+
195
+ const latency = Date.now() - start;
196
+
197
+ // Emit Response (Assistant)
198
+ try {
199
+ const cloned = response.clone();
200
+ const oText = await cloned.text();
201
+ const oHash = hash(oText);
202
+
203
+ emit({
204
+ session_id: GLOBAL_OBJ.__dropout_session_id__,
205
+ turn_index: activeTurn,
206
+ direction: 'ai_to_user',
207
+ turn_role: 'assistant',
208
+ latency_ms: latency,
209
+ provider,
210
+ model,
211
+ content: activeConfig.privacyMode === 'full' ? oText : null,
212
+ content_hash: oHash,
213
+ metadata_flags: {
214
+ non_adaptive_response: oHash === lastResponseHash ? 1 : 0,
215
+ turn_boundary_confirmed: 1,
119
216
  status: response.status
120
- });
121
- } catch (e) { _this.log("⚠️ Error reading response body", e); }
217
+ }
218
+ });
219
+ lastResponseHash = oHash;
220
+ } catch (e) { log("⚠️ Failed to read response body"); }
122
221
 
123
- return response;
222
+ lastTurnConfirmed = true;
223
+ return response;
224
+ };
225
+ log("✅ Patch Applied: global.fetch");
226
+ }
227
+
228
+ // --- B. PATCH HTTP/HTTPS (Axios / Genkit / Node SDKs) ---
229
+ const patchNodeRequest = (module, moduleName) => {
230
+ const originalRequest = module.request;
231
+ module.request = function (...args) {
232
+ // Resolve URL
233
+ let url;
234
+ let options;
235
+ if (typeof args[0] === 'string') {
236
+ url = args[0];
237
+ options = args[1] || {};
238
+ } else {
239
+ options = args[0] || {};
240
+ const protocol = options.protocol || 'https:';
241
+ const host = options.hostname || options.host || 'localhost';
242
+ const path = options.path || '/';
243
+ url = `${protocol}//${host}${path}`;
244
+ }
245
+
246
+ // We can't check body here yet, so we rely on URL
247
+ if (!isAiRequest(url, null)) {
248
+ return originalRequest.apply(this, args);
249
+ }
250
+
251
+ log(`⚡ [${moduleName.toUpperCase()}] Intercepting: ${url}`);
252
+
253
+ const start = Date.now();
254
+ const clientRequest = originalRequest.apply(this, args);
255
+
256
+ // Capture Request Body
257
+ const reqChunks = [];
258
+ const originalWrite = clientRequest.write;
259
+ const originalEnd = clientRequest.end;
260
+
261
+ clientRequest.write = function (...writeArgs) {
262
+ if (writeArgs[0]) reqChunks.push(Buffer.from(writeArgs[0]));
263
+ return originalWrite.apply(this, writeArgs);
124
264
  };
125
- this.log("✅ Patch Applied: global.fetch");
126
- }
127
265
 
128
- // B. Patch Node HTTPS/HTTP (Used by Axios, Google Vertex SDK, Legacy LangChain)
129
- const patchNodeRequest = (module, moduleName) => {
130
- const originalRequest = module.request;
131
- module.request = function (...args) {
132
- // Resolve URL from varied arguments
133
- let url;
134
- let options;
135
- if (typeof args[0] === 'string') {
136
- url = args[0];
137
- options = args[1] || {};
266
+ clientRequest.end = function (...endArgs) {
267
+ if (endArgs[0]) reqChunks.push(Buffer.from(endArgs[0]));
268
+
269
+ // Request Complete - Emit User Turn
270
+ const reqBody = Buffer.concat(reqChunks).toString('utf8');
271
+ const { provider, model } = normalize(url, reqBody);
272
+ const pHash = hash(reqBody);
273
+
274
+ let activeTurn;
275
+ if (lastTurnConfirmed) {
276
+ activeTurn = turnIndex++;
277
+ lastTurnConfirmed = false;
138
278
  } else {
139
- options = args[0] || {};
140
- const protocol = options.protocol || 'https:';
141
- const host = options.hostname || options.host || 'localhost';
142
- const path = options.path || '/';
143
- url = `${protocol}//${host}${path}`;
279
+ activeTurn = turnIndex > 0 ? turnIndex - 1 : 0;
144
280
  }
145
281
 
146
- // PASSTHROUGH: If not AI, ignore
147
- if (!_this.isAiRequest(url)) {
148
- return originalRequest.apply(this, args);
149
- }
282
+ // Save state on request object for response handler
283
+ clientRequest._dropout_turn = activeTurn;
284
+ clientRequest._dropout_provider = provider;
285
+ clientRequest._dropout_model = model;
286
+
287
+ emit({
288
+ session_id: GLOBAL_OBJ.__dropout_session_id__,
289
+ turn_index: activeTurn,
290
+ direction: 'user_to_ai',
291
+ turn_role: 'user',
292
+ provider,
293
+ model,
294
+ content: activeConfig.privacyMode === 'full' ? reqBody : null,
295
+ content_hash: pHash,
296
+ metadata_flags: { method: moduleName.toUpperCase(), url: url }
297
+ });
298
+ lastPromptHash = pHash;
299
+
300
+ return originalEnd.apply(this, endArgs);
301
+ };
150
302
 
151
- _this.log(`⚡ [${moduleName.toUpperCase()}] Intercepting request to: ${url}`);
152
-
153
- const startTime = Date.now();
154
- const clientRequest = originalRequest.apply(this, args);
155
-
156
- // Capture Request Body (Chunks)
157
- const chunks = [];
158
- const originalWrite = clientRequest.write;
159
- const originalEnd = clientRequest.end;
160
-
161
- clientRequest.write = function (...writeArgs) {
162
- if (writeArgs[0]) chunks.push(Buffer.from(writeArgs[0]));
163
- return originalWrite.apply(this, writeArgs);
164
- };
165
-
166
- clientRequest.end = function (...endArgs) {
167
- if (endArgs[0]) chunks.push(Buffer.from(endArgs[0]));
168
- // Store full body on the request object for later
169
- clientRequest._fullBody = Buffer.concat(chunks).toString('utf8');
170
- return originalEnd.apply(this, endArgs);
171
- };
172
-
173
- // Capture Response
174
- clientRequest.on('response', (res) => {
175
- const resChunks = [];
176
- res.on('data', (chunk) => resChunks.push(chunk));
177
- res.on('end', () => {
178
- const resBody = Buffer.concat(resChunks).toString('utf8');
179
- _this.emit({
180
- url,
181
- method: moduleName.toUpperCase(),
182
- request: clientRequest._fullBody,
183
- response: resBody,
184
- latency: Date.now() - startTime,
185
- status: res.statusCode
186
- });
303
+ // Capture Response
304
+ clientRequest.on('response', (res) => {
305
+ const resChunks = [];
306
+ res.on('data', (chunk) => resChunks.push(chunk));
307
+ res.on('end', () => {
308
+ const resBody = Buffer.concat(resChunks).toString('utf8');
309
+ const latency = Date.now() - start;
310
+ const oHash = hash(resBody);
311
+
312
+ emit({
313
+ session_id: GLOBAL_OBJ.__dropout_session_id__,
314
+ turn_index: clientRequest._dropout_turn || 0,
315
+ direction: 'ai_to_user',
316
+ turn_role: 'assistant',
317
+ latency_ms: latency,
318
+ provider: clientRequest._dropout_provider,
319
+ model: clientRequest._dropout_model,
320
+ content: activeConfig.privacyMode === 'full' ? resBody : null,
321
+ content_hash: oHash,
322
+ metadata_flags: { status: res.statusCode }
187
323
  });
324
+
325
+ lastResponseHash = oHash;
326
+ lastTurnConfirmed = true;
188
327
  });
328
+ });
189
329
 
190
- return clientRequest;
191
- };
330
+ return clientRequest;
192
331
  };
332
+ };
193
333
 
194
- patchNodeRequest(https, 'https');
195
- patchNodeRequest(http, 'http');
196
- this.log("✅ Patch Applied: http/https");
197
- }
334
+ patchNodeRequest(https, 'https');
335
+ patchNodeRequest(http, 'http');
336
+ log("✅ Patch Applied: http/https");
198
337
 
199
- // --- EMITTER ---
200
- emit(data) {
201
- // 1. Construct Payload
202
- // We combine Request + Response into a single content blob for simplicity in this universal mode
203
- const content = `--- REQUEST ---\n${data.request}\n\n--- RESPONSE ---\n${data.response}`;
204
-
205
- const payload = {
206
- project_id: this.projectId,
207
- session_id: global.__dropout_session_id__,
208
- turn_role: 'assistant', // Default to assistant for system-captured logs
209
- turn_index: 0, // In universal mode, we capture raw streams
210
- //content_blob: this.privacy === 'full' ? content : null,
211
- content: this.privacy === 'full' ? content : null,
212
- metadata_flags: {
213
- latency: data.latency,
214
- url: data.url,
215
- method: data.method,
216
- status: data.status,
217
- captured_via: 'universal_interceptor'
218
- },
219
- received_at: new Date().toISOString()
220
- };
338
+ isPatched = true;
339
+ }
221
340
 
222
- this.log(`🚀 Sending Capture to Supabase (${data.latency}ms)`);
341
+ // --- MAIN CLASS EXPORT ---
223
342
 
224
- // 2. Fire and Forget (Using unpatched fetch mechanism)
225
- // We use a clean http request to avoid triggering our own hooks if we used global.fetch
226
- const req = https.request(DROPOUT_INGEST_URL, {
227
- method: 'POST',
228
- headers: {
229
- 'Content-Type': 'application/json',
230
- 'x-dropout-key': this.apiKey
231
- }
232
- });
343
+ class Dropout {
344
+ constructor(config = {}) {
345
+ if (instance) return instance;
233
346
 
234
- req.on('error', (e) => this.log("❌ Upload Failed", e.message));
235
- req.write(JSON.stringify(payload));
236
- req.end();
347
+ if (!config.apiKey || !config.projectId) {
348
+ console.warn("[Dropout] Missing apiKey or projectId. Tracking disabled.");
349
+ return;
350
+ }
351
+
352
+ // Update Global Configuration
353
+ activeConfig.apiKey = config.apiKey;
354
+ activeConfig.projectId = config.projectId;
355
+ if (config.privacyMode) activeConfig.privacyMode = config.privacyMode;
356
+ activeConfig.debug = config.debug || false;
357
+
358
+ // Initialize Identity
359
+ if (!GLOBAL_OBJ.__dropout_session_id__) {
360
+ GLOBAL_OBJ.__dropout_session_id__ = generateSessionId();
361
+ }
362
+
363
+ // Apply the patch
364
+ if (activeConfig.debug) console.log(`[Dropout] 🟢 Initialized for Project: ${config.projectId}`);
365
+ applyPatch();
366
+ instance = this;
237
367
  }
238
368
  }
239
369
 
370
+ // Auto-Start
371
+ if (process.env.DROPOUT_PROJECT_ID && process.env.DROPOUT_API_KEY) {
372
+ new Dropout({
373
+ projectId: process.env.DROPOUT_PROJECT_ID,
374
+ apiKey: process.env.DROPOUT_API_KEY,
375
+ debug: process.env.DROPOUT_DEBUG === 'true'
376
+ });
377
+ }
378
+
240
379
  module.exports = Dropout;