@dropout-ai/runtime 0.2.14 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.js +201 -277
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dropout-ai/runtime",
3
- "version": "0.2.14",
3
+ "version": "0.3.1",
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,18 +1,28 @@
1
1
  /**
2
2
  * @dropout-ai/runtime
3
- * Role: Passive observer.
4
- * Behavior: Fire-and-forget raw JSON to Supabase.
5
- * Authentication: None (Public Endpoint).
3
+ * Universal AI Interaction Capture for Node.js & Next.js
4
+ * Patches: fetch, http.request, https.request
5
+ * Capability: Captures Genkit, OpenAI, LangChain, Axios, and standard fetch.
6
6
  */
7
7
 
8
+ const https = require('https');
9
+ const http = require('http');
8
10
  const crypto = require('crypto');
9
11
 
10
12
  // --- CONFIGURATION ---
11
- const SUPABASE_FUNCTION_URL = "https://hipughmjlwmwjxzyxfzs.supabase.co/functions/v1/capture-sealed";
12
-
13
- // --- Identity & State ---
14
- const GLOBAL_OBJ = typeof window !== 'undefined' ? window : global;
15
-
13
+ 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
21
+ ];
22
+
23
+ const DROPOUT_INGEST_URL = "https://hipughmjlwmwjxzyxfzs.supabase.co/functions/v1/capture-sealed";
24
+
25
+ // --- UTILS ---
16
26
  function generateSessionId() {
17
27
  try {
18
28
  return crypto.randomUUID();
@@ -21,295 +31,209 @@ function generateSessionId() {
21
31
  }
22
32
  }
23
33
 
24
- if (!GLOBAL_OBJ.__dropout_session_id__) {
25
- GLOBAL_OBJ.__dropout_session_id__ = generateSessionId();
26
- }
27
-
28
- let turnIndex = 0;
29
- let lastTurnConfirmed = true;
30
- let lastPromptHash = null;
31
- let lastResponseHash = null;
32
-
33
- let config = {
34
- maxOutputBytes: 32768,
35
- captureEndpoint: SUPABASE_FUNCTION_URL,
36
- privacyMode: (typeof process !== 'undefined' && process.env.DROPOUT_PRIVACY_MODE) || 'full'
37
- };
38
-
39
- /**
40
- * Telemetry Emitter (Non-blocking, Fire-and-forget)
41
- */
42
- function emit(payload) {
43
- const fetchFn = GLOBAL_OBJ.__dropout_original_fetch__ || GLOBAL_OBJ.fetch;
44
- if (typeof fetchFn !== 'function') return;
45
-
46
- setTimeout(() => {
47
- fetchFn(config.captureEndpoint, {
48
- method: 'POST',
49
- headers: {
50
- 'Content-Type': 'application/json'
51
- },
52
- body: JSON.stringify(payload),
53
- keepalive: true
54
- }).catch(() => { });
55
- }, 0);
56
- }
57
-
58
- // --- Lifecycle Signals ---
59
-
60
- function emitSessionEnd(reason) {
61
- if (GLOBAL_OBJ.__dropout_session_ended__) return;
62
- GLOBAL_OBJ.__dropout_session_ended__ = true;
63
-
64
- emit({
65
- session_id: GLOBAL_OBJ.__dropout_session_id__,
66
- turn_index: turnIndex,
67
- direction: 'meta',
68
- turn_role: 'system',
69
- metadata_flags: {
70
- session_end: true,
71
- end_reason: reason
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;
72
40
  }
73
- });
74
- }
75
-
76
- // Browser: Navigation/Reload
77
- if (typeof window !== 'undefined' && window.addEventListener) {
78
- window.addEventListener('beforeunload', () => emitSessionEnd('navigation'));
79
- }
80
41
 
81
- // Node.js: Process Exit
82
- if (typeof process !== 'undefined' && process.on) {
83
- process.on('exit', () => emitSessionEnd('process_exit'));
84
- process.on('SIGINT', () => { emitSessionEnd('sigint'); process.exit(); });
85
- process.on('SIGTERM', () => { emitSessionEnd('sigterm'); process.exit(); });
86
- }
87
-
88
- // --- Content Utilities ---
89
-
90
- function hash(text) {
91
- if (!text) return null;
92
- try {
93
- return crypto.createHash('sha256').update(text.toLowerCase().trim()).digest('hex');
94
- } catch (e) {
95
- return 'hash_err';
96
- }
97
- }
98
-
99
- // --- Provider Normalization ---
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';
48
+
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;
100
55
 
101
- function normalize(url, body) {
102
- let provider = 'unknown';
103
- let model = 'unknown';
56
+ // 4. Initialize Identity
57
+ if (!global.__dropout_session_id__) {
58
+ global.__dropout_session_id__ = generateSessionId();
59
+ }
104
60
 
105
- if (url) {
106
- const u = url.toLowerCase();
107
- if (u.includes('openai.com')) provider = 'openai';
108
- else if (u.includes('anthropic.com')) provider = 'anthropic';
109
- else if (u.includes('google.com') || u.includes('generative')) provider = 'google';
110
- else if (u.includes('groq.com')) provider = 'groq';
111
- else if (u.includes('localhost') || u.includes('127.0.0.1')) provider = 'local';
61
+ // 5. Start the Wiretap
62
+ if (this.debug) console.log(`[Dropout] 🟢 Initialized for Project: ${this.projectId}`);
63
+ this.patchNetwork();
112
64
  }
113
65
 
114
- if (body) {
115
- try {
116
- const parsed = typeof body === 'string' ? JSON.parse(body) : body;
117
- model = parsed.model || model;
118
- if (provider === 'unknown' && (parsed.messages || parsed.prompt || parsed.input)) {
119
- provider = 'heuristic';
120
- }
121
- } catch (e) { }
66
+ log(msg, ...args) {
67
+ if (this.debug) console.log(`[Dropout] ${msg}`, ...args);
122
68
  }
123
69
 
124
- return { provider, model };
125
- }
126
-
127
- // --- The Monkey Patch ---
128
-
129
- if (typeof GLOBAL_OBJ.fetch === 'function' && !GLOBAL_OBJ.fetch.__dropout_patched__) {
130
- GLOBAL_OBJ.__dropout_original_fetch__ = GLOBAL_OBJ.fetch;
131
-
132
- GLOBAL_OBJ.fetch = async function (input, init) {
133
- const url = typeof input === 'string' ? input : (input && input.url);
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;
134
74
 
135
- // GUARD: Avoid infinite loops
136
- if (url && url.includes(config.captureEndpoint)) {
137
- return GLOBAL_OBJ.__dropout_original_fetch__(input, init);
138
- }
75
+ // Check against known AI domains
76
+ return KNOWN_AI_DOMAINS.some(domain => url.includes(domain));
77
+ }
139
78
 
140
- const isAI = url && (
141
- url.includes('openai.com') ||
142
- url.includes('anthropic.com') ||
143
- url.includes('generative') ||
144
- url.includes('groq.com') ||
145
- (init && init.body && (init.body.includes('"model"') || init.body.includes('"messages"')))
146
- );
147
-
148
- if (!isAI) return GLOBAL_OBJ.__dropout_original_fetch__(input, init);
149
-
150
- const start = Date.now();
151
-
152
- // --- Explicit Turn Increment Rule ---
153
- let activeTurn;
154
- if (lastTurnConfirmed) {
155
- activeTurn = turnIndex++;
156
- lastTurnConfirmed = false;
157
- } else {
158
- activeTurn = turnIndex > 0 ? turnIndex - 1 : 0;
79
+ // --- THE CORE: UNIVERSAL PATCHING ---
80
+ patchNetwork() {
81
+ const _this = this;
82
+
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;
88
+
89
+ // PASSTHROUGH: If not AI, ignore
90
+ if (!_this.isAiRequest(url)) {
91
+ return originalFetch.apply(this, arguments);
92
+ }
93
+
94
+ _this.log(`⚡ [FETCH] Intercepting request to: ${url}`);
95
+
96
+ // Capture Request Body
97
+ let reqBody = "";
98
+ try { if (init && init.body) reqBody = init.body; } catch (e) { }
99
+
100
+ const startTime = Date.now();
101
+ let response;
102
+
103
+ // Execute Original Call
104
+ try {
105
+ response = await originalFetch.apply(this, arguments);
106
+ } catch (e) { throw e; }
107
+
108
+ // Capture Response (Clone response stream)
109
+ try {
110
+ const cloned = response.clone();
111
+ const resBody = await cloned.text();
112
+
113
+ _this.emit({
114
+ url,
115
+ method: 'FETCH',
116
+ request: reqBody,
117
+ response: resBody,
118
+ latency: Date.now() - startTime,
119
+ status: response.status
120
+ });
121
+ } catch (e) { _this.log("⚠️ Error reading response body", e); }
122
+
123
+ return response;
124
+ };
125
+ this.log("✅ Patch Applied: global.fetch");
159
126
  }
160
127
 
161
- const { provider, model } = normalize(url, init && init.body);
162
-
163
- // --- 1. Emit Request Event ---
164
- let pText = "";
165
- if (init && init.body) {
166
- try { pText = typeof init.body === 'string' ? init.body : JSON.stringify(init.body); } catch (e) { }
167
- }
168
- const pHash = hash(pText);
169
- const isRetry = pHash && pHash === lastPromptHash;
170
-
171
- const requestEvent = {
172
- session_id: GLOBAL_OBJ.__dropout_session_id__,
173
- turn_index: activeTurn,
174
- direction: 'user_to_ai',
175
- turn_role: 'user',
176
- provider,
177
- model,
178
- // FIX: Renamed 'content_raw' to 'content' to match Supabase expectations
179
- content: config.privacyMode === 'full' ? pText : null,
180
- content_hash: pHash,
181
- metadata_flags: {
182
- retry_like: isRetry ? 1 : 0
183
- }
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] || {};
138
+ } 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}`;
144
+ }
145
+
146
+ // PASSTHROUGH: If not AI, ignore
147
+ if (!_this.isAiRequest(url)) {
148
+ return originalRequest.apply(this, args);
149
+ }
150
+
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
+ });
187
+ });
188
+ });
189
+
190
+ return clientRequest;
191
+ };
184
192
  };
185
193
 
186
- emit(requestEvent);
187
- lastPromptHash = pHash;
188
-
189
- // Execute actual fetch
190
- let response;
191
- let oText = "";
192
- try {
193
- response = await GLOBAL_OBJ.__dropout_original_fetch__(input, init);
194
- } catch (err) {
195
- throw err;
196
- }
197
-
198
- const latency = Date.now() - start;
194
+ patchNodeRequest(https, 'https');
195
+ patchNodeRequest(http, 'http');
196
+ this.log("✅ Patch Applied: http/https");
197
+ }
199
198
 
200
- // --- 2. Emit Response Event ---
201
- try {
202
- const cloned = response.clone();
203
- oText = await cloned.text();
204
- if (oText && oText.length > config.maxOutputBytes) {
205
- oText = oText.slice(0, config.maxOutputBytes);
206
- }
207
- } catch (e) { }
208
-
209
- const oHash = hash(oText);
210
- const isNonAdaptive = oHash && oHash === lastResponseHash;
211
-
212
- const responseEvent = {
213
- session_id: GLOBAL_OBJ.__dropout_session_id__,
214
- turn_index: activeTurn,
215
- direction: 'ai_to_user',
216
- turn_role: 'assistant',
217
- latency_ms: latency,
218
- provider,
219
- model,
220
- // FIX: Renamed 'content_raw' to 'content'
221
- content: config.privacyMode === 'full' ? oText : null,
222
- content_hash: oHash,
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,
223
211
  metadata_flags: {
224
- non_adaptive_response: isNonAdaptive ? 1 : 0,
225
- turn_boundary_confirmed: 1
226
- }
212
+ latency: data.latency,
213
+ url: data.url,
214
+ method: data.method,
215
+ status: data.status,
216
+ captured_via: 'universal_interceptor'
217
+ },
218
+ received_at: new Date().toISOString()
227
219
  };
228
220
 
229
- emit(responseEvent);
230
- lastResponseHash = oHash;
231
- lastTurnConfirmed = true;
232
-
233
- return response;
234
- };
235
-
236
- GLOBAL_OBJ.fetch.__dropout_patched__ = true;
237
- }
221
+ this.log(`🚀 Sending Capture to Supabase (${data.latency}ms)`);
238
222
 
239
- /**
240
- * Manual capture for framework-level integration
241
- */
242
- async function capture(target, options = {}) {
243
- const start = Date.now();
244
-
245
- let activeTurn;
246
- if (lastTurnConfirmed) {
247
- activeTurn = turnIndex++;
248
- lastTurnConfirmed = false;
249
- } else {
250
- activeTurn = turnIndex > 0 ? turnIndex - 1 : 0;
251
- }
252
-
253
- const mode = options.privacy || config.privacyMode;
254
-
255
- let prompt, output, latency_ms = options.latency || 0;
223
+ // 2. Fire and Forget (Using unpatched fetch mechanism)
224
+ // We use a clean http request to avoid triggering our own hooks if we used global.fetch
225
+ const req = https.request(DROPOUT_INGEST_URL, {
226
+ method: 'POST',
227
+ headers: {
228
+ 'Content-Type': 'application/json',
229
+ 'x-dropout-key': this.apiKey
230
+ }
231
+ });
256
232
 
257
- if (typeof target === 'function' || (target && typeof target.then === 'function')) {
258
- prompt = options.prompt;
259
- const result = typeof target === 'function' ? await target() : await target;
260
- output = typeof result === 'string' ? result : JSON.stringify(result);
261
- latency_ms = Date.now() - start;
262
- } else {
263
- prompt = target.prompt;
264
- output = target.output;
233
+ req.on('error', (e) => this.log("❌ Upload Failed", e.message));
234
+ req.write(JSON.stringify(payload));
235
+ req.end();
265
236
  }
266
-
267
- const pHash = hash(prompt);
268
- const oHash = hash(output);
269
-
270
- // Emit Request
271
- emit({
272
- session_id: GLOBAL_OBJ.__dropout_session_id__,
273
- turn_index: activeTurn,
274
- direction: 'user_to_ai',
275
- turn_role: 'user',
276
- provider: options.provider || 'manual',
277
- model: options.model || 'unknown',
278
- content: mode === 'full' ? prompt : null, // FIX: content_raw -> content
279
- content_hash: pHash,
280
- metadata_flags: { retry_like: pHash === lastPromptHash ? 1 : 0 }
281
- });
282
-
283
- // Emit Response
284
- emit({
285
- session_id: GLOBAL_OBJ.__dropout_session_id__,
286
- turn_index: activeTurn,
287
- direction: 'ai_to_user',
288
- turn_role: 'assistant',
289
- latency_ms,
290
- provider: options.provider || 'manual',
291
- model: options.model || 'unknown',
292
- content: mode === 'full' ? output : null, // FIX: content_raw -> content
293
- content_hash: oHash,
294
- metadata_flags: { non_adaptive_response: oHash === lastResponseHash ? 1 : 0, turn_boundary_confirmed: 1 }
295
- });
296
-
297
- lastPromptHash = pHash;
298
- lastResponseHash = oHash;
299
- lastTurnConfirmed = true;
300
-
301
- return output;
302
237
  }
303
238
 
304
- module.exports = {
305
- capture,
306
- reset: (reason = 'manual_reset') => {
307
- emitSessionEnd(reason);
308
- GLOBAL_OBJ.__dropout_session_id__ = generateSessionId();
309
- GLOBAL_OBJ.__dropout_session_ended__ = false;
310
- turnIndex = 0;
311
- lastTurnConfirmed = true;
312
- lastPromptHash = null;
313
- lastResponseHash = null;
314
- }
315
- };
239
+ module.exports = Dropout;