@dropout-ai/runtime 0.4.5 → 0.5.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 +138 -293
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dropout-ai/runtime",
3
- "version": "0.4.5",
3
+ "version": "0.5.1",
4
4
  "description": "Invisible Node.js runtime for understanding behavioral impact of AI interactions.",
5
5
  "main": "src/index.js",
6
6
  "types": "src/index.d.ts",
package/src/index.js CHANGED
@@ -1,29 +1,30 @@
1
1
  /**
2
2
  * @dropout-ai/runtime
3
3
  * Universal AI Interaction Capture for Node.js
4
- * Stability: High (Browser-Safe, Silent Failures)
5
- * Features: Auto-Discovery, Connectivity Check, Dual-Schema Support
4
+ * Stability: Production (Async-Safe, Zero Config)
6
5
  */
7
6
 
8
7
  let https, http, crypto;
8
+ const { AsyncLocalStorage } = require('async_hooks');
9
9
 
10
10
  // --- DEFAULT CONFIGURATION ---
11
- const SUPABASE_FUNCTION_URL = "https://hipughmjlwmwjxzyxfzs.supabase.co/functions/v1/capture-sealed";
11
+ const SUPABASE_FUNCTION_URL =
12
+ "https://hipughmjlwmwjxzyxfzs.supabase.co/functions/v1/capture-sealed";
12
13
 
13
14
  // Known AI Domains
14
15
  const KNOWN_AI_DOMAINS = [
15
- 'api.openai.com', 'api.anthropic.com', 'generativelanguage.googleapis.com',
16
- 'aiplatform.googleapis.com', 'api.groq.com', 'api.mistral.ai', 'api.cohere.ai'
16
+ 'api.openai.com',
17
+ 'api.anthropic.com',
18
+ 'generativelanguage.googleapis.com',
19
+ 'aiplatform.googleapis.com',
20
+ 'api.groq.com',
21
+ 'api.mistral.ai',
22
+ 'api.cohere.ai'
17
23
  ];
18
24
 
19
25
  class Dropout {
20
- // Singleton Instance Storage
21
26
  static instance = null;
22
27
 
23
- /**
24
- * Universal Initialization Method (Idempotent)
25
- * Users call this explicitly in their code.
26
- */
27
28
  static init(config) {
28
29
  if (!Dropout.instance) {
29
30
  new Dropout(config);
@@ -32,87 +33,49 @@ class Dropout {
32
33
  }
33
34
 
34
35
  constructor(config = {}) {
35
- // đŸ›Ąī¸ SINGLETON GUARD (Prevent multiple initializations)
36
- if (Dropout.instance) {
37
- if (config.debug) console.log("[Dropout] â„šī¸ Already initialized. Returning existing instance.");
38
- return Dropout.instance;
39
- }
36
+ if (Dropout.instance) return Dropout.instance;
40
37
 
41
- // đŸ›Ąī¸ 1. BROWSER GUARD (Client-Side Protection)
42
- if (typeof window !== 'undefined' && typeof window.document !== 'undefined') {
43
- if (config.debug) console.warn("[Dropout] âš ī¸ Skipped: Browser detected. SDK is Server-Only.");
44
- return;
45
- }
38
+ // đŸ›Ąī¸ Browser Guard
39
+ if (typeof window !== 'undefined') return;
46
40
 
47
- // đŸ›Ąī¸ 2. CREDENTIAL GUARD
41
+ // đŸ›Ąī¸ Credentials Guard
48
42
  if (!config.apiKey || !config.projectId) {
49
43
  if (config.debug) {
50
- console.warn("\x1b[31m%s\x1b[0m", "[Dropout] 🔴 Initialization Failed: Missing API Key or Project ID.");
44
+ console.warn("[Dropout] 🔴 Missing API Key or Project ID.");
51
45
  }
52
46
  return;
53
47
  }
54
48
 
55
- // đŸ›Ąī¸ 3. DEPENDENCY CHECK
56
49
  try {
57
50
  https = require('https');
58
51
  http = require('http');
59
52
  crypto = require('crypto');
60
- } catch (e) {
61
- if (config.debug) console.warn("[Dropout] âš ī¸ Skipped: Node core modules missing.");
53
+ } catch {
62
54
  return;
63
55
  }
64
56
 
65
- // --- INITIALIZATION ---
66
- try {
67
- this.config = config;
68
- this.projectId = config.projectId;
69
- this.apiKey = config.apiKey;
70
- this.debug = config.debug || false;
71
- this.privacy = config.privacy || 'full';
72
- this.captureEndpoint = config.captureEndpoint || SUPABASE_FUNCTION_URL;
73
- this.maxOutputBytes = 32768;
74
-
75
- // State
76
- this.turnIndex = 0;
77
- this.lastTurnConfirmed = true;
78
- this.lastPromptHash = null;
79
- this.lastResponseHash = null;
80
-
81
- // Global Guard (Extra safety for HMR environments like Next.js)
82
- if (global.__dropout_initialized__) {
83
- if (this.debug) console.log("[Dropout] â„šī¸ Global flag found. Skipping re-initialization.");
84
- Dropout.instance = this; // Re-bind if lost
85
- return;
86
- }
87
- global.__dropout_initialized__ = true;
88
-
89
- // ✅ Session Identity (Instance-Level, Not Global)
90
- // Accept session_id from config OR generate a default
91
- // Host app controls session lifecycle via startNewSession()
92
- this.currentSessionId = config.sessionId || this.generateSessionId();
93
-
94
- // ✅ VISUAL SUCCESS INDICATOR
95
- if (this.debug) {
96
- console.log('\x1b[32m%s\x1b[0m', `[Dropout] đŸŸĸ Online | Project: ${this.projectId}`);
97
- }
98
-
99
- // Bindings
100
- this.log = this.log.bind(this);
101
- this.emit = this.emit.bind(this);
57
+ this.projectId = config.projectId;
58
+ this.apiKey = config.apiKey;
59
+ this.debug = config.debug || false;
60
+ this.privacy = config.privacy || 'full';
61
+ this.captureEndpoint = config.captureEndpoint || SUPABASE_FUNCTION_URL;
62
+ this.maxOutputBytes = 32768;
102
63
 
103
- // 4. Start Network Interceptor
104
- this.patchNetwork();
64
+ // ✅ ASYNC CONTEXT STORE (THE FIX)
65
+ this.ctx = new AsyncLocalStorage();
105
66
 
106
- // Lock the instance
107
- Dropout.instance = this;
67
+ this.patchNetwork();
108
68
 
109
- } catch (err) {
110
- // THE ULTIMATE CATCH-ALL: Ensure app NEVER crashes
111
- if (config.debug) console.error("[Dropout] ❌ Initialization Error:", err);
69
+ if (this.debug) {
70
+ console.log(`[Dropout] đŸŸĸ Online | Project: ${this.projectId}`);
112
71
  }
72
+
73
+ Dropout.instance = this;
113
74
  }
114
75
 
115
- // --- UTILS ---
76
+ // -------------------------
77
+ // Utilities
78
+ // -------------------------
116
79
  log(msg, ...args) {
117
80
  if (this.debug) console.log(`[Dropout] ${msg}`, ...args);
118
81
  }
@@ -120,315 +83,198 @@ class Dropout {
120
83
  generateSessionId() {
121
84
  try {
122
85
  return crypto.randomUUID();
123
- } catch (e) {
124
- return 'sess_' + Math.random().toString(36).substring(2, 12) + Date.now().toString(36);
125
- }
126
- }
127
-
128
- /**
129
- * 🔑 START NEW SESSION
130
- * Call this when user clicks "New Chat" or starts a new conversation thread.
131
- * Resets turn counter and generates a fresh session_id.
132
- * @param {string} [customSessionId] - Optional custom session ID, otherwise auto-generates
133
- * @returns {string} The new session ID
134
- */
135
- startNewSession(customSessionId) {
136
- this.currentSessionId = customSessionId || this.generateSessionId();
137
- this.turnIndex = 0; // Reset turn counter for new session
138
- this.lastTurnConfirmed = true;
139
- this.lastPromptHash = null;
140
- this.lastResponseHash = null;
141
- if (this.debug) {
142
- console.log(`[Dropout] 🔄 New Session Started: ${this.currentSessionId}`);
86
+ } catch {
87
+ return 'sess_' + Date.now().toString(36);
143
88
  }
144
- return this.currentSessionId;
145
89
  }
146
90
 
147
91
  hash(text) {
148
92
  if (!text) return null;
149
93
  try {
150
- return crypto.createHash('sha256').update(text.toLowerCase().trim()).digest('hex');
151
- } catch (e) { return 'hash_err'; }
94
+ return crypto.createHash('sha256')
95
+ .update(text.toLowerCase().trim())
96
+ .digest('hex');
97
+ } catch {
98
+ return 'hash_err';
99
+ }
100
+ }
101
+
102
+ // -------------------------
103
+ // Session Context Helpers
104
+ // -------------------------
105
+ getSession() {
106
+ let store = this.ctx.getStore();
107
+
108
+ // 🔑 AUTO-CREATE SESSION IF MISSING
109
+ if (!store) {
110
+ store = {
111
+ session_id: this.generateSessionId(),
112
+ turn_index: 0,
113
+ last_prompt_hash: null,
114
+ last_response_hash: null,
115
+ last_turn_confirmed: true
116
+ };
117
+ this.ctx.enterWith(store);
118
+ }
119
+
120
+ return store;
152
121
  }
153
122
 
123
+ // -------------------------
124
+ // AI Detection
125
+ // -------------------------
154
126
  normalize(url, body) {
155
127
  let provider = 'custom';
156
128
  let model = 'unknown';
129
+
157
130
  try {
158
131
  if (url) {
159
132
  const u = url.toLowerCase();
160
133
  if (u.includes('openai')) provider = 'openai';
161
134
  else if (u.includes('anthropic')) provider = 'anthropic';
162
- else if (u.includes('google') || u.includes('gemini') || u.includes('aiplatform')) provider = 'google';
135
+ else if (u.includes('google')) provider = 'google';
163
136
  else if (u.includes('groq')) provider = 'groq';
164
137
  else if (u.includes('mistral')) provider = 'mistral';
165
138
  else if (u.includes('cohere')) provider = 'cohere';
166
- else if (u.includes('localhost') || u.includes('127.0.0.1')) provider = 'local';
167
139
  }
140
+
168
141
  if (body) {
169
142
  const parsed = typeof body === 'string' ? JSON.parse(body) : body;
170
- if (parsed.model) model = parsed.model;
171
- if (provider === 'custom' && (parsed.messages || parsed.prompt)) provider = 'heuristic';
143
+ if (parsed?.model) model = parsed.model;
172
144
  }
173
- } catch (e) { }
145
+ } catch { }
146
+
174
147
  return { provider, model };
175
148
  }
176
149
 
177
- isAiRequest(url, bodyString) {
150
+ isAiRequest(url, body) {
178
151
  if (!url) return false;
179
152
  if (url.includes(this.captureEndpoint)) return false;
180
153
  if (KNOWN_AI_DOMAINS.some(d => url.includes(d))) return true;
181
- if (bodyString) {
182
- return (bodyString.includes('"model"') || bodyString.includes('"messages"')) &&
183
- (bodyString.includes('"user"') || bodyString.includes('"prompt"'));
154
+
155
+ if (body) {
156
+ return (
157
+ body.includes('"model"') &&
158
+ (body.includes('"messages"') || body.includes('"prompt"'))
159
+ );
184
160
  }
161
+
185
162
  return false;
186
163
  }
187
164
 
188
- // --- EMITTER ---
165
+ // -------------------------
166
+ // Emit
167
+ // -------------------------
189
168
  emit(payload) {
190
169
  try {
191
- if (!this.apiKey || !this.projectId) return;
170
+ const req = (this.captureEndpoint.startsWith('http:') ? http : https)
171
+ .request(this.captureEndpoint, {
172
+ method: 'POST',
173
+ headers: {
174
+ 'Content-Type': 'application/json',
175
+ 'x-dropout-key': this.apiKey
176
+ }
177
+ });
192
178
 
193
- const finalPayload = {
179
+ req.on('error', () => { });
180
+ req.write(JSON.stringify({
194
181
  project_id: this.projectId,
195
- session_id: payload.session_id,
196
- turn_index: payload.turn_index,
197
- turn_role: payload.turn_role,
198
- // ✅ DIRECTION LOGIC APPLIED HERE
199
- direction: payload.turn_role === 'user' ? 'user_to_ai' : 'ai_to_user',
200
- provider: payload.provider,
201
- model: payload.model,
202
- latency_ms: payload.latency_ms || null,
203
- content: payload.content,
204
- content_hash: payload.content_hash,
205
- metadata_flags: payload.metadata_flags,
206
- received_at: new Date().toISOString()
207
- };
208
-
209
- this.log(`🚀 Sending Capture [${payload.turn_role}]`);
210
-
211
- const isHttp = this.captureEndpoint.startsWith('http:');
212
- const requestModule = isHttp ? http : https;
213
-
214
- const req = requestModule.request(this.captureEndpoint, {
215
- method: 'POST',
216
- headers: {
217
- 'Content-Type': 'application/json',
218
- 'x-dropout-key': this.apiKey
219
- }
220
- });
221
-
222
- req.on('error', (e) => this.log("❌ Upload Failed (Silent)", e.message));
223
- req.write(JSON.stringify(finalPayload));
182
+ received_at: new Date().toISOString(),
183
+ ...payload
184
+ }));
224
185
  req.end();
225
- } catch (e) {
226
- this.log("âš ī¸ Emit Error (Silent)", e);
227
- }
186
+ } catch { }
228
187
  }
229
188
 
230
- // --- CORE PATCHING ---
189
+ // -------------------------
190
+ // Network Patching
191
+ // -------------------------
231
192
  patchNetwork() {
232
193
  const _this = this;
233
194
 
234
- // --- A. PATCH FETCH ---
195
+ // ---- FETCH ----
235
196
  if (global.fetch) {
236
197
  const originalFetch = global.fetch;
198
+
237
199
  global.fetch = async function (input, init) {
238
200
  let url;
239
- try { url = typeof input === 'string' ? input : input?.url; } catch (e) { return originalFetch.apply(this, arguments); }
201
+ try { url = typeof input === 'string' ? input : input?.url; }
202
+ catch { return originalFetch.apply(this, arguments); }
240
203
 
241
- let bodyStr = "";
242
- if (init && init.body) {
243
- try { bodyStr = typeof init.body === 'string' ? init.body : JSON.stringify(init.body); } catch (e) { }
204
+ let bodyStr = '';
205
+ if (init?.body) {
206
+ try { bodyStr = typeof init.body === 'string' ? init.body : JSON.stringify(init.body); }
207
+ catch { }
244
208
  }
245
209
 
246
210
  if (!_this.isAiRequest(url, bodyStr)) {
247
211
  return originalFetch.apply(this, arguments);
248
212
  }
249
213
 
250
- _this.log(`⚡ [FETCH] Intercepting: ${url}`);
214
+ const session = _this.getSession();
251
215
  const start = Date.now();
252
-
253
- let activeTurn;
254
- if (_this.lastTurnConfirmed) {
255
- activeTurn = _this.turnIndex++;
256
- _this.lastTurnConfirmed = false;
257
- } else {
258
- activeTurn = _this.turnIndex > 0 ? _this.turnIndex - 1 : 0;
259
- }
260
-
261
216
  const { provider, model } = _this.normalize(url, bodyStr);
262
217
  const pHash = _this.hash(bodyStr);
263
218
 
219
+ const turn =
220
+ session.last_turn_confirmed
221
+ ? session.turn_index++
222
+ : Math.max(session.turn_index - 1, 0);
223
+
224
+ session.last_turn_confirmed = false;
225
+
264
226
  _this.emit({
265
- session_id: _this.currentSessionId,
266
- turn_index: activeTurn,
227
+ session_id: session.session_id,
228
+ turn_index: turn,
267
229
  turn_role: 'user',
230
+ direction: 'user_to_ai',
268
231
  provider,
269
232
  model,
270
233
  content: _this.privacy === 'full' ? bodyStr : null,
271
234
  content_hash: pHash,
272
- metadata_flags: { retry_like: pHash === _this.lastPromptHash ? 1 : 0, method: 'FETCH', url: url }
235
+ metadata_flags: {
236
+ retry_like: pHash === session.last_prompt_hash ? 1 : 0
237
+ }
273
238
  });
274
- _this.lastPromptHash = pHash;
275
239
 
276
- let response;
277
- try {
278
- response = await originalFetch.apply(this, arguments);
279
- } catch (err) { throw err; }
240
+ session.last_prompt_hash = pHash;
280
241
 
242
+ const response = await originalFetch.apply(this, arguments);
281
243
  const latency = Date.now() - start;
282
244
 
283
245
  try {
284
- const cloned = response.clone();
285
- let oText = await cloned.text();
286
- if (oText && oText.length > _this.maxOutputBytes) oText = oText.slice(0, _this.maxOutputBytes);
287
- const oHash = _this.hash(oText);
246
+ const text = await response.clone().text();
247
+ const oHash = _this.hash(text);
288
248
 
289
249
  _this.emit({
290
- session_id: _this.currentSessionId,
291
- turn_index: activeTurn,
250
+ session_id: session.session_id,
251
+ turn_index: turn,
292
252
  turn_role: 'assistant',
293
- latency_ms: latency,
253
+ direction: 'ai_to_user',
294
254
  provider,
295
255
  model,
296
- content: _this.privacy === 'full' ? oText : null,
256
+ latency_ms: latency,
257
+ content: _this.privacy === 'full' ? text.slice(0, _this.maxOutputBytes) : null,
297
258
  content_hash: oHash,
298
259
  metadata_flags: {
299
- non_adaptive_response: oHash === _this.lastResponseHash ? 1 : 0,
260
+ non_adaptive_response: oHash === session.last_response_hash ? 1 : 0,
300
261
  turn_boundary_confirmed: 1,
301
262
  status: response.status
302
263
  }
303
264
  });
304
- _this.lastResponseHash = oHash;
305
- _this.lastTurnConfirmed = true;
306
- } catch (e) { _this.log("âš ī¸ Failed to read response body"); }
265
+
266
+ session.last_response_hash = oHash;
267
+ session.last_turn_confirmed = true;
268
+ } catch { }
307
269
 
308
270
  return response;
309
271
  };
310
- this.log("✅ Patch Applied: global.fetch");
311
272
  }
312
-
313
- // --- B. PATCH NODE HTTP/HTTPS ---
314
- const patchNodeRequest = (module, moduleName) => {
315
- const originalRequest = module.request;
316
- module.request = function (...args) {
317
- let url;
318
- try {
319
- if (typeof args[0] === 'string') {
320
- url = args[0];
321
- } else {
322
- const opts = args[0] || {};
323
- const protocol = opts.protocol || (moduleName === 'https' ? 'https:' : 'http:');
324
- const host = opts.hostname || opts.host || 'localhost';
325
- const path = opts.path || '/';
326
- url = `${protocol}//${host}${path}`;
327
- }
328
- } catch (e) { return originalRequest.apply(this, args); }
329
-
330
- if (!_this.isAiRequest(url, null)) {
331
- return originalRequest.apply(this, args);
332
- }
333
-
334
- _this.log(`⚡ [${moduleName.toUpperCase()}] Intercepting: ${url}`);
335
- const start = Date.now();
336
-
337
- const clientRequest = originalRequest.apply(this, args);
338
- const reqChunks = [];
339
- const originalWrite = clientRequest.write;
340
- const originalEnd = clientRequest.end;
341
-
342
- clientRequest.write = function (...writeArgs) {
343
- try {
344
- const chunk = writeArgs[0];
345
- if (chunk && (typeof chunk === 'string' || Buffer.isBuffer(chunk))) {
346
- reqChunks.push(Buffer.from(chunk));
347
- }
348
- } catch (e) { }
349
- return originalWrite.apply(this, writeArgs);
350
- };
351
-
352
- clientRequest.end = function (...endArgs) {
353
- try {
354
- const chunk = endArgs[0];
355
- if (chunk && (typeof chunk === 'string' || Buffer.isBuffer(chunk))) {
356
- reqChunks.push(Buffer.from(chunk));
357
- }
358
-
359
- const reqBody = Buffer.concat(reqChunks).toString('utf8');
360
- const { provider, model } = _this.normalize(url, reqBody);
361
- const pHash = _this.hash(reqBody);
362
-
363
- let activeTurn;
364
- if (_this.lastTurnConfirmed) {
365
- activeTurn = _this.turnIndex++;
366
- _this.lastTurnConfirmed = false;
367
- } else {
368
- activeTurn = _this.turnIndex > 0 ? _this.turnIndex - 1 : 0;
369
- }
370
-
371
- clientRequest._dropout_turn = activeTurn;
372
- clientRequest._dropout_provider = provider;
373
- clientRequest._dropout_model = model;
374
-
375
- _this.emit({
376
- session_id: _this.currentSessionId,
377
- turn_index: activeTurn,
378
- turn_role: 'user',
379
- provider,
380
- model,
381
- content: _this.privacy === 'full' ? reqBody : null,
382
- content_hash: pHash,
383
- metadata_flags: { retry_like: pHash === _this.lastPromptHash ? 1 : 0, method: moduleName.toUpperCase(), url: url }
384
- });
385
- _this.lastPromptHash = pHash;
386
- } catch (e) { _this.log("âš ī¸ Request Capture Failed"); }
387
-
388
- return originalEnd.apply(this, endArgs);
389
- };
390
-
391
- clientRequest.on('response', (res) => {
392
- const resChunks = [];
393
- res.on('data', (chunk) => resChunks.push(chunk));
394
- res.on('end', () => {
395
- try {
396
- let resBody = Buffer.concat(resChunks).toString('utf8');
397
- if (resBody && resBody.length > _this.maxOutputBytes) resBody = resBody.slice(0, _this.maxOutputBytes);
398
-
399
- const latency = Date.now() - start;
400
- const oHash = _this.hash(resBody);
401
-
402
- _this.emit({
403
- session_id: _this.currentSessionId,
404
- turn_index: clientRequest._dropout_turn || 0,
405
- turn_role: 'assistant',
406
- latency_ms: latency,
407
- provider: clientRequest._dropout_provider,
408
- model: clientRequest._dropout_model,
409
- content: _this.privacy === 'full' ? resBody : null,
410
- content_hash: oHash,
411
- metadata_flags: { status: res.statusCode, non_adaptive_response: oHash === _this.lastResponseHash ? 1 : 0 }
412
- });
413
-
414
- _this.lastResponseHash = oHash;
415
- _this.lastTurnConfirmed = true;
416
- } catch (e) { _this.log("âš ī¸ Response Capture Failed"); }
417
- });
418
- });
419
-
420
- return clientRequest;
421
- };
422
- };
423
-
424
- patchNodeRequest(https, 'https');
425
- patchNodeRequest(http, 'http');
426
- this.log("✅ Patch Applied: http/https");
427
273
  }
428
274
  }
429
275
 
430
- // Auto-Start (Backwards Compatibility & Environment Variable Support)
431
- if (typeof process !== 'undefined' && process.env.DROPOUT_PROJECT_ID && process.env.DROPOUT_API_KEY) {
276
+ // Auto-init
277
+ if (process?.env?.DROPOUT_PROJECT_ID && process?.env?.DROPOUT_API_KEY) {
432
278
  Dropout.init({
433
279
  projectId: process.env.DROPOUT_PROJECT_ID,
434
280
  apiKey: process.env.DROPOUT_API_KEY,
@@ -436,6 +282,5 @@ if (typeof process !== 'undefined' && process.env.DROPOUT_PROJECT_ID && process.
436
282
  });
437
283
  }
438
284
 
439
- // đŸ›Ąī¸ DUAL EXPORT FIX (Crucial for TypeScript + Require compatibility)
440
285
  Dropout.default = Dropout;
441
- module.exports = Dropout;
286
+ module.exports = Dropout;