@oxygen-agent/cli 1.894.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.
@@ -0,0 +1,291 @@
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
+ export const OXYGEN_BRIEFING_RULES = [
42
+ // ---- Surface-neutral product doctrine ----------------------------------
43
+ {
44
+ id: "native-primitives-only",
45
+ rule: "Use the hosted primitives; never rebuild one with local scripts, cron, files, or a second engine.",
46
+ home: "doctrine",
47
+ probes: [/never rebuild [^.]*scripts, cron, files/i],
48
+ },
49
+ {
50
+ id: "records-are-canon",
51
+ rule: "Records hold canonical truth; a Table row is a candidate promoted onto Records, never the canon.",
52
+ home: "doctrine",
53
+ probes: [/Tables hold [^.]*promoted onto Records/i, /Records hold canonical truth/i],
54
+ },
55
+ {
56
+ id: "tables-own-column-work",
57
+ rule: "Tables own typed rows, formulas, AI/tool columns, waterfalls, cell state, and run provenance.",
58
+ home: "doctrine",
59
+ probes: [/waterfalls/i],
60
+ },
61
+ {
62
+ id: "row-dependencies-stay-columns",
63
+ rule: "Simple row dependencies stay chained Table columns instead of becoming a Workflow.",
64
+ home: "doctrine",
65
+ probes: [/row dependencies stay chained Table columns/i, /chained Table columns/i],
66
+ },
67
+ {
68
+ id: "linkedin-routing-by-interaction-state",
69
+ rule: "Interaction state, not recipient count, routes LinkedIn work: net-new is Sequences even for one recipient and one step; an existing thread or a single direct email is Messages.",
70
+ home: "doctrine",
71
+ probes: [/Interaction state, not recipient count/i],
72
+ },
73
+ {
74
+ id: "sequences-own-sender-rotation",
75
+ rule: "Sequences own sender rotation — rotating senders is never hand-rolled outside the primitive.",
76
+ home: "doctrine",
77
+ probes: [/sender rotation/i, /rotating senders/i],
78
+ },
79
+ {
80
+ id: "sequences-own-suppression",
81
+ rule: "Sequences own suppression.",
82
+ home: "doctrine",
83
+ probes: [/suppression/i],
84
+ },
85
+ {
86
+ id: "sequences-own-reply-stop",
87
+ rule: "Sequences own reply-stop.",
88
+ home: "doctrine",
89
+ probes: [/reply-stop/i],
90
+ },
91
+ {
92
+ id: "workflows-own-deterministic-orchestration",
93
+ rule: "Deterministic orchestration belongs to hosted Workflows, not to a local runner.",
94
+ home: "doctrine",
95
+ probes: [/belongs to hosted OXYGEN Workflows/i],
96
+ },
97
+ {
98
+ id: "workflow-triggers-include-webhook-and-event",
99
+ rule: "Workflow triggers include scheduled/cron, webhook, and event delivery — not only manual calls.",
100
+ home: "doctrine",
101
+ probes: [/webhook, event/i],
102
+ },
103
+ {
104
+ id: "workflows-can-wait",
105
+ rule: "Waiting is a Workflow step, so a delay does not justify a local script.",
106
+ home: "doctrine",
107
+ probes: [/branching, waiting/i],
108
+ },
109
+ {
110
+ id: "workflow-files-are-authoring-inputs",
111
+ rule: "Local workflow files are authoring inputs only; the hosted definition is the runtime.",
112
+ home: "doctrine",
113
+ probes: [/local files are authoring inputs only/i],
114
+ },
115
+ {
116
+ id: "workflow-outreach-enrolls-never-raw-sends",
117
+ rule: "Workflow outreach enrolls into an active, bounded Sequence and never sends raw provider messages.",
118
+ home: "doctrine",
119
+ probes: [/never sends raw provider messages/i, /never a raw provider send/i],
120
+ },
121
+ {
122
+ id: "agents-own-adaptive-runs",
123
+ rule: "Adaptive goals, threads, and checkpoints belong to governed Agents, which own first-class runs and may call Workflows as child actions.",
124
+ home: "doctrine",
125
+ probes: [/governed Agents/i],
126
+ },
127
+ {
128
+ id: "no-competing-store",
129
+ rule: "Neither Workflows nor Agents is a store: a draft is Messages, a note is Records activity.",
130
+ home: "doctrine",
131
+ probes: [/a draft is Messages/i],
132
+ },
133
+ {
134
+ id: "messages-stored-once",
135
+ rule: "Messages hold conversation content, stored once across channels.",
136
+ home: "doctrine",
137
+ probes: [/conversation content, stored once/i],
138
+ },
139
+ {
140
+ id: "signals-only-record",
141
+ rule: "Signals record that something happened; acting on one is Workflows or Sequences.",
142
+ home: "doctrine",
143
+ probes: [/Signals hold durable typed events/i],
144
+ },
145
+ {
146
+ id: "knowledge-cite-slugs",
147
+ rule: "Cite the slugs of the Knowledge pages an answer rests on.",
148
+ home: "doctrine",
149
+ probes: [/cite their slugs/i, /cite the slugs/i, /by slug/i],
150
+ },
151
+ {
152
+ id: "knowledge-no-near-duplicates",
153
+ rule: "Search before minting a near-duplicate Knowledge page.",
154
+ home: "doctrine",
155
+ probes: [/near-duplicate/i],
156
+ },
157
+ {
158
+ id: "knowledge-file-findings-back",
159
+ rule: "File durable findings back into Knowledge instead of leaving them in chat memory.",
160
+ home: "doctrine",
161
+ probes: [/file durable findings/i, /file findings back/i],
162
+ },
163
+ {
164
+ id: "knowledge-canonical-is-gated",
165
+ rule: "Canonical voice and positioning changes stay proposal-gated.",
166
+ home: "doctrine",
167
+ probes: [/proposal-gated/i],
168
+ },
169
+ {
170
+ id: "knowledge-pinned-context-and-off-voice",
171
+ rule: "Pinned canonical context is auto-applied to AI copy; if it is reported missing, say so rather than write off-voice copy.",
172
+ home: "doctrine",
173
+ probes: [/Pinned canonical context/i],
174
+ },
175
+ {
176
+ id: "knowledge-never-off-voice",
177
+ rule: "Never silently produce off-voice copy when the voice context is missing.",
178
+ home: "doctrine",
179
+ probes: [/off-voice/i],
180
+ },
181
+ {
182
+ id: "paid-work-needs-preview-scope-ceiling",
183
+ rule: "Paid provider work and external writes need a preview on a small sample, exact scope, approval, and a hard ceiling.",
184
+ home: "doctrine",
185
+ probes: [/preview on a small sample/i],
186
+ },
187
+ {
188
+ id: "spend-authority-is-not-tool-authority",
189
+ rule: "Never infer spend authority from tool authority.",
190
+ home: "doctrine",
191
+ probes: [/Never infer spend authority from tool authority/i],
192
+ },
193
+ // ---- MCP mechanics and literal tool names -------------------------------
194
+ {
195
+ id: "capability-search-returns-gateways",
196
+ rule: "Capability search returns ownership, boundaries, spend posture, gateways, and ranked tools — read it before guessing.",
197
+ home: "mcp",
198
+ probes: [/gateways/i],
199
+ },
200
+ {
201
+ id: "session-starts-with-whoami",
202
+ rule: "A session starts at `oxygen_whoami` for user, org, and factual onboarding markers.",
203
+ home: "mcp",
204
+ probes: [/oxygen_whoami/],
205
+ },
206
+ {
207
+ id: "context-resolve-before-gtm-work",
208
+ rule: "Call `oxygen_context_resolve` before context-dependent GTM work.",
209
+ home: "mcp",
210
+ probes: [/oxygen_context_resolve/],
211
+ },
212
+ {
213
+ id: "onboarding-never-blocks",
214
+ rule: "The onboarding gate is advisory: if the user declines, continue — never block a concrete ask.",
215
+ home: "mcp",
216
+ probes: [/never block a concrete ask/i],
217
+ },
218
+ {
219
+ id: "toolset-pack-recovery",
220
+ rule: "On 'No such tool available', reconnect with the returned `?toolset=<toolset_pack>`; never load a full manifest by default.",
221
+ home: "mcp",
222
+ probes: [/\?toolset=<toolset_pack>/],
223
+ },
224
+ {
225
+ // Both halves of the full-profile rule are load-bearing and they were carried
226
+ // separately in the pre-refactor text: the restraint (not by default) and the
227
+ // sanctioned case (registry-spanning work). Dropping the second half leaves an
228
+ // agent knowing full is discouraged but never knowing when it is earned.
229
+ id: "toolset-full-is-for-registry-spanning-work",
230
+ rule: "`?toolset=full` is reserved for work spanning the registry, never the default profile.",
231
+ home: "mcp",
232
+ probes: [/for registry-spanning work/i],
233
+ },
234
+ {
235
+ // Distinct from `onboarding-never-blocks`: that one governs the missing-context
236
+ // gate, this one governs the recipe detour. The pre-refactor text stated both and
237
+ // the first refactor collapsed them into one, which is why each is pinned alone.
238
+ id: "route-a-concrete-ask-directly",
239
+ rule: "A concrete ask is routed directly; playbook discovery is never pushed in front of it.",
240
+ home: "mcp",
241
+ probes: [/route a concrete ask directly/i],
242
+ },
243
+ {
244
+ id: "knowledge-is-index-first",
245
+ rule: "Knowledge work is index-first through `oxygen_knowledge_index`.",
246
+ home: "mcp",
247
+ probes: [/oxygen_knowledge_index/],
248
+ },
249
+ {
250
+ id: "open-only-relevant-pages",
251
+ rule: "Open only the relevant Knowledge pages rather than pulling the whole wiki into context.",
252
+ home: "mcp",
253
+ probes: [/open(?:ing)? only (?:the )?relevant pages/i],
254
+ },
255
+ {
256
+ id: "linkedin-public-read-is-cookieless",
257
+ rule: "Public or third-party LinkedIn reads use cookieless `scraper.*`; `linkedin.*` (Unipile) is only for the connected account.",
258
+ home: "mcp",
259
+ probes: [/cookieless `scraper\.\*`/],
260
+ },
261
+ {
262
+ id: "crustdata-only-for-indexed-datasets",
263
+ rule: "Use Crustdata only for a distinct indexed or longitudinal dataset the native scraper does not provide.",
264
+ home: "mcp",
265
+ probes: [/Crustdata only for an? [^.]*dataset/i],
266
+ },
267
+ {
268
+ id: "no-silent-costlier-fallback",
269
+ rule: "Never silently fall back to a costlier provider; surface unavailability.",
270
+ home: "mcp",
271
+ probes: [/Never silently fall back to a costlier provider/i],
272
+ },
273
+ {
274
+ id: "surface-deep-links",
275
+ rule: "Surface the `web_url` deep-links responses return.",
276
+ home: "mcp",
277
+ probes: [/web_url/],
278
+ },
279
+ {
280
+ id: "surface-inline-widgets",
281
+ rule: "Surface the inline `ui://` widgets responses return.",
282
+ home: "mcp",
283
+ probes: [/ui:\/\//],
284
+ },
285
+ {
286
+ id: "customer-terminals-use-oxygen",
287
+ rule: "Customer terminals use `oxygen`; `oxygen-dev` is internal unless requested.",
288
+ home: "mcp",
289
+ probes: [/`oxygen-dev` is internal/],
290
+ },
291
+ ];
@@ -0,0 +1,11 @@
1
+ import { type PrimitiveRouteCard } from "./capability-discovery.js";
2
+ /** The only route-card fields the doctrine reads, so a test can inject a roster. */
3
+ export type DoctrinePrimitive = Pick<PrimitiveRouteCard, "layer" | "primitive">;
4
+ /**
5
+ * Render the doctrine for a roster of primitives. Callers pass nothing; the
6
+ * parameter is the seam that lets a test prove the roster is derived by feeding
7
+ * a grown or shrunk route array and watching the output follow.
8
+ */
9
+ export declare function renderOxygenProductDoctrine(routes?: readonly DoctrinePrimitive[]): string;
10
+ /** The rendered doctrine for the shipped primitive roster. */
11
+ export declare const OXYGEN_PRODUCT_DOCTRINE: string;
@@ -0,0 +1,70 @@
1
+ import { OXYGEN_PRIMITIVE_ROUTES } from "./capability-discovery.js";
2
+ /**
3
+ * The surface-neutral OXYGEN product briefing: what OXYGEN is, which primitive
4
+ * owns which fact, and what paid work costs in authority.
5
+ *
6
+ * It exists because the briefing used to live only in the MCP server's
7
+ * `instructions` string, so an external Claude Code session was told the five
8
+ * layers and the ownership law while our own Workspace Copilot and Agent runs —
9
+ * the surfaces a non-expert customer actually uses — were told neither. Anything
10
+ * here must be true of EVERY surface: no tool names, no connector/profile
11
+ * mechanics, no host quirks. Those stay with the surface that owns them.
12
+ *
13
+ * The primitive roster is DERIVED from the capability route cards rather than
14
+ * restated. `capability-discovery.ts` already throws at module load if its cards
15
+ * drift from `RECIPE_PRIMITIVES`, so deriving here inherits that check: a
16
+ * sixteenth primitive, or a retired one, cannot ship with the doctrine still
17
+ * describing the old fifteen. A hand-typed roster is exactly the copy that rots
18
+ * silently, which is how it rotted in the first place.
19
+ *
20
+ * Every behavioural rule this text must state is pinned in
21
+ * `product-briefing-rules.ts`. Compress the prose freely; dropping a rule is a
22
+ * product decision that belongs in that ledger, not a side effect of a refactor.
23
+ */
24
+ /** Rendering order for the stack. Only layers that own a primitive get a line. */
25
+ const LAYER_ORDER = ["Control", "Knowledge", "Data", "Action", "External"];
26
+ /** `knowledge-graph` -> `Knowledge Graph`; the slug is the source, the label is derived. */
27
+ function primitiveLabel(primitive) {
28
+ return primitive
29
+ .split("-")
30
+ .map((word) => (word ? word[0].toUpperCase() + word.slice(1) : word))
31
+ .join(" ");
32
+ }
33
+ function joinLabels(labels) {
34
+ if (labels.length <= 1)
35
+ return labels[0] ?? "";
36
+ if (labels.length === 2)
37
+ return `${labels[0]} and ${labels[1]}`;
38
+ return `${labels.slice(0, -1).join(", ")}, and ${labels[labels.length - 1]}`;
39
+ }
40
+ function rosterLines(routes) {
41
+ return LAYER_ORDER.flatMap((layer) => {
42
+ const labels = routes.filter((card) => card.layer === layer).map((card) => primitiveLabel(card.primitive));
43
+ return labels.length ? [`- ${layer}: ${joinLabels(labels)}.`] : [];
44
+ }).join("\n");
45
+ }
46
+ /**
47
+ * Render the doctrine for a roster of primitives. Callers pass nothing; the
48
+ * parameter is the seam that lets a test prove the roster is derived by feeding
49
+ * a grown or shrunk route array and watching the output follow.
50
+ */
51
+ export function renderOxygenProductDoctrine(routes = OXYGEN_PRIMITIVE_ROUTES) {
52
+ return `OXYGEN is hosted revenue infrastructure for B2B startups; OXYGEN OS is its five-layer stack. Control surfaces (CLI, MCP, web, desktop, Copilot) call one contract, own no state; External is the platforms OXYGEN acts on. Between them, the ${routes.length} primitives, each owning its facts:
53
+
54
+ ${rosterLines(routes)}
55
+
56
+ Use native primitives so state, approvals, costs, provenance, and runs stay hosted and inspectable; never rebuild one with scripts, cron, files, or a second engine.
57
+
58
+ Ownership law — one owner per fact, no primitive re-implements another:
59
+
60
+ - Records hold canonical truth; Tables hold disposable work promoted onto Records — a row is a candidate, not canon. Tables own typed rows, formulas, AI and tool columns, waterfalls, cell state, and provenance; simple row dependencies stay chained Table columns, not Workflows.
61
+ - Interaction state, not recipient count, owns LinkedIn routing: net-new outreach is Sequences even for one recipient and one step; a reply in an existing thread or one direct email is Messages, holding conversation content, stored once. Cadence, enrollment, sender rotation, suppression, and reply-stop are Sequences.
62
+ - Signals hold durable typed events and record only that something happened; acting on one is Workflows or Sequences; an inbound reply is both, a reaction only a Signal.
63
+ - Deterministic scheduled, cron, webhook, event, branching, waiting, retrying, or approval-gated orchestration belongs to hosted OXYGEN Workflows: schema → lint → apply → call, and local files are authoring inputs only. Adaptive goals, persistent threads, and checkpoints belong to governed Agents, owning first-class runs and calling Workflows as child actions; neither compiles into the other nor is a store — a draft is Messages, a note Records activity, and Workflow outreach enrolls into an active, bounded Sequence and never sends raw provider messages.
64
+ - Posts hold broadcast artifacts and engagement telemetry; Publishing owns approval-gated scheduling, dispatch, retries, and provenance; Ads owns competitor ad intelligence; Tags are the cross-primitive label vocabulary; Dashboards curates native reporting; Observability is the read lens over runs, costs, and failures, never a second engine.
65
+ - The Knowledge Graph describes kinds of customer (ICP, personas, offers, rubrics, voice); Records holds the companies and people they describe. Durable knowledge belongs there, not chat memory: read pages, cite their slugs, search before minting a near-duplicate, file durable findings back. Canonical voice and positioning stay proposal-gated. Pinned canonical context is auto-applied to AI copy; if reported missing, say so rather than write off-voice. Recipes package motions that read it, advisory only.
66
+
67
+ Paid provider work and external writes require a preview on a small sample, exact scope, approval, and a hard credit or action ceiling. Never infer spend authority from tool authority. Surface deep-links, costs, failures, retries, and approval needs, not a bare success; name missing context, never guess.`;
68
+ }
69
+ /** The rendered doctrine for the shipped primitive roster. */
70
+ export const OXYGEN_PRODUCT_DOCTRINE = renderOxygenProductDoctrine();
@@ -193,20 +193,21 @@ export declare function espFromMxHosts(hosts: readonly string[] | null | undefin
193
193
  /**
194
194
  * row_values keys an enrollment's phone number may live under, in
195
195
  * send-precedence order, for WhatsApp sends. The enroll path resolves the FIRST
196
- * present key to a Unipile WhatsApp attendee id (E.164). Mirrors
196
+ * present key to a Unipile WhatsApp attendee id. Mirrors
197
197
  * SEQUENCE_EMAIL_COLUMN_KEYS so the WhatsApp send + lookup paths can't drift.
198
198
  */
199
199
  export declare const SEQUENCE_PHONE_COLUMN_KEYS: readonly ["phone", "phone_number", "mobile", "mobile_phone", "Phone"];
200
200
  /**
201
- * Normalize a raw phone number to the attendee id Unipile's WhatsApp
202
- * `chats_create` accepts (E.164 digits — no '+', spaces, or separators). Returns
203
- * null for an implausibly short/long number.
204
- *
205
- * NOTE (R1 — the one unconfirmed Unipile contract): the exact attendee format
206
- * Unipile wants to open a COLD WhatsApp chat is the single thing to confirm
207
- * against a live WhatsApp account. If Unipile needs a different shape (a resolved
208
- * attendee id, or a "<digits>@s.whatsapp.net" jid), THIS function is the only
209
- * seam to change — every caller routes through it.
201
+ * Normalize a phone-shaped value to the digits Oxygen uses as its stable local
202
+ * WhatsApp identity. A classic WhatsApp phone JID is accepted and reduced to its
203
+ * local digits; a privacy-preserving `@lid` value is not a phone and is rejected.
204
+ */
205
+ export declare function whatsAppPhoneDigits(phone: string): string | null;
206
+ /**
207
+ * Normalize a raw phone number to the exact recipient identifier Unipile's
208
+ * WhatsApp `chats_create` accepts. A real phone becomes the classic
209
+ * `<digits>@s.whatsapp.net` JID. Already-resolved classic or `@lid` identifiers
210
+ * pass through unchanged so replaying a durable action never double-suffixes it.
210
211
  */
211
212
  export declare function whatsAppAttendeeIdForPhone(phone: string): string | null;
212
213
  /**
@@ -216,10 +217,16 @@ export declare function whatsAppAttendeeIdForPhone(phone: string): string | null
216
217
  * enroll/plan path and any preview count can't drift.
217
218
  */
218
219
  export declare function whatsAppAttendeeIdFromRow(rowValues: Record<string, unknown> | null | undefined, phoneColumnKey?: string | null): string | null;
220
+ /**
221
+ * Resolve the stable local phone identity from a lead row without leaking the
222
+ * provider-specific JID into a Table phone column. This deliberately shares the
223
+ * same source-key precedence as WhatsApp dispatch.
224
+ */
225
+ export declare function whatsAppPhoneDigitsFromRow(rowValues: Record<string, unknown> | null | undefined, phoneColumnKey?: string | null): string | null;
219
226
  /**
220
227
  * Normalize a raw phone number to strict E.164 (`+` then 2-15 digits, no leading
221
228
  * zero) for the CALL channel. Deliberately stricter than
222
- * whatsAppAttendeeIdForPhone, which accepts any 7-15 bare digits: on WhatsApp a
229
+ * whatsAppPhoneDigits, which accepts any 7-15 bare digits: on WhatsApp a
223
230
  * bad guess fails an API call, but on the phone it rings a real stranger.
224
231
  *
225
232
  * Rules — anything ambiguous returns null rather than guessing:
@@ -457,27 +457,42 @@ export function espFromMxHosts(hosts) {
457
457
  /**
458
458
  * row_values keys an enrollment's phone number may live under, in
459
459
  * send-precedence order, for WhatsApp sends. The enroll path resolves the FIRST
460
- * present key to a Unipile WhatsApp attendee id (E.164). Mirrors
460
+ * present key to a Unipile WhatsApp attendee id. Mirrors
461
461
  * SEQUENCE_EMAIL_COLUMN_KEYS so the WhatsApp send + lookup paths can't drift.
462
462
  */
463
463
  export const SEQUENCE_PHONE_COLUMN_KEYS = ["phone", "phone_number", "mobile", "mobile_phone", "Phone"];
464
464
  /**
465
- * Normalize a raw phone number to the attendee id Unipile's WhatsApp
466
- * `chats_create` accepts (E.164 digits — no '+', spaces, or separators). Returns
467
- * null for an implausibly short/long number.
468
- *
469
- * NOTE (R1 — the one unconfirmed Unipile contract): the exact attendee format
470
- * Unipile wants to open a COLD WhatsApp chat is the single thing to confirm
471
- * against a live WhatsApp account. If Unipile needs a different shape (a resolved
472
- * attendee id, or a "<digits>@s.whatsapp.net" jid), THIS function is the only
473
- * seam to change — every caller routes through it.
465
+ * Normalize a phone-shaped value to the digits Oxygen uses as its stable local
466
+ * WhatsApp identity. A classic WhatsApp phone JID is accepted and reduced to its
467
+ * local digits; a privacy-preserving `@lid` value is not a phone and is rejected.
474
468
  */
475
- export function whatsAppAttendeeIdForPhone(phone) {
476
- const digits = phone.trim().replace(/[^\d]/g, "");
469
+ export function whatsAppPhoneDigits(phone) {
470
+ const trimmed = phone.trim().toLowerCase();
471
+ if (/^\d{7,15}@lid$/u.test(trimmed))
472
+ return null;
473
+ const classicJid = /^(\d{7,15})@s\.whatsapp\.net$/u.exec(trimmed);
474
+ if (classicJid?.[1])
475
+ return classicJid[1];
476
+ if (trimmed.includes("@"))
477
+ return null;
478
+ const digits = trimmed.replace(/[^\d]/g, "");
477
479
  if (digits.length < 7 || digits.length > 15)
478
480
  return null;
479
481
  return digits;
480
482
  }
483
+ /**
484
+ * Normalize a raw phone number to the exact recipient identifier Unipile's
485
+ * WhatsApp `chats_create` accepts. A real phone becomes the classic
486
+ * `<digits>@s.whatsapp.net` JID. Already-resolved classic or `@lid` identifiers
487
+ * pass through unchanged so replaying a durable action never double-suffixes it.
488
+ */
489
+ export function whatsAppAttendeeIdForPhone(phone) {
490
+ const trimmed = phone.trim().toLowerCase();
491
+ if (/^\d{7,15}@(s\.whatsapp\.net|lid)$/u.test(trimmed))
492
+ return trimmed;
493
+ const digits = whatsAppPhoneDigits(trimmed);
494
+ return digits ? `${digits}@s.whatsapp.net` : null;
495
+ }
481
496
  /**
482
497
  * Resolve a lead's WhatsApp attendee id from its row_values: read the configured
483
498
  * phone column (or fall back to SEQUENCE_PHONE_COLUMN_KEYS in precedence order),
@@ -498,10 +513,29 @@ export function whatsAppAttendeeIdFromRow(rowValues, phoneColumnKey) {
498
513
  }
499
514
  return null;
500
515
  }
516
+ /**
517
+ * Resolve the stable local phone identity from a lead row without leaking the
518
+ * provider-specific JID into a Table phone column. This deliberately shares the
519
+ * same source-key precedence as WhatsApp dispatch.
520
+ */
521
+ export function whatsAppPhoneDigitsFromRow(rowValues, phoneColumnKey) {
522
+ if (!rowValues)
523
+ return null;
524
+ const keys = phoneColumnKey ? [phoneColumnKey, ...SEQUENCE_PHONE_COLUMN_KEYS] : [...SEQUENCE_PHONE_COLUMN_KEYS];
525
+ for (const key of keys) {
526
+ const value = rowValues[key];
527
+ if (typeof value === "string" && value.trim()) {
528
+ const digits = whatsAppPhoneDigits(value);
529
+ if (digits)
530
+ return digits;
531
+ }
532
+ }
533
+ return null;
534
+ }
501
535
  /**
502
536
  * Normalize a raw phone number to strict E.164 (`+` then 2-15 digits, no leading
503
537
  * zero) for the CALL channel. Deliberately stricter than
504
- * whatsAppAttendeeIdForPhone, which accepts any 7-15 bare digits: on WhatsApp a
538
+ * whatsAppPhoneDigits, which accepts any 7-15 bare digits: on WhatsApp a
505
539
  * bad guess fails an API call, but on the phone it rings a real stranger.
506
540
  *
507
541
  * Rules — anything ambiguous returns null rather than guessing:
@@ -1,4 +1,4 @@
1
- export declare const OXYGEN_VERSION = "1.894.0";
1
+ export declare const OXYGEN_VERSION = "1.906.0";
2
2
  export declare const OXYGEN_MINIMUM_CLI_VERSION = "1.181.0";
3
3
  export declare const MANAGED_INBOX_MINIMUM_CLI_VERSION = "1.326.2";
4
4
  export declare const SUPPORT_AGENT_REPLY_MINIMUM_CLI_VERSION = "1.747.0";
@@ -1,4 +1,4 @@
1
- export const OXYGEN_VERSION = "1.894.0";
1
+ export const OXYGEN_VERSION = "1.906.0";
2
2
  // The GLOBAL CLI compatibility floor: the oldest CLI allowed to call any
3
3
  // operational route. Raising it hard-rejects every older CLI from the entire
4
4
  // product, so it obeys one law, enforced by scripts/ci/cli-min-version-gate.mjs:
@@ -171,6 +171,11 @@
171
171
  "import": "./dist/schedule-label.js",
172
172
  "default": "./dist/schedule-label.js"
173
173
  },
174
+ "./product-briefing-rules": {
175
+ "types": "./dist/product-briefing-rules.d.ts",
176
+ "import": "./dist/product-briefing-rules.js",
177
+ "default": "./dist/product-briefing-rules.js"
178
+ },
174
179
  "./publishing-limits": {
175
180
  "types": "./dist/publishing-limits.d.ts",
176
181
  "import": "./dist/publishing-limits.js",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxygen-agent/cli",
3
- "version": "1.894.0",
3
+ "version": "1.906.0",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -34,10 +34,12 @@
34
34
  "dependencies": {
35
35
  "@aws-sdk/client-s3": "3.1050.0",
36
36
  "@aws-sdk/s3-request-presigner": "3.1050.0",
37
+ "@langfuse/otel": "^5.11.0",
38
+ "@langfuse/tracing": "^5.11.0",
37
39
  "@opentelemetry/api": "1.9.1",
40
+ "@opentelemetry/sdk-trace-base": "2.10.0",
38
41
  "commander": "14.0.3",
39
42
  "esbuild": "0.27.7",
40
- "langfuse": "3.38.20",
41
43
  "read-excel-file": "6.0.3",
42
44
  "typescript": "6.0.3",
43
45
  "@oxygen/formula": "0.0.0",