@dropout-ai/runtime 0.3.3 → 0.3.4

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 +246 -302
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dropout-ai/runtime",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
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,8 +1,7 @@
1
1
  /**
2
2
  * @dropout-ai/runtime
3
3
  * Universal AI Interaction Capture for Node.js & Next.js
4
- * Role: Passive observer.
5
- * Behavior: Fire-and-forget raw JSON to Supabase.
4
+ * Patches: fetch, http.request, https.request
6
5
  * Capability: Captures Genkit, OpenAI, LangChain, Axios, and standard fetch.
7
6
  */
8
7
 
@@ -19,355 +18,300 @@ const KNOWN_AI_DOMAINS = [
19
18
  'aiplatform.googleapis.com', 'api.groq.com', 'api.mistral.ai', 'api.cohere.ai'
20
19
  ];
21
20
 
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;
39
-
40
- // --- UTILS ---
41
-
42
- function log(msg, ...args) {
43
- if (activeConfig.debug) console.log(`[Dropout] ${msg}`, ...args);
44
- }
21
+ class Dropout {
22
+ constructor(config = {}) {
23
+ // 1. Validation
24
+ if (!config.apiKey || !config.projectId) {
25
+ console.warn("[Dropout] ⚠️ Initialization Skipped: Missing apiKey or projectId.");
26
+ return;
27
+ }
45
28
 
46
- function generateSessionId() {
47
- try {
48
- return crypto.randomUUID();
49
- } catch (e) {
50
- return 'sess_' + Math.random().toString(36).substring(2, 12) + Date.now().toString(36);
51
- }
52
- }
29
+ // 2. Config Setup
30
+ this.config = config;
31
+ this.projectId = config.projectId;
32
+ this.apiKey = config.apiKey;
33
+ this.debug = config.debug || false;
34
+ this.privacy = config.privacy || 'full';
35
+ this.captureEndpoint = SUPABASE_FUNCTION_URL;
36
+
37
+ // 3. Singleton Guard
38
+ if (global.__dropout_initialized__) {
39
+ if (this.debug) console.log("[Dropout] ℹ️ Already initialized. Skipping patch.");
40
+ return;
41
+ }
42
+ global.__dropout_initialized__ = true;
53
43
 
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
- }
44
+ // 4. Initialize Identity
45
+ if (!global.__dropout_session_id__) {
46
+ global.__dropout_session_id__ = this.generateSessionId();
47
+ }
60
48
 
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
- }
49
+ // 5. Start the Wiretap
50
+ if (this.debug) console.log(`[Dropout] 🟢 Initialized for Project: ${this.projectId}`);
75
51
 
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) { }
52
+ // Bind methods to 'this' to avoid scope loss
53
+ this.log = this.log.bind(this);
54
+ this.isAiRequest = this.isAiRequest.bind(this);
55
+ this.patchNetwork();
81
56
  }
82
- return { provider, model };
83
- }
84
-
85
- function isAiRequest(url, bodyString) {
86
- if (!url) return false;
87
- // Guard: Infinite Loop Prevention
88
- if (url.includes(activeConfig.captureEndpoint)) return false;
89
57
 
90
- // 1. Check Known Domains
91
- if (KNOWN_AI_DOMAINS.some(d => url.includes(d))) return true;
92
-
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"'));
58
+ // --- UTILS ---
59
+ log(msg, ...args) {
60
+ if (this.debug) console.log(`[Dropout] ${msg}`, ...args);
97
61
  }
98
- return false;
99
- }
100
-
101
- // --- EMITTER (Authenticated) ---
102
-
103
- function emit(payload) {
104
- // 1. Guard: Do not emit if not initialized
105
- if (!activeConfig.apiKey || !activeConfig.projectId) return;
106
-
107
- // 2. Construct Final Payload
108
- const finalPayload = {
109
- ...payload,
110
- project_id: activeConfig.projectId,
111
62
 
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
- };
116
-
117
- log(`🚀 Sending Capture (${payload.turn_role})`);
118
-
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
63
+ generateSessionId() {
64
+ try {
65
+ return crypto.randomUUID();
66
+ } catch (e) {
67
+ return 'sess_' + Math.random().toString(36).substring(2, 12) + Date.now().toString(36);
125
68
  }
126
- });
127
-
128
- req.on('error', (e) => log("❌ Upload Failed", e.message));
129
- req.write(JSON.stringify(finalPayload));
130
- req.end();
131
- }
132
-
133
- // --- MONKEY PATCH ---
134
-
135
- function applyPatch() {
136
- if (isPatched) return;
137
-
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);
143
-
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
- }
149
-
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();
69
+ }
158
70
 
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
- }
71
+ hash(text) {
72
+ if (!text) return null;
73
+ try {
74
+ return crypto.createHash('sha256').update(text.toLowerCase().trim()).digest('hex');
75
+ } catch (e) { return 'hash_err'; }
76
+ }
167
77
 
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;
78
+ normalize(url, body) {
79
+ let provider = 'custom';
80
+ let model = 'unknown';
81
+
82
+ if (url) {
83
+ const u = url.toLowerCase();
84
+ if (u.includes('openai')) provider = 'openai';
85
+ else if (u.includes('anthropic')) provider = 'anthropic';
86
+ else if (u.includes('google') || u.includes('gemini') || u.includes('aiplatform')) provider = 'google';
87
+ else if (u.includes('groq')) provider = 'groq';
88
+ else if (u.includes('mistral')) provider = 'mistral';
89
+ else if (u.includes('cohere')) provider = 'cohere';
90
+ else if (u.includes('localhost') || u.includes('127.0.0.1')) provider = 'local';
91
+ }
188
92
 
189
- // Actual Call
190
- let response;
93
+ if (body) {
191
94
  try {
192
- response = await GLOBAL_OBJ.__dropout_original_fetch__(input, init);
193
- } catch (err) { throw err; }
95
+ const parsed = typeof body === 'string' ? JSON.parse(body) : body;
96
+ if (parsed.model) model = parsed.model;
97
+ } catch (e) { }
98
+ }
99
+ return { provider, model };
100
+ }
194
101
 
195
- const latency = Date.now() - start;
102
+ isAiRequest(url, bodyString) {
103
+ if (!url) return false;
104
+ // Guard: Infinite Loop Prevention
105
+ if (url.includes(this.captureEndpoint)) return false;
196
106
 
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,
216
- status: response.status
217
- }
218
- });
219
- lastResponseHash = oHash;
220
- } catch (e) { log("⚠️ Failed to read response body"); }
107
+ // 1. Check Known Domains
108
+ if (KNOWN_AI_DOMAINS.some(d => url.includes(d))) return true;
221
109
 
222
- lastTurnConfirmed = true;
223
- return response;
224
- };
225
- log(" Patch Applied: global.fetch");
110
+ // 2. Heuristic Check (for custom endpoints)
111
+ if (bodyString) {
112
+ return (bodyString.includes('"model"') || bodyString.includes('"messages"')) &&
113
+ (bodyString.includes('"user"') || bodyString.includes('"prompt"'));
114
+ }
115
+ return false;
226
116
  }
227
117
 
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
- }
118
+ // --- EMITTER ---
119
+ emit(payload) {
120
+ // 1. Guard
121
+ if (!this.apiKey || !this.projectId) return;
122
+
123
+ // 2. Construct Payload
124
+ const finalPayload = {
125
+ ...payload,
126
+ project_id: this.projectId,
127
+ content_blob: payload.content, // Map for new schema
128
+ content: payload.content, // Map for old schema (fallback)
129
+ received_at: new Date().toISOString()
130
+ };
250
131
 
251
- log(`⚡ [${moduleName.toUpperCase()}] Intercepting: ${url}`);
132
+ this.log(`🚀 Sending Capture (${payload.turn_role})`);
252
133
 
253
- const start = Date.now();
254
- const clientRequest = originalRequest.apply(this, args);
134
+ // 3. Fire and Forget using HTTPS (Bypassing our own patches via pure request)
135
+ // We use a clean request to avoid triggering our own hooks if we used global.fetch
136
+ const req = https.request(this.captureEndpoint, {
137
+ method: 'POST',
138
+ headers: {
139
+ 'Content-Type': 'application/json',
140
+ 'x-dropout-key': this.apiKey
141
+ }
142
+ });
255
143
 
256
- // Capture Request Body
257
- const reqChunks = [];
258
- const originalWrite = clientRequest.write;
259
- const originalEnd = clientRequest.end;
144
+ req.on('error', (e) => this.log("❌ Upload Failed", e.message));
145
+ req.write(JSON.stringify(finalPayload));
146
+ req.end();
147
+ }
260
148
 
261
- clientRequest.write = function (...writeArgs) {
262
- if (writeArgs[0]) reqChunks.push(Buffer.from(writeArgs[0]));
263
- return originalWrite.apply(this, writeArgs);
264
- };
149
+ // --- CORE PATCHING ---
150
+ patchNetwork() {
151
+ const _this = this; // Capture instance
265
152
 
266
- clientRequest.end = function (...endArgs) {
267
- if (endArgs[0]) reqChunks.push(Buffer.from(endArgs[0]));
153
+ // --- A. PATCH FETCH ---
154
+ if (global.fetch) {
155
+ const originalFetch = global.fetch;
156
+ global.fetch = async function (input, init) {
157
+ const url = typeof input === 'string' ? input : input?.url;
268
158
 
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);
159
+ // Safe Body Check
160
+ let bodyStr = "";
161
+ if (init && init.body) {
162
+ try { bodyStr = typeof init.body === 'string' ? init.body : JSON.stringify(init.body); } catch (e) { }
163
+ }
273
164
 
274
- let activeTurn;
275
- if (lastTurnConfirmed) {
276
- activeTurn = turnIndex++;
277
- lastTurnConfirmed = false;
278
- } else {
279
- activeTurn = turnIndex > 0 ? turnIndex - 1 : 0;
165
+ // Guard
166
+ if (!_this.isAiRequest(url, bodyStr)) {
167
+ return originalFetch.apply(this, arguments);
280
168
  }
281
169
 
282
- // Save state on request object for response handler
283
- clientRequest._dropout_turn = activeTurn;
284
- clientRequest._dropout_provider = provider;
285
- clientRequest._dropout_model = model;
170
+ _this.log(`⚡ [FETCH] Intercepting: ${url}`);
171
+ const start = Date.now();
286
172
 
287
- emit({
288
- session_id: GLOBAL_OBJ.__dropout_session_id__,
289
- turn_index: activeTurn,
290
- direction: 'user_to_ai',
173
+ // Emit Request
174
+ const { provider, model } = _this.normalize(url, bodyStr);
175
+ _this.emit({
176
+ session_id: global.__dropout_session_id__,
177
+ turn_index: 0,
291
178
  turn_role: 'user',
292
179
  provider,
293
180
  model,
294
- content: activeConfig.privacyMode === 'full' ? reqBody : null,
295
- content_hash: pHash,
296
- metadata_flags: { method: moduleName.toUpperCase(), url: url }
181
+ content: _this.privacy === 'full' ? bodyStr : null,
182
+ metadata_flags: { method: 'FETCH', url: url }
297
183
  });
298
- lastPromptHash = pHash;
299
184
 
300
- return originalEnd.apply(this, endArgs);
301
- };
302
-
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',
185
+ // Execute
186
+ let response;
187
+ try {
188
+ response = await originalFetch.apply(this, arguments);
189
+ } catch (err) { throw err; }
190
+
191
+ const latency = Date.now() - start;
192
+
193
+ // Emit Response
194
+ try {
195
+ const cloned = response.clone();
196
+ const oText = await cloned.text();
197
+ _this.emit({
198
+ session_id: global.__dropout_session_id__,
199
+ turn_index: 0,
316
200
  turn_role: 'assistant',
317
201
  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 }
202
+ provider,
203
+ model,
204
+ content: _this.privacy === 'full' ? oText : null,
205
+ metadata_flags: { status: response.status }
323
206
  });
207
+ } catch (e) { _this.log("⚠️ Failed to read response body"); }
324
208
 
325
- lastResponseHash = oHash;
326
- lastTurnConfirmed = true;
327
- });
328
- });
209
+ return response;
210
+ };
211
+ this.log("✅ Patch Applied: global.fetch");
212
+ }
329
213
 
330
- return clientRequest;
331
- };
332
- };
214
+ // --- B. PATCH NODE HTTP/HTTPS ---
215
+ const patchNodeRequest = (module, moduleName) => {
216
+ const originalRequest = module.request;
217
+ module.request = function (...args) {
218
+ let url;
219
+ // Argument Resolution (url, options, callback) or (options, callback)
220
+ if (typeof args[0] === 'string') {
221
+ url = args[0];
222
+ } else {
223
+ const opts = args[0] || {};
224
+ const protocol = opts.protocol || (moduleName === 'https' ? 'https:' : 'http:');
225
+ const host = opts.hostname || opts.host || 'localhost';
226
+ const path = opts.path || '/';
227
+ url = `${protocol}//${host}${path}`;
228
+ }
333
229
 
334
- patchNodeRequest(https, 'https');
335
- patchNodeRequest(http, 'http');
336
- log("✅ Patch Applied: http/https");
230
+ if (!_this.isAiRequest(url, null)) {
231
+ return originalRequest.apply(this, args);
232
+ }
337
233
 
338
- isPatched = true;
339
- }
234
+ _this.log(`⚡ [${moduleName.toUpperCase()}] Intercepting: ${url}`);
235
+ const start = Date.now();
340
236
 
341
- // --- MAIN CLASS EXPORT ---
237
+ const clientRequest = originalRequest.apply(this, args);
342
238
 
343
- class Dropout {
344
- constructor(config = {}) {
345
- if (instance) return instance;
239
+ // Capture Buffers
240
+ const reqChunks = [];
241
+ const originalWrite = clientRequest.write;
242
+ const originalEnd = clientRequest.end;
346
243
 
347
- if (!config.apiKey || !config.projectId) {
348
- console.warn("[Dropout] Missing apiKey or projectId. Tracking disabled.");
349
- return;
350
- }
244
+ // SAFE WRITE PATCH
245
+ clientRequest.write = function (...writeArgs) {
246
+ const chunk = writeArgs[0];
247
+ // Only buffer if it's data (String or Buffer), ignore objects/callbacks
248
+ if (chunk && (typeof chunk === 'string' || Buffer.isBuffer(chunk))) {
249
+ reqChunks.push(Buffer.from(chunk));
250
+ }
251
+ return originalWrite.apply(this, writeArgs);
252
+ };
253
+
254
+ // SAFE END PATCH
255
+ clientRequest.end = function (...endArgs) {
256
+ const chunk = endArgs[0];
257
+ // Only buffer if it's data
258
+ if (chunk && (typeof chunk === 'string' || Buffer.isBuffer(chunk))) {
259
+ reqChunks.push(Buffer.from(chunk));
260
+ }
351
261
 
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;
262
+ // Emit Request
263
+ const reqBody = Buffer.concat(reqChunks).toString('utf8');
264
+ const { provider, model } = _this.normalize(url, reqBody);
265
+
266
+ // Attach state to request for response handler
267
+ clientRequest._dropout_meta = { provider, model };
268
+
269
+ _this.emit({
270
+ session_id: global.__dropout_session_id__,
271
+ turn_index: 0,
272
+ turn_role: 'user',
273
+ provider,
274
+ model,
275
+ content: _this.privacy === 'full' ? reqBody : null,
276
+ metadata_flags: { method: moduleName.toUpperCase(), url: url }
277
+ });
357
278
 
358
- // Initialize Identity
359
- if (!GLOBAL_OBJ.__dropout_session_id__) {
360
- GLOBAL_OBJ.__dropout_session_id__ = generateSessionId();
361
- }
279
+ return originalEnd.apply(this, endArgs);
280
+ };
281
+
282
+ // Capture Response
283
+ clientRequest.on('response', (res) => {
284
+ const resChunks = [];
285
+ res.on('data', (chunk) => resChunks.push(chunk));
286
+ res.on('end', () => {
287
+ const resBody = Buffer.concat(resChunks).toString('utf8');
288
+ const latency = Date.now() - start;
289
+ const meta = clientRequest._dropout_meta || {};
290
+
291
+ _this.emit({
292
+ session_id: global.__dropout_session_id__,
293
+ turn_index: 0,
294
+ turn_role: 'assistant',
295
+ latency_ms: latency,
296
+ provider: meta.provider,
297
+ model: meta.model,
298
+ content: _this.privacy === 'full' ? resBody : null,
299
+ metadata_flags: { status: res.statusCode }
300
+ });
301
+ });
302
+ });
303
+
304
+ return clientRequest;
305
+ };
306
+ };
362
307
 
363
- // Apply the patch
364
- if (activeConfig.debug) console.log(`[Dropout] 🟢 Initialized for Project: ${config.projectId}`);
365
- applyPatch();
366
- instance = this;
308
+ patchNodeRequest(https, 'https');
309
+ patchNodeRequest(http, 'http');
310
+ this.log("✅ Patch Applied: http/https");
367
311
  }
368
312
  }
369
313
 
370
- // Auto-Start
314
+ // Auto-Start (Node Preload)
371
315
  if (process.env.DROPOUT_PROJECT_ID && process.env.DROPOUT_API_KEY) {
372
316
  new Dropout({
373
317
  projectId: process.env.DROPOUT_PROJECT_ID,