@oxygen-agent/cli 1.893.0 → 1.906.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.
Files changed (26) hide show
  1. package/README.md +1 -1
  2. package/dist/command-manifest.js +8 -3
  3. package/dist/index.js +199 -36
  4. package/node_modules/@oxygen/shared/dist/capability-discovery.js +19 -1
  5. package/node_modules/@oxygen/shared/dist/copilot-plan.d.ts +137 -0
  6. package/node_modules/@oxygen/shared/dist/copilot-plan.js +435 -0
  7. package/node_modules/@oxygen/shared/dist/dnc-identities.d.ts +10 -0
  8. package/node_modules/@oxygen/shared/dist/dnc-identities.js +23 -0
  9. package/node_modules/@oxygen/shared/dist/egress-transport-readiness.d.ts +60 -0
  10. package/node_modules/@oxygen/shared/dist/egress-transport-readiness.js +67 -0
  11. package/node_modules/@oxygen/shared/dist/index.d.ts +3 -0
  12. package/node_modules/@oxygen/shared/dist/index.js +3 -0
  13. package/node_modules/@oxygen/shared/dist/langfuse.d.ts +57 -5
  14. package/node_modules/@oxygen/shared/dist/langfuse.js +243 -42
  15. package/node_modules/@oxygen/shared/dist/product-briefing-rules.d.ts +58 -0
  16. package/node_modules/@oxygen/shared/dist/product-briefing-rules.js +291 -0
  17. package/node_modules/@oxygen/shared/dist/product-doctrine.d.ts +11 -0
  18. package/node_modules/@oxygen/shared/dist/product-doctrine.js +70 -0
  19. package/node_modules/@oxygen/shared/dist/sending-seats.d.ts +31 -8
  20. package/node_modules/@oxygen/shared/dist/sending-seats.js +19 -15
  21. package/node_modules/@oxygen/shared/dist/sequences.d.ts +18 -11
  22. package/node_modules/@oxygen/shared/dist/sequences.js +47 -13
  23. package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
  24. package/node_modules/@oxygen/shared/dist/version.js +1 -1
  25. package/node_modules/@oxygen/shared/package.json +5 -0
  26. package/package.json +4 -2
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Shared/dedicated send-drain handoff timing. The worker and every status
3
+ * surface must evaluate the same readiness envelope: inventory existence alone
4
+ * is not proof that a dedicated process may claim sends yet.
5
+ */
6
+ /** Longest a process may reuse one control-plane scope snapshot. */
7
+ export const EGRESS_SEND_SCOPE_CACHE_MAX_AGE_MS = 60_000;
8
+ /** Longest a tenant cycle may start provider sends under that snapshot. */
9
+ export const EGRESS_SEND_AUTHORITY_RETENTION_MS = 45_000;
10
+ /** Clock/scheduling margin between the old authority expiring and the new one starting. */
11
+ export const EGRESS_SEND_HANDOFF_MARGIN_MS = 5_000;
12
+ /**
13
+ * No new drain may become authoritative until every old cached scope AND every
14
+ * send cycle started on that cache's final millisecond have expired.
15
+ */
16
+ export const DEDICATED_EGRESS_HANDOFF_DELAY_MS = EGRESS_SEND_SCOPE_CACHE_MAX_AGE_MS
17
+ + EGRESS_SEND_AUTHORITY_RETENTION_MS
18
+ + EGRESS_SEND_HANDOFF_MARGIN_MS;
19
+ export const DEDICATED_EGRESS_LIVENESS_MAX_AGE_MS = 30 * 60_000;
20
+ const FLY_DEDICATED_APP_PATTERN = /^oxygen-egress-(dev|prod)-[a-z0-9][a-z0-9-]*$/;
21
+ export function evaluateDedicatedEgressTransportReadiness(input, now = Date.now()) {
22
+ const createdAtMs = input.createdAt?.getTime() ?? Number.POSITIVE_INFINITY;
23
+ if (now - createdAtMs < DEDICATED_EGRESS_HANDOFF_DELAY_MS) {
24
+ return { ready: false, reason: "handoff_pending" };
25
+ }
26
+ const verifiedAtMs = input.vendorVerifiedAt?.getTime() ?? Number.NEGATIVE_INFINITY;
27
+ if (now - verifiedAtMs > DEDICATED_EGRESS_LIVENESS_MAX_AGE_MS) {
28
+ return { ready: false, reason: "vendor_verification_missing_or_stale" };
29
+ }
30
+ return { ready: true };
31
+ }
32
+ /**
33
+ * The app name Fly assigns to this inventory row, or null when the row cannot
34
+ * authorize any dedicated process. Automated Fly purchase stores the app name
35
+ * as metadata.vendor_order_id because one app is exactly one vendor lease.
36
+ */
37
+ export function readFlyDedicatedEgressAppName(input) {
38
+ const raw = input.metadata?.vendor_order_id;
39
+ if (typeof raw !== "string" || raw.length > 63)
40
+ return null;
41
+ const match = FLY_DEDICATED_APP_PATTERN.exec(raw);
42
+ return match?.[1] === input.environment ? raw : null;
43
+ }
44
+ /**
45
+ * Fly-specific readiness shared by the drain, status, and purchase replay.
46
+ * Timestamps cannot authorize an app by themselves: an orphan process for the
47
+ * same org must match the exact active row's immutable Fly app identity.
48
+ */
49
+ export function evaluateFlyDedicatedEgressTransportReadiness(input, now = Date.now()) {
50
+ const appName = readFlyDedicatedEgressAppName(input);
51
+ if (!appName)
52
+ return { ready: false, reason: "dedicated_app_identity_missing" };
53
+ const readiness = evaluateDedicatedEgressTransportReadiness(input, now);
54
+ return readiness.ready ? { ready: true, appName } : readiness;
55
+ }
56
+ /**
57
+ * Retiring a drain is the inverse handoff: shared must remain excluded until
58
+ * every process that could have cached dedicated authority has expired it.
59
+ * Writers stamp this field with DB time while preserving the rest of metadata.
60
+ */
61
+ export function dedicatedEgressRetirementStillCooling(metadata, now = Date.now()) {
62
+ const raw = metadata?.transport_retired_at;
63
+ if (typeof raw !== "string")
64
+ return false;
65
+ const retiredAt = Date.parse(raw);
66
+ return Number.isFinite(retiredAt) && now - retiredAt <= DEDICATED_EGRESS_HANDOFF_DELAY_MS;
67
+ }
@@ -24,6 +24,7 @@ export * from "./crm-activity-events.js";
24
24
  export * from "./column-types.js";
25
25
  export * from "./copilot-errors.js";
26
26
  export * from "./copilot-journeys.js";
27
+ export * from "./copilot-plan.js";
27
28
  export * from "./credit-guidance.js";
28
29
  export * from "./directory.js";
29
30
  export * from "./email-tracking-token.js";
@@ -49,6 +50,7 @@ export * from "./member-columns.js";
49
50
  export * from "./microsoft-consent-url.js";
50
51
  export * from "./networks.js";
51
52
  export * from "./person-name.js";
53
+ export * from "./product-doctrine.js";
52
54
  export * from "./recipes.js";
53
55
  export * from "./sequence-template.js";
54
56
  export * from "./sequence-crm-events.js";
@@ -75,6 +77,7 @@ export * from "./postgres-text.js";
75
77
  export * from "./tags.js";
76
78
  export * from "./telemetry.js";
77
79
  export * from "./tenant-database-secret.js";
80
+ export * from "./egress-transport-readiness.js";
78
81
  export * from "./timing.js";
79
82
  export * from "./type-guards.js";
80
83
  export * from "./worker-failures-queue.js";
@@ -24,6 +24,7 @@ export * from "./crm-activity-events.js";
24
24
  export * from "./column-types.js";
25
25
  export * from "./copilot-errors.js";
26
26
  export * from "./copilot-journeys.js";
27
+ export * from "./copilot-plan.js";
27
28
  export * from "./credit-guidance.js";
28
29
  export * from "./directory.js";
29
30
  export * from "./email-tracking-token.js";
@@ -49,6 +50,7 @@ export * from "./member-columns.js";
49
50
  export * from "./microsoft-consent-url.js";
50
51
  export * from "./networks.js";
51
52
  export * from "./person-name.js";
53
+ export * from "./product-doctrine.js";
52
54
  export * from "./recipes.js";
53
55
  export * from "./sequence-template.js";
54
56
  export * from "./sequence-crm-events.js";
@@ -86,6 +88,7 @@ export * from "./postgres-text.js";
86
88
  export * from "./tags.js";
87
89
  export * from "./telemetry.js";
88
90
  export * from "./tenant-database-secret.js";
91
+ export * from "./egress-transport-readiness.js";
89
92
  export * from "./timing.js";
90
93
  export * from "./type-guards.js";
91
94
  export * from "./worker-failures-queue.js";
@@ -1,4 +1,3 @@
1
- import { Langfuse } from "langfuse";
2
1
  type EnvMap = Record<string, string | undefined>;
3
2
  export type LlmObservationLevel = "DEBUG" | "DEFAULT" | "WARNING" | "ERROR";
4
3
  export type LlmTraceBody = {
@@ -10,6 +9,13 @@ export type LlmTraceBody = {
10
9
  output?: unknown;
11
10
  metadata?: Record<string, unknown>;
12
11
  tags?: string[];
12
+ /**
13
+ * Root-observation span. v3 emitted a durationless trace object; v5's root IS
14
+ * an observation, so a root emitted once at completion should carry the real
15
+ * turn/run window. Defaults to "now" for both when omitted.
16
+ */
17
+ startTime?: Date;
18
+ endTime?: Date;
13
19
  };
14
20
  export type LlmSpanBody = {
15
21
  id: string;
@@ -22,6 +28,14 @@ export type LlmSpanBody = {
22
28
  endTime?: Date;
23
29
  level?: LlmObservationLevel;
24
30
  statusMessage?: string | null;
31
+ /**
32
+ * v5 observations-first correlation. Pass the SAME sessionId/userId the trace
33
+ * carries: in v5 these live on every observation, and session-level cost only
34
+ * adds up if the cost-bearing generations carry the session too. Optional so
35
+ * an emitter that has no session still traces.
36
+ */
37
+ sessionId?: string | null;
38
+ userId?: string | null;
25
39
  };
26
40
  export type LlmGenerationBody = LlmSpanBody & {
27
41
  model?: string | null;
@@ -36,6 +50,9 @@ export type LlmEventBody = {
36
50
  input?: unknown;
37
51
  metadata?: Record<string, unknown>;
38
52
  startTime?: Date;
53
+ /** See LlmSpanBody.sessionId. */
54
+ sessionId?: string | null;
55
+ userId?: string | null;
39
56
  };
40
57
  export type LlmTracingClient = {
41
58
  trace(body: LlmTraceBody): void;
@@ -53,16 +70,51 @@ export type LlmTracingClient = {
53
70
  */
54
71
  export declare function isLlmTracingEnabled(env?: EnvMap): boolean;
55
72
  export declare function resolveLlmTracingEnvironment(env?: EnvMap): string;
56
- type LangfuseLike = Pick<Langfuse, "trace" | "span" | "generation" | "event" | "flushAsync" | "shutdownAsync"> & {
57
- on?: (event: string, listener: (...args: unknown[]) => void) => void;
73
+ /**
74
+ * Deterministic Langfuse trace id for an external seed (copilot turn id, agent
75
+ * run id, AI-column run id).
76
+ *
77
+ * Byte-identical to `createTraceId(seed)` from @langfuse/tracing — sha256 of the
78
+ * UTF-8 seed, first 32 hex chars — but SYNCHRONOUS. The official helper returns
79
+ * a Promise (it uses WebCrypto), and every cross-link site here writes the id on
80
+ * a hot, synchronous path: the tenant ledger's `turn_started` payload and the
81
+ * Axiom `copilot.turn.finished` rollup both carry `langfuse_trace_id`, and a
82
+ * tracer's `traceId` is read synchronously. langfuse.test.ts pins this against
83
+ * the SDK's own implementation so the two can never drift.
84
+ */
85
+ export declare function llmTraceIdForSeed(seed: string): string;
86
+ export type LlmEmissionKind = "span" | "generation" | "event";
87
+ /** v5 correlating attributes, propagated onto the emitted observation. */
88
+ export type LlmCorrelation = {
89
+ traceName?: string;
90
+ userId?: string;
91
+ sessionId?: string;
92
+ tags?: string[];
93
+ };
94
+ export type LlmEmission = {
95
+ kind: LlmEmissionKind;
96
+ /** External seed (turn/run id) — hashed into the W3C trace id. */
97
+ traceSeed: string;
98
+ name: string;
99
+ /** Observation-level attributes, already compacted/bounded. */
100
+ attributes: Record<string, unknown>;
101
+ /** Correlating attributes applied via propagateAttributes(). */
102
+ correlation: LlmCorrelation;
103
+ startTime: Date;
104
+ endTime?: Date;
105
+ };
106
+ export type LlmEmitter = {
107
+ emit(emission: LlmEmission): void;
108
+ flush(): Promise<void>;
109
+ shutdown(): Promise<void>;
58
110
  };
59
111
  /**
60
112
  * Construct a fail-open Langfuse client, or `null` when tracing is disabled or
61
113
  * misconfigured. Prefer the process-wide `getLlmTracingClient` in app code;
62
- * this direct factory exists for tests (inject `langfuseImpl`).
114
+ * this direct factory exists for tests (inject `emitterImpl`).
63
115
  */
64
116
  export declare function createLlmTracingClient(env?: EnvMap, options?: {
65
- langfuseImpl?: LangfuseLike;
117
+ emitterImpl?: LlmEmitter;
66
118
  }): LlmTracingClient | null;
67
119
  export declare function getLlmTracingClient(env?: EnvMap): LlmTracingClient | null;
68
120
  /** Flush the singleton if it exists. Never rejects. Hang off request/cycle ends. */
@@ -1,18 +1,40 @@
1
1
  // LLM-observability transport (ADR 0014): Langfuse is the ONE sanctioned store
2
2
  // for full prompt/completion/tool-IO payloads. Axiom stays metadata-only (log.ts
3
3
  // redaction drops prompt/input/output-named fields BY DESIGN — that boundary is
4
- // unchanged), and PostHog stays sanitized product analytics. This module
5
- // deliberately rides the official `langfuse` SDK's own batched ingestion instead
6
- // of the OTel pipeline so prompt-bearing telemetry can never reach the Axiom
7
- // OTLP exporters by construction.
4
+ // unchanged), and PostHog stays sanitized product analytics.
5
+ //
6
+ // Langfuse SDK v5 is OpenTelemetry-based, so the v3-era "never touch OTel"
7
+ // isolation is now bought a different way: this module builds its OWN
8
+ // TracerProvider, hands it to Langfuse via setLangfuseTracerProvider(), and
9
+ // NEVER calls .register(). That matters — @vercel/otel (web) and NodeSDK
10
+ // (worker) install Axiom OTLP exporters on the GLOBAL provider, and every
11
+ // processor on a provider sees every span it creates. Registering globally
12
+ // would therefore ship prompts, completions and tool IO straight into
13
+ // oxygen-traces. The private provider is the load-bearing part of this file.
8
14
  //
9
15
  // Fail-open doctrine: tracing must never fail or stall product work. Every
10
16
  // method swallows internally (worst case: one throttled metadata-only warn via
11
17
  // log()), `flush()`/`shutdown()` never reject and are time-bounded, and a
12
- // missing flag/key yields `null` (callers no-op). Deterministic observation ids
13
- // are the caller's job — Langfuse upserts by id, which is what makes worker
14
- // lease-reclaim replays converge instead of duplicating.
15
- import { Langfuse } from "langfuse";
18
+ // missing flag/key yields `null` (callers no-op).
19
+ //
20
+ // v5 semantics (CHANGED from v3):
21
+ // * Trace ids stay deterministic: sha256(seed) — same trace per turn/run, so
22
+ // lease-reclaim replays still converge onto ONE trace. See
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.
29
+ // * v5 is observations-first: correlating attributes (userId, sessionId,
30
+ // tags) must ride EVERY observation, not just the root, or per-session cost
31
+ // rollups miss the cost-bearing generations. They are applied through
32
+ // propagateAttributes() around each emission — which is why the span/
33
+ // generation/event bodies carry sessionId/userId at all.
34
+ // * Trace-level input/output is deprecated in v5. Overall IO goes on the ROOT
35
+ // observation instead; setTraceIO()/setActiveTraceIO() are deliberately not
36
+ // used here.
37
+ import { createHash } from "node:crypto";
16
38
  import { log } from "./log.js";
17
39
  const FLUSH_TIMEOUT_MS = 5_000;
18
40
  const WARN_THROTTLE_MS = 30_000;
@@ -56,6 +78,34 @@ export function resolveLlmTracingEnvironment(env = process.env) {
56
78
  return flyEnv === "production" ? "production" : "development";
57
79
  return "development";
58
80
  }
81
+ /**
82
+ * Deterministic Langfuse trace id for an external seed (copilot turn id, agent
83
+ * run id, AI-column run id).
84
+ *
85
+ * Byte-identical to `createTraceId(seed)` from @langfuse/tracing — sha256 of the
86
+ * UTF-8 seed, first 32 hex chars — but SYNCHRONOUS. The official helper returns
87
+ * a Promise (it uses WebCrypto), and every cross-link site here writes the id on
88
+ * a hot, synchronous path: the tenant ledger's `turn_started` payload and the
89
+ * Axiom `copilot.turn.finished` rollup both carry `langfuse_trace_id`, and a
90
+ * tracer's `traceId` is read synchronously. langfuse.test.ts pins this against
91
+ * the SDK's own implementation so the two can never drift.
92
+ */
93
+ export function llmTraceIdForSeed(seed) {
94
+ return createHash("sha256").update(seed, "utf8").digest("hex").slice(0, 32);
95
+ }
96
+ /**
97
+ * Deterministic synthetic parent span id for one trace.
98
+ *
99
+ * v5 does not let a caller choose observation ids, and the worker emits a single
100
+ * run's observations from different processes and slices — so there is no real
101
+ * root span id to nest under. Every observation of a run instead attaches to
102
+ * this stable pseudo-parent, which reproduces EXACTLY the flat shape the v3
103
+ * adapter already produced (v3 passed only `traceId`, never
104
+ * `parentObservationId`, so observations were already siblings of the trace).
105
+ */
106
+ function rootSpanIdForSeed(seed) {
107
+ return createHash("sha256").update(`langfuse-root:${seed}`, "utf8").digest("hex").slice(0, 16);
108
+ }
59
109
  // Bound one JSON-bearing field. Over the cap → an explicit truncation marker
60
110
  // (never a silently clipped payload that parses as complete).
61
111
  function boundJsonField(value) {
@@ -79,7 +129,7 @@ function boundJsonField(value) {
79
129
  function compact(body) {
80
130
  const out = {};
81
131
  for (const [key, value] of Object.entries(body)) {
82
- if (value === undefined)
132
+ if (value === undefined || value === null)
83
133
  continue;
84
134
  out[key] = key === "input" || key === "output" ? boundJsonField(value) : value;
85
135
  }
@@ -97,13 +147,89 @@ function boundedNever(rejectable, warn) {
97
147
  });
98
148
  });
99
149
  }
150
+ /**
151
+ * The real v4 emitter. Everything OTel is loaded LAZILY, on first emission, so
152
+ * a runtime with tracing disabled (the packed CLI, every test) never pays for
153
+ * the OTel tree — matching the "inert unless enabled" doctrine the flag already
154
+ * promises.
155
+ */
156
+ function createOtelEmitter(env, warn) {
157
+ let handle = null;
158
+ const init = () => {
159
+ handle ??= (async () => {
160
+ try {
161
+ const [{ LangfuseSpanProcessor }, { BasicTracerProvider }, tracing] = await Promise.all([
162
+ import("@langfuse/otel"),
163
+ import("@opentelemetry/sdk-trace-base"),
164
+ import("@langfuse/tracing"),
165
+ ]);
166
+ const processor = new LangfuseSpanProcessor({
167
+ publicKey: env.LANGFUSE_PUBLIC_KEY,
168
+ secretKey: env.LANGFUSE_SECRET_KEY,
169
+ ...(env.LANGFUSE_BASE_URL?.trim() ? { baseUrl: env.LANGFUSE_BASE_URL.trim() } : {}),
170
+ environment: resolveLlmTracingEnvironment(env),
171
+ });
172
+ // PRIVATE provider. Deliberately NOT .register()ed — see the file
173
+ // header: the global provider carries the Axiom OTLP exporters, and a
174
+ // processor there would receive every prompt-bearing span.
175
+ //
176
+ // v5's smart default span filter needs no override here: this provider
177
+ // only ever creates spans through the Langfuse tracer, and
178
+ // `langfuse-sdk` spans are in the default allow-list.
179
+ const provider = new BasicTracerProvider({ spanProcessors: [processor] });
180
+ tracing.setLangfuseTracerProvider(provider);
181
+ return {
182
+ processor,
183
+ startObservation: tracing.startObservation,
184
+ propagateAttributes: tracing.propagateAttributes,
185
+ };
186
+ }
187
+ catch (error) {
188
+ warn(error, { stage: "init" });
189
+ return null;
190
+ }
191
+ })();
192
+ return handle;
193
+ };
194
+ return {
195
+ emit: (emission) => {
196
+ void init()
197
+ .then((h) => {
198
+ if (!h)
199
+ return;
200
+ const traceId = llmTraceIdForSeed(emission.traceSeed);
201
+ // propagateAttributes is scope-based in v5: the observation must be
202
+ // created INSIDE the callback to inherit userId/sessionId/tags.
203
+ h.propagateAttributes(emission.correlation, () => {
204
+ // The kind is a union, so no single overload matches it. Every
205
+ // overload returns an observation extending the same base, and the
206
+ // only method used here is .end() — so resolving against the span
207
+ // overload is safe while the real kind is passed at runtime.
208
+ const observation = h.startObservation(emission.name, emission.attributes, {
209
+ asType: emission.kind,
210
+ startTime: emission.startTime,
211
+ parentSpanContext: {
212
+ traceId,
213
+ spanId: rootSpanIdForSeed(emission.traceSeed),
214
+ traceFlags: 1,
215
+ },
216
+ });
217
+ observation.end(emission.endTime);
218
+ });
219
+ })
220
+ .catch((error) => warn(error, { stage: emission.kind }));
221
+ },
222
+ flush: () => init().then((h) => h?.processor.forceFlush() ?? Promise.resolve()),
223
+ shutdown: () => init().then((h) => h?.processor.shutdown() ?? Promise.resolve()),
224
+ };
225
+ }
100
226
  /**
101
227
  * Construct a fail-open Langfuse client, or `null` when tracing is disabled or
102
228
  * misconfigured. Prefer the process-wide `getLlmTracingClient` in app code;
103
- * this direct factory exists for tests (inject `langfuseImpl`).
229
+ * this direct factory exists for tests (inject `emitterImpl`).
104
230
  */
105
231
  export function createLlmTracingClient(env = process.env, options) {
106
- if (!options?.langfuseImpl && !isLlmTracingEnabled(env))
232
+ if (!options?.emitterImpl && !isLlmTracingEnabled(env))
107
233
  return null;
108
234
  let lastWarnAtMs = 0;
109
235
  const warn = (error, context) => {
@@ -117,30 +243,7 @@ export function createLlmTracingClient(env = process.env, options) {
117
243
  ...context,
118
244
  });
119
245
  };
120
- let sdk;
121
- try {
122
- sdk =
123
- options?.langfuseImpl ??
124
- new Langfuse({
125
- publicKey: env.LANGFUSE_PUBLIC_KEY,
126
- secretKey: env.LANGFUSE_SECRET_KEY,
127
- ...(env.LANGFUSE_BASE_URL?.trim() ? { baseUrl: env.LANGFUSE_BASE_URL.trim() } : {}),
128
- environment: resolveLlmTracingEnvironment(env),
129
- sdkIntegration: "oxygen",
130
- });
131
- }
132
- catch (error) {
133
- warn(error, { stage: "construct" });
134
- return null;
135
- }
136
- // The SDK surfaces async ingest failures on its emitter; unheard, they are
137
- // unhandled rejections. Route them into the throttled warn.
138
- try {
139
- sdk.on?.("error", (error) => warn(error, { stage: "ingest" }));
140
- }
141
- catch {
142
- // an emitter-less test double is fine
143
- }
246
+ const emitter = options?.emitterImpl ?? createOtelEmitter(env, warn);
144
247
  const guarded = (fn, stage) => {
145
248
  try {
146
249
  fn();
@@ -149,13 +252,110 @@ export function createLlmTracingClient(env = process.env, options) {
149
252
  warn(error, { stage });
150
253
  }
151
254
  };
255
+ // propagateAttributes rejects non-string ids and anything over 200 chars, and
256
+ // drops the whole attribute with a warning rather than truncating. Normalize
257
+ // here so a stray null/oversized id degrades to "absent", never to a dropped
258
+ // correlation on every observation of the run.
259
+ const correlate = (input) => {
260
+ const bounded = (value) => {
261
+ if (typeof value !== "string")
262
+ return undefined;
263
+ const trimmed = value.trim();
264
+ if (trimmed === "" || trimmed.length > 200)
265
+ return undefined;
266
+ return trimmed;
267
+ };
268
+ return compact({
269
+ traceName: input.traceName,
270
+ sessionId: bounded(input.sessionId),
271
+ userId: bounded(input.userId),
272
+ tags: input.tags,
273
+ });
274
+ };
152
275
  return {
153
- trace: (body) => guarded(() => void sdk.trace(compact(body)), "trace"),
154
- span: (body) => guarded(() => void sdk.span(compact(body)), "span"),
155
- generation: (body) => guarded(() => void sdk.generation(compact(body)), "generation"),
156
- event: (body) => guarded(() => void sdk.event(compact(body)), "event"),
157
- flush: () => boundedNever(sdk.flushAsync(), (error) => warn(error, { stage: "flush" })),
158
- shutdown: () => boundedNever(sdk.shutdownAsync(), (error) => warn(error, { stage: "shutdown" })),
276
+ trace: (body) => guarded(() => {
277
+ const startTime = body.startTime ?? new Date();
278
+ emitter.emit({
279
+ kind: "span",
280
+ traceSeed: body.id,
281
+ name: body.name,
282
+ // Overall trace IO lives on this ROOT observation — v5 deprecates
283
+ // trace-level input/output, so it is deliberately not set separately.
284
+ attributes: compact({
285
+ input: body.input,
286
+ output: body.output,
287
+ metadata: { ...(body.metadata ?? {}), oxygen_observation_id: body.id },
288
+ }),
289
+ correlation: correlate({
290
+ traceName: body.name,
291
+ sessionId: body.sessionId,
292
+ userId: body.userId,
293
+ ...(body.tags ? { tags: body.tags } : {}),
294
+ }),
295
+ startTime,
296
+ endTime: body.endTime ?? startTime,
297
+ });
298
+ }, "trace"),
299
+ span: (body) => guarded(() => {
300
+ emitter.emit({
301
+ kind: "span",
302
+ traceSeed: body.traceId,
303
+ name: body.name,
304
+ attributes: compact({
305
+ input: body.input,
306
+ output: body.output,
307
+ level: body.level,
308
+ statusMessage: body.statusMessage,
309
+ metadata: { ...(body.metadata ?? {}), oxygen_observation_id: body.id },
310
+ }),
311
+ correlation: correlate({ sessionId: body.sessionId, userId: body.userId }),
312
+ startTime: body.startTime ?? new Date(),
313
+ ...(body.endTime ? { endTime: body.endTime } : {}),
314
+ });
315
+ }, "span"),
316
+ generation: (body) => guarded(() => {
317
+ emitter.emit({
318
+ kind: "generation",
319
+ traceSeed: body.traceId,
320
+ name: body.name,
321
+ attributes: compact({
322
+ input: body.input,
323
+ output: body.output,
324
+ level: body.level,
325
+ statusMessage: body.statusMessage,
326
+ model: body.model,
327
+ completionStartTime: body.completionStartTime,
328
+ usageDetails: body.usageDetails,
329
+ costDetails: body.costDetails,
330
+ metadata: { ...(body.metadata ?? {}), oxygen_observation_id: body.id },
331
+ }),
332
+ // The cost-bearing observation: v5 session cost only rolls up when
333
+ // the generation itself carries the session.
334
+ correlation: correlate({ sessionId: body.sessionId, userId: body.userId }),
335
+ startTime: body.startTime ?? new Date(),
336
+ ...(body.endTime ? { endTime: body.endTime } : {}),
337
+ });
338
+ }, "generation"),
339
+ event: (body) => guarded(() => {
340
+ const startTime = body.startTime ?? new Date();
341
+ emitter.emit({
342
+ kind: "event",
343
+ traceSeed: body.traceId,
344
+ name: body.name,
345
+ attributes: compact({
346
+ input: body.input,
347
+ metadata: { ...(body.metadata ?? {}), oxygen_observation_id: body.id },
348
+ }),
349
+ correlation: correlate({ sessionId: body.sessionId, userId: body.userId }),
350
+ startTime,
351
+ endTime: startTime,
352
+ });
353
+ }, "event"),
354
+ // The "never rejects, bounded at ~5s" contract is the CLIENT's, so it is
355
+ // enforced here rather than inside one emitter — an emitter that throws
356
+ // synchronously or rejects must still not escape into product code.
357
+ flush: () => boundedNever((async () => emitter.flush())(), (error) => warn(error, { stage: "flush" })),
358
+ shutdown: () => boundedNever((async () => emitter.shutdown())(), (error) => warn(error, { stage: "shutdown" })),
159
359
  };
160
360
  }
161
361
  // --- Process-wide singleton (both runtimes construct at most one client) ------
@@ -188,7 +388,8 @@ export function flushLlmTracing(env = process.env) {
188
388
  // then call scheduleLlmTracingFlush() and the flush runs post-response instead
189
389
  // of adding latency inside the request. Off-web (worker, tests) there is no
190
390
  // scheduler and the flush degrades to fire-and-forget — the worker's cycle-end
191
- // awaited flush + SDK interval flush are the durability guarantee there.
391
+ // awaited flush + the processor's own interval flush are the durability
392
+ // guarantee there.
192
393
  //
193
394
  // The registration lives on globalThis, not in a module-local: Next.js gives
194
395
  // instrumentation.ts and each route handler their own copy of this module, so a
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The briefing contract: every behavioural rule the OXYGEN session briefing has
3
+ * to state, and which half of it owns that rule.
4
+ *
5
+ * WHY THIS FILE EXISTS. The briefing is one string an agent reads once per
6
+ * session, so its only failure mode is silent omission: nothing crashes, no
7
+ * type breaks, and the rule simply stops being said. That happened. Splitting
8
+ * the MCP `instructions` into shared doctrine plus an MCP remainder dropped
9
+ * sixteen rules — sender rotation, the webhook/event/wait triggers, "never
10
+ * sends raw provider messages", the Knowledge citation and near-duplicate
11
+ * discipline, `ui://`, Crustdata routing, and more — while every existing
12
+ * assertion stayed green, because those assertions pinned lengths, the derived
13
+ * roster, and a handful of literal phrases, none of which is the rule set.
14
+ *
15
+ * WHY THIS SHAPE. There is no way to derive "this prose still tells an agent to
16
+ * rotate senders" from code, so the rule set has to be written down. What the
17
+ * ledger adds over sixteen inline `toContain` calls is:
18
+ *
19
+ * - Each entry names the behaviour in `rule`, so a future editor deleting a
20
+ * sentence sees what they are deleting rather than an opaque magic string.
21
+ * - `probes` is a disjunction: ANY match satisfies the rule. The briefing lives
22
+ * under a hard character budget, so it gets compressed often; probes key on
23
+ * the distinctive noun ("sender rotation") and accept alternate phrasings, so
24
+ * honest rewording stays green while deletion goes red.
25
+ * - `home` makes the shared/MCP split testable in both directions: a doctrine
26
+ * rule missing from the doctrine is red, an MCP-mechanics rule that leaks into
27
+ * the surface-neutral doctrine is red, and a rule stated in both halves is red
28
+ * (duplication is what the split was meant to end, and it is paid for twice in
29
+ * every session's context).
30
+ *
31
+ * WHAT IT DOES NOT CATCH, stated plainly so nobody over-trusts it:
32
+ *
33
+ * - Truth. A probe proves a phrase is present, not that the surrounding
34
+ * sentence is correct or still says the right thing.
35
+ * - Rules never entered here. New behaviour has to be added to this ledger by
36
+ * hand; the file is only as complete as its last review. `MINIMUM_RULE_COUNT`
37
+ * in the tests is a ratchet against quietly gutting the ledger itself, not a
38
+ * proof of completeness.
39
+ * - Whether an agent obeys any of it at runtime. That is an eval, not a test.
40
+ */
41
+ /** Which half of the composed briefing must state a rule. */
42
+ export type BriefingHome = "doctrine" | "mcp";
43
+ export interface BriefingRule {
44
+ /** Stable identifier. Rename the prose, never this. */
45
+ id: string;
46
+ /** The behaviour in plain English: what an agent loses if this goes missing. */
47
+ rule: string;
48
+ /**
49
+ * `doctrine` — surface-neutral product truth, equally valid for a Copilot
50
+ * turn, an Agent run, and an MCP session; lives in `product-doctrine.ts`.
51
+ * `mcp` — MCP mechanics or literal `oxygen_*` tool names; lives in the MCP
52
+ * server's remainder, and must stay out of the doctrine.
53
+ */
54
+ home: BriefingHome;
55
+ /** Accepted phrasings. Any one match satisfies the rule. */
56
+ probes: readonly RegExp[];
57
+ }
58
+ export declare const OXYGEN_BRIEFING_RULES: readonly BriefingRule[];