@dropout-ai/runtime 0.5.1 → 0.5.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 +217 -159
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dropout-ai/runtime",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
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,11 +1,10 @@
1
1
  /**
2
2
  * @dropout-ai/runtime
3
3
  * Universal AI Interaction Capture for Node.js
4
- * Stability: Production (Async-Safe, Zero Config)
4
+ * Stability: High (Browser-Safe, Next.js Safe)
5
5
  */
6
6
 
7
- let https, http, crypto;
8
- const { AsyncLocalStorage } = require('async_hooks');
7
+ let https, http, crypto, AsyncLocalStorage;
9
8
 
10
9
  // --- DEFAULT CONFIGURATION ---
11
10
  const SUPABASE_FUNCTION_URL =
@@ -13,133 +12,141 @@ const SUPABASE_FUNCTION_URL =
13
12
 
14
13
  // Known AI Domains
15
14
  const KNOWN_AI_DOMAINS = [
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'
15
+ "api.openai.com",
16
+ "api.anthropic.com",
17
+ "generativelanguage.googleapis.com",
18
+ "aiplatform.googleapis.com",
19
+ "api.groq.com",
20
+ "api.mistral.ai",
21
+ "api.cohere.ai",
23
22
  ];
24
23
 
25
24
  class Dropout {
26
25
  static instance = null;
27
26
 
28
27
  static init(config) {
29
- if (!Dropout.instance) {
30
- new Dropout(config);
31
- }
28
+ if (!Dropout.instance) new Dropout(config);
32
29
  return Dropout.instance;
33
30
  }
34
31
 
35
32
  constructor(config = {}) {
36
33
  if (Dropout.instance) return Dropout.instance;
37
34
 
38
- // 🛡️ Browser Guard
39
- if (typeof window !== 'undefined') return;
35
+ // 🛡️ Browser Guard (prevents client execution)
36
+ if (typeof window !== "undefined" && typeof window.document !== "undefined") {
37
+ if (config.debug) console.warn("[Dropout] ⚠️ Skipped: Browser detected. SDK is Server-Only.");
38
+ return;
39
+ }
40
40
 
41
- // 🛡️ Credentials Guard
41
+ // 🛡️ Credential Guard
42
42
  if (!config.apiKey || !config.projectId) {
43
43
  if (config.debug) {
44
- console.warn("[Dropout] 🔴 Missing API Key or Project ID.");
44
+ console.warn("[Dropout] Missing API Key or Project ID");
45
45
  }
46
46
  return;
47
47
  }
48
48
 
49
+ // 🛡️ Safe Dependency Import
49
50
  try {
50
- https = require('https');
51
- http = require('http');
52
- crypto = require('crypto');
51
+ https = require("https");
52
+ http = require("http");
53
+ crypto = require("crypto");
54
+ // Import async_hooks dynamically to avoid browser build crashes
55
+ const asyncHooks = require("async_hooks");
56
+ AsyncLocalStorage = asyncHooks.AsyncLocalStorage;
53
57
  } catch {
58
+ if (config.debug) console.warn("[Dropout] ⚠️ Node core modules missing.");
54
59
  return;
55
60
  }
56
61
 
57
62
  this.projectId = config.projectId;
58
63
  this.apiKey = config.apiKey;
59
64
  this.debug = config.debug || false;
60
- this.privacy = config.privacy || 'full';
65
+ this.privacy = config.privacy || "full";
61
66
  this.captureEndpoint = config.captureEndpoint || SUPABASE_FUNCTION_URL;
62
67
  this.maxOutputBytes = 32768;
63
68
 
64
- // ✅ ASYNC CONTEXT STORE (THE FIX)
65
- this.ctx = new AsyncLocalStorage();
69
+ // --------------------------------------------------
70
+ // ASYNC CONTEXT (LAZY, NODE-ONLY)
71
+ // --------------------------------------------------
72
+ this.storage = null;
73
+ if (AsyncLocalStorage) {
74
+ this.storage = new AsyncLocalStorage();
75
+ }
66
76
 
77
+ // --------------------------------------------------
78
+ // NETWORK PATCHING
79
+ // --------------------------------------------------
67
80
  this.patchNetwork();
68
81
 
69
82
  if (this.debug) {
70
- console.log(`[Dropout] 🟢 Online | Project: ${this.projectId}`);
83
+ console.log(`[Dropout] 🟢 Online | Project ${this.projectId}`);
71
84
  }
72
85
 
73
86
  Dropout.instance = this;
74
87
  }
75
88
 
76
- // -------------------------
77
- // Utilities
78
- // -------------------------
79
- log(msg, ...args) {
80
- if (this.debug) console.log(`[Dropout] ${msg}`, ...args);
81
- }
82
-
89
+ // --------------------------------------------------
90
+ // UTILITIES
91
+ // --------------------------------------------------
83
92
  generateSessionId() {
84
93
  try {
85
94
  return crypto.randomUUID();
86
95
  } catch {
87
- return 'sess_' + Date.now().toString(36);
96
+ return "sess_" + Date.now().toString(36);
88
97
  }
89
98
  }
90
99
 
91
100
  hash(text) {
92
101
  if (!text) return null;
93
102
  try {
94
- return crypto.createHash('sha256')
103
+ return crypto
104
+ .createHash("sha256")
95
105
  .update(text.toLowerCase().trim())
96
- .digest('hex');
106
+ .digest("hex");
97
107
  } catch {
98
- return 'hash_err';
108
+ return "hash_err";
99
109
  }
100
110
  }
101
111
 
102
- // -------------------------
103
- // Session Context Helpers
104
- // -------------------------
105
- getSession() {
106
- let store = this.ctx.getStore();
112
+ // --------------------------------------------------
113
+ // CONTEXT API (OPTIONAL BUT SAFE)
114
+ // --------------------------------------------------
115
+ run(sessionId, callback) {
116
+ // If storage isn't available, just run the callback
117
+ if (!this.storage) return callback();
107
118
 
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
+ const context = {
120
+ sessionId: sessionId || this.generateSessionId(),
121
+ turnIndex: 0,
122
+ };
123
+
124
+ return this.storage.run(context, callback);
125
+ }
119
126
 
120
- return store;
127
+ getContext() {
128
+ if (!this.storage) return null;
129
+ return this.storage.getStore() || null;
121
130
  }
122
131
 
123
- // -------------------------
124
- // AI Detection
125
- // -------------------------
132
+ // --------------------------------------------------
133
+ // NORMALIZATION
134
+ // --------------------------------------------------
126
135
  normalize(url, body) {
127
- let provider = 'custom';
128
- let model = 'unknown';
136
+ let provider = "custom";
137
+ let model = "unknown";
129
138
 
130
139
  try {
131
- if (url) {
132
- const u = url.toLowerCase();
133
- if (u.includes('openai')) provider = 'openai';
134
- else if (u.includes('anthropic')) provider = 'anthropic';
135
- else if (u.includes('google')) provider = 'google';
136
- else if (u.includes('groq')) provider = 'groq';
137
- else if (u.includes('mistral')) provider = 'mistral';
138
- else if (u.includes('cohere')) provider = 'cohere';
139
- }
140
+ const u = url?.toLowerCase() || "";
141
+ if (u.includes("openai")) provider = "openai";
142
+ else if (u.includes("anthropic")) provider = "anthropic";
143
+ else if (u.includes("google")) provider = "google";
144
+ else if (u.includes("groq")) provider = "groq";
145
+ else if (u.includes("mistral")) provider = "mistral";
146
+ else if (u.includes("cohere")) provider = "cohere";
140
147
 
141
148
  if (body) {
142
- const parsed = typeof body === 'string' ? JSON.parse(body) : body;
149
+ const parsed = typeof body === "string" ? JSON.parse(body) : body;
143
150
  if (parsed?.model) model = parsed.model;
144
151
  }
145
152
  } catch { }
@@ -148,9 +155,8 @@ class Dropout {
148
155
  }
149
156
 
150
157
  isAiRequest(url, body) {
151
- if (!url) return false;
152
- if (url.includes(this.captureEndpoint)) return false;
153
- if (KNOWN_AI_DOMAINS.some(d => url.includes(d))) return true;
158
+ if (!url || url.includes(this.captureEndpoint)) return false;
159
+ if (KNOWN_AI_DOMAINS.some((d) => url.includes(d))) return true;
154
160
 
155
161
  if (body) {
156
162
  return (
@@ -158,129 +164,181 @@ class Dropout {
158
164
  (body.includes('"messages"') || body.includes('"prompt"'))
159
165
  );
160
166
  }
161
-
162
167
  return false;
163
168
  }
164
169
 
165
- // -------------------------
166
- // Emit
167
- // -------------------------
170
+ // --------------------------------------------------
171
+ // EMITTER
172
+ // --------------------------------------------------
168
173
  emit(payload) {
169
174
  try {
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
- });
178
-
179
- req.on('error', () => { });
180
- req.write(JSON.stringify({
175
+ const finalPayload = {
181
176
  project_id: this.projectId,
177
+ session_id: payload.session_id,
178
+ turn_index: payload.turn_index,
179
+ turn_role: payload.turn_role,
180
+ direction:
181
+ payload.turn_role === "user" ? "user_to_ai" : "ai_to_user",
182
+ provider: payload.provider,
183
+ model: payload.model,
184
+ latency_ms: payload.latency_ms || null,
185
+ content: payload.content,
186
+ content_hash: payload.content_hash,
187
+ metadata_flags: payload.metadata_flags,
182
188
  received_at: new Date().toISOString(),
183
- ...payload
184
- }));
189
+ };
190
+
191
+ const requestModule = this.captureEndpoint.startsWith("http:")
192
+ ? http
193
+ : https;
194
+
195
+ const req = requestModule.request(this.captureEndpoint, {
196
+ method: "POST",
197
+ headers: {
198
+ "Content-Type": "application/json",
199
+ "x-dropout-key": this.apiKey,
200
+ },
201
+ });
202
+
203
+ req.on("error", () => { });
204
+ req.write(JSON.stringify(finalPayload));
185
205
  req.end();
186
206
  } catch { }
187
207
  }
188
208
 
189
- // -------------------------
190
- // Network Patching
191
- // -------------------------
209
+ // --------------------------------------------------
210
+ // NETWORK PATCHING (INTELLIGENT)
211
+ // --------------------------------------------------
192
212
  patchNetwork() {
193
213
  const _this = this;
194
214
 
195
- // ---- FETCH ----
196
- if (global.fetch) {
197
- const originalFetch = global.fetch;
215
+ if (!global.fetch) return;
198
216
 
199
- global.fetch = async function (input, init) {
200
- let url;
201
- try { url = typeof input === 'string' ? input : input?.url; }
202
- catch { return originalFetch.apply(this, arguments); }
217
+ const originalFetch = global.fetch;
203
218
 
204
- let bodyStr = '';
205
- if (init?.body) {
206
- try { bodyStr = typeof init.body === 'string' ? init.body : JSON.stringify(init.body); }
207
- catch { }
208
- }
219
+ global.fetch = async function (input, init) {
220
+ let url;
221
+ try {
222
+ url = typeof input === "string" ? input : input?.url;
223
+ } catch {
224
+ return originalFetch.apply(this, arguments);
225
+ }
226
+
227
+ let bodyStr = "";
228
+ let parsedBody = null;
229
+ if (init?.body) {
230
+ try {
231
+ bodyStr =
232
+ typeof init.body === "string"
233
+ ? init.body
234
+ : JSON.stringify(init.body);
235
+ parsedBody = JSON.parse(bodyStr);
236
+ } catch { }
237
+ }
209
238
 
210
- if (!_this.isAiRequest(url, bodyStr)) {
211
- return originalFetch.apply(this, arguments);
239
+ if (!_this.isAiRequest(url, bodyStr)) {
240
+ return originalFetch.apply(this, arguments);
241
+ }
242
+
243
+ // 🕵️ 1. SESSION LOGIC (PRIORITY ORDER)
244
+ // A. Explicit Context (if wrapped in .run())
245
+ const ctx = _this.getContext();
246
+ let activeSessionId = ctx?.sessionId;
247
+
248
+ if (!activeSessionId && parsedBody) {
249
+ // B. Explicit Override in Payload
250
+ const explicitId =
251
+ parsedBody.session_id ||
252
+ parsedBody.metadata?.session_id ||
253
+ parsedBody.metadata?.sessionId;
254
+
255
+ if (explicitId) {
256
+ activeSessionId = explicitId;
257
+ } else {
258
+ // C. SMART FINGERPRINTING (The Fix)
259
+ const userId =
260
+ parsedBody.user ||
261
+ parsedBody.userId ||
262
+ parsedBody.metadata?.user_id ||
263
+ "anon_user";
264
+
265
+ let rootContext = "";
266
+ // Grab first 64 chars of the FIRST message as "Conversation Anchor"
267
+ if (parsedBody.messages?.length > 0) {
268
+ rootContext = parsedBody.messages[0].content?.slice(0, 64) || "";
269
+ } else if (parsedBody.prompt) {
270
+ rootContext =
271
+ typeof parsedBody.prompt === "string"
272
+ ? parsedBody.prompt.slice(0, 64)
273
+ : JSON.stringify(parsedBody.prompt).slice(0, 64);
274
+ }
275
+
276
+ if (rootContext) {
277
+ // Salt with Date (YYYY-MM-DD) to differentiate sessions across days
278
+ const dateSalt = new Date().toISOString().slice(0, 10);
279
+ activeSessionId =
280
+ "sess_" +
281
+ _this.hash(`${userId}::${rootContext}::${dateSalt}`).substring(0, 12);
282
+ }
212
283
  }
284
+ }
213
285
 
214
- const session = _this.getSession();
215
- const start = Date.now();
216
- const { provider, model } = _this.normalize(url, bodyStr);
217
- const pHash = _this.hash(bodyStr);
286
+ // D. Fallback
287
+ activeSessionId = activeSessionId || _this.generateSessionId();
218
288
 
219
- const turn =
220
- session.last_turn_confirmed
221
- ? session.turn_index++
222
- : Math.max(session.turn_index - 1, 0);
289
+ const { provider, model } = _this.normalize(url, bodyStr);
290
+ const pHash = _this.hash(bodyStr);
291
+ const start = Date.now();
223
292
 
224
- session.last_turn_confirmed = false;
293
+ _this.emit({
294
+ session_id: activeSessionId,
295
+ turn_index: 0,
296
+ turn_role: "user",
297
+ provider,
298
+ model,
299
+ content: _this.privacy === "full" ? bodyStr : null,
300
+ content_hash: pHash,
301
+ metadata_flags: { method: "FETCH", url },
302
+ });
303
+
304
+ const response = await originalFetch.apply(this, arguments);
305
+
306
+ try {
307
+ const cloned = response.clone();
308
+ let oText = await cloned.text();
309
+ if (oText.length > _this.maxOutputBytes) {
310
+ oText = oText.slice(0, _this.maxOutputBytes);
311
+ }
225
312
 
226
313
  _this.emit({
227
- session_id: session.session_id,
228
- turn_index: turn,
229
- turn_role: 'user',
230
- direction: 'user_to_ai',
314
+ session_id: activeSessionId,
315
+ turn_index: 1,
316
+ turn_role: "assistant",
317
+ latency_ms: Date.now() - start,
231
318
  provider,
232
319
  model,
233
- content: _this.privacy === 'full' ? bodyStr : null,
234
- content_hash: pHash,
235
- metadata_flags: {
236
- retry_like: pHash === session.last_prompt_hash ? 1 : 0
237
- }
320
+ content: _this.privacy === "full" ? oText : null,
321
+ content_hash: _this.hash(oText),
322
+ metadata_flags: { status: response.status },
238
323
  });
324
+ } catch { }
239
325
 
240
- session.last_prompt_hash = pHash;
241
-
242
- const response = await originalFetch.apply(this, arguments);
243
- const latency = Date.now() - start;
244
-
245
- try {
246
- const text = await response.clone().text();
247
- const oHash = _this.hash(text);
248
-
249
- _this.emit({
250
- session_id: session.session_id,
251
- turn_index: turn,
252
- turn_role: 'assistant',
253
- direction: 'ai_to_user',
254
- provider,
255
- model,
256
- latency_ms: latency,
257
- content: _this.privacy === 'full' ? text.slice(0, _this.maxOutputBytes) : null,
258
- content_hash: oHash,
259
- metadata_flags: {
260
- non_adaptive_response: oHash === session.last_response_hash ? 1 : 0,
261
- turn_boundary_confirmed: 1,
262
- status: response.status
263
- }
264
- });
265
-
266
- session.last_response_hash = oHash;
267
- session.last_turn_confirmed = true;
268
- } catch { }
269
-
270
- return response;
271
- };
272
- }
326
+ return response;
327
+ };
273
328
  }
274
329
  }
275
330
 
276
- // Auto-init
277
- if (process?.env?.DROPOUT_PROJECT_ID && process?.env?.DROPOUT_API_KEY) {
331
+ // Auto-init via env (safe)
332
+ if (
333
+ typeof process !== "undefined" &&
334
+ process.env.DROPOUT_PROJECT_ID &&
335
+ process.env.DROPOUT_API_KEY
336
+ ) {
278
337
  Dropout.init({
279
338
  projectId: process.env.DROPOUT_PROJECT_ID,
280
339
  apiKey: process.env.DROPOUT_API_KEY,
281
- debug: process.env.DROPOUT_DEBUG === 'true'
282
340
  });
283
341
  }
284
342
 
285
343
  Dropout.default = Dropout;
286
- module.exports = Dropout;
344
+ module.exports = Dropout;