generalcontext 0.0.0-stage → 0.3.0

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.
package/dist/index.js ADDED
@@ -0,0 +1,1567 @@
1
+ // Copyright 2026 General Context Labs, Inc.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ // Record your agent's model calls for General Context, without changing how it runs.
4
+ //
5
+ // import OpenAI from 'openai';
6
+ // import { initialize, withSession } from 'generalcontext';
7
+ //
8
+ // initialize({ instrumentModules: { openAI: OpenAI } });
9
+ // await withSession(conversationId, async () => { ... });
10
+ //
11
+ // Every SDK model call (history sent, tools offered, reply and tool calls) is queued and sent in
12
+ // the background. Raw fetch capture is opt-in with instrumentFetch: true. Your agent never waits on General Context, and a failure on our side is
13
+ // logged and dropped, never thrown. No runtime dependencies.
14
+ import { AsyncLocalStorage } from 'node:async_hooks';
15
+ import { createHash, createHmac, randomBytes, randomUUID } from 'node:crypto';
16
+ import { request as httpRequest } from 'node:http';
17
+ import { request as httpsRequest } from 'node:https';
18
+ import { createRequire } from 'node:module';
19
+ export const VERSION = '0.3.0';
20
+ const BATCH = 20;
21
+ const MAX_QUEUE = 5000;
22
+ const MAX_CALL_BYTES = 4_000_000; // the server rejects larger calls
23
+ const MAX_QUEUE_BYTES = 64_000_000;
24
+ const MAX_BATCH_BYTES = 4_000_000;
25
+ const SEND_TIMEOUT_MS = 15_000;
26
+ const DIRECT_TIMEOUT_MS = 10_000; // reportOutcome and logEvent
27
+ const EXIT_FLUSH_MS = 2_000;
28
+ const FLUSH_TIMEOUT_MS = 10_000; // flush() default, like the Python SDK
29
+ const RETRY_PAUSE_MS = 30_000; // after the collector fails to answer, wait this long before sending again
30
+ const DEFAULT_BASE_URL = 'https://generalcontextlabs.com';
31
+ const config = {
32
+ apiKey: undefined,
33
+ baseUrl: undefined,
34
+ appName: undefined,
35
+ scrub: null,
36
+ beforeSend: null,
37
+ disableBatch: false,
38
+ };
39
+ const scope = new AsyncLocalStorage();
40
+ const queue = [];
41
+ let queuedBytes = 0;
42
+ let initialized = false;
43
+ let sending = null;
44
+ let timer = null;
45
+ let originalFetch = globalThis.fetch;
46
+ let exitHooked = false;
47
+ let stopped = false; // set when the exit flush runs out of time
48
+ let pausedUntil = 0; // background sends wait until then after a failed send; flush() still tries
49
+ // Prepared calls the collector refused for now (busy, failing, unreachable). Sent before the queue.
50
+ const retry = [];
51
+ let retryBytes = 0;
52
+ const inflight = new Set();
53
+ const apiKey = () => config.apiKey ?? process.env.GENERALCONTEXT_API_KEY;
54
+ // Explicit option, then env var, then default. Trailing slashes are trimmed without a regex.
55
+ const trimSlashes = (url) => { let end = url.length; while (end > 0 && url[end - 1] === '/')
56
+ end--; return url.slice(0, end); };
57
+ const baseUrl = () => trimSlashes(config.baseUrl ?? process.env.GENERALCONTEXT_BASE_URL ?? DEFAULT_BASE_URL);
58
+ const warn = (message) => console.warn(`[generalcontext] ${message}`);
59
+ // Warn at most once per minute for each key, so a broken collector cannot flood logs.
60
+ const lastWarned = new Map();
61
+ function warnLimited(key, message) {
62
+ const now = Date.now();
63
+ if (now - (lastWarned.get(key) ?? -Infinity) < 60_000)
64
+ return;
65
+ lastWarned.set(key, now);
66
+ warn(message);
67
+ }
68
+ // Only send the API key over https, or plain http to this machine for local development.
69
+ function secureUrl(url) {
70
+ try {
71
+ const u = new URL(url);
72
+ return u.protocol === 'https:' || (u.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(u.hostname));
73
+ }
74
+ catch {
75
+ return false;
76
+ }
77
+ }
78
+ const disabledByEnv = () => /^(1|true|yes)$/i.test((process.env.GENERALCONTEXT_DISABLED ?? '').trim());
79
+ /** Configure recording once per process, before your agent makes model calls. */
80
+ export function initialize(options = {}) {
81
+ if (initialized) {
82
+ warn('initialize was already called; ignoring the second call');
83
+ return;
84
+ }
85
+ initialized = true;
86
+ if (disabledByEnv()) {
87
+ warn('GENERALCONTEXT_DISABLED is set; recording is off');
88
+ return;
89
+ }
90
+ config.apiKey = options.apiKey ?? process.env.GENERALCONTEXT_API_KEY;
91
+ config.baseUrl = options.baseUrl;
92
+ config.appName = options.appName;
93
+ config.scrub = redactor(options.redact);
94
+ config.beforeSend = options.beforeSend ?? null;
95
+ config.disableBatch = options.disableBatch ?? false;
96
+ if (!config.apiKey)
97
+ warn('no API key; set GENERALCONTEXT_API_KEY or pass apiKey');
98
+ if (!secureUrl(baseUrl()))
99
+ warn(`baseUrl ${baseUrl()} is not https; nothing will be sent`);
100
+ const modules = options.instrumentModules ?? autoload();
101
+ // openai, @anthropic-ai/sdk and @google/genai ship separate CommonJS and ES module builds with their own classes.
102
+ // require() above found the CommonJS ones, which is what a CommonJS app uses. An ES module app (import OpenAI
103
+ // from 'openai') gets the other build, so patch that too. patchMethod skips a method already patched.
104
+ // The imports settle a few ticks after initialize() returns, before an app's own `await import(...)` of the
105
+ // same module resumes. A call made synchronously right after initialize(), before any await, can run
106
+ // unrecorded: await ready() first in that case.
107
+ // OpenAI Realtime (voice) connection classes live in their own entry points. openai/realtime/ws needs the
108
+ // optional `ws` package, so either may be missing.
109
+ if (!options.instrumentModules || options.instrumentModules.openAI) {
110
+ const require = createRequire(import.meta.url);
111
+ for (const name of REALTIME_MODULES) {
112
+ try {
113
+ patchRealtime(require(name));
114
+ }
115
+ catch { /* not installed */ }
116
+ }
117
+ const realtime = Promise.all(REALTIME_MODULES.map(name => import(name).then(patchRealtime, () => { }))).then(() => { });
118
+ pending = pending.then(() => realtime);
119
+ }
120
+ if (!options.instrumentModules) {
121
+ const esm = [['openai', patchOpenAI], ['@anthropic-ai/sdk', patchAnthropic]];
122
+ if (modules.googleGenAI)
123
+ esm.push(['@google/genai', patchGemini]);
124
+ // @aws-sdk/client-bedrock-runtime resolves to one class from both require() and import(), so it needs nothing here.
125
+ let found = Object.values(modules).some(Boolean);
126
+ const realtime = pending;
127
+ pending = Promise.all(esm.map(([name, patch]) => import(name).then(m => { found = true; patch(m); }, () => { })))
128
+ .then(() => { if (!found)
129
+ warn(NO_SDK); }).then(() => realtime);
130
+ }
131
+ if (modules.openAI)
132
+ patchOpenAI(modules.openAI);
133
+ if (modules.anthropic)
134
+ patchAnthropic(modules.anthropic);
135
+ if (modules.googleGenAI)
136
+ patchGemini(modules.googleGenAI);
137
+ if (modules.bedrockRuntime)
138
+ patchBedrock(modules.bedrockRuntime);
139
+ if (options.instrumentFetch ?? false)
140
+ patchFetch();
141
+ }
142
+ let pending = Promise.resolve();
143
+ // Resolution is from this file's location, so an SDK installed in another project's node_modules is not found.
144
+ const NO_SDK = 'found no model SDK to record (openai, @anthropic-ai/sdk, @google/genai, @aws-sdk/client-bedrock-runtime). '
145
+ + 'Install generalcontext in the same project as your model SDK, or pass instrumentModules.';
146
+ /**
147
+ * Resolves once initialize() has patched every SDK build it found, including the ES module builds it loads
148
+ * asynchronously. Await it when a model call may run before the app's next await after initialize(). Never rejects.
149
+ */
150
+ export function ready() {
151
+ return pending;
152
+ }
153
+ // Load installed SDKs when the caller did not pass them. Bundlers can hide these; pass instrumentModules there.
154
+ function autoload() {
155
+ const require = createRequire(import.meta.url);
156
+ const load = (name) => { try {
157
+ return require(name);
158
+ }
159
+ catch {
160
+ return undefined;
161
+ } };
162
+ return {
163
+ openAI: load('openai'), anthropic: load('@anthropic-ai/sdk'),
164
+ googleGenAI: load('@google/genai'), bedrockRuntime: load('@aws-sdk/client-bedrock-runtime'),
165
+ };
166
+ }
167
+ // Accept a module namespace, its default export, or the client class itself.
168
+ function clientClass(value, name) {
169
+ const v = value;
170
+ if (typeof v === 'function' && (v.Messages || v.Chat))
171
+ return v;
172
+ return v?.[name] ?? v?.default?.[name] ?? (typeof v === 'function' ? v : typeof v?.default === 'function' ? v.default : undefined);
173
+ }
174
+ function patchOpenAI(value) {
175
+ const OpenAI = clientClass(value, 'OpenAI');
176
+ patchMethod(OpenAI?.Chat?.Completions?.prototype, 'create', 'openai-chat');
177
+ patchMethod(OpenAI?.Responses?.prototype, 'create', 'openai-responses');
178
+ }
179
+ function patchAnthropic(value) {
180
+ const Anthropic = clientClass(value, 'Anthropic');
181
+ // messages.stream() calls messages.create({ stream: true }) internally, so it is covered too.
182
+ patchMethod(Anthropic?.Messages?.prototype, 'create', 'anthropic');
183
+ // client.beta.messages: create, parse, stream, toolRunner and threads all post through beta.messages.create.
184
+ patchMethod(Anthropic?.Beta?.Messages?.prototype, 'create', 'anthropic');
185
+ }
186
+ const exported = (value, name) => {
187
+ const v = value;
188
+ return v?.[name] ?? v?.default?.[name] ?? (typeof v === 'function' && v.name === name ? v : undefined);
189
+ };
190
+ // @google/genai assigns generateContent and generateContentStream as instance properties in the Models
191
+ // constructor. A setter on the prototype sees that assignment and stores the recording version instead.
192
+ function patchGemini(value) {
193
+ const proto = exported(value, 'Models')?.prototype;
194
+ if (!proto)
195
+ return;
196
+ const wrappers = {
197
+ generateContent: fn => recording(fn, 'gemini'),
198
+ generateContentStream: fn => geminiStreamRecording(fn),
199
+ };
200
+ for (const [name, wrapFn] of Object.entries(wrappers)) {
201
+ const existing = Object.getOwnPropertyDescriptor(proto, name);
202
+ if (existing?.set && existing.set[MARK])
203
+ continue;
204
+ if (typeof existing?.value === 'function') {
205
+ patchMethod(proto, name, 'gemini');
206
+ continue;
207
+ }
208
+ const set = function (fn) {
209
+ const v = typeof fn === 'function' && !fn[MARK] ? wrapFn(fn) : fn;
210
+ Object.defineProperty(this, name, { value: v, writable: true, configurable: true, enumerable: true });
211
+ };
212
+ set[MARK] = true;
213
+ Object.defineProperty(proto, name, { get: () => undefined, set, configurable: true });
214
+ }
215
+ }
216
+ function patchBedrock(value) {
217
+ const proto = exported(value, 'BedrockRuntimeClient')?.prototype;
218
+ const original = proto?.send;
219
+ if (typeof original !== 'function' || original[MARK])
220
+ return;
221
+ proto.send = bedrockRecording(original);
222
+ }
223
+ const MARK = Symbol.for('generalcontext.recorded');
224
+ function patchMethod(owner, name, api) {
225
+ const original = owner?.[name];
226
+ if (typeof original !== 'function' || original[MARK])
227
+ return;
228
+ owner[name] = recording(original, api);
229
+ }
230
+ /** Tag every call made inside `run` with one session. `conversation` is the same function. */
231
+ export function withSession(session, run) {
232
+ const s = typeof session === 'string' ? { sessionId: session } : session;
233
+ const tags = { id: String(s.sessionId) };
234
+ if (s.userId)
235
+ tags.user_id = s.userId;
236
+ if (s.channel)
237
+ tags.channel = s.channel;
238
+ if (s.product)
239
+ tags.product = s.product;
240
+ const account = s.account == null ? '' : String(s.account).trim().slice(0, 120);
241
+ if (account)
242
+ tags.account = account;
243
+ if (s.metadata)
244
+ tags.metadata = s.metadata;
245
+ return scope.run({ ...scope.getStore(), tags }, run);
246
+ }
247
+ /** Older name for withSession. Accepts { id } as well as { sessionId }. */
248
+ export function conversation(tags, run) {
249
+ if (typeof tags === 'object' && 'id' in tags) {
250
+ const { id, ...rest } = tags;
251
+ return withSession({ ...rest, sessionId: id }, run);
252
+ }
253
+ return withSession(tags, run);
254
+ }
255
+ /** Tag every call made inside `run` with a task name, such as one step of your agent loop. */
256
+ export function task(name, run) {
257
+ return scope.run({ ...scope.getStore(), task: name }, run);
258
+ }
259
+ const CLIP_OVER = 200_000; // strings longer than this many characters are clipped
260
+ const CLIP_HEAD = 100_000;
261
+ const CLIP_TAIL = 20_000;
262
+ const BINARY_MIN = 256; // shorter base64 payloads are left alone
263
+ const BASE64 = /^[A-Za-z0-9+/_-]+={0,2}$/;
264
+ const DATA_URL = /^data:([^,;]*)((?:;[^,;]*)*);base64,/;
265
+ const omitted = (payload) => `[omitted ${payload.length} bytes sha256:${createHash('sha256').update(payload).digest('hex').slice(0, 12)}]`;
266
+ /**
267
+ * The copy of a call that is recorded. Never changes `value`, and never touches what the caller sends to the provider.
268
+ * Agents resend the whole run on every call, so without this, later calls pass the per-call limit and are lost.
269
+ *
270
+ * - Inline binary becomes a placeholder that keeps the shape: a base64 data URL keeps its prefix
271
+ * (`data:image/png;base64,[omitted N bytes sha256:ab12cd34ef56]`), and a base64 `data`, `result` or
272
+ * `b64_json` field (Anthropic `source.data`, Gemini `inlineData.data`, OpenAI `input_audio.data`, Responses
273
+ * `image_generation_call.result`) becomes `[omitted N bytes sha256:...]`. N is the length of the base64 text
274
+ * and the hash is the first 12 hex digits of the sha256 of that text, so the same image gets the same placeholder
275
+ * in every call. Media types stay. Binary is never recorded: it does not help review and it is a privacy risk.
276
+ * - Any string over 200,000 characters keeps its first 100,000 and last 20,000 characters around
277
+ * `\n[... clipped N characters ...]\n`.
278
+ *
279
+ * Runs before beforeSend and redaction. A call still over the limit afterwards is dropped.
280
+ */
281
+ export function compactForRecording(value, key, depth = 0) {
282
+ if (typeof value === 'string') {
283
+ if (value.length > BINARY_MIN) {
284
+ const url = DATA_URL.exec(value.slice(0, 512));
285
+ if (url)
286
+ return value.slice(0, url[0].length) + omitted(value.slice(url[0].length));
287
+ if ((key === 'data' || key === 'result' || key === 'b64_json') && BASE64.test(value))
288
+ return omitted(value);
289
+ }
290
+ if (value.length > CLIP_OVER)
291
+ return `${value.slice(0, CLIP_HEAD)}\n[... clipped ${value.length - CLIP_HEAD - CLIP_TAIL} characters ...]\n${value.slice(-CLIP_TAIL)}`;
292
+ return value;
293
+ }
294
+ if (value === null || typeof value !== 'object' || depth > 64)
295
+ return value;
296
+ if (typeof value.toJSON === 'function')
297
+ value = value.toJSON();
298
+ if (value === null || typeof value !== 'object')
299
+ return compactForRecording(value, key, depth + 1);
300
+ if (Array.isArray(value))
301
+ return value.map(item => compactForRecording(item, undefined, depth + 1));
302
+ const out = {};
303
+ for (const [k, v] of Object.entries(value))
304
+ out[k] = compactForRecording(v, k, depth + 1);
305
+ return out;
306
+ }
307
+ // Runs before the caller's await continues, so only take the snapshot here. Redaction and beforeSend run at send time.
308
+ function record(call, at, scrub) {
309
+ try {
310
+ if (!apiKey()) {
311
+ warnLimited('no-key', 'GENERALCONTEXT_API_KEY is not set; a model call was not recorded');
312
+ return;
313
+ }
314
+ const json = JSON.stringify(compactForRecording({ ...call, request: withoutRequestOptions(call.request), conversation: at?.tags ?? null, task: at?.task ?? null }));
315
+ const bytes = Buffer.byteLength(json);
316
+ if (bytes > MAX_CALL_BYTES) {
317
+ warnLimited('oversized', `a model call of ${bytes} bytes is over the ${MAX_CALL_BYTES} byte limit and was dropped`);
318
+ return;
319
+ }
320
+ if (queue.length >= MAX_QUEUE || queuedBytes + bytes > MAX_QUEUE_BYTES) {
321
+ warnLimited('queue-full', 'queue is full; a model call was dropped');
322
+ return;
323
+ }
324
+ queue.push({ json, bytes, scrub });
325
+ queuedBytes += bytes;
326
+ hookExit();
327
+ if (Date.now() < pausedUntil)
328
+ return; // the collector just failed; a timer resumes sending
329
+ if (config.disableBatch || queue.length >= BATCH)
330
+ void send();
331
+ else if (!timer) {
332
+ timer = setTimeout(() => { timer = null; void send(); }, 500);
333
+ timer.unref?.();
334
+ }
335
+ }
336
+ catch (error) {
337
+ warnLimited('record', `could not record a model call: ${error.message}`);
338
+ }
339
+ }
340
+ // Flush once when the event loop empties, capped at EXIT_FLUSH_MS. Pending requests are aborted at the cap.
341
+ function hookExit() {
342
+ if (exitHooked)
343
+ return;
344
+ exitHooked = true;
345
+ process.once('beforeExit', () => {
346
+ const cap = setTimeout(() => { stopped = true; for (const c of inflight)
347
+ c.abort(); }, EXIT_FLUSH_MS);
348
+ cap.unref?.();
349
+ void flush().finally(() => clearTimeout(cap));
350
+ });
351
+ }
352
+ // Credentials are never sent, whatever `redact` says. They reach a recording when an app pastes a key into a
353
+ // prompt, a tool returns one, or a request carries one in its options. Each pattern needs the provider's
354
+ // fixed prefix, so ordinary text is never touched. The key name stays, so a reader knows one was there.
355
+ const SECRETS = [
356
+ ['anthropic-key', /\bsk-ant-[A-Za-z0-9_-]{20,}/g],
357
+ ['openai-key', /\bsk-(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{20,}/g],
358
+ ['stripe-key', /\b[rsp]k_(?:live|test)_[A-Za-z0-9]{16,}/g],
359
+ ['google-key', /\bAIza[A-Za-z0-9_-]{35}\b/g],
360
+ ['aws-key', /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g],
361
+ ['github-token', /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{40,})/g],
362
+ ['slack-token', /\bxox[abposr]-[A-Za-z0-9-]{10,}/g],
363
+ ['generalcontext-key', /\bgck_[a-f0-9]{48}\b/g],
364
+ ['jwt', /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g],
365
+ ['bearer-token', /(?<=\bBearer\s)[A-Za-z0-9._~+/-]{20,}=*/g],
366
+ // A password inside a connection string or URL: postgres://user:PASSWORD@host. Only the password goes.
367
+ ['password', /(?<=\b[a-z][a-z0-9+.-]{1,20}:\/\/[^\s:/@]{1,64}:)[^\s@/]{1,128}(?=@)/g],
368
+ ['private-key', /-----BEGIN [A-Z ]{0,20}PRIVATE KEY-----(?:[A-Za-z0-9+/=\s:,]|-(?!-)){1,16384}?-----END [A-Z ]{0,20}PRIVATE KEY-----/g],
369
+ ];
370
+ export function maskSecrets(text) {
371
+ if (text.length < 16)
372
+ return text;
373
+ return SECRETS.reduce((out, [kind, pattern]) => out.replace(pattern, `<${kind}>`), text);
374
+ }
375
+ // Request options that carry credentials or transport settings, not what the model was asked. Dropped from the
376
+ // recorded request: Gemini's httpOptions (per-request headers) and anything spelled like a header map.
377
+ const REQUEST_OPTIONS = new Set(['httpOptions', 'http_options', 'headers', 'extra_headers', 'extraHeaders', 'extra_query', 'extraQuery', 'abortSignal', 'signal']);
378
+ function withoutRequestOptions(value, depth = 0) {
379
+ if (!value || typeof value !== 'object' || depth > 3)
380
+ return value;
381
+ if (Array.isArray(value))
382
+ return value;
383
+ const out = {};
384
+ for (const [k, v] of Object.entries(value))
385
+ if (!REQUEST_OPTIONS.has(k))
386
+ out[k] = k === 'config' ? withoutRequestOptions(v, depth + 1) : v;
387
+ return out;
388
+ }
389
+ // Apply beforeSend and redaction. Returns the JSON to send, or null to drop.
390
+ function prepare(entry) {
391
+ let call = JSON.parse(entry.json);
392
+ if (config.beforeSend) {
393
+ try {
394
+ call = config.beforeSend(call);
395
+ }
396
+ catch (error) {
397
+ warnLimited('before-send', `beforeSend threw; a model call was dropped: ${error.message}`);
398
+ return null;
399
+ }
400
+ if (call == null)
401
+ return null;
402
+ }
403
+ const scrub = entry.scrub === undefined ? config.scrub : entry.scrub;
404
+ return JSON.stringify(scrubbed(call, scrub ? (text) => scrub(maskSecrets(text)) : maskSecrets));
405
+ }
406
+ // Answers worth retrying: the collector is busy (429) or failing (5xx). Anything else (a bad key, a body too
407
+ // large) will fail again the same way, so that batch is dropped with a warning.
408
+ const retryable = (status) => status === 429 || status === 408 || status >= 500;
409
+ // Retry-After in seconds (or an HTTP date), kept between 1 s and 5 minutes.
410
+ function retryAfterMs(value) {
411
+ const raw = Array.isArray(value) ? value[0] : value;
412
+ if (!raw)
413
+ return undefined;
414
+ const seconds = /^\d+$/.test(raw.trim()) ? Number(raw) * 1000 : Date.parse(raw) - Date.now();
415
+ return Number.isFinite(seconds) ? Math.min(300_000, Math.max(1000, seconds)) : undefined;
416
+ }
417
+ // While a caller awaits flush(), reportOutcome() or logEvent(), our sockets keep the process alive
418
+ // so the await can finish. Otherwise they are unref'd: background sending never keeps a finished
419
+ // process alive, even against a collector that accepts and never answers.
420
+ let waiters = 0;
421
+ const sockets = new Set();
422
+ function holdOpen(work) {
423
+ if (waiters++ === 0)
424
+ for (const socket of sockets)
425
+ socket.ref();
426
+ return work.finally(() => { if (--waiters === 0)
427
+ for (const socket of sockets)
428
+ socket.unref(); });
429
+ }
430
+ /**
431
+ * POST to the collector with node:http(s), not fetch, so the socket can be unref'd (see above).
432
+ * Redirects are never followed (node:http does not follow them), so the key never reaches another host.
433
+ */
434
+ function postRaw(url, body, signal) {
435
+ return new Promise((resolve, reject) => {
436
+ const target = new URL(url);
437
+ const send = target.protocol === 'https:' ? httpsRequest : httpRequest;
438
+ const req = send(target, {
439
+ method: 'POST', signal, agent: false,
440
+ headers: { Authorization: `Bearer ${apiKey()}`, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
441
+ }, res => {
442
+ res.resume(); // drain, so the socket is freed
443
+ res.on('end', () => resolve({ ok: (res.statusCode ?? 0) >= 200 && (res.statusCode ?? 0) < 300, status: res.statusCode ?? 0,
444
+ retryAfterMs: retryAfterMs(res.headers['retry-after']) }));
445
+ res.on('error', reject);
446
+ });
447
+ req.on('socket', socket => {
448
+ sockets.add(socket);
449
+ socket.once('close', () => sockets.delete(socket));
450
+ if (waiters === 0)
451
+ socket.unref();
452
+ });
453
+ req.on('error', reject);
454
+ req.end(body);
455
+ });
456
+ }
457
+ async function post(path, body, timeoutMs) {
458
+ const url = `${baseUrl()}${path}`;
459
+ if (!secureUrl(url)) {
460
+ warnLimited('insecure', `refusing to send to ${baseUrl()}: baseUrl must be https`);
461
+ return null;
462
+ }
463
+ if (stopped)
464
+ return null;
465
+ const controller = new AbortController();
466
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
467
+ timeout.unref?.();
468
+ inflight.add(controller);
469
+ try {
470
+ return await postRaw(url, body, controller.signal);
471
+ }
472
+ catch (error) {
473
+ warnLimited(`send:${path}`, `could not reach ${path}: ${error.message}`);
474
+ return null;
475
+ }
476
+ finally {
477
+ clearTimeout(timeout);
478
+ inflight.delete(controller);
479
+ }
480
+ }
481
+ function send() {
482
+ if (sending)
483
+ return sending;
484
+ sending = (async () => {
485
+ const envelope = JSON.stringify({ sdk: `node/${VERSION}`, app: config.appName });
486
+ while ((queue.length || retry.length) && !stopped) {
487
+ // Up to BATCH calls and MAX_BATCH_BYTES per request. Calls refused earlier go first, already prepared.
488
+ const parts = retry.splice(0, BATCH);
489
+ retryBytes = retry.reduce((sum, part) => sum + part.length, 0);
490
+ let size = envelope.length + 16 + parts.reduce((sum, part) => sum + part.length + 1, 0);
491
+ while (queue.length && parts.length < BATCH) {
492
+ const entry = queue[0];
493
+ let json;
494
+ try {
495
+ json = prepare(entry);
496
+ }
497
+ catch (error) {
498
+ warnLimited('prepare', `could not prepare a model call: ${error.message}`);
499
+ json = null;
500
+ }
501
+ const bytes = json === null ? 0 : Buffer.byteLength(json);
502
+ if (json !== null && bytes > MAX_CALL_BYTES) {
503
+ warnLimited('oversized', `a model call of ${bytes} bytes is over the ${MAX_CALL_BYTES} byte limit and was dropped`);
504
+ json = null;
505
+ }
506
+ if (json !== null && parts.length && size + bytes + 1 > MAX_BATCH_BYTES)
507
+ break; // send what we have; retry this one next
508
+ queue.shift();
509
+ queuedBytes -= entry.bytes;
510
+ if (json === null)
511
+ continue;
512
+ parts.push(json);
513
+ size += bytes + 1;
514
+ }
515
+ if (!parts.length)
516
+ continue;
517
+ const body = `{"calls":[${parts.join(',')}],${envelope.slice(1)}`;
518
+ const response = await post('/api/platform-collect/sdk', body, SEND_TIMEOUT_MS);
519
+ if (response && !response.ok && !retryable(response.status))
520
+ warnLimited('rejected', `${parts.length} calls not accepted: ${response.status}`);
521
+ if ((!response || (!response.ok && retryable(response.status))) && !stopped) {
522
+ // Busy, failing, unreachable, hung or timed out: put this batch back at the front, already prepared, and
523
+ // try again after a pause. Nothing recorded is lost to a deploy or a brief outage.
524
+ retry.unshift(...parts);
525
+ retryBytes += parts.reduce((sum, part) => sum + part.length, 0);
526
+ if (retryBytes > MAX_QUEUE_BYTES) {
527
+ const dropped = retry.splice(BATCH * 10);
528
+ retryBytes = retry.reduce((sum, part) => sum + part.length, 0);
529
+ warnLimited('queue-full', `${dropped.length} calls dropped while the collector was unavailable`);
530
+ }
531
+ const pause = response?.retryAfterMs ?? RETRY_PAUSE_MS;
532
+ // Unreachable, hung or timed out. Stop here instead of trying every queued batch in turn (15 s each),
533
+ // which kept the process alive for minutes after the app finished. The queue stays bounded and is
534
+ // retried after a pause, on flush(), or at exit.
535
+ pausedUntil = Date.now() + pause;
536
+ const resume = setTimeout(() => { if (queue.length || retry.length)
537
+ void send(); }, pause);
538
+ resume.unref?.();
539
+ break;
540
+ }
541
+ }
542
+ })().catch(() => { }).finally(() => { sending = null; });
543
+ return sending;
544
+ }
545
+ /**
546
+ * Wait until recorded calls are sent, for at most `timeoutMs` (default 10 s). Call before a serverless
547
+ * function or script exits. Resolves true when the queue was sent, false at the deadline. Never throws.
548
+ * Sending continues in the background after the deadline.
549
+ */
550
+ export async function flush(timeoutMs = FLUSH_TIMEOUT_MS) {
551
+ if (timer) {
552
+ clearTimeout(timer);
553
+ timer = null;
554
+ }
555
+ let deadline;
556
+ try {
557
+ const sent = send().then(() => queue.length === 0 && retry.length === 0, () => false);
558
+ // The deadline timer stays referenced: an awaited flush() must be able to finish and report.
559
+ const late = new Promise(resolve => { deadline = setTimeout(() => resolve(false), Math.max(0, timeoutMs)); });
560
+ return await holdOpen(Promise.race([sent, late]));
561
+ }
562
+ catch {
563
+ return false;
564
+ }
565
+ finally {
566
+ clearTimeout(deadline);
567
+ }
568
+ }
569
+ const scrubText = (value) => (config.scrub ? config.scrub(maskSecrets(value)) : maskSecrets(value));
570
+ /** Mark how a session ended. Returns true when the platform accepted it. Never throws. */
571
+ export async function reportOutcome(sessionId, outcome, note) {
572
+ try {
573
+ if (!apiKey()) {
574
+ warnLimited('no-key', 'GENERALCONTEXT_API_KEY is not set; outcome not reported');
575
+ return false;
576
+ }
577
+ await flush();
578
+ const body = { session_id: scrubText(String(sessionId)), outcome, note: note == null ? null : scrubText(note) };
579
+ const response = await holdOpen(post('/api/platform-collect/outcomes', JSON.stringify(body), DIRECT_TIMEOUT_MS));
580
+ return Boolean(response?.ok);
581
+ }
582
+ catch {
583
+ return false;
584
+ }
585
+ }
586
+ /** Record something that happened in your systems, such as 'order.refunded'. Pass a stable eventId so retries dedupe. Never throws. */
587
+ export async function logEvent(event) {
588
+ try {
589
+ if (!apiKey()) {
590
+ warnLimited('no-key', 'GENERALCONTEXT_API_KEY is not set; event not logged');
591
+ return false;
592
+ }
593
+ await flush();
594
+ const timestamp = event.timestamp instanceof Date ? event.timestamp.toISOString() : event.timestamp ?? new Date().toISOString();
595
+ const sessionId = scrubText(String(event.sessionId));
596
+ const properties = Object.fromEntries(Object.entries(event.properties ?? {}).map(([k, v]) => [k, typeof v === 'string' ? scrubText(v) : v]));
597
+ const body = { events: [{
598
+ event_id: event.eventId ?? randomUUID(), event_type: scrubText(event.eventType), timestamp,
599
+ session_id: sessionId, properties,
600
+ }] };
601
+ const response = await holdOpen(post('/api/platform-collect/events', JSON.stringify(body), DIRECT_TIMEOUT_MS));
602
+ return Boolean(response?.ok);
603
+ }
604
+ catch {
605
+ return false;
606
+ }
607
+ }
608
+ // Wrap an SDK create method. The SDK's own promise is returned untouched; recording happens on a side branch.
609
+ function recording(create, api, scrub) {
610
+ const wrapped = function (...args) {
611
+ const started = new Date().toISOString();
612
+ const at = scope.getStore();
613
+ const request = args[0];
614
+ // Mark the call so the fetch patch does not record it a second time.
615
+ const reply = scope.run({ ...at, inSdkCall: true }, () => create.apply(this, args));
616
+ if (!reply || typeof reply.then !== 'function')
617
+ return reply;
618
+ // The SDKs' APIPromise reads the body inside parseResponse. Helpers such as parse() build a new promise that
619
+ // calls it again, so keep the first result: the body is read once and both sides get the same object.
620
+ const api_ = reply;
621
+ if (typeof api_.parseResponse === 'function') {
622
+ try {
623
+ const parseOnce = api_.parseResponse;
624
+ const seen = new WeakMap();
625
+ const parse = function (...a) {
626
+ const key = a[1];
627
+ if (!key || typeof key !== 'object')
628
+ return parseOnce.apply(this, a);
629
+ if (!seen.has(key))
630
+ seen.set(key, parseOnce.apply(this, a));
631
+ return seen.get(key);
632
+ };
633
+ api_.parseResponse = parse;
634
+ const thenUnwrap = api_._thenUnwrap;
635
+ if (typeof thenUnwrap === 'function') {
636
+ api_._thenUnwrap = function (transform) {
637
+ const next = thenUnwrap.call(this, transform);
638
+ if (next && typeof next.parseResponse === 'function') {
639
+ next.parseResponse = async (client, props) => {
640
+ const parsed = await parse.call(reply, client, props);
641
+ const out = transform(parsed, props);
642
+ const id = parsed?._request_id;
643
+ if (id && out && typeof out === 'object' && !('_request_id' in out))
644
+ Object.defineProperty(out, '_request_id', { value: id, enumerable: false });
645
+ return out;
646
+ };
647
+ }
648
+ return next;
649
+ };
650
+ }
651
+ }
652
+ catch { /* leave the SDK promise as it was */ }
653
+ }
654
+ reply.then(response => {
655
+ const done = (result, error = null) => record({ api, request, response: api === 'gemini' ? plainGemini(result) : result, started_at: started, ended_at: new Date().toISOString(), error }, at, scrub);
656
+ if (request?.stream && (isStream(response) || isLegacyStream(response)))
657
+ observeStream(response, api, done);
658
+ else
659
+ done(response);
660
+ }, error => {
661
+ record({ api, request, response: null, started_at: started, ended_at: new Date().toISOString(), error: String(error?.message ?? error) }, at, scrub);
662
+ });
663
+ return reply;
664
+ };
665
+ wrapped[MARK] = true;
666
+ return wrapped;
667
+ }
668
+ const isStream = (value) => typeof value?.iterator === 'function';
669
+ // openai before 4.15 has no `iterator` field: the Stream class only defines [Symbol.asyncIterator].
670
+ const isLegacyStream = (value) => !!value && typeof value === 'object' && typeof value[Symbol.asyncIterator] === 'function';
671
+ // Both SDKs' Stream classes read chunks through `this.iterator`, including tee() and toReadableStream().
672
+ // Replacing it keeps the object, its type and its methods, and lets us see each chunk once.
673
+ // A stream the caller stops early (break, return or a throw inside for await, which aborts the request) or
674
+ // that fails is still recorded, with what was assembled so far and an error. The caller's exception is untouched.
675
+ function observeStream(stream, api, done) {
676
+ const legacy = !isStream(stream);
677
+ const source = legacy ? stream[Symbol.asyncIterator].bind(stream) : stream.iterator.bind(stream);
678
+ const observed = async function* () {
679
+ const assemble = api === 'anthropic' ? anthropicAssembler() : openAIAssembler();
680
+ let error = 'stream ended early: the caller stopped reading before the response completed';
681
+ try {
682
+ for await (const chunk of { [Symbol.asyncIterator]: source }) {
683
+ try {
684
+ assemble.add(chunk);
685
+ }
686
+ catch { /* never break the caller's stream */ }
687
+ yield chunk;
688
+ }
689
+ error = null;
690
+ }
691
+ catch (failure) {
692
+ error = String(failure?.message ?? failure);
693
+ throw failure;
694
+ }
695
+ finally {
696
+ let result = null;
697
+ try {
698
+ result = assemble.result();
699
+ }
700
+ catch { /* record the error alone */ }
701
+ done(result, error);
702
+ }
703
+ };
704
+ // An own property shadows the class method on this instance only.
705
+ if (legacy)
706
+ Object.defineProperty(stream, Symbol.asyncIterator, { value: observed, writable: true, configurable: true });
707
+ else
708
+ stream.iterator = observed;
709
+ }
710
+ function openAIAssembler() {
711
+ let model, id, usage = null, final = null, partial = null;
712
+ const items = [];
713
+ const choices = [];
714
+ return {
715
+ add(chunk) {
716
+ // Responses API events: the final response arrives whole.
717
+ // Keep the latest snapshot and finished output items too, so a stream stopped early still records what arrived.
718
+ if (chunk.type) {
719
+ if (chunk.type === 'response.completed')
720
+ final = chunk.response;
721
+ else if (chunk.response)
722
+ partial = chunk.response;
723
+ else if (chunk.type === 'response.output_item.done' && chunk.item)
724
+ items[chunk.output_index ?? items.length] = chunk.item;
725
+ return;
726
+ }
727
+ model ??= chunk.model;
728
+ id ??= chunk.id;
729
+ if (chunk.usage)
730
+ usage = chunk.usage;
731
+ for (const c of chunk.choices ?? []) {
732
+ const choice = choices[c.index] ??= { index: c.index, message: { role: 'assistant', content: null }, finish_reason: null };
733
+ const d = c.delta ?? {};
734
+ if (d.role)
735
+ choice.message.role = d.role;
736
+ if (d.content)
737
+ choice.message.content = (choice.message.content ?? '') + d.content;
738
+ for (const t of d.tool_calls ?? []) {
739
+ const calls = choice.message.tool_calls ??= [];
740
+ const call = calls[t.index] ??= { id: t.id, type: 'function', function: { name: '', arguments: '' } };
741
+ if (t.id)
742
+ call.id = t.id;
743
+ if (t.function?.name)
744
+ call.function.name += t.function.name;
745
+ if (t.function?.arguments)
746
+ call.function.arguments += t.function.arguments;
747
+ }
748
+ if (c.finish_reason)
749
+ choice.finish_reason = c.finish_reason;
750
+ }
751
+ },
752
+ result: () => final ?? (partial ? { ...partial, output: items.filter(Boolean) } : { id, object: 'chat.completion', model, choices: choices.filter(Boolean), usage }),
753
+ };
754
+ }
755
+ function anthropicAssembler() {
756
+ let message = null;
757
+ const json = [];
758
+ return {
759
+ add(event) {
760
+ if (event.type === 'message_start')
761
+ message = { ...event.message, content: [] };
762
+ else if (!message)
763
+ return;
764
+ else if (event.type === 'content_block_start') {
765
+ message.content[event.index] = { ...event.content_block };
766
+ json[event.index] = '';
767
+ }
768
+ else if (event.type === 'content_block_delta') {
769
+ const block = message.content[event.index], d = event.delta;
770
+ if (d.type === 'text_delta')
771
+ block.text = (block.text ?? '') + d.text;
772
+ else if (d.type === 'input_json_delta')
773
+ json[event.index] += d.partial_json;
774
+ else if (d.type === 'thinking_delta')
775
+ block.thinking = (block.thinking ?? '') + d.thinking;
776
+ }
777
+ else if (event.type === 'content_block_stop') {
778
+ const block = message.content[event.index];
779
+ if (json[event.index]) {
780
+ try {
781
+ block.input = JSON.parse(json[event.index]);
782
+ }
783
+ catch {
784
+ block.input = json[event.index];
785
+ }
786
+ }
787
+ }
788
+ else if (event.type === 'message_delta') {
789
+ Object.assign(message, event.delta);
790
+ message.usage = { ...message.usage, ...event.usage };
791
+ }
792
+ },
793
+ result: () => message,
794
+ };
795
+ }
796
+ // Record raw fetch POSTs to chat-completions endpoints made without a patched SDK.
797
+ function patchFetch() {
798
+ const current = globalThis.fetch;
799
+ if (typeof current !== 'function' || current[MARK])
800
+ return;
801
+ originalFetch = current;
802
+ const patched = async function (input, init) {
803
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
804
+ const method = (init?.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase();
805
+ const at = scope.getStore();
806
+ const watch = method === 'POST' && /\/chat\/completions\/?(\?.*)?$/.test(url) && !at?.inSdkCall && !url.startsWith(`${baseUrl()}/api/platform-collect/`);
807
+ if (!watch)
808
+ return current(input, init);
809
+ const started = new Date().toISOString();
810
+ let request = null;
811
+ try {
812
+ request = typeof init?.body === 'string' ? JSON.parse(init.body) : input instanceof Request ? await input.clone().json() : null;
813
+ }
814
+ catch { /* not JSON */ }
815
+ const base = { api: 'openai-chat', request, started_at: started };
816
+ let response;
817
+ try {
818
+ response = await current(input, init);
819
+ }
820
+ catch (error) {
821
+ record({ ...base, response: null, ended_at: new Date().toISOString(), error: String(error?.message ?? error) }, at);
822
+ throw error;
823
+ }
824
+ try {
825
+ const copy = response.clone();
826
+ void (async () => {
827
+ try {
828
+ const text = await copy.text();
829
+ const ended = new Date().toISOString();
830
+ if (!response.ok)
831
+ return record({ ...base, response: null, ended_at: ended, error: `HTTP ${response.status}: ${text.slice(0, 500)}` }, at);
832
+ const sse = (response.headers.get('content-type') ?? '').includes('text/event-stream');
833
+ record({ ...base, response: sse ? assembleSSE(text) : JSON.parse(text), ended_at: ended, error: null }, at);
834
+ }
835
+ catch (error) {
836
+ warn(`could not record a fetch call: ${error.message}`);
837
+ }
838
+ })();
839
+ }
840
+ catch { /* body already used */ }
841
+ return response;
842
+ };
843
+ patched[MARK] = true;
844
+ globalThis.fetch = patched;
845
+ }
846
+ function assembleSSE(text) {
847
+ const assemble = openAIAssembler();
848
+ for (const line of text.split('\n')) {
849
+ const data = line.startsWith('data:') ? line.slice(5).trim() : '';
850
+ if (data && data !== '[DONE]') {
851
+ try {
852
+ assemble.add(JSON.parse(data));
853
+ }
854
+ catch { /* skip bad event */ }
855
+ }
856
+ }
857
+ return assemble.result();
858
+ }
859
+ // generateContentStream resolves to an async generator. Hand the caller a generator that yields the same chunks.
860
+ function geminiStreamRecording(create, scrub) {
861
+ const wrapped = function (...args) {
862
+ const started = new Date().toISOString();
863
+ const at = scope.getStore();
864
+ const request = args[0];
865
+ const reply = scope.run({ ...at, inSdkCall: true }, () => create.apply(this, args));
866
+ if (!reply || typeof reply.then !== 'function')
867
+ return reply;
868
+ const fail = (error) => record({ api: 'gemini', request, response: null, started_at: started, ended_at: new Date().toISOString(), error: String(error?.message ?? error) }, at, scrub);
869
+ return reply.then(source => {
870
+ if (!source || typeof source[Symbol.asyncIterator] !== 'function')
871
+ return source;
872
+ return (async function* () {
873
+ const assemble = geminiAssembler();
874
+ let finished = false;
875
+ try {
876
+ for await (const chunk of source) {
877
+ try {
878
+ assemble.add(chunk);
879
+ }
880
+ catch { /* never break the caller's stream */ }
881
+ yield chunk;
882
+ }
883
+ finished = true;
884
+ }
885
+ catch (error) {
886
+ fail(error);
887
+ throw error;
888
+ }
889
+ finally {
890
+ if (finished)
891
+ record({ api: 'gemini', request, response: assemble.result(), started_at: started, ended_at: new Date().toISOString(), error: null }, at, scrub);
892
+ }
893
+ })();
894
+ }, error => { fail(error); throw error; });
895
+ };
896
+ wrapped[MARK] = true;
897
+ return wrapped;
898
+ }
899
+ // The response as plain JSON, without the raw HTTP headers the SDK attaches.
900
+ function plainGemini(response) {
901
+ if (!response || typeof response !== 'object')
902
+ return response;
903
+ const { sdkHttpResponse, ...rest } = response;
904
+ return rest;
905
+ }
906
+ function geminiAssembler() {
907
+ const out = { candidates: [] };
908
+ const parts = [];
909
+ let candidate = null;
910
+ return {
911
+ add(chunk) {
912
+ const c = plainGemini(chunk);
913
+ if (c.usageMetadata)
914
+ out.usageMetadata = c.usageMetadata;
915
+ if (c.modelVersion)
916
+ out.modelVersion = c.modelVersion;
917
+ if (c.responseId)
918
+ out.responseId = c.responseId;
919
+ const first = c.candidates?.[0];
920
+ if (!first)
921
+ return;
922
+ candidate ??= { content: { role: 'model', parts } };
923
+ if (first.content?.role)
924
+ candidate.content.role = first.content.role;
925
+ if (first.finishReason)
926
+ candidate.finishReason = first.finishReason;
927
+ for (const part of first.content?.parts ?? []) {
928
+ const last = parts[parts.length - 1];
929
+ if (typeof part.text === 'string' && last && typeof last.text === 'string' && Boolean(last.thought) === Boolean(part.thought) && Object.keys(part).every(k => k === 'text' || k === 'thought'))
930
+ last.text += part.text;
931
+ else
932
+ parts.push({ ...part });
933
+ }
934
+ },
935
+ result: () => ({ ...out, candidates: candidate ? [candidate] : [] }),
936
+ };
937
+ }
938
+ // Images and documents arrive as bytes. Send a marker in their place, never the bytes.
939
+ function omitBytes(value, depth = 0) {
940
+ if (value instanceof Uint8Array || value instanceof ArrayBuffer || ArrayBuffer.isView(value))
941
+ return { omitted: 'bytes' };
942
+ if (depth > 32 || value === null || typeof value !== 'object')
943
+ return value;
944
+ if (Array.isArray(value))
945
+ return value.map(item => omitBytes(item, depth + 1));
946
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, omitBytes(v, depth + 1)]));
947
+ }
948
+ const CONVERSE = new Set(['ConverseCommand', 'ConverseStreamCommand']);
949
+ function bedrockRecording(send, scrub) {
950
+ const wrapped = function (...args) {
951
+ const command = args[0];
952
+ const name = command?.constructor?.name ?? '';
953
+ const input = command?.input;
954
+ // By class name, or by input shape when a bundler renamed the classes. InvokeModel has a body, not messages.
955
+ const converse = CONVERSE.has(name) || (typeof input?.modelId === 'string' && Array.isArray(input?.messages) && !('body' in input));
956
+ if (!converse)
957
+ return send.apply(this, args);
958
+ const started = new Date().toISOString();
959
+ const at = scope.getStore();
960
+ let request = null;
961
+ try {
962
+ request = omitBytes(command?.input);
963
+ }
964
+ catch { /* record without the request */ }
965
+ const reply = scope.run({ ...at, inSdkCall: true }, () => send.apply(this, args));
966
+ if (!reply || typeof reply.then !== 'function')
967
+ return reply;
968
+ const done = (response, error) => record({ api: 'bedrock-converse', request, response, started_at: started, ended_at: new Date().toISOString(), error }, at, scrub);
969
+ reply.then(output => {
970
+ try {
971
+ const { $metadata, ...rest } = output ?? {};
972
+ const stream = rest.stream;
973
+ if (stream && typeof stream[Symbol.asyncIterator] === 'function') {
974
+ output.stream = (async function* () {
975
+ const assemble = converseAssembler();
976
+ let finished = false;
977
+ try {
978
+ for await (const event of stream) {
979
+ try {
980
+ assemble.add(event);
981
+ }
982
+ catch { /* never break the caller's stream */ }
983
+ yield event;
984
+ }
985
+ finished = true;
986
+ }
987
+ catch (error) {
988
+ done(null, String(error?.message ?? error));
989
+ throw error;
990
+ }
991
+ finally {
992
+ if (finished)
993
+ done(assemble.result(), null);
994
+ }
995
+ })();
996
+ }
997
+ else
998
+ done(omitBytes(rest), null);
999
+ }
1000
+ catch (error) {
1001
+ warnLimited('record', `could not record a model call: ${error.message}`);
1002
+ }
1003
+ }, error => done(null, String(error?.message ?? error)));
1004
+ return reply;
1005
+ };
1006
+ wrapped[MARK] = true;
1007
+ return wrapped;
1008
+ }
1009
+ function converseAssembler() {
1010
+ const content = [];
1011
+ const json = [];
1012
+ const out = { output: { message: { role: 'assistant', content } }, stopReason: null };
1013
+ return {
1014
+ add(event) {
1015
+ if (event.messageStart?.role)
1016
+ out.output.message.role = event.messageStart.role;
1017
+ const start = event.contentBlockStart;
1018
+ if (start) {
1019
+ const tool = start.start?.toolUse;
1020
+ content[start.contentBlockIndex ?? content.length] = tool ? { toolUse: { toolUseId: tool.toolUseId, name: tool.name, input: {} } } : {};
1021
+ }
1022
+ const delta = event.contentBlockDelta;
1023
+ if (delta) {
1024
+ const i = delta.contentBlockIndex ?? 0, d = delta.delta ?? {};
1025
+ const block = content[i] ??= {};
1026
+ if (typeof d.text === 'string')
1027
+ block.text = (block.text ?? '') + d.text;
1028
+ if (d.toolUse) {
1029
+ block.toolUse ??= { input: {} };
1030
+ json[i] = (json[i] ?? '') + (d.toolUse.input ?? '');
1031
+ }
1032
+ if (d.reasoningContent) {
1033
+ const r = block.reasoningContent ??= { reasoningText: { text: '' } };
1034
+ if (d.reasoningContent.text)
1035
+ r.reasoningText.text += d.reasoningContent.text;
1036
+ if (d.reasoningContent.signature)
1037
+ r.reasoningText.signature = d.reasoningContent.signature;
1038
+ }
1039
+ }
1040
+ const stop = event.contentBlockStop;
1041
+ if (stop && json[stop.contentBlockIndex] && content[stop.contentBlockIndex]?.toolUse) {
1042
+ try {
1043
+ content[stop.contentBlockIndex].toolUse.input = JSON.parse(json[stop.contentBlockIndex]);
1044
+ }
1045
+ catch {
1046
+ content[stop.contentBlockIndex].toolUse.input = json[stop.contentBlockIndex];
1047
+ }
1048
+ }
1049
+ if (event.messageStop)
1050
+ out.stopReason = event.messageStop.stopReason ?? null;
1051
+ if (event.metadata?.usage)
1052
+ out.usage = event.metadata.usage;
1053
+ if (event.metadata?.metrics)
1054
+ out.metrics = event.metadata.metrics;
1055
+ },
1056
+ result: () => ({ ...out, output: { message: { ...out.output.message, content: content.filter(Boolean) } } }),
1057
+ };
1058
+ }
1059
+ // ---- OpenAI Realtime (voice) ----------------------------------------------------------------
1060
+ //
1061
+ // A realtime session is one WebSocket carrying many responses. Each response.done becomes one 'openai-realtime'
1062
+ // call shaped like a Responses call: request { model, instructions, tools, input } and response { id, output, usage },
1063
+ // so the platform folds a whole phone call into one conversation. Only text is kept: transcripts, text, tool calls
1064
+ // and tool results. Audio bytes are never read into this state. Events are observed, never changed or delayed.
1065
+ const REALTIME_MODULES = ['openai/realtime/ws', 'openai/realtime/websocket'];
1066
+ const REALTIME_MAX_ITEMS = 200;
1067
+ // A finished response is recorded once the next response starts, the socket closes, or this long after
1068
+ // response.done, so an interruption that arrives while its audio is still playing is on the call.
1069
+ const REALTIME_HOLD_MS = 60_000;
1070
+ const RT_STATE = Symbol.for('generalcontext.realtime');
1071
+ const rtLive = new Set();
1072
+ let rtExitHooked = false;
1073
+ function patchRealtime(value) {
1074
+ const v = value;
1075
+ for (const cls of [v?.OpenAIRealtimeWS, v?.OpenAIRealtimeWebSocket, v?.default?.OpenAIRealtimeWS, v?.default?.OpenAIRealtimeWebSocket]) {
1076
+ const proto = cls?.prototype;
1077
+ if (!proto || typeof proto.send !== 'function' || typeof proto._emit !== 'function' || Object.prototype.hasOwnProperty.call(proto, MARK))
1078
+ continue;
1079
+ Object.defineProperty(proto, MARK, { value: true });
1080
+ patchRealtimeMethods(proto, undefined);
1081
+ // The constructor assigns this.socket in the app's async scope, so that is where the session id is read.
1082
+ // Automatic reconnects assign it again; the state (and its session) stays the same.
1083
+ const SOCKET = Symbol('socket');
1084
+ Object.defineProperty(proto, 'socket', {
1085
+ configurable: true,
1086
+ get() { return this[SOCKET]; },
1087
+ set(socket) {
1088
+ Object.defineProperty(this, SOCKET, { value: socket, writable: true, configurable: true });
1089
+ try {
1090
+ watchSocketClose(this, rtState(this, undefined), socket);
1091
+ }
1092
+ catch { /* never break the app */ }
1093
+ },
1094
+ });
1095
+ }
1096
+ }
1097
+ function patchRealtimeMethods(owner, scrub) {
1098
+ const emit = owner._emit, send = owner.send, sendRef = owner.sendResponseCreate;
1099
+ if (typeof emit === 'function' && !emit[MARK]) {
1100
+ owner._emit = function (name, ...args) {
1101
+ try {
1102
+ if (name === 'event')
1103
+ rtServerEvent(rtState(this, scrub), args[0]);
1104
+ else if (name === 'close')
1105
+ rtClose(rtState(this, scrub)); // connections with automatic reconnects
1106
+ }
1107
+ catch { /* never break the app */ }
1108
+ return emit.call(this, name, ...args);
1109
+ };
1110
+ owner._emit[MARK] = true;
1111
+ }
1112
+ for (const [key, original] of [['send', send], ['sendResponseCreate', sendRef]]) {
1113
+ if (typeof original !== 'function' || original[MARK])
1114
+ continue;
1115
+ owner[key] = function (event, ...rest) {
1116
+ try {
1117
+ rtClientEvent(rtState(this, scrub), event);
1118
+ }
1119
+ catch { /* never break the app */ }
1120
+ return original.call(this, event, ...rest);
1121
+ };
1122
+ owner[key][MARK] = true;
1123
+ }
1124
+ }
1125
+ function rtState(conn, scrub) {
1126
+ let state = conn[RT_STATE];
1127
+ if (!state) {
1128
+ state = { at: scope.getStore(), scrub, entries: [], seq: 0, speechStoppedAt: null, current: null, held: null, closed: false, bytesPerMs: 48, sentSeq: 0 };
1129
+ try {
1130
+ state.model = new URL(String(conn.url)).searchParams.get('model') ?? undefined;
1131
+ }
1132
+ catch { /* no url yet */ }
1133
+ Object.defineProperty(conn, RT_STATE, { value: state, configurable: true });
1134
+ }
1135
+ else if (scrub !== undefined && state.scrub === undefined)
1136
+ state.scrub = scrub;
1137
+ return state;
1138
+ }
1139
+ const rtWatched = new WeakSet();
1140
+ function watchSocketClose(conn, state, socket) {
1141
+ if (!socket || typeof socket !== 'object' || rtWatched.has(socket))
1142
+ return;
1143
+ rtWatched.add(socket);
1144
+ const onClose = () => { if (conn.socket === socket || conn.socket === undefined)
1145
+ rtClose(state); };
1146
+ if (typeof socket.on === 'function')
1147
+ socket.on('close', onClose);
1148
+ else if (typeof socket.addEventListener === 'function')
1149
+ socket.addEventListener('close', onClose);
1150
+ }
1151
+ const rtText = (item) => {
1152
+ const parts = [];
1153
+ for (const part of Array.isArray(item?.content) ? item.content : []) {
1154
+ const text = typeof part?.text === 'string' ? part.text : typeof part?.transcript === 'string' ? part.transcript : null;
1155
+ if (text)
1156
+ parts.push(text);
1157
+ }
1158
+ return parts.length ? parts.join('') : null;
1159
+ };
1160
+ const rtHasAudio = (item) => (Array.isArray(item?.content) ? item.content : []).some((p) => /audio/.test(String(p?.type)));
1161
+ // A realtime item as a Responses item, text only. Returns null when there is no text yet (audio awaiting its transcript).
1162
+ function rtConvert(item) {
1163
+ if (!item || typeof item !== 'object')
1164
+ return null;
1165
+ if (item.type === 'function_call')
1166
+ return { type: 'function_call', call_id: item.call_id, name: item.name, arguments: item.arguments ?? '' };
1167
+ if (item.type === 'function_call_output')
1168
+ return { type: 'function_call_output', call_id: item.call_id, output: item.output };
1169
+ if (item.type !== 'message')
1170
+ return null;
1171
+ const audio = rtHasAudio(item);
1172
+ // Caller audio with no transcript (input transcription is off, or it has not arrived yet) is kept as an
1173
+ // empty spoken turn, so the call still shows that the caller spoke. A transcript fills it in later.
1174
+ const text = rtText(item) ?? (audio && item.role !== 'assistant' ? '' : null);
1175
+ if (text == null)
1176
+ return null;
1177
+ if (item.role === 'assistant')
1178
+ return { type: 'message', role: 'assistant', content: [{ type: 'output_text', text }], ...(audio ? { audio: true } : {}) };
1179
+ return { role: item.role ?? 'user', content: [{ type: 'input_text', text }], ...(audio ? { audio: true } : {}) };
1180
+ }
1181
+ function rtEntry(state, id, match) {
1182
+ let entry = id ? state.entries.find(e => e.id === id) : undefined;
1183
+ if (!entry && match)
1184
+ entry = state.entries.find(e => !e.id && match(e));
1185
+ if (entry) {
1186
+ if (id)
1187
+ entry.id = id;
1188
+ return entry;
1189
+ }
1190
+ entry = { id: id ?? null, item: null, seq: ++state.seq };
1191
+ state.entries.push(entry);
1192
+ if (state.entries.length > REALTIME_MAX_ITEMS)
1193
+ state.entries.splice(0, state.entries.length - REALTIME_MAX_ITEMS);
1194
+ return entry;
1195
+ }
1196
+ function rtUpsert(state, raw, responseId) {
1197
+ const id = typeof raw?.id === 'string' ? raw.id : null;
1198
+ const converted = rtConvert(raw);
1199
+ // The app's own conversation.item.create usually has no id. Match the server's echo of it by content.
1200
+ const same = converted && JSON.stringify(converted);
1201
+ const entry = rtEntry(state, id, e => !!same && !!e.item && JSON.stringify(e.item) === same);
1202
+ // An untranscribed echo of an item never erases a transcript that already arrived.
1203
+ const blank = converted && 'role' in converted && converted.role !== 'assistant' && !converted.content?.[0]?.text;
1204
+ if (converted && !(blank && entry.item))
1205
+ entry.item = { ...entry.item, ...converted };
1206
+ if (responseId)
1207
+ entry.response = responseId;
1208
+ }
1209
+ function rtServerEvent(state, event) {
1210
+ const type = typeof event?.type === 'string' ? event.type : '';
1211
+ const now = new Date().toISOString();
1212
+ switch (type) {
1213
+ case 'session.created':
1214
+ case 'session.updated': {
1215
+ const s = event.session ?? {};
1216
+ if (typeof s.model === 'string')
1217
+ state.model = s.model;
1218
+ if ('instructions' in s)
1219
+ state.instructions = s.instructions;
1220
+ if ('tools' in s)
1221
+ state.tools = s.tools;
1222
+ rtFormat(state, s);
1223
+ break;
1224
+ }
1225
+ case 'conversation.item.added':
1226
+ case 'conversation.item.created':
1227
+ case 'conversation.item.done': {
1228
+ const r = state.current;
1229
+ const own = r && typeof event.item?.id === 'string' && r.output_ids.has(event.item.id) ? r.id : undefined;
1230
+ rtUpsert(state, event.item, own);
1231
+ break;
1232
+ }
1233
+ case 'conversation.item.input_audio_transcription.completed': {
1234
+ const entry = rtEntry(state, event.item_id);
1235
+ entry.item = { role: 'user', content: [{ type: 'input_text', text: String(event.transcript ?? '') }], audio: true, at: now };
1236
+ break;
1237
+ }
1238
+ // Every user turn starts with speech_started. It interrupts the response being generated, or the last one
1239
+ // while its audio is still playing (audio length estimated from the delta sizes, the bytes are not kept).
1240
+ case 'input_audio_buffer.speech_started': {
1241
+ const r = state.current ?? state.held;
1242
+ if (r && (r === state.current || Date.now() < rtPlaybackEnd(state, r)))
1243
+ r.voice.interrupted = true;
1244
+ break;
1245
+ }
1246
+ case 'input_audio_buffer.speech_stopped':
1247
+ state.speechStoppedAt = now;
1248
+ break;
1249
+ case 'response.created': {
1250
+ rtRelease(state);
1251
+ if (state.current)
1252
+ rtFinish(state, 'a new response started before response.done');
1253
+ state.current = rtStart(state, event.response?.id, now);
1254
+ break;
1255
+ }
1256
+ case 'response.output_item.added':
1257
+ case 'response.output_item.done':
1258
+ rtOwn(state, event.response_id, event.item?.id);
1259
+ rtUpsert(state, event.item, rtResponseFor(state, event.response_id)?.id);
1260
+ break;
1261
+ case 'response.output_audio.delta':
1262
+ case 'response.audio.delta': {
1263
+ const r = rtResponseFor(state, event.response_id);
1264
+ if (r && !r.voice.first_audio_at) {
1265
+ r.voice.first_audio_at = now;
1266
+ r.firstAudioMs = Date.now();
1267
+ // The caller has been waiting since they stopped speaking, through any silent response before
1268
+ // this one (a tool call): the wait ends at the first audio they hear.
1269
+ if (state.speechStoppedAt)
1270
+ r.voice.speech_stopped_at = state.speechStoppedAt;
1271
+ state.speechStoppedAt = null;
1272
+ }
1273
+ if (r && typeof event.delta === 'string')
1274
+ r.audioBytes += Math.floor(event.delta.length * 3 / 4);
1275
+ break;
1276
+ }
1277
+ case 'response.output_audio_transcript.done':
1278
+ case 'response.audio_transcript.done':
1279
+ case 'response.output_text.done':
1280
+ case 'response.text.done': {
1281
+ const r = rtResponseFor(state, event.response_id);
1282
+ rtOwn(state, event.response_id, event.item_id);
1283
+ const entry = rtEntry(state, event.item_id);
1284
+ const text = String(event.transcript ?? event.text ?? '');
1285
+ const audio = type.includes('audio');
1286
+ entry.item = { type: 'message', role: 'assistant', content: [{ type: 'output_text', text }], ...(audio ? { audio: true } : {}) };
1287
+ if (r)
1288
+ entry.response = r.id;
1289
+ break;
1290
+ }
1291
+ case 'response.function_call_arguments.done': {
1292
+ rtOwn(state, event.response_id, event.item_id);
1293
+ const entry = rtEntry(state, event.item_id);
1294
+ entry.item = { type: 'function_call', call_id: event.call_id, name: event.name, arguments: event.arguments ?? '' };
1295
+ const r = rtResponseFor(state, event.response_id);
1296
+ if (r)
1297
+ entry.response = r.id;
1298
+ break;
1299
+ }
1300
+ case 'conversation.item.truncated':
1301
+ rtTruncate(state, event.item_id, event.audio_end_ms);
1302
+ break;
1303
+ case 'error': {
1304
+ const r = state.current;
1305
+ if (r)
1306
+ r.error = `realtime error: ${String(event.error?.message ?? 'unknown error')}${event.error?.code ? ` (${event.error.code})` : ''}`;
1307
+ break;
1308
+ }
1309
+ case 'response.done': {
1310
+ const res = event.response ?? {};
1311
+ let r = rtResponseFor(state, res.id);
1312
+ if (!r)
1313
+ r = state.current = rtStart(state, res.id, now);
1314
+ for (const item of Array.isArray(res.output) ? res.output : []) {
1315
+ rtOwn(state, r.id, item?.id);
1316
+ rtUpsert(state, item, r.id);
1317
+ }
1318
+ r.ended_at = now;
1319
+ r.usage = res.usage ?? null;
1320
+ if (res.status === 'failed' && !r.error)
1321
+ r.error = `response failed: ${JSON.stringify(res.status_details ?? null)}`;
1322
+ if (res.status === 'cancelled')
1323
+ r.voice.interrupted = true;
1324
+ if (state.current === r)
1325
+ state.current = null;
1326
+ rtHold(state, r);
1327
+ break;
1328
+ }
1329
+ }
1330
+ }
1331
+ function rtClientEvent(state, event) {
1332
+ switch (event?.type) {
1333
+ case 'session.update': {
1334
+ const s = event.session ?? {};
1335
+ if (typeof s.model === 'string')
1336
+ state.model = s.model;
1337
+ if ('instructions' in s)
1338
+ state.instructions = s.instructions;
1339
+ if ('tools' in s)
1340
+ state.tools = s.tools;
1341
+ rtFormat(state, s);
1342
+ break;
1343
+ }
1344
+ case 'conversation.item.create':
1345
+ rtUpsert(state, event.item);
1346
+ break;
1347
+ case 'response.create': {
1348
+ const r = event.response ?? {};
1349
+ if ('instructions' in r || 'tools' in r)
1350
+ state.next = { instructions: r.instructions, tools: r.tools };
1351
+ break;
1352
+ }
1353
+ case 'response.cancel':
1354
+ rtInterrupt(state);
1355
+ break;
1356
+ case 'conversation.item.truncate':
1357
+ rtTruncate(state, event.item_id, event.audio_end_ms);
1358
+ break;
1359
+ }
1360
+ }
1361
+ function rtStart(state, id, now) {
1362
+ const r = {
1363
+ id: typeof id === 'string' ? id : `resp_${randomUUID()}`, seq: ++state.seq, started_at: now, error: null, output_ids: new Set(), audioBytes: 0,
1364
+ voice: { speech_stopped_at: state.speechStoppedAt, first_audio_at: null, interrupted: false },
1365
+ };
1366
+ if (state.next) {
1367
+ r.instructions = state.next.instructions;
1368
+ r.tools = state.next.tools;
1369
+ state.next = undefined;
1370
+ }
1371
+ rtLive.add(state);
1372
+ return r;
1373
+ }
1374
+ // Output audio is pcm16 at 24 kHz (48 bytes per ms) unless the session picked G.711 (8 bytes per ms).
1375
+ function rtFormat(state, session) {
1376
+ const format = session?.audio?.output?.format?.type ?? session?.output_audio_format;
1377
+ if (typeof format === 'string')
1378
+ state.bytesPerMs = /pcmu|pcma|g711/.test(format) ? 8 : 48;
1379
+ }
1380
+ const rtPlaybackEnd = (state, r) => r.firstAudioMs === undefined ? 0 : r.firstAudioMs + r.audioBytes / state.bytesPerMs;
1381
+ const rtResponseFor = (state, id) => [state.current, state.held].find(r => r && (!id || r.id === id)) ?? null;
1382
+ function rtOwn(state, responseId, itemId) {
1383
+ const r = rtResponseFor(state, responseId);
1384
+ if (r && typeof itemId === 'string')
1385
+ r.output_ids.add(itemId);
1386
+ }
1387
+ // The response the caller is hearing: the one in flight, else the last finished one still playing.
1388
+ function rtInterrupt(state) {
1389
+ const r = state.current ?? state.held;
1390
+ if (r)
1391
+ r.voice.interrupted = true;
1392
+ }
1393
+ function rtTruncate(state, itemId, ms) {
1394
+ const r = [state.current, state.held].find(x => x && typeof itemId === 'string' && x.output_ids.has(itemId)) ?? state.current ?? state.held;
1395
+ if (!r)
1396
+ return;
1397
+ r.voice.interrupted = true;
1398
+ if (typeof ms === 'number')
1399
+ r.voice.truncated_at_ms = ms;
1400
+ }
1401
+ function rtHold(state, r) {
1402
+ rtRelease(state);
1403
+ state.held = r;
1404
+ r.timer = setTimeout(() => { if (state.held === r)
1405
+ rtRelease(state); }, REALTIME_HOLD_MS);
1406
+ r.timer.unref?.();
1407
+ if (!rtExitHooked) {
1408
+ rtExitHooked = true;
1409
+ process.once('beforeExit', () => {
1410
+ for (const s of rtLive) {
1411
+ rtRelease(s);
1412
+ }
1413
+ if (queue.length)
1414
+ void flush(EXIT_FLUSH_MS);
1415
+ });
1416
+ }
1417
+ }
1418
+ function rtRelease(state) {
1419
+ const r = state.held;
1420
+ if (!r)
1421
+ return;
1422
+ state.held = null;
1423
+ if (r.timer)
1424
+ clearTimeout(r.timer);
1425
+ rtRecord(state, r);
1426
+ if (!state.current)
1427
+ rtLive.delete(state);
1428
+ }
1429
+ function rtFinish(state, error) {
1430
+ const r = state.current;
1431
+ if (!r)
1432
+ return;
1433
+ state.current = null;
1434
+ r.ended_at = new Date().toISOString();
1435
+ r.error ??= error;
1436
+ rtRecord(state, r);
1437
+ }
1438
+ function rtClose(state) {
1439
+ if (state.closed)
1440
+ return;
1441
+ state.closed = true;
1442
+ rtRelease(state);
1443
+ rtFinish(state, 'connection closed before response.done');
1444
+ rtLive.delete(state);
1445
+ }
1446
+ function rtRecord(state, r) {
1447
+ try {
1448
+ const input = [], output = [];
1449
+ // Timing goes on the stored assistant item too, so every later call's input still carries it.
1450
+ const stopped = r.voice.speech_stopped_at ? Date.parse(r.voice.speech_stopped_at) : NaN;
1451
+ const audio = r.voice.first_audio_at ? Date.parse(r.voice.first_audio_at) : NaN;
1452
+ const timing = {
1453
+ latency_ms: Number.isFinite(stopped) && Number.isFinite(audio) ? audio - stopped : null, interrupted: r.voice.interrupted,
1454
+ };
1455
+ if (r.voice.truncated_at_ms !== undefined)
1456
+ timing.truncated_at_ms = r.voice.truncated_at_ms;
1457
+ for (const e of state.entries) {
1458
+ if (!e.item)
1459
+ continue;
1460
+ const mine = e.response === r.id || (e.id && r.output_ids.has(e.id));
1461
+ if (mine && e.item.role === 'assistant')
1462
+ e.item = { ...e.item, voice: timing };
1463
+ if (mine)
1464
+ output.push(e.item);
1465
+ else if (e.seq < r.seq && e.seq > state.sentSeq)
1466
+ input.push(e.item);
1467
+ }
1468
+ const request = {
1469
+ model: state.model ?? null,
1470
+ instructions: r.instructions ?? state.instructions ?? null,
1471
+ tools: r.tools ?? state.tools ?? [],
1472
+ input,
1473
+ };
1474
+ // After the first reply, only what is new since the last one, chained to it, as the Responses API does.
1475
+ if (state.lastId)
1476
+ request.previous_response_id = state.lastId;
1477
+ state.lastId = r.id;
1478
+ state.sentSeq = Math.max(state.sentSeq, ...state.entries.filter(e => e.item && (e.seq < r.seq || e.response === r.id || (e.id && r.output_ids.has(e.id)))).map(e => e.seq));
1479
+ record({
1480
+ api: 'openai-realtime', request, response: { id: r.id, output, usage: r.usage ?? null },
1481
+ started_at: r.started_at, ended_at: r.ended_at ?? new Date().toISOString(), error: r.error, voice: { ...r.voice },
1482
+ }, state.at?.tags && !state.at.tags.channel ? { ...state.at, tags: { ...state.at.tags, channel: 'voice' } } : state.at, state.scrub);
1483
+ }
1484
+ catch (error) {
1485
+ warnLimited('record', `could not record a realtime response: ${error.message}`);
1486
+ }
1487
+ }
1488
+ const PATTERNS = [
1489
+ // Bounded repeats and a token-start lookbehind keep this linear on adversarial input.
1490
+ ['email', /(?<![A-Za-z0-9._%+-])[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9-]{1,63}(?:\.[A-Za-z0-9-]{1,63}){0,8}\.[A-Za-z]{2,24}/g],
1491
+ ['card', /\b(?:\d[ -]?){13,16}\b/g],
1492
+ ['ssn', /\b\d{3}-\d{2}-\d{4}\b/g],
1493
+ ['phone', /(?<![\w])\+?1?[\s.-]?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}(?![\w])/g],
1494
+ ];
1495
+ function redactor(redact) {
1496
+ if (typeof redact === 'function')
1497
+ return redact;
1498
+ if (!redact)
1499
+ return null;
1500
+ let salt = process.env.GENERALCONTEXT_REDACT_SALT;
1501
+ if (!salt) {
1502
+ warn('GENERALCONTEXT_REDACT_SALT is not set; placeholders will differ between processes');
1503
+ salt = randomBytes(16).toString('hex');
1504
+ }
1505
+ const placeholder = (kind, value) => {
1506
+ const digits = value.replace(/\D/g, '');
1507
+ const normal = kind === 'email' ? value.toLowerCase() : kind === 'phone' ? digits.slice(-10) : digits;
1508
+ return `<${kind}:${createHmac('sha256', salt).update(normal).digest('hex').slice(0, 6)}>`;
1509
+ };
1510
+ return text => PATTERNS.reduce((result, [kind, pattern]) => result.replace(pattern, match => placeholder(kind, match)), text);
1511
+ }
1512
+ function scrubbed(value, scrub) {
1513
+ if (!scrub)
1514
+ return value;
1515
+ if (typeof value === 'string')
1516
+ return scrub(value);
1517
+ if (Array.isArray(value))
1518
+ return value.map(item => scrubbed(item, scrub));
1519
+ if (value && typeof value === 'object')
1520
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, scrubbed(item, scrub)]));
1521
+ return value;
1522
+ }
1523
+ /**
1524
+ * Record one client instance only, without initialize(). Returns the same client.
1525
+ * `redact` applies to this client only. If initialize() already patched the SDK, its settings apply instead.
1526
+ */
1527
+ export function wrap(client, options = {}) {
1528
+ if (disabledByEnv())
1529
+ return client;
1530
+ const scrub = options.redact === undefined ? undefined : redactor(options.redact);
1531
+ const target = client;
1532
+ const owners = [];
1533
+ if (target.chat?.completions?.create)
1534
+ owners.push([target.chat.completions, 'openai-chat']);
1535
+ if (target.responses?.create)
1536
+ owners.push([target.responses, 'openai-responses']);
1537
+ if (!target.chat && target.messages?.create)
1538
+ owners.push([target.messages, 'anthropic']);
1539
+ if (!target.chat && target.beta?.messages?.create)
1540
+ owners.push([target.beta.messages, 'anthropic']);
1541
+ const models = target.models;
1542
+ const gemini = typeof models?.generateContent === 'function';
1543
+ const bedrock = typeof target.send === 'function' && target.config && !gemini && !owners.length;
1544
+ // An OpenAIRealtimeWS or OpenAIRealtimeWebSocket connection. Its session is the one wrap() runs in.
1545
+ const realtime = typeof target.send === 'function' && typeof target._emit === 'function' && 'socket' in target;
1546
+ if (realtime) {
1547
+ patchRealtimeMethods(target, scrub);
1548
+ try {
1549
+ watchSocketClose(target, rtState(target, scrub), target.socket);
1550
+ }
1551
+ catch { /* never break the app */ }
1552
+ return client;
1553
+ }
1554
+ if (!owners.length && !gemini && !bedrock)
1555
+ throw new TypeError('wrap expects an OpenAI, Anthropic, Google Gen AI or Bedrock Runtime client, or an OpenAI Realtime connection');
1556
+ for (const [owner, api] of owners) {
1557
+ if (!owner.create[MARK])
1558
+ owner.create = recording(owner.create.bind(owner), api, scrub);
1559
+ }
1560
+ if (gemini && !models.generateContent[MARK])
1561
+ models.generateContent = recording(models.generateContent.bind(models), 'gemini', scrub);
1562
+ if (gemini && typeof models.generateContentStream === 'function' && !models.generateContentStream[MARK])
1563
+ models.generateContentStream = geminiStreamRecording(models.generateContentStream.bind(models), scrub);
1564
+ if (bedrock && !target.send[MARK])
1565
+ target.send = bedrockRecording(target.send.bind(target), scrub);
1566
+ return client;
1567
+ }