@oxygen-agent/cli 1.632.3 → 1.662.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.
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The `oxygen-logs` field budget — one shared contract for both log shippers.
3
+ *
4
+ * WHY THIS EXISTS. Axiom caps a dataset at 1,024 distinct fields. `oxygen-logs`
5
+ * is shared by the Fly worker, the Vercel web runtime, and the Vercel log drain,
6
+ * and it reached that cap: measured 2026-08-08, **1,026 fields**. At the cap a
7
+ * single unrecognised key does not get dropped — Axiom rejects the ENTIRE ingest
8
+ * batch with HTTP 400, including every schema-stable record travelling with it.
9
+ *
10
+ * The worker already survived this, with a 400-triggered fallback that packed
11
+ * unknown keys into `message` and stayed there for the process lifetime. The web
12
+ * shipper had no such path: it logged `web.log_shipper.ingest_failed` and threw
13
+ * the batch away — 409 times in the 7 days to 2026-08-08, invisible to every
14
+ * Lane A query because the failure line could not ship itself either.
15
+ *
16
+ * So the strategy inverts. Instead of reacting to a 400, both shippers now pack
17
+ * PROACTIVELY: a fixed allowlist stays flat and queryable, and everything else —
18
+ * including every field any future code invents — is serialized into `message`
19
+ * under `worker_fields`. New instrumentation can then be added freely without
20
+ * anyone having to remember that a new key can take down log shipping globally.
21
+ *
22
+ * THE ALLOWLIST IS A LOAD-BEARING CONTRACT IN BOTH DIRECTIONS.
23
+ *
24
+ * - A field that a monitor, dashboard, or checked-in query reads **flat** must be
25
+ * in this set. Removing one does not break ingestion; it silently moves the
26
+ * value into `message` and the query starts returning zero, which reads as an
27
+ * all-clear forever.
28
+ * - A field that a monitor reads **out of `worker_fields`** must NOT be here.
29
+ * `[P3] OXYGEN Prod Egress order reconcile stuck` does
30
+ * `extend wf = parse_json(tostring(message)).worker_fields | dcount(tostring(wf.order_ref))`
31
+ * — promoting `order_ref` to a flat field would make `wf.order_ref` null and
32
+ * that monitor would report zero stuck orders while orders were stuck.
33
+ *
34
+ * Both directions are asserted by `scripts/ci/log-field-budget.mjs` against the
35
+ * real seed and dashboard JSON, so a change to either side fails loudly instead
36
+ * of quietly blinding a monitor.
37
+ */
38
+ /**
39
+ * Fields that stay top-level in `oxygen-logs`.
40
+ *
41
+ * Grouped by why each group is here. Do not add a field without a reader — the
42
+ * whole point of the budget is that flat columns are a scarce, spoken-for
43
+ * resource.
44
+ */
45
+ export declare const AXIOM_STABLE_FIELDS: ReadonlySet<string>;
46
+ /**
47
+ * The Axiom **map field** every non-allowlisted key is nested under.
48
+ *
49
+ * Map fields are Axiom's own answer to this problem and they are strictly better
50
+ * than the JSON-string packing this module shipped first: data inside a map field
51
+ * does not count toward the dataset field limit at all, AND it stays natively
52
+ * queryable with its types intact — `['worker_fields']['order_ref']`, no
53
+ * `parse_json(tostring(message))` wrapper, no everything-becomes-a-string.
54
+ *
55
+ * Proven on `oxygen-logs-dev` 2026-08-08: three records carrying three novel keys
56
+ * plus a nested object moved the dataset field count by exactly **+1** — the map
57
+ * field itself — where the same records as flat keys would have cost five columns.
58
+ *
59
+ * The name is deliberately unchanged from the old JSON blob's inner key, so every
60
+ * runbook, monitor, and memory that says "overflow lives in worker_fields" is
61
+ * still true; only the access path got shorter.
62
+ *
63
+ * The map field must EXIST on the dataset before ingest, or the keys land as
64
+ * ordinary flat fields and the budget is defeated silently. Create it with:
65
+ * POST /v2/datasets/{dataset}/mapfields {"name": "worker_fields"}
66
+ * `scripts/ops/axiom-mapfields.mjs` does this idempotently for every log dataset.
67
+ */
68
+ export declare const AXIOM_OVERFLOW_MAP_FIELD = "worker_fields";
69
+ /**
70
+ * Bound on the serialized overflow, so one pathological record cannot push a whole
71
+ * batch past Axiom's request limits. 64 KB is far above any real log line and far
72
+ * below anything that would matter.
73
+ */
74
+ export declare const MAX_PACKED_MESSAGE_LENGTH: number;
75
+ /**
76
+ * Splits a flat `log()` record into the allowlisted columns plus one map field
77
+ * holding everything else.
78
+ *
79
+ * The resulting shape is the query contract:
80
+ *
81
+ * ```kusto
82
+ * ['oxygen-logs']
83
+ * | where msg == 'worker.egress_order_reconcile_stuck'
84
+ * | summarize dcount(tostring(['worker_fields']['order_ref']))
85
+ * ```
86
+ *
87
+ * `worker_fields` keeps its name even for web records: it is already the word
88
+ * every existing monitor, dashboard, and runbook uses for "the overflow", and
89
+ * renaming it would silently zero those queries for no gain.
90
+ */
91
+ export declare function packAxiomLogRecord(record: Record<string, unknown>): Record<string, unknown>;
@@ -0,0 +1,262 @@
1
+ /**
2
+ * The `oxygen-logs` field budget — one shared contract for both log shippers.
3
+ *
4
+ * WHY THIS EXISTS. Axiom caps a dataset at 1,024 distinct fields. `oxygen-logs`
5
+ * is shared by the Fly worker, the Vercel web runtime, and the Vercel log drain,
6
+ * and it reached that cap: measured 2026-08-08, **1,026 fields**. At the cap a
7
+ * single unrecognised key does not get dropped — Axiom rejects the ENTIRE ingest
8
+ * batch with HTTP 400, including every schema-stable record travelling with it.
9
+ *
10
+ * The worker already survived this, with a 400-triggered fallback that packed
11
+ * unknown keys into `message` and stayed there for the process lifetime. The web
12
+ * shipper had no such path: it logged `web.log_shipper.ingest_failed` and threw
13
+ * the batch away — 409 times in the 7 days to 2026-08-08, invisible to every
14
+ * Lane A query because the failure line could not ship itself either.
15
+ *
16
+ * So the strategy inverts. Instead of reacting to a 400, both shippers now pack
17
+ * PROACTIVELY: a fixed allowlist stays flat and queryable, and everything else —
18
+ * including every field any future code invents — is serialized into `message`
19
+ * under `worker_fields`. New instrumentation can then be added freely without
20
+ * anyone having to remember that a new key can take down log shipping globally.
21
+ *
22
+ * THE ALLOWLIST IS A LOAD-BEARING CONTRACT IN BOTH DIRECTIONS.
23
+ *
24
+ * - A field that a monitor, dashboard, or checked-in query reads **flat** must be
25
+ * in this set. Removing one does not break ingestion; it silently moves the
26
+ * value into `message` and the query starts returning zero, which reads as an
27
+ * all-clear forever.
28
+ * - A field that a monitor reads **out of `worker_fields`** must NOT be here.
29
+ * `[P3] OXYGEN Prod Egress order reconcile stuck` does
30
+ * `extend wf = parse_json(tostring(message)).worker_fields | dcount(tostring(wf.order_ref))`
31
+ * — promoting `order_ref` to a flat field would make `wf.order_ref` null and
32
+ * that monitor would report zero stuck orders while orders were stuck.
33
+ *
34
+ * Both directions are asserted by `scripts/ci/log-field-budget.mjs` against the
35
+ * real seed and dashboard JSON, so a change to either side fails loudly instead
36
+ * of quietly blinding a monitor.
37
+ */
38
+ /**
39
+ * Fields that stay top-level in `oxygen-logs`.
40
+ *
41
+ * Grouped by why each group is here. Do not add a field without a reader — the
42
+ * whole point of the budget is that flat columns are a scarce, spoken-for
43
+ * resource.
44
+ */
45
+ export const AXIOM_STABLE_FIELDS = new Set([
46
+ // --- Envelope stamped by log() on every record ---------------------------
47
+ "ts",
48
+ "level",
49
+ "msg",
50
+ "service_name",
51
+ "oxygen_version",
52
+ "sha",
53
+ "region",
54
+ "env",
55
+ "surface",
56
+ // --- Ambient LogContext (packages/shared/src/log.ts) ----------------------
57
+ // These are the correlation dimensions every query joins on. A record that
58
+ // loses them is debuggable only by full-text search.
59
+ "request_id",
60
+ "trace_id",
61
+ "org_id",
62
+ "organization_id",
63
+ "user_id",
64
+ "tool_name",
65
+ "provider",
66
+ "operation",
67
+ "tenant_database_id",
68
+ "run_id",
69
+ "chunk_id",
70
+ "item_id",
71
+ "worker_id",
72
+ "conversation_id",
73
+ // --- Error shape (errorFields) -------------------------------------------
74
+ "error_id",
75
+ "error_name",
76
+ "error_message",
77
+ "error_code",
78
+ // Next.js stamps `digest` on a serialized server error; it is the only value
79
+ // that joins a browser error report back to its `next.request_error` row.
80
+ "digest",
81
+ // --- Primitive identity --------------------------------------------------
82
+ "workflow_id",
83
+ "workflow_run_id",
84
+ "table_id",
85
+ "row_id",
86
+ "column_id",
87
+ "action_run_id",
88
+ "trigger_id",
89
+ // --- Request/route shape (web, CLI, MCP) ---------------------------------
90
+ // Read by `cli.command_failed`, `next.request_error`, and the MCP/CLI health
91
+ // dashboard, which groups failures by route and command.
92
+ "path",
93
+ "route",
94
+ "route_path",
95
+ "route_type",
96
+ "router_kind",
97
+ "method",
98
+ "command",
99
+ "latency_ms",
100
+ "framework_code",
101
+ "component",
102
+ // --- Outcome vocabulary --------------------------------------------------
103
+ "mode",
104
+ "status",
105
+ "outcome",
106
+ "reason",
107
+ "role",
108
+ "phase",
109
+ "step",
110
+ "dataset",
111
+ // --- Monitor-critical gauges ---------------------------------------------
112
+ // Each of these is read FLAT by a monitor in docs/observability/monitors.json.
113
+ // `broken_tenant_oldest_age_ms` is the value `[P1] Tenant database broken`
114
+ // alerts on; `pending_count` is what `[P2] tenant migration drift` minimises.
115
+ "broken_tenant_oldest_age_ms",
116
+ "pending_count",
117
+ "divergence",
118
+ "window_start",
119
+ // Group keys for `notifyByGroup` alerts. These are what put the offender in
120
+ // the notification subject — packed away, the alert still fires and names
121
+ // nobody, which is the whole reason those alerts were grouped.
122
+ "sender_account_id",
123
+ "stripe_event_type",
124
+ // --- Worker queue counters (worker cycle telemetry) ----------------------
125
+ "queue",
126
+ "ready",
127
+ "claimable",
128
+ "leased",
129
+ "oldest_pending_age_seconds",
130
+ "stalled_tenant_count",
131
+ "active_runs",
132
+ "skipped_to",
133
+ "ticket_reason",
134
+ "queue_wait_ms",
135
+ "ticket_age_ms",
136
+ "quantum_duration_ms",
137
+ "tenants",
138
+ "claimed",
139
+ "completed",
140
+ "skipped",
141
+ "retrying",
142
+ "failed",
143
+ "expired",
144
+ "finalized",
145
+ "repaired",
146
+ "chainChildrenDispatched",
147
+ "ingestionClaimed",
148
+ "ingestionCompleted",
149
+ "ingestionRetrying",
150
+ "ingestionFailed",
151
+ "ingestionExpired",
152
+ "ingestionFinalized",
153
+ "ingestionRepaired",
154
+ "workflowClaimed",
155
+ "workflowCompleted",
156
+ "workflowRetrying",
157
+ "workflowFailed",
158
+ "workflowExpired",
159
+ "workflowAwaitingApproval",
160
+ "workflowApprovalsExpired",
161
+ "workflowWaiting",
162
+ "workflowCronInitialized",
163
+ "workflowCronEnqueued",
164
+ "workflowCronFailed",
165
+ "linkedinPlanned",
166
+ "linkedinDispatched",
167
+ "linkedinDeferred",
168
+ "linkedinSkipped",
169
+ "linkedinFailed",
170
+ "publishingClaimed",
171
+ "publishingPublished",
172
+ "publishingFailed",
173
+ "publishingDeferred",
174
+ "publishingLeasesRevived",
175
+ "publishingLeasesFailed",
176
+ ]);
177
+ /**
178
+ * The Axiom **map field** every non-allowlisted key is nested under.
179
+ *
180
+ * Map fields are Axiom's own answer to this problem and they are strictly better
181
+ * than the JSON-string packing this module shipped first: data inside a map field
182
+ * does not count toward the dataset field limit at all, AND it stays natively
183
+ * queryable with its types intact — `['worker_fields']['order_ref']`, no
184
+ * `parse_json(tostring(message))` wrapper, no everything-becomes-a-string.
185
+ *
186
+ * Proven on `oxygen-logs-dev` 2026-08-08: three records carrying three novel keys
187
+ * plus a nested object moved the dataset field count by exactly **+1** — the map
188
+ * field itself — where the same records as flat keys would have cost five columns.
189
+ *
190
+ * The name is deliberately unchanged from the old JSON blob's inner key, so every
191
+ * runbook, monitor, and memory that says "overflow lives in worker_fields" is
192
+ * still true; only the access path got shorter.
193
+ *
194
+ * The map field must EXIST on the dataset before ingest, or the keys land as
195
+ * ordinary flat fields and the budget is defeated silently. Create it with:
196
+ * POST /v2/datasets/{dataset}/mapfields {"name": "worker_fields"}
197
+ * `scripts/ops/axiom-mapfields.mjs` does this idempotently for every log dataset.
198
+ */
199
+ export const AXIOM_OVERFLOW_MAP_FIELD = "worker_fields";
200
+ /**
201
+ * Bound on the serialized overflow, so one pathological record cannot push a whole
202
+ * batch past Axiom's request limits. 64 KB is far above any real log line and far
203
+ * below anything that would matter.
204
+ */
205
+ export const MAX_PACKED_MESSAGE_LENGTH = 64 * 1024;
206
+ /**
207
+ * Splits a flat `log()` record into the allowlisted columns plus one map field
208
+ * holding everything else.
209
+ *
210
+ * The resulting shape is the query contract:
211
+ *
212
+ * ```kusto
213
+ * ['oxygen-logs']
214
+ * | where msg == 'worker.egress_order_reconcile_stuck'
215
+ * | summarize dcount(tostring(['worker_fields']['order_ref']))
216
+ * ```
217
+ *
218
+ * `worker_fields` keeps its name even for web records: it is already the word
219
+ * every existing monitor, dashboard, and runbook uses for "the overflow", and
220
+ * renaming it would silently zero those queries for no gain.
221
+ */
222
+ export function packAxiomLogRecord(record) {
223
+ const stable = {};
224
+ const overflow = {};
225
+ for (const [key, value] of Object.entries(record)) {
226
+ if (AXIOM_STABLE_FIELDS.has(key))
227
+ stable[key] = value;
228
+ else
229
+ overflow[key] = value;
230
+ }
231
+ // The capped dataset predates the Copilot column names, so `turn_id` and
232
+ // `session_id` cannot become flat fields. Copy them into the semantically
233
+ // equivalent correlation columns that DO exist — a Langfuse trace id is the
234
+ // turn id, and a Copilot session is the conversation grouping its turns —
235
+ // while leaving the exact original names inside the packed blob.
236
+ const copilotTraceId = record.turn_id ?? record.langfuse_trace_id;
237
+ if (stable.trace_id === undefined &&
238
+ typeof copilotTraceId === "string" &&
239
+ copilotTraceId.length > 0) {
240
+ stable.trace_id = copilotTraceId;
241
+ }
242
+ if (stable.conversation_id === undefined &&
243
+ typeof record.session_id === "string" &&
244
+ record.session_id.length > 0) {
245
+ stable.conversation_id = record.session_id;
246
+ }
247
+ if (Object.keys(overflow).length === 0)
248
+ return stable;
249
+ // Nested under the map field as a real object — NOT stringified. Axiom stores
250
+ // map contents without allocating a column per key, and keeps them queryable
251
+ // and typed.
252
+ stable[AXIOM_OVERFLOW_MAP_FIELD] =
253
+ JSON.stringify(overflow).length <= MAX_PACKED_MESSAGE_LENGTH
254
+ ? overflow
255
+ : {
256
+ // Never silently truncate to nothing: the key NAMES alone are usually
257
+ // enough to identify which call site produced the oversized record.
258
+ _truncated: true,
259
+ _keys: Object.keys(overflow),
260
+ };
261
+ return stable;
262
+ }
@@ -87,6 +87,42 @@ export const OXYGEN_CAPABILITY_ROUTES = [
87
87
  endpointSections: ["admin", "db", "worker"],
88
88
  intentTerms: ["admin", "worker", "queue", "tenant repair", "database", "db status", "stuck jobs", "operations"],
89
89
  },
90
+ {
91
+ // Control-layer interaction state, deliberately NOT a sixteenth primitive
92
+ // (ADR 0020): a comment or an approval request is state ABOUT a GTM
93
+ // artifact, and the hard gate is honored by the owning primitive's own
94
+ // route. `primitive: null` is what keeps the fifteen-primitive assertion
95
+ // in OXYGEN_PRIMITIVE_ROUTES true.
96
+ id: "collaboration",
97
+ layer: "Control",
98
+ primitive: null,
99
+ owns: "Threaded internal comments on any workspace object, second-party approval requests, the opt-in gate that blocks a live action until approved, and the restricted client role.",
100
+ notFor: "Public comments on a published post (Posts/Publishing), the artifact itself, or a run's own approval card — those belong to their owning primitive.",
101
+ execution: "Comment or request a decision on a subject reference (`<kind>:<id>`); an admin arms the gate, and the owning primitive refuses its live path until an approval lands.",
102
+ posture: "workspace_write",
103
+ gatewayTools: ["oxygen_approvals_inbox", "oxygen_approvals_request", "oxygen_approvals_decide", "oxygen_comments_add", "oxygen_comments_list", "oxygen_approvals_gate_set"],
104
+ gatewayCommands: ["approvals inbox", "approvals request", "approvals decide", "comments add", "comments list", "approvals gate set"],
105
+ skills: ["oxygen-collaboration"],
106
+ endpointSections: ["collab"],
107
+ intentTerms: [
108
+ "collaboration",
109
+ "comment thread",
110
+ "internal comment",
111
+ "leave a comment",
112
+ "sign off",
113
+ "signoff",
114
+ "approval request",
115
+ "request approval",
116
+ "approval gate",
117
+ "client approval",
118
+ "client review",
119
+ "client login",
120
+ "client role",
121
+ "go no go",
122
+ "waiting on me",
123
+ ],
124
+ negativeTerms: ["public comment", "linkedin comment", "post comment"],
125
+ },
90
126
  {
91
127
  id: "agency-directory",
92
128
  layer: "Control",
@@ -188,7 +224,7 @@ export const OXYGEN_CAPABILITY_ROUTES = [
188
224
  gatewayCommands: ["publishing comments list", "publishing analytics summary", "posts get", "posts create"],
189
225
  skills: ["oxygen-linkedin-marketing"],
190
226
  endpointSections: [],
191
- intentTerms: ["post artifact", "social post", "post comments", "public comments", "comments needing reply", "post reactions", "post performance", "posting analytics", "broadcast content"],
227
+ intentTerms: ["post artifact", "social post", "post comments", "public comments", "unanswered comments", "comments needing reply", "owned posts", "post reactions", "post performance", "posting analytics", "broadcast content"],
192
228
  },
193
229
  {
194
230
  id: "signals",
@@ -475,6 +511,16 @@ function explicitCapabilityIntent(query) {
475
511
  return ROUTE_BY_PRIMITIVE.get("workflows") ?? null;
476
512
  if (isOwnedPostCommentIntent(query))
477
513
  return ROUTE_BY_PRIMITIVE.get("posts") ?? null;
514
+ // Asking a second party to decide is Collaboration even when the thing being
515
+ // decided is a campaign, a table, or a post — the subject noun would
516
+ // otherwise win outright ("campaign" is an explicit Sequences intent below,
517
+ // and that is right for every ask that is not about the decision itself).
518
+ // Deliberately narrow: bare "approve"/"approval" stays with the owning
519
+ // primitive (a publishing approval, an Observability approval queue), and
520
+ // this sits after the owned-post-comment check so a PUBLIC comment on a
521
+ // published post still routes to Posts.
522
+ if (isSecondPartyDecisionIntent(query))
523
+ return ROUTE_BY_ID.get("collaboration") ?? null;
478
524
  // A deferred acquisition motion starts with the public-data owner. This keeps
479
525
  // "find/qualify now, contact later" from skipping straight to the final write
480
526
  // step; the recommendation still hands the eventual initiation to Sequences.
@@ -743,12 +789,21 @@ function isNetNewLinkedInInitiation(query) {
743
789
  return false;
744
790
  return !/\b(sequence|campaign|cadence|enroll|nurture|multi[ -]step|multi[ -]recipient|leads|contacts|recipients|audience|rotate senders?|stop on reply|day\s*\d+)\b/.test(query);
745
791
  }
792
+ function isSecondPartyDecisionIntent(query) {
793
+ const decisionLanguage = /\b(sign ?off|signoff|approval request|request approval|approval gate|go no go)\b/.test(query);
794
+ const clientDecision = /\bclient\b.{0,20}\b(approv\w*|sign ?off|review|decide|decision|login|role)\b/.test(query)
795
+ || /\b(approv\w*|sign ?off|decision)\b.{0,20}\bfrom the client\b/.test(query);
796
+ const internalThread = /\b(comment|comments|thread)\b/.test(query)
797
+ && /\b(internal|client|teammate|workspace member|discuss)\b/.test(query)
798
+ && !/\b(linkedin|public|published|post|posts|publishing)\b/.test(query);
799
+ return decisionLanguage || clientDecision || internalThread;
800
+ }
746
801
  function isOwnedPostCommentIntent(query) {
747
802
  if (!/\bcomments?\b/.test(query) || !/\b(linkedin|posts?|publishing)\b/.test(query))
748
803
  return false;
749
- const owned = /\b(my|our|own)\b|\boxygen[ -]published\b|\bpublished through oxygen\b/.test(query);
804
+ const owned = /\b(my|our|own|owned)\b|\boxygen[ -]published\b|\bpublished through oxygen\b/.test(query);
750
805
  const operatorWork = /\b(needs?|needing|awaiting)\b.{0,20}\b(response|reply|answer)\b/.test(query)
751
- || /\b(reply|respond|answer|triage|queue)\b/.test(query);
806
+ || /\b(reply|respond|answer|triage|queue|unanswered|unreplied)\b/.test(query);
752
807
  return owned || operatorWork;
753
808
  }
754
809
  function isHostedWorkflowIntent(query) {
@@ -770,7 +825,7 @@ function isPublicLinkedInRead(query) {
770
825
  return false;
771
826
  const disclaimsConnectedAccount = /\b(without|no|not using|does not require)\b.{0,24}\bconnected(?: linkedin)? account\b/.test(query);
772
827
  const connectedOwnership = !disclaimsConnectedAccount
773
- && /\b(my|our|own|connected|connections|followers|inbox|messages|recruiter|sales\s*navigator|salesnav|unipile|viewers)\b/.test(query);
828
+ && /\b(my|our|own|owned|connected|connections|followers|inbox|messages|recruiter|sales\s*navigator|salesnav|unipile|viewers)\b/.test(query);
774
829
  if (connectedOwnership)
775
830
  return false;
776
831
  return /\b(comments?|companies|company|competitor|cookieless|engagers?|harvest|posts?|profiles?|public|reactions?|reactors?|scrape|scraper|search|third[ -]party)\b/.test(query);