@oxygen-agent/cli 1.936.1 → 1.982.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 (71) hide show
  1. package/README.md +1 -1
  2. package/dist/admin-primary-providers-render.js +9 -1
  3. package/dist/cli-values.d.ts +14 -0
  4. package/dist/cli-values.js +26 -0
  5. package/dist/command-manifest.js +30 -2
  6. package/dist/functions-commands.js +13 -5
  7. package/dist/help.js +2 -0
  8. package/dist/index.js +1509 -290
  9. package/dist/knowledge-repository-commands.d.ts +6 -0
  10. package/dist/knowledge-repository-commands.js +198 -0
  11. package/dist/skills.js +20 -0
  12. package/dist/ugc-commands.js +470 -15
  13. package/node_modules/@oxygen/recipe-sdk/dist/index.d.ts +2 -0
  14. package/node_modules/@oxygen/shared/dist/byok-connect.d.ts +11 -6
  15. package/node_modules/@oxygen/shared/dist/byok-connect.js +14 -6
  16. package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +8 -0
  17. package/node_modules/@oxygen/shared/dist/capability-discovery.js +152 -20
  18. package/node_modules/@oxygen/shared/dist/copilot-errors.js +3 -0
  19. package/node_modules/@oxygen/shared/dist/copilot-journeys.d.ts +19 -1
  20. package/node_modules/@oxygen/shared/dist/copilot-journeys.generated.d.ts +19 -0
  21. package/node_modules/@oxygen/shared/dist/copilot-journeys.generated.js +26 -0
  22. package/node_modules/@oxygen/shared/dist/copilot-journeys.js +8 -41
  23. package/node_modules/@oxygen/shared/dist/email-dsn.d.ts +60 -0
  24. package/node_modules/@oxygen/shared/dist/email-dsn.js +120 -0
  25. package/node_modules/@oxygen/shared/dist/email-warmup-readiness.d.ts +64 -0
  26. package/node_modules/@oxygen/shared/dist/email-warmup-readiness.js +90 -0
  27. package/node_modules/@oxygen/shared/dist/inbox-avatar-url.d.ts +28 -0
  28. package/node_modules/@oxygen/shared/dist/inbox-avatar-url.js +57 -0
  29. package/node_modules/@oxygen/shared/dist/index.d.ts +10 -0
  30. package/node_modules/@oxygen/shared/dist/index.js +10 -0
  31. package/node_modules/@oxygen/shared/dist/knowledge-bases.d.ts +74 -0
  32. package/node_modules/@oxygen/shared/dist/knowledge-bases.js +456 -0
  33. package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.d.ts +56 -48
  34. package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.js +50 -49
  35. package/node_modules/@oxygen/shared/dist/knowledge-repository.d.ts +22 -0
  36. package/node_modules/@oxygen/shared/dist/knowledge-repository.js +121 -0
  37. package/node_modules/@oxygen/shared/dist/knowledge-vault-markdown.d.ts +20 -0
  38. package/node_modules/@oxygen/shared/dist/knowledge-vault-markdown.js +155 -0
  39. package/node_modules/@oxygen/shared/dist/langfuse.d.ts +8 -3
  40. package/node_modules/@oxygen/shared/dist/langfuse.js +177 -130
  41. package/node_modules/@oxygen/shared/dist/llm-payload.d.ts +10 -0
  42. package/node_modules/@oxygen/shared/dist/llm-payload.js +54 -0
  43. package/node_modules/@oxygen/shared/dist/llm-usage.d.ts +11 -0
  44. package/node_modules/@oxygen/shared/dist/llm-usage.js +30 -0
  45. package/node_modules/@oxygen/shared/dist/mailbox-import.d.ts +10 -0
  46. package/node_modules/@oxygen/shared/dist/mailbox-import.js +53 -0
  47. package/node_modules/@oxygen/shared/dist/plan-limits.d.ts +8 -0
  48. package/node_modules/@oxygen/shared/dist/plan-limits.js +8 -0
  49. package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +1 -1
  50. package/node_modules/@oxygen/shared/dist/pricing-sheet.js +1 -1
  51. package/node_modules/@oxygen/shared/dist/product-analytics-core.d.ts +98 -0
  52. package/node_modules/@oxygen/shared/dist/product-analytics-core.js +159 -0
  53. package/node_modules/@oxygen/shared/dist/product-analytics-environment.d.ts +18 -0
  54. package/node_modules/@oxygen/shared/dist/product-analytics-environment.js +46 -0
  55. package/node_modules/@oxygen/shared/dist/product-analytics-events.d.ts +116 -0
  56. package/node_modules/@oxygen/shared/dist/product-analytics-events.js +120 -0
  57. package/node_modules/@oxygen/shared/dist/recipes.d.ts +6 -0
  58. package/node_modules/@oxygen/shared/dist/recipes.js +23 -0
  59. package/node_modules/@oxygen/shared/dist/sequences.d.ts +126 -2
  60. package/node_modules/@oxygen/shared/dist/sequences.js +280 -4
  61. package/node_modules/@oxygen/shared/dist/ugc-amplification-identity.d.ts +2 -0
  62. package/node_modules/@oxygen/shared/dist/ugc-amplification-identity.js +24 -0
  63. package/node_modules/@oxygen/shared/dist/ugc.d.ts +29 -1
  64. package/node_modules/@oxygen/shared/dist/user-capability-routing.js +8 -1
  65. package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
  66. package/node_modules/@oxygen/shared/dist/version.js +3 -1
  67. package/node_modules/@oxygen/shared/dist/workspace-file-storage.d.ts +6 -2
  68. package/node_modules/@oxygen/shared/dist/workspace-file-storage.js +15 -4
  69. package/node_modules/@oxygen/shared/package.json +15 -0
  70. package/node_modules/@oxygen/workflows/dist/graph/lint.js +22 -0
  71. package/package.json +2 -1
@@ -21,11 +21,9 @@
21
21
  // * Trace ids stay deterministic: sha256(seed) — same trace per turn/run, so
22
22
  // lease-reclaim replays still converge onto ONE trace. See
23
23
  // llmTraceIdForSeed.
24
- // * Observation ids can NO LONGER be chosen. v5 observation ids are W3C span
25
- // ids minted by OTel. The caller's stable id (`gen:<turn>:<n>`, `tool:…`)
26
- // is preserved as metadata.oxygen_observation_id for correlation, but it no
27
- // longer upserts: a replayed slice appends duplicate observations to the
28
- // same trace instead of overwriting them.
24
+ // * A private OTel id generator emits one real deterministic root per seed.
25
+ // Child ids identify physical observations; oxygen_observation_id preserves
26
+ // the caller's logical identity without conflating repeated executions.
29
27
  // * v5 is observations-first: correlating attributes (userId, sessionId,
30
28
  // tags) must ride EVERY observation, not just the root, or per-session cost
31
29
  // rollups miss the cost-bearing generations. They are applied through
@@ -34,7 +32,9 @@
34
32
  // * Trace-level input/output is deprecated in v5. Overall IO goes on the ROOT
35
33
  // observation instead; setTraceIO()/setActiveTraceIO() are deliberately not
36
34
  // used here.
37
- import { createHash } from "node:crypto";
35
+ import { createHash, randomBytes } from "node:crypto";
36
+ import { OXYGEN_VERSION } from "./version.js";
37
+ import { prepareLlmPayload } from "./llm-payload.js";
38
38
  import { log } from "./log.js";
39
39
  const FLUSH_TIMEOUT_MS = 5_000;
40
40
  const WARN_THROTTLE_MS = 30_000;
@@ -45,9 +45,7 @@ const WARN_THROTTLE_MS = 30_000;
45
45
  * meant every flush that ran long lost its spans instead of finishing late.
46
46
  */
47
47
  const LANGFUSE_EXPORT_TIMEOUT_SECONDS = 10;
48
- // Defensive per-field bound, well under Langfuse's ~1 MB event cap. Copilot
49
- // transcripts max out around 150 KB; anything larger is truncated with an
50
- // explicit marker rather than risking a rejected ingestion batch.
48
+ // Scores are API strings; unlike observation payloads they cannot use chunks.
51
49
  const MAX_JSON_FIELD_CHARS = 400_000;
52
50
  /**
53
51
  * FAIL CLOSED: LLM tracing is on only when OXYGEN_LLM_TRACING_ENABLED is exactly
@@ -100,42 +98,13 @@ export function resolveLlmTracingEnvironment(env = process.env) {
100
98
  export function llmTraceIdForSeed(seed) {
101
99
  return createHash("sha256").update(seed, "utf8").digest("hex").slice(0, 32);
102
100
  }
103
- /**
104
- * Deterministic synthetic parent span id for one trace.
105
- *
106
- * v5 does not let a caller choose observation ids, and the worker emits a single
107
- * run's observations from different processes and slices — so there is no real
108
- * root span id to nest under. Every observation of a run instead attaches to
109
- * this stable pseudo-parent, which reproduces EXACTLY the flat shape the v3
110
- * adapter already produced (v3 passed only `traceId`, never
111
- * `parentObservationId`, so observations were already siblings of the trace).
112
- */
101
+ /** The real root's stable W3C span id, also used across worker resumes. */
113
102
  function rootSpanIdForSeed(seed) {
114
103
  return createHash("sha256").update(`langfuse-root:${seed}`, "utf8").digest("hex").slice(0, 16);
115
104
  }
116
- // Bound one JSON-bearing field. Over the cap → an explicit truncation marker
117
- // (never a silently clipped payload that parses as complete).
118
- function boundJsonField(value) {
119
- if (value === undefined || value === null)
120
- return value;
121
- let serialized;
122
- try {
123
- serialized = JSON.stringify(value) ?? "";
124
- }
125
- catch {
126
- return { truncated: true, reason: "unserializable" };
127
- }
128
- if (serialized.length <= MAX_JSON_FIELD_CHARS)
129
- return value;
130
- return {
131
- truncated: true,
132
- chars: serialized.length,
133
- preview: serialized.slice(0, MAX_JSON_FIELD_CHARS),
134
- };
135
- }
136
105
  // A score's `comment` is free text (a thumbs-down reason is user-authored and
137
106
  // unbounded) and the API types it as a STRING, so it cannot take
138
- // boundJsonField's `{truncated, preview}` envelope. It gets the same
107
+ // a recoverable payload envelope. It gets the same
139
108
  // MAX_JSON_FIELD_CHARS ceiling and the same "explicit marker, never a silent
140
109
  // clip" rule, with the marker counted INSIDE the cap so the bound holds.
141
110
  const COMMENT_TRUNCATION_MARKER = "…[truncated]";
@@ -149,13 +118,13 @@ function compact(body) {
149
118
  for (const [key, value] of Object.entries(body)) {
150
119
  if (value === undefined || value === null)
151
120
  continue;
152
- out[key] = key === "input" || key === "output" ? boundJsonField(value) : value;
121
+ out[key] = value;
153
122
  }
154
123
  return out;
155
124
  }
156
- function boundedNever(rejectable, warn) {
125
+ function boundedNever(rejectable, warn, timeoutMs = FLUSH_TIMEOUT_MS) {
157
126
  return new Promise((resolve) => {
158
- const timer = setTimeout(resolve, FLUSH_TIMEOUT_MS);
127
+ const timer = setTimeout(resolve, timeoutMs);
159
128
  timer.unref?.();
160
129
  rejectable
161
130
  .catch((error) => warn(error))
@@ -165,52 +134,98 @@ function boundedNever(rejectable, warn) {
165
134
  });
166
135
  });
167
136
  }
168
- /**
169
- * The real v4 emitter. Everything OTel is loaded LAZILY, on first emission, so
170
- * a runtime with tracing disabled (the packed CLI, every test) never pays for
171
- * the OTel tree — matching the "inert unless enabled" doctrine the flag already
172
- * promises.
173
- */
137
+ /** Lazy, private, always-sampled OTel transport. Never register it globally. */
174
138
  function createOtelEmitter(env, warn) {
175
139
  let handle = null;
176
140
  const init = () => {
177
141
  handle ??= (async () => {
178
142
  try {
179
- const [{ LangfuseSpanProcessor }, { BasicTracerProvider }, tracing] = await Promise.all([
180
- import("@langfuse/otel"),
181
- import("@opentelemetry/sdk-trace-base"),
182
- import("@langfuse/tracing"),
143
+ const [{ LangfuseSpanProcessor }, { BasicTracerProvider, AlwaysOnSampler }, tracing, otel, core] = await Promise.all([
144
+ import("@langfuse/otel"), import("@opentelemetry/sdk-trace-base"),
145
+ import("@langfuse/tracing"), import("@opentelemetry/api"), import("@langfuse/core"),
183
146
  ]);
184
147
  const processor = new LangfuseSpanProcessor({
185
148
  publicKey: env.LANGFUSE_PUBLIC_KEY,
186
149
  secretKey: env.LANGFUSE_SECRET_KEY,
187
150
  ...(env.LANGFUSE_BASE_URL?.trim() ? { baseUrl: env.LANGFUSE_BASE_URL.trim() } : {}),
188
151
  environment: resolveLlmTracingEnvironment(env),
189
- // SECONDS, and the OTLP POST's own deadline. The SDK default is 5,
190
- // which is exactly FLUSH_TIMEOUT_MS — zero headroom, so a batch that
191
- // needed 6s was guaranteed to die on the transport and be dropped:
192
- // 37 `llm_tracing.ingest_failed` warns over 30 days to 2026-09-09,
193
- // every one stage='flush' / 'Request timed out', against 275,970
194
- // traces Langfuse accepted in the same window. 10s gives the POST
195
- // room without touching FLUSH_TIMEOUT_MS — boundedNever still
196
- // releases the caller at 5s, so a slow Langfuse can never hold a
197
- // worker tick.
152
+ release: env.VERCEL_GIT_COMMIT_SHA || env.OXYGEN_GIT_SHA || OXYGEN_VERSION,
198
153
  timeout: LANGFUSE_EXPORT_TIMEOUT_SECONDS,
154
+ // SimpleSpanProcessor hits the exporter's concurrent-request limit
155
+ // on large chunk bursts. Batch them, starting promptly; after() drains
156
+ // the batch with a bound above the exporter's own deadline.
157
+ exportMode: "batched",
158
+ flushInterval: 1,
159
+ flushAt: 128,
199
160
  });
200
- // PRIVATE provider. Deliberately NOT .register()ed — see the file
201
- // header: the global provider carries the Axiom OTLP exporters, and a
202
- // processor there would receive every prompt-bearing span.
203
- //
204
- // v5's smart default span filter needs no override here: this provider
205
- // only ever creates spans through the Langfuse tracer, and
206
- // `langfuse-sdk` spans are in the default allow-list.
207
- const provider = new BasicTracerProvider({ spanProcessors: [processor] });
208
- tracing.setLangfuseTracerProvider(provider);
209
- return {
210
- processor,
211
- startObservation: tracing.startObservation,
212
- propagateAttributes: tracing.propagateAttributes,
161
+ let identity;
162
+ const provider = new BasicTracerProvider({
163
+ spanProcessors: [{
164
+ onStart: (span) => {
165
+ // Directly pass the private parent claim to the processor. A
166
+ // standalone process may have no global context manager at all;
167
+ // context.with()/propagateAttributes alone would silently lose
168
+ // the claim and user/session on every observation in that case.
169
+ const parentContext = identity && !identity.isRoot
170
+ ? core.setLangfuseTraceIdInBaggage(otel.ROOT_CONTEXT, identity.traceId)
171
+ : otel.ROOT_CONTEXT;
172
+ processor.onStart(span, parentContext);
173
+ if (identity)
174
+ span.setAttributes(compact({
175
+ "user.id": identity.correlation.userId,
176
+ "session.id": identity.correlation.sessionId,
177
+ "langfuse.trace.name": identity.correlation.traceName,
178
+ "langfuse.trace.tags": identity.correlation.tags,
179
+ }));
180
+ },
181
+ onEnd: (span) => processor.onEnd(span),
182
+ forceFlush: () => processor.forceFlush(),
183
+ shutdown: () => processor.shutdown(),
184
+ }], sampler: new AlwaysOnSampler(),
185
+ idGenerator: {
186
+ generateTraceId: () => identity?.traceId ?? randomBytes(16).toString("hex"),
187
+ generateSpanId: () => identity?.spanId ?? randomBytes(8).toString("hex"),
188
+ },
189
+ });
190
+ // Only used to avoid repeatedly creating provisional roots in this
191
+ // process. A final root emission always updates the same physical id.
192
+ const roots = new Set();
193
+ const emit = (emission) => {
194
+ const traceId = llmTraceIdForSeed(emission.traceSeed);
195
+ const rootId = rootSpanIdForSeed(emission.traceSeed);
196
+ if (!emission.isRoot && !roots.has(traceId)) {
197
+ emit({
198
+ kind: "span", traceSeed: emission.traceSeed, isRoot: true,
199
+ observationId: rootId, name: emission.correlation.traceName ?? emission.name.split(".")[0] + ".run",
200
+ attributes: { version: OXYGEN_VERSION, metadata: { oxygen_trace_root: true, oxygen_trace_seed: emission.traceSeed, oxygen_capture_version: 2 } },
201
+ correlation: emission.correlation, startTime: emission.startTime, endTime: emission.startTime,
202
+ });
203
+ }
204
+ if (emission.isRoot) {
205
+ roots.add(traceId);
206
+ if (roots.size > 4096)
207
+ roots.delete(roots.values().next().value);
208
+ }
209
+ // SDK configuration is module-local, whereas Next bundles and tests
210
+ // may construct more than one private client. Select THIS provider
211
+ // immediately before the synchronous creation; never use global OTel.
212
+ tracing.setLangfuseTracerProvider(provider);
213
+ identity = { traceId, spanId: emission.observationId ?? randomBytes(8).toString("hex"), isRoot: emission.isRoot === true, correlation: emission.correlation };
214
+ try {
215
+ otel.context.with(emission.isRoot ? otel.ROOT_CONTEXT : core.setLangfuseTraceIdInBaggage(otel.ROOT_CONTEXT, traceId), () => tracing.propagateAttributes(emission.correlation, () => {
216
+ const observation = tracing.startObservation(emission.name, emission.attributes, {
217
+ asType: emission.kind, startTime: emission.startTime,
218
+ ...(emission.isRoot ? {} : { parentSpanContext: { traceId, spanId: emission.parentObservationId ?? rootId, traceFlags: 1 } }),
219
+ });
220
+ if (emission.kind !== "event")
221
+ observation.end(emission.endTime);
222
+ }));
223
+ }
224
+ finally {
225
+ identity = undefined;
226
+ }
213
227
  };
228
+ return { processor, emit };
214
229
  }
215
230
  catch (error) {
216
231
  warn(error, { stage: "init" });
@@ -220,33 +235,7 @@ function createOtelEmitter(env, warn) {
220
235
  return handle;
221
236
  };
222
237
  return {
223
- emit: (emission) => {
224
- void init()
225
- .then((h) => {
226
- if (!h)
227
- return;
228
- const traceId = llmTraceIdForSeed(emission.traceSeed);
229
- // propagateAttributes is scope-based in v5: the observation must be
230
- // created INSIDE the callback to inherit userId/sessionId/tags.
231
- h.propagateAttributes(emission.correlation, () => {
232
- // The kind is a union, so no single overload matches it. Every
233
- // overload returns an observation extending the same base, and the
234
- // only method used here is .end() — so resolving against the span
235
- // overload is safe while the real kind is passed at runtime.
236
- const observation = h.startObservation(emission.name, emission.attributes, {
237
- asType: emission.kind,
238
- startTime: emission.startTime,
239
- parentSpanContext: {
240
- traceId,
241
- spanId: rootSpanIdForSeed(emission.traceSeed),
242
- traceFlags: 1,
243
- },
244
- });
245
- observation.end(emission.endTime);
246
- });
247
- })
248
- .catch((error) => warn(error, { stage: emission.kind }));
249
- },
238
+ emit: (emission) => { void init().then((h) => h?.emit(emission)).catch((error) => warn(error, { stage: emission.kind })); },
250
239
  flush: () => init().then((h) => h?.processor.forceFlush() ?? Promise.resolve()),
251
240
  shutdown: () => init().then((h) => h?.processor.shutdown() ?? Promise.resolve()),
252
241
  };
@@ -330,7 +319,52 @@ export function createLlmTracingClient(env = process.env, options) {
330
319
  ...context,
331
320
  });
332
321
  };
333
- const emitter = options?.emitterImpl ?? createOtelEmitter(env, warn);
322
+ const transport = options?.emitterImpl ?? createOtelEmitter(env, warn);
323
+ const emitter = {
324
+ ...transport,
325
+ emit: (emission) => {
326
+ const observationId = emission.observationId ?? randomBytes(8).toString("hex");
327
+ const metadata = { ...emission.attributes.metadata, oxygen_capture_version: 2 };
328
+ const attributes = { ...emission.attributes, version: OXYGEN_VERSION, metadata };
329
+ const chunkEmissions = [];
330
+ const capture = (value, field, key) => {
331
+ const prepared = prepareLlmPayload(value, key);
332
+ for (const chunk of prepared.chunks) {
333
+ chunkEmissions.push({
334
+ kind: "event", traceSeed: emission.traceSeed, name: "llm.payload_chunk", parentObservationId: observationId,
335
+ attributes: { version: OXYGEN_VERSION, input: chunk, metadata: {
336
+ oxygen_capture_version: 2, oxygen_payload_id: chunk.payload_id,
337
+ oxygen_payload_chunk_index: chunk.index, oxygen_payload_chunk_count: prepared.chunks.length,
338
+ oxygen_observation_id: `${metadata.oxygen_observation_id ?? observationId}:payload:${field}:${chunk.index}`,
339
+ } },
340
+ correlation: emission.correlation, startTime: emission.endTime ?? emission.startTime, endTime: emission.endTime ?? emission.startTime,
341
+ });
342
+ }
343
+ return prepared.value;
344
+ };
345
+ for (const key of ["input", "output", "modelParameters"]) {
346
+ if (key in attributes)
347
+ attributes[key] = capture(attributes[key], key);
348
+ }
349
+ if (typeof attributes.statusMessage === "string") {
350
+ const status = prepareLlmPayload(attributes.statusMessage);
351
+ if (typeof status.value === "string" && status.value.length <= 2000)
352
+ attributes.statusMessage = status.value;
353
+ else {
354
+ metadata.status_message = attributes.statusMessage;
355
+ attributes.statusMessage = "See metadata.status_message for full error detail";
356
+ }
357
+ }
358
+ for (const [key, value] of Object.entries(metadata))
359
+ metadata[key] = capture(value, `metadata.${key}`, key);
360
+ if (attributes.modelParameters && typeof attributes.modelParameters === "object") {
361
+ attributes.modelParameters = Object.fromEntries(Object.entries(attributes.modelParameters).map(([key, value]) => [key, typeof value === "number" || typeof value === "string" ? value : JSON.stringify(value)]));
362
+ }
363
+ transport.emit({ ...emission, observationId, attributes });
364
+ for (const chunk of chunkEmissions)
365
+ transport.emit(chunk);
366
+ },
367
+ };
334
368
  const scorer = options?.scorerImpl ?? createApiScorer(env, warn);
335
369
  const guarded = (fn, stage) => {
336
370
  try {
@@ -360,19 +394,44 @@ export function createLlmTracingClient(env = process.env, options) {
360
394
  tags: input.tags,
361
395
  });
362
396
  };
397
+ const modelObservation = (body, kind) => guarded(() => {
398
+ emitter.emit({
399
+ kind,
400
+ traceSeed: body.traceId,
401
+ name: body.name,
402
+ attributes: compact({
403
+ input: body.input,
404
+ output: body.output,
405
+ level: body.level,
406
+ statusMessage: body.statusMessage,
407
+ model: body.model,
408
+ modelParameters: body.modelParameters,
409
+ completionStartTime: body.completionStartTime,
410
+ usageDetails: body.usageDetails,
411
+ costDetails: body.costDetails,
412
+ metadata: { ...(body.metadata ?? {}), oxygen_observation_id: body.id },
413
+ }),
414
+ // The cost-bearing observation: v5 session cost only rolls up when
415
+ // the generation itself carries the session.
416
+ correlation: correlate({ sessionId: body.sessionId, userId: body.userId }),
417
+ startTime: body.startTime ?? new Date(),
418
+ ...(body.endTime ? { endTime: body.endTime } : {}),
419
+ });
420
+ }, kind);
363
421
  return {
364
422
  trace: (body) => guarded(() => {
365
423
  const startTime = body.startTime ?? new Date();
366
424
  emitter.emit({
367
425
  kind: "span",
368
426
  traceSeed: body.id,
427
+ observationId: rootSpanIdForSeed(body.id), isRoot: true,
369
428
  name: body.name,
370
429
  // Overall trace IO lives on this ROOT observation — v5 deprecates
371
430
  // trace-level input/output, so it is deliberately not set separately.
372
431
  attributes: compact({
373
432
  input: body.input,
374
433
  output: body.output,
375
- metadata: { ...(body.metadata ?? {}), oxygen_observation_id: body.id },
434
+ metadata: { ...(body.metadata ?? {}), oxygen_observation_id: body.id, oxygen_trace_root: true, oxygen_trace_seed: body.id },
376
435
  }),
377
436
  correlation: correlate({
378
437
  traceName: body.name,
@@ -383,6 +442,15 @@ export function createLlmTracingClient(env = process.env, options) {
383
442
  startTime,
384
443
  endTime: body.endTime ?? startTime,
385
444
  });
445
+ if (body.metadata?.observation_scope === "execution_slice") {
446
+ const sliceStart = typeof body.metadata.slice_started_at === "string" ? new Date(body.metadata.slice_started_at) : startTime;
447
+ emitter.emit({
448
+ kind: "span", traceSeed: body.id, name: `${body.name}.slice`,
449
+ attributes: compact({ input: body.input, output: body.output, metadata: { ...body.metadata, oxygen_observation_id: `${body.id}:slice:${body.metadata.slice_id}` } }),
450
+ correlation: correlate({ sessionId: body.sessionId, userId: body.userId }),
451
+ startTime: Number.isFinite(sliceStart.getTime()) ? sliceStart : startTime, endTime: body.endTime ?? startTime,
452
+ });
453
+ }
386
454
  }, "trace"),
387
455
  span: (body) => guarded(() => {
388
456
  emitter.emit({
@@ -401,29 +469,8 @@ export function createLlmTracingClient(env = process.env, options) {
401
469
  ...(body.endTime ? { endTime: body.endTime } : {}),
402
470
  });
403
471
  }, "span"),
404
- generation: (body) => guarded(() => {
405
- emitter.emit({
406
- kind: "generation",
407
- traceSeed: body.traceId,
408
- name: body.name,
409
- attributes: compact({
410
- input: body.input,
411
- output: body.output,
412
- level: body.level,
413
- statusMessage: body.statusMessage,
414
- model: body.model,
415
- completionStartTime: body.completionStartTime,
416
- usageDetails: body.usageDetails,
417
- costDetails: body.costDetails,
418
- metadata: { ...(body.metadata ?? {}), oxygen_observation_id: body.id },
419
- }),
420
- // The cost-bearing observation: v5 session cost only rolls up when
421
- // the generation itself carries the session.
422
- correlation: correlate({ sessionId: body.sessionId, userId: body.userId }),
423
- startTime: body.startTime ?? new Date(),
424
- ...(body.endTime ? { endTime: body.endTime } : {}),
425
- });
426
- }, "generation"),
472
+ generation: (body) => modelObservation(body, "generation"),
473
+ embedding: (body) => modelObservation(body, "embedding"),
427
474
  event: (body) => guarded(() => {
428
475
  const startTime = body.startTime ?? new Date();
429
476
  emitter.emit({
@@ -452,10 +499,10 @@ export function createLlmTracingClient(env = process.env, options) {
452
499
  }
453
500
  },
454
501
  // The "never rejects, bounded at ~5s" contract is the CLIENT's, so it is
455
- // enforced here rather than inside one emitter — an emitter that throws
502
+ // enforced here (15s for serverless after()) rather than inside one emitter — an emitter that throws
456
503
  // synchronously or rejects must still not escape into product code.
457
- flush: () => boundedNever((async () => emitter.flush())(), (error) => warn(error, { stage: "flush" })),
458
- shutdown: () => boundedNever((async () => emitter.shutdown())(), (error) => warn(error, { stage: "shutdown" })),
504
+ flush: () => boundedNever((async () => emitter.flush())(), (error) => warn(error, { stage: "flush" }), env.VERCEL ? 15_000 : FLUSH_TIMEOUT_MS),
505
+ shutdown: () => boundedNever((async () => emitter.shutdown())(), (error) => warn(error, { stage: "shutdown" }), env.VERCEL ? 15_000 : FLUSH_TIMEOUT_MS),
459
506
  };
460
507
  }
461
508
  // --- Process-wide singleton (both runtimes construct at most one client) ------
@@ -0,0 +1,10 @@
1
+ export type LlmPayloadChunk = {
2
+ payload_id: string;
3
+ index: number;
4
+ data: string;
5
+ };
6
+ /** Only credentials are masked: prompts, tool arguments and token counts stay. */
7
+ export declare function prepareLlmPayload(value: unknown, fieldKey?: string): {
8
+ value: unknown;
9
+ chunks: LlmPayloadChunk[];
10
+ };
@@ -0,0 +1,54 @@
1
+ import { createHash } from "node:crypto";
2
+ import { redactSecretsInString } from "./redaction.js";
3
+ // Bound individual OTLP fields in UTF-8 bytes. Large payloads remain fully
4
+ // recoverable in child events rather than losing the end of a model response.
5
+ const INLINE_BYTES = 128 * 1024;
6
+ const CHUNK_BYTES = 64 * 1024;
7
+ const SECRET_KEY = /^(?:authorization|proxy-authorization|cookie|set-cookie|password|passwd|secret|(?:x[-_]?)?api[-_]?key|x[-_]?auth[-_]?token|access[-_]?token|refresh[-_]?token|client[-_]?secret|private[-_]?key|connection[-_]?uri|database[-_]?url)$/i;
8
+ const SECRET_QUERY = /([?&](?:token|access_token|api_key|key|signature|x-amz-signature|x-goog-signature)=)[^&#\s"]+/gi;
9
+ /** Only credentials are masked: prompts, tool arguments and token counts stay. */
10
+ export function prepareLlmPayload(value, fieldKey = "") {
11
+ if (SECRET_KEY.test(fieldKey))
12
+ value = "[REDACTED]";
13
+ if (value === undefined || value === null)
14
+ return { value, chunks: [] };
15
+ let serialized;
16
+ try {
17
+ // Tool arguments and SSE frames are often JSON encoded inside a string.
18
+ // Inspect those nested documents too; redacting only outer object keys
19
+ // would leave an API key inside tool_calls[].function.arguments intact.
20
+ const serialize = (input, nestedDepth = 0) => JSON.stringify(input, (key, item) => {
21
+ if (SECRET_KEY.test(key))
22
+ return "[REDACTED]";
23
+ if (typeof item !== "string")
24
+ return item;
25
+ let text = redactSecretsInString(item).replace(SECRET_QUERY, "$1[REDACTED]");
26
+ if (/^\s*[\[{]/.test(text)) {
27
+ try {
28
+ const parsed = JSON.parse(text);
29
+ if (nestedDepth >= 32)
30
+ return "[REDACTED_NESTED_JSON_DEPTH]";
31
+ text = serialize(parsed, nestedDepth + 1);
32
+ }
33
+ catch { /* ordinary prompt text, not a JSON document */ }
34
+ }
35
+ return text;
36
+ }) ?? "null";
37
+ serialized = serialize(value);
38
+ }
39
+ catch {
40
+ return { value: { truncated: true, reason: "unserializable" }, chunks: [] };
41
+ }
42
+ const bytes = Buffer.from(serialized, "utf8");
43
+ if (bytes.length <= INLINE_BYTES)
44
+ return { value: JSON.parse(serialized), chunks: [] };
45
+ const id = createHash("sha256").update(bytes).digest("hex");
46
+ const chunks = [];
47
+ for (let offset = 0; offset < bytes.length; offset += CHUNK_BYTES) {
48
+ chunks.push({ payload_id: id, index: chunks.length, data: bytes.subarray(offset, offset + CHUNK_BYTES).toString("base64") });
49
+ }
50
+ return {
51
+ value: { oxygen_payload: { id, encoding: "base64-json-utf8", bytes: bytes.length, chunks: chunks.length, sha256: id } },
52
+ chunks,
53
+ };
54
+ }
@@ -0,0 +1,11 @@
1
+ /** Inclusive provider counts. Keep the original provider usage on the observation's metadata. */
2
+ export type LlmProviderUsage = {
3
+ promptTokens?: number | null | undefined;
4
+ completionTokens?: number | null | undefined;
5
+ reasoningTokens?: number | null | undefined;
6
+ cachedPromptTokens?: number | null | undefined;
7
+ cacheWriteTokens?: number | null | undefined;
8
+ totalTokens?: number | null | undefined;
9
+ };
10
+ /** Langfuse usage keys are mutually exclusive buckets, not inclusive breakdowns. */
11
+ export declare function normalizeLlmUsageDetails(usage: LlmProviderUsage | null | undefined): Record<string, number> | undefined;
@@ -0,0 +1,30 @@
1
+ /** Langfuse usage keys are mutually exclusive buckets, not inclusive breakdowns. */
2
+ export function normalizeLlmUsageDetails(usage) {
3
+ if (!usage)
4
+ return undefined;
5
+ const count = (n) => typeof n === "number" && Number.isFinite(n) && n >= 0 ? Math.floor(n) : undefined;
6
+ const input = count(usage.promptTokens);
7
+ const output = count(usage.completionTokens);
8
+ const details = {};
9
+ if (input !== undefined) {
10
+ const cached = Math.min(input, count(usage.cachedPromptTokens) ?? 0);
11
+ const created = Math.min(input - cached, count(usage.cacheWriteTokens) ?? 0);
12
+ details.input = input - cached - created;
13
+ if (cached > 0)
14
+ details.input_cached_tokens = cached;
15
+ if (created > 0)
16
+ details.input_cache_creation_tokens = created;
17
+ }
18
+ if (output !== undefined) {
19
+ // Some providers return a detail count larger than its inclusive parent.
20
+ // Preserve the parent total rather than inventing extra billed tokens.
21
+ const reasoning = Math.min(output, count(usage.reasoningTokens) ?? 0);
22
+ details.output = output - reasoning;
23
+ if (reasoning > 0)
24
+ details.output_reasoning_tokens = reasoning;
25
+ }
26
+ const total = input !== undefined && output !== undefined ? input + output : count(usage.totalTokens);
27
+ if (total !== undefined)
28
+ details.total = total;
29
+ return Object.keys(details).length > 0 ? details : undefined;
30
+ }
@@ -8,6 +8,16 @@ export type NormalizedMailboxImportRow = {
8
8
  infrastructure_platform?: "google_workspace" | "microsoft_365" | "microsoft_azure";
9
9
  tenant_id?: string;
10
10
  app_password?: string;
11
+ /**
12
+ * WHO the inbox sends as (v1.954.0 — an inbox always belongs to a sender).
13
+ * Non-secret identity, so it rides the same whitelist as the address: a vendor
14
+ * export that already knows the person spares the customer a guessed profile
15
+ * named from the address local part.
16
+ */
17
+ first_name?: string;
18
+ last_name?: string;
19
+ display_name?: string;
20
+ sender_profile_id?: string;
11
21
  };
12
22
  export type MailboxImportValidationSummary = {
13
23
  valid: true;
@@ -49,6 +49,27 @@ const MAILBOX_TENANT_HEADERS = new Set([
49
49
  "microsofttenantid",
50
50
  "tenantid",
51
51
  ]);
52
+ const MAILBOX_FIRST_NAME_HEADERS = new Set([
53
+ "first",
54
+ "firstname",
55
+ "givenname",
56
+ ]);
57
+ const MAILBOX_LAST_NAME_HEADERS = new Set([
58
+ "familyname",
59
+ "last",
60
+ "lastname",
61
+ "surname",
62
+ ]);
63
+ const MAILBOX_DISPLAY_NAME_HEADERS = new Set([
64
+ "displayname",
65
+ "fromname",
66
+ "name",
67
+ "sendername",
68
+ ]);
69
+ const MAILBOX_SENDER_PROFILE_HEADERS = new Set([
70
+ "senderprofile",
71
+ "senderprofileid",
72
+ ]);
52
73
  const MAILBOX_NON_TRANSFERABLE_SECRET_HEADERS = new Set([
53
74
  "accesstoken",
54
75
  "applicationsecret",
@@ -328,6 +349,17 @@ function normalizeMailboxExportRow(row, index, mode) {
328
349
  ? "microsoft_azure"
329
350
  : null);
330
351
  const infrastructurePlatform = normalizeMailboxInfrastructurePlatform(infrastructurePlatformRaw, provider, index);
352
+ // WHO the inbox sends as. Carried through because an inbox always belongs to a
353
+ // sender (v1.954.0): a file that already names the person is the difference
354
+ // between a real sender profile and one OXYGEN guesses from the address.
355
+ const firstName = readMailboxIdentityName(byHeader, MAILBOX_FIRST_NAME_HEADERS, index, "first_name");
356
+ const lastName = readMailboxIdentityName(byHeader, MAILBOX_LAST_NAME_HEADERS, index, "last_name");
357
+ const displayName = readMailboxIdentityName(byHeader, MAILBOX_DISPLAY_NAME_HEADERS, index, "display_name");
358
+ const senderProfileId = readUniqueMailboxExportString(byHeader, MAILBOX_SENDER_PROFILE_HEADERS, index, "sender_profile_id", (value) => value.trim().toLowerCase());
359
+ if (senderProfileId &&
360
+ !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(senderProfileId)) {
361
+ throw new OxygenError("invalid_request", `mailboxes[${index}].sender_profile_id must be a sender profile id (UUID). List them with \`oxygen senders profiles list\`, or drop the column and let the row's first/last name name the sender.`, { exitCode: 1 });
362
+ }
331
363
  return {
332
364
  email_address: email.trim().toLowerCase(),
333
365
  provider,
@@ -338,6 +370,10 @@ function normalizeMailboxExportRow(row, index, mode) {
338
370
  ? { infrastructure_platform: infrastructurePlatform }
339
371
  : {}),
340
372
  ...(tenantId ? { tenant_id: tenantId.trim().toLowerCase() } : {}),
373
+ ...(firstName ? { first_name: firstName } : {}),
374
+ ...(lastName ? { last_name: lastName } : {}),
375
+ ...(displayName ? { display_name: displayName } : {}),
376
+ ...(senderProfileId ? { sender_profile_id: senderProfileId } : {}),
341
377
  ...(mode === "credential" &&
342
378
  provider === "google" &&
343
379
  distinctPasswords[0] !== undefined
@@ -345,6 +381,23 @@ function normalizeMailboxExportRow(row, index, mode) {
345
381
  : {}),
346
382
  };
347
383
  }
384
+ /**
385
+ * One identity half off an export row. Header-bound (the From name rides an SMTP
386
+ * header), so a CR/LF is refused by field name rather than silently stripped — the
387
+ * caller is usually holding hundreds of rows and needs to know which one.
388
+ */
389
+ function readMailboxIdentityName(byHeader, headers, index, field) {
390
+ const value = readUniqueMailboxExportString(byHeader, headers, index, field, (raw) => raw.trim());
391
+ if (!value)
392
+ return null;
393
+ if (value.length > 200) {
394
+ throw new OxygenError("invalid_request", `mailboxes[${index}].${field} must be at most 200 characters.`, { exitCode: 1 });
395
+ }
396
+ if (/[\r\n]/.test(value)) {
397
+ throw new OxygenError("invalid_request", `mailboxes[${index}].${field} must be a single line.`, { exitCode: 1 });
398
+ }
399
+ return value;
400
+ }
348
401
  function normalizeMailboxExportHeader(value) {
349
402
  return value
350
403
  .replace(/^\uFEFF/, "")
@@ -78,6 +78,14 @@ export type PlanLimits = {
78
78
  */
79
79
  export declare const TABLE_IMPORT_ROW_LIMIT: 3000000;
80
80
  export { VERCEL_REQUEST_BODY_LIMIT_BYTES } from "./import-limits.js";
81
+ /**
82
+ * OXYGEN's own JSON-body ceiling for every `/api/cli/*` route (enforced by
83
+ * `assertCliJsonBodyWithinLimit` on the content-length header). Shared so the CLI
84
+ * pre-splits a row batch by measured bytes instead of learning the number from a
85
+ * 413. Must stay below VERCEL_REQUEST_BODY_LIMIT_BYTES, and must never be raised
86
+ * in the CLI alone — an older server would still 413 at the old number.
87
+ */
88
+ export declare const MAX_CLI_JSON_BODY_BYTES = 2000000;
81
89
  /**
82
90
  * The per-rung limit matrix. `ai_live` deliberately equals `tool_live` at every
83
91
  * rung — one "live actions" mental model; the per-call cost asymmetry between