@oxygen-agent/cli 1.936.1 → 1.948.1

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 (33) hide show
  1. package/README.md +1 -1
  2. package/dist/command-manifest.js +24 -2
  3. package/dist/help.js +1 -0
  4. package/dist/index.js +342 -54
  5. package/dist/ugc-commands.js +353 -12
  6. package/node_modules/@oxygen/shared/dist/byok-connect.d.ts +11 -6
  7. package/node_modules/@oxygen/shared/dist/byok-connect.js +14 -6
  8. package/node_modules/@oxygen/shared/dist/capability-discovery.js +53 -5
  9. package/node_modules/@oxygen/shared/dist/email-dsn.d.ts +60 -0
  10. package/node_modules/@oxygen/shared/dist/email-dsn.js +120 -0
  11. package/node_modules/@oxygen/shared/dist/email-warmup-readiness.d.ts +64 -0
  12. package/node_modules/@oxygen/shared/dist/email-warmup-readiness.js +90 -0
  13. package/node_modules/@oxygen/shared/dist/index.d.ts +6 -0
  14. package/node_modules/@oxygen/shared/dist/index.js +6 -0
  15. package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.d.ts +50 -21
  16. package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.js +47 -21
  17. package/node_modules/@oxygen/shared/dist/langfuse.d.ts +8 -3
  18. package/node_modules/@oxygen/shared/dist/langfuse.js +177 -130
  19. package/node_modules/@oxygen/shared/dist/llm-payload.d.ts +10 -0
  20. package/node_modules/@oxygen/shared/dist/llm-payload.js +54 -0
  21. package/node_modules/@oxygen/shared/dist/llm-usage.d.ts +11 -0
  22. package/node_modules/@oxygen/shared/dist/llm-usage.js +30 -0
  23. package/node_modules/@oxygen/shared/dist/product-analytics-core.d.ts +98 -0
  24. package/node_modules/@oxygen/shared/dist/product-analytics-core.js +159 -0
  25. package/node_modules/@oxygen/shared/dist/product-analytics-environment.d.ts +18 -0
  26. package/node_modules/@oxygen/shared/dist/product-analytics-environment.js +46 -0
  27. package/node_modules/@oxygen/shared/dist/product-analytics-events.d.ts +92 -0
  28. package/node_modules/@oxygen/shared/dist/product-analytics-events.js +96 -0
  29. package/node_modules/@oxygen/shared/dist/ugc.d.ts +21 -1
  30. package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
  31. package/node_modules/@oxygen/shared/dist/version.js +1 -1
  32. package/node_modules/@oxygen/workflows/dist/graph/lint.js +22 -0
  33. package/package.json +1 -1
@@ -210,11 +210,11 @@ export const OXYGEN_CAPABILITY_ROUTES = [
210
210
  primitive: "tables",
211
211
  owns: "Typed working datasets, rows, formulas, AI/tool/waterfall columns, reusable Functions with isolated drafts and published versions, cell state, projects, and run provenance.",
212
212
  notFor: "Canonical CRM truth, message cadence, or an off-platform spreadsheet runtime.",
213
- execution: "Create and run work in hosted OXYGEN Tables; validate a small sample before bounded paid runs.",
213
+ execution: "Create and run work in hosted OXYGEN Tables; validate a small sample before bounded paid runs. For standard person or company enrichment, `columns add <table> --preset person_enrich|company_enrich` (MCP oxygen_columns_add with preset) adds the maintained bundle in one call before any hand-built tool column.",
214
214
  posture: "mixed",
215
215
  gatewayTools: ["oxygen_tables_create", "oxygen_columns_add", "oxygen_enrich_column_preview", "oxygen_tables_link_bulk", "oxygen_callables_manage"],
216
216
  gatewayCommands: ["tables create", "columns add", "enrich-column preview", "tables link", "functions list", "functions draft"],
217
- skills: ["oxygen-gtm", "oxygen-table-tidy", "oxygen-diagnostics", "oxygen-clay-migration"],
217
+ skills: ["oxygen-gtm", "oxygen-table-tidy", "oxygen-diagnostics", "oxygen-clay-migration", "oxygen-linkedin-marketing"],
218
218
  endpointSections: ["action-columns", "callables", "functions", "columns", "company-enrichment", "enrich-column", "enrichment", "projects", "table-action-items", "table-action-runs", "table-ingestion-runs", "tables"],
219
219
  // "link"/"join"/"connect"/"relate" route here for `tables link`. Added after a
220
220
  // blind user eval asked for exactly "link two tables" and was routed to
@@ -335,10 +335,13 @@ export const OXYGEN_CAPABILITY_ROUTES = [
335
335
  execution: "Create and schedule on OXYGEN; explicit approval releases the hosted publisher.",
336
336
  posture: "external_write",
337
337
  gatewayTools: ["oxygen_publishing_posts_create", "oxygen_publishing_posts_list", "oxygen_publishing_posts_approve", "oxygen_ugc_get"],
338
- gatewayCommands: ["publishing posts create", "publishing posts list", "publishing posts approve", "ugc programs list"],
338
+ gatewayCommands: ["publishing posts create", "publishing posts list", "publishing posts approve", "ugc programs list", "ugc programs duplicate", "ugc programs archive"],
339
339
  skills: ["oxygen-linkedin-marketing", "oxygen-ugc"],
340
340
  endpointSections: ["publishing", "ugc"],
341
- intentTerms: ["publish", "publishing", "schedule post", "content calendar", "posting calendar", "approve post", "social publishing", "ugc", "creator program", "user generated content", "creator voice", "sponsored creator"],
341
+ // "remove program" / "delete program" landed on route: null in a blind eval
342
+ // (2026-09-10); a program is a UGC noun here, and its lifecycle verbs must
343
+ // resolve to the group that owns `ugc programs archive|delete|duplicate`.
344
+ intentTerms: ["publish", "publishing", "schedule post", "content calendar", "posting calendar", "approve post", "social publishing", "ugc", "creator program", "user generated content", "creator voice", "sponsored creator", "ugc program", "archive program", "delete program", "remove program", "duplicate program", "copy program"],
342
345
  },
343
346
  {
344
347
  id: "workflows",
@@ -555,6 +558,13 @@ export function serializeCapabilityRoute(route) {
555
558
  function normalizeIntent(query) {
556
559
  return query.toLowerCase().replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim();
557
560
  }
561
+ function isLinkedInProfileWatcherIntent(query) {
562
+ return /\blinkedin\b/.test(query)
563
+ && /\bprofiles?\b/.test(query)
564
+ && /\b(watcher|watch|watching|monitor|monitoring|daily|every day|recurring)\b/.test(query)
565
+ && (/\b(engagers?|engaging|engagement|reactions?|comments?|posts?)\b/.test(query) || /\bprofile watcher\b/.test(query))
566
+ && !isNetNewLinkedInInitiation(query);
567
+ }
558
568
  function explicitCapabilityIntent(query) {
559
569
  if (/\b(infographic|graphic designer|render html|carousel pages|visual design|gtm flow image)\b/.test(query))
560
570
  return ROUTE_BY_ID.get("visual-rendering") ?? null;
@@ -574,6 +584,8 @@ function explicitCapabilityIntent(query) {
574
584
  if (isMailboxOnboardingIntent(query)) {
575
585
  return ROUTE_BY_ID.get("sending-infrastructure") ?? null;
576
586
  }
587
+ if (isLinkedInProfileWatcherIntent(query))
588
+ return ROUTE_BY_PRIMITIVE.get("tables") ?? null;
577
589
  if (isHostedWorkflowIntent(query))
578
590
  return ROUTE_BY_PRIMITIVE.get("workflows") ?? null;
579
591
  if (isOwnedPostCommentIntent(query))
@@ -612,6 +624,17 @@ function explicitCapabilityIntent(query) {
612
624
  if (/\b(my|our|own)\b.{0,40}\blinkedin\b.{0,40}\b(post )?engagers?\b/.test(query)) {
613
625
  return ROUTE_BY_PRIMITIVE.get("signals") ?? null;
614
626
  }
627
+ // Grading addresses a row ALREADY holds — "verify these emails", "which are
628
+ // safe to send", "catch-all or valid" — is Tables work (the Email verification
629
+ // column: `columns add --capability verify_email`, previewed and run through
630
+ // `enrich-column`), with `verify email` as the one-shot twin. Without this
631
+ // rule the bare word "email" scored the Messages card, and a 2026-09-10 blind
632
+ // user asking "verify email deliverability safe to send" was routed to
633
+ // `email send`. Sits after the mailbox rules above so warm-up and sender
634
+ // deliverability keep their owner, and after the scheduling rule so "every
635
+ // Monday verify new signups" still lands on Workflows.
636
+ if (isEmailVerificationIntent(query))
637
+ return ROUTE_BY_PRIMITIVE.get("tables") ?? null;
615
638
  if (/\b(recipe|playbook|proven play|what should i do)\b/.test(query)) {
616
639
  return ROUTE_BY_PRIMITIVE.get("recipes") ?? null;
617
640
  }
@@ -752,12 +775,27 @@ function recommendationsFor(card, query) {
752
775
  };
753
776
  }
754
777
  if (card.primitive === "tables") {
778
+ if (isLinkedInProfileWatcherIntent(query)) {
779
+ return {
780
+ tools: ["oxygen_tables_watcher"],
781
+ commands: ["tables watcher preview", "tables watcher create", "tables watcher get", "tables watcher update", "tables watcher pause", "tables watcher resume"],
782
+ };
783
+ }
755
784
  if (/\b(table )?(action )?runs?\b/.test(query)) {
756
785
  return {
757
786
  tools: ["oxygen_table_runs_get", "oxygen_table_runs_items", "oxygen_table_runs_wait", "oxygen_table_runs_retry_failed"],
758
787
  commands: ["table-runs get", "table-runs items", "table-runs wait", "table-runs retry-failed"],
759
788
  };
760
789
  }
790
+ if (isEmailVerificationIntent(query)) {
791
+ // Define-without-running first (0 credits), then the free preview that
792
+ // prices it, then the approved run, then the one-shot for a handful of
793
+ // addresses that never needed a table.
794
+ return {
795
+ tools: ["oxygen_columns_add", "oxygen_enrich_column_preview", "oxygen_enrich_column_run", "oxygen_verify_email"],
796
+ commands: ["columns add", "enrich-column preview", "enrich-column run", "verify email"],
797
+ };
798
+ }
761
799
  if (/\b(waterfall|enrich|enrichment|work email|mobile phone)\b/.test(query)) {
762
800
  return {
763
801
  tools: ["oxygen_enrich_column_preview", "oxygen_columns_add", "oxygen_enrich_column_run", "oxygen_table_runs_get"],
@@ -889,7 +927,7 @@ function recommendationsFor(card, query) {
889
927
  if (card.primitive === "signals" && /\blinkedin\b.{0,40}\bengagers?\b/.test(query)) {
890
928
  return {
891
929
  tools: ["oxygen_engagement_list_engagers", "oxygen_signals_list", "oxygen_signals_leads_today"],
892
- commands: ["engagement list", "signals list", "signals leads-today"],
930
+ commands: ["engagement engagers", "signals list", "signals leads-today"],
893
931
  };
894
932
  }
895
933
  if (card.id === "connected-whatsapp" && /\b(account|connect|limits?|sync)\b/.test(query)) {
@@ -900,6 +938,16 @@ function recommendationsFor(card, query) {
900
938
  }
901
939
  return { tools: [...card.gatewayTools], commands: [...card.gatewayCommands] };
902
940
  }
941
+ // An email-VERIFICATION ask names a grading verb next to the addresses, or the
942
+ // addresses next to a grade. "check my emails" is deliberately not matched
943
+ // (that is an inbox), and bare "deliverability" is left to the sending rail —
944
+ // only "are these emails deliverable" counts. Bare "bounce" describes an
945
+ // observed delivery failure too; require predictive wording or "bounce risk".
946
+ function isEmailVerificationIntent(query) {
947
+ const gradeThenAddress = /\b(verif(?:y|ied|ication)|validat(?:e|ed|ion)|grade|scrub|clean)\b.{0,40}\b(e ?mails?|email addresses|addresses)\b/;
948
+ const addressThenGrade = /\b(e ?mails?|addresses)\b.{0,40}\b(verif(?:y|ied|ication)|validat(?:e|ed|ion)|valid|invalid|deliverable|(?:will|would|might) bounce|bounce risk|risky|safe to (?:send|email)|catch ?all|accept ?all)\b/;
949
+ return gradeThenAddress.test(query) || addressThenGrade.test(query);
950
+ }
903
951
  function isInboxAvatarIntent(query) {
904
952
  return /\b(avatar|profile (?:picture|photo)|headshot|hosted (?:picture|image)|mailbox (?:picture|photo))\b/.test(query);
905
953
  }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * PURE classifier for delivery-status notifications (bounce NDRs / DSNs) that
3
+ * land back in a sending mailbox's own inbox.
4
+ *
5
+ * WHY this exists: a mailbox-provider reputation block is the loudest
6
+ * deliverability signal a workspace ever receives, and it arrives as an ordinary
7
+ * inbound email — "Message rejected", "support.google.com/mail/answer/69585" —
8
+ * that no OXYGEN surface reads. A warm-up vendor reporting 96% inbox placement
9
+ * cannot contradict it, because the vendor only measures its own seed network.
10
+ * Classifying the DSN turns the workspace's own inbox into first-party
11
+ * deliverability evidence: OXYGEN's send log said we sent, the DSN says the
12
+ * receiving provider refused it, and only the second one is the truth.
13
+ *
14
+ * Pure by design — no clock, no I/O, no provider call — so the same
15
+ * classification runs in the tenant rollup, a worker sweep, and a unit test.
16
+ * Output is stable machine data (kinds and codes), never prose.
17
+ */
18
+ /** What the notification actually says happened. */
19
+ export type DeliveryStatusNotificationKind =
20
+ /** The receiving provider refused the message on reputation/policy grounds. */
21
+ "provider_rejected"
22
+ /** The address does not exist (list hygiene, not reputation). */
23
+ | "no_such_user"
24
+ /** The recipient mailbox is over quota. */
25
+ | "mailbox_full"
26
+ /** Transient: retried, not dead. */
27
+ | "delayed"
28
+ /**
29
+ * The envelope proves it is a DSN, but nothing classifiable was stored — the
30
+ * body was never synced, so only the subject survives. Kept distinct from
31
+ * "other" because counting these as "other" reads as "we looked and found
32
+ * nothing wrong", when the truth is that we could not look.
33
+ */
34
+ | "unclassified"
35
+ /** A DSN with real content we can recognize but not attribute. */
36
+ | "other";
37
+ /** Every kind, in one place, so callers can build a complete counts map. */
38
+ export declare const DELIVERY_STATUS_NOTIFICATION_KINDS: readonly ["provider_rejected", "no_such_user", "mailbox_full", "delayed", "unclassified", "other"];
39
+ /** Which mailbox provider generated the notification, when it is knowable. */
40
+ export type DeliveryStatusNotificationProvider = "google" | "microsoft" | "unknown";
41
+ export type DeliveryStatusNotification = {
42
+ kind: DeliveryStatusNotificationKind;
43
+ provider: DeliveryStatusNotificationProvider;
44
+ /** The address the original message was addressed to, when the DSN names it. */
45
+ recipient: string | null;
46
+ /** Enhanced status code (5.7.1) when present, else the bare SMTP reply (550). */
47
+ smtpCode: string | null;
48
+ };
49
+ /**
50
+ * Classify one stored inbound message as a delivery-status notification.
51
+ *
52
+ * Returns null when the message is not a DSN at all — the caller's SQL prefilter
53
+ * is intentionally wide, and a false positive here would invent a deliverability
54
+ * incident out of a customer reply.
55
+ */
56
+ export declare function classifyDeliveryStatusNotification(input: {
57
+ fromAddress?: string | null;
58
+ subject?: string | null;
59
+ bodyText?: string | null;
60
+ }): DeliveryStatusNotification | null;
@@ -0,0 +1,120 @@
1
+ /**
2
+ * PURE classifier for delivery-status notifications (bounce NDRs / DSNs) that
3
+ * land back in a sending mailbox's own inbox.
4
+ *
5
+ * WHY this exists: a mailbox-provider reputation block is the loudest
6
+ * deliverability signal a workspace ever receives, and it arrives as an ordinary
7
+ * inbound email — "Message rejected", "support.google.com/mail/answer/69585" —
8
+ * that no OXYGEN surface reads. A warm-up vendor reporting 96% inbox placement
9
+ * cannot contradict it, because the vendor only measures its own seed network.
10
+ * Classifying the DSN turns the workspace's own inbox into first-party
11
+ * deliverability evidence: OXYGEN's send log said we sent, the DSN says the
12
+ * receiving provider refused it, and only the second one is the truth.
13
+ *
14
+ * Pure by design — no clock, no I/O, no provider call — so the same
15
+ * classification runs in the tenant rollup, a worker sweep, and a unit test.
16
+ * Output is stable machine data (kinds and codes), never prose.
17
+ */
18
+ /** Every kind, in one place, so callers can build a complete counts map. */
19
+ export const DELIVERY_STATUS_NOTIFICATION_KINDS = [
20
+ "provider_rejected",
21
+ "no_such_user",
22
+ "mailbox_full",
23
+ "delayed",
24
+ "unclassified",
25
+ "other",
26
+ ];
27
+ // A message only enters classification when its envelope looks like a DSN. The
28
+ // SQL prefilter that feeds this is deliberately loose (ILIKE), so this gate is
29
+ // what keeps an ordinary customer reply that happens to contain the word
30
+ // "blocked" out of the deliverability evidence.
31
+ const DSN_FROM_PATTERN = /(mailer-daemon|mail-daemon|postmaster|microsoftexchange)/i;
32
+ const DSN_SUBJECT_PATTERN = /(undeliver|delivery status notification|mail delivery fail|delivery has failed|returned mail)/i;
33
+ const GOOGLE_BLOCK_URL = /support\.google\.com\/mail\/answer\/69585/i;
34
+ const GOOGLE_FROM = /@(?:[a-z0-9-]+\.)*(?:googlemail|google)\.com\b/i;
35
+ const MICROSOFT_FROM = /(?:@(?:[a-z0-9-]+\.)*(?:outlook|office365|hotmail|microsoft)\.com\b|microsoftexchange|\bexchange\b)/i;
36
+ const MICROSOFT_BODY = /(microsoft exchange|exchange server|office\s?365)/i;
37
+ // Enhanced status code (RFC 3463): class.subject.detail, where subject/detail can
38
+ // be multi-digit (Google emits 5.7.708). Kept separate from the bare 3-digit SMTP
39
+ // reply so a caller can tell "550" from "5.7.1".
40
+ const ENHANCED_CODE = /\b([2-5]\.\d{1,3}\.\d{1,3})\b/;
41
+ const SMTP_REPLY_CODE = /\b([2-5]\d{2})\b/;
42
+ const REPUTATION_BLOCK = /\b5\.7\.\d{1,3}\b/;
43
+ const MAILBOX_FULL_CODE = /\b[45]\.2\.2\b/;
44
+ const NO_SUCH_USER_CODE = /\b5\.1\.\d{1,3}\b/;
45
+ const TRANSIENT_CODE = /(\b4\.\d{1,3}\.\d{1,3}\b|\b4\d{2}\b)/;
46
+ const MAILBOX_FULL_TEXT = /(mailbox (?:is )?full|over quota|quota exceeded|insufficient storage)/i;
47
+ const NO_SUCH_USER_TEXT = /(does ?n[o']?t exist|nosuchuser|no such user|user unknown|unknown user|address not found|recipient (?:address )?(?:not found|rejected)|invalid recipient)/i;
48
+ const PROVIDER_REJECTED_TEXT = /(message rejected|has been blocked|\bblocked\b|\bbanned\b|\bspam\b|local policy violation|unsolicited|reputation)/i;
49
+ const TRANSIENT_TEXT = /(temporar|will retry|try again later|rate ?limit|throttl)/i;
50
+ // Greedy address capture: the character class already stops at whitespace and
51
+ // angle brackets, so trailing sentence punctuation is stripped afterwards. A lazy
52
+ // capture would stop at the dot inside "gmail.com".
53
+ const ADDRESS = "([^\\s<>,;()]+@[^\\s<>,;()]+)";
54
+ const RECIPIENT_PATTERNS = [
55
+ new RegExp(`your message to\\s+<?${ADDRESS}>?`, "i"),
56
+ new RegExp(`\\bto\\s+<?${ADDRESS}>?\\s+(?:was|were|could not|couldn't|has been|had been)`, "i"),
57
+ // Machine-readable half of a real RFC 3464 report, when the provider sends one.
58
+ new RegExp(`(?:final|original)-recipient:\\s*rfc822;\\s*<?${ADDRESS}>?`, "i"),
59
+ ];
60
+ function readRecipient(haystack) {
61
+ for (const pattern of RECIPIENT_PATTERNS) {
62
+ const match = pattern.exec(haystack);
63
+ const value = match?.[1]?.replace(/[.,;:>]+$/, "").trim().toLowerCase();
64
+ if (value && value.includes("@"))
65
+ return value;
66
+ }
67
+ return null;
68
+ }
69
+ function readProvider(from, haystack) {
70
+ // The 69585 URL is decisive: only Google emits it, and it is the exact article
71
+ // a Gmail reputation block cites.
72
+ if (GOOGLE_BLOCK_URL.test(haystack) || GOOGLE_FROM.test(from))
73
+ return "google";
74
+ if (MICROSOFT_FROM.test(from) || MICROSOFT_BODY.test(haystack))
75
+ return "microsoft";
76
+ return "unknown";
77
+ }
78
+ function readKind(subject, haystack, hasBody) {
79
+ // Order is load-bearing. A Google "(Delay)" report can quote block-sounding
80
+ // prose while the message is still queued, so the transient marker wins first;
81
+ // an explicit enhanced code then beats keyword matching, because "blocked" and
82
+ // "spam" appear in boilerplate that a 5.1.1 no-such-user report also carries.
83
+ if (/\(delay\)/i.test(subject))
84
+ return "delayed";
85
+ if (REPUTATION_BLOCK.test(haystack))
86
+ return "provider_rejected";
87
+ if (MAILBOX_FULL_CODE.test(haystack) || MAILBOX_FULL_TEXT.test(haystack))
88
+ return "mailbox_full";
89
+ if (NO_SUCH_USER_CODE.test(haystack) || NO_SUCH_USER_TEXT.test(haystack))
90
+ return "no_such_user";
91
+ if (PROVIDER_REJECTED_TEXT.test(haystack))
92
+ return "provider_rejected";
93
+ if (TRANSIENT_CODE.test(haystack) || TRANSIENT_TEXT.test(haystack))
94
+ return "delayed";
95
+ // Nothing matched AND there was no body to match against: the sync stored the
96
+ // envelope only. Reporting that as "other" would be a false clean.
97
+ return hasBody ? "other" : "unclassified";
98
+ }
99
+ /**
100
+ * Classify one stored inbound message as a delivery-status notification.
101
+ *
102
+ * Returns null when the message is not a DSN at all — the caller's SQL prefilter
103
+ * is intentionally wide, and a false positive here would invent a deliverability
104
+ * incident out of a customer reply.
105
+ */
106
+ export function classifyDeliveryStatusNotification(input) {
107
+ const from = typeof input.fromAddress === "string" ? input.fromAddress : "";
108
+ const subject = typeof input.subject === "string" ? input.subject : "";
109
+ const body = typeof input.bodyText === "string" ? input.bodyText : "";
110
+ if (!DSN_FROM_PATTERN.test(from) && !DSN_SUBJECT_PATTERN.test(subject))
111
+ return null;
112
+ const haystack = `${subject}\n${body}`;
113
+ const smtpCode = ENHANCED_CODE.exec(haystack)?.[1] ?? SMTP_REPLY_CODE.exec(haystack)?.[1] ?? null;
114
+ return {
115
+ kind: readKind(subject, haystack, body.trim().length > 0),
116
+ provider: readProvider(from, haystack),
117
+ recipient: readRecipient(haystack),
118
+ smtpCode,
119
+ };
120
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * PURE warm-up readiness rule: what daily cold volume a sending mailbox can
3
+ * actually carry, given its warm-up age rather than a provider's opinion of it.
4
+ *
5
+ * WHY it lives in @oxygen/shared and not next to the other email-health rules in
6
+ * @oxygen/integrations: the tenant rollup computes this per mailbox, and
7
+ * @oxygen/tenant-db must never import @oxygen/integrations (integrations depends
8
+ * on tenant-db). Duplicating the thresholds so each package could own a copy is
9
+ * exactly how two surfaces start recommending different numbers, so the rule is
10
+ * defined once here and re-exported from
11
+ * packages/integrations/src/email-health/health-state.ts, which stays the
12
+ * email-health surface every caller reads.
13
+ *
14
+ * DIRECTIONAL, like everything else in the deliverability cluster: it produces a
15
+ * recommendation and a launch-preview warning. It never clamps a cap, never
16
+ * pauses a mailbox, and never changes what a sequence sends.
17
+ */
18
+ /** Below this warm-up day a mailbox should carry only the starter cold volume. */
19
+ export declare const WARMUP_EARLY_DAY_LIMIT = 14;
20
+ /** Below this warm-up day a mailbox should stay at the reduced cold volume. */
21
+ export declare const WARMUP_ESTABLISHED_DAY_LIMIT = 28;
22
+ /** Recommended cold sends/day before day 14 (and for an un-warmed young mailbox). */
23
+ export declare const WARMUP_EARLY_DAILY_CAP = 5;
24
+ /** Recommended cold sends/day between day 14 and day 28. */
25
+ export declare const WARMUP_ESTABLISHED_DAILY_CAP = 10;
26
+ /** A warm-up health score under this is treated as "not ready for volume". */
27
+ export declare const WARMUP_HEALTH_SCORE_FLOOR = 60;
28
+ /** Hard-bounce rate above which a fleet is burning its domains, not just its list. */
29
+ export declare const HARD_BOUNCE_RATE_CEILING = 0.03;
30
+ /** Below this send volume a bounce rate is noise, not a signal. */
31
+ export declare const HARD_BOUNCE_RATE_MIN_SENDS = 20;
32
+ /**
33
+ * The DIRECTIONAL per-mailbox daily cold-send ceiling warm-up readiness supports,
34
+ * or null when nothing in the evidence argues for holding volume back.
35
+ *
36
+ * Pure — the caller resolves `mailboxAgeDays` from created_at, so this stays
37
+ * clock-free and identical on every surface.
38
+ *
39
+ * - warm-up day < 14 -> 5/day
40
+ * - mailbox younger than 28 days, with either no active
41
+ * warm-up or no warm-up day reported at all -> 5/day
42
+ * - warm-up day < 28 -> 10/day
43
+ * - warm-up health score < 60 -> at most 5/day
44
+ * - otherwise -> null (no advice)
45
+ *
46
+ * The second rule is the one that matters for a freshly provisioned fleet: a
47
+ * mailbox whose warm-up never started (state "unknown") reports no day at all,
48
+ * and a rule keyed only on the day number would have said nothing about the exact
49
+ * mailboxes most likely to get blocked.
50
+ */
51
+ export declare function recommendedDailyCapForWarmup(input: {
52
+ warmupState?: string | null;
53
+ warmupDay?: number | null;
54
+ warmupHealthScore?: number | null;
55
+ mailboxAgeDays?: number | null;
56
+ }): number | null;
57
+ /**
58
+ * True when a hard-bounce count is high enough, over enough sends, to be a real
59
+ * reputation problem rather than list noise.
60
+ */
61
+ export declare function hardBounceRateIsHigh(input: {
62
+ hardBounces: number;
63
+ coldSends: number;
64
+ }): boolean;
@@ -0,0 +1,90 @@
1
+ /**
2
+ * PURE warm-up readiness rule: what daily cold volume a sending mailbox can
3
+ * actually carry, given its warm-up age rather than a provider's opinion of it.
4
+ *
5
+ * WHY it lives in @oxygen/shared and not next to the other email-health rules in
6
+ * @oxygen/integrations: the tenant rollup computes this per mailbox, and
7
+ * @oxygen/tenant-db must never import @oxygen/integrations (integrations depends
8
+ * on tenant-db). Duplicating the thresholds so each package could own a copy is
9
+ * exactly how two surfaces start recommending different numbers, so the rule is
10
+ * defined once here and re-exported from
11
+ * packages/integrations/src/email-health/health-state.ts, which stays the
12
+ * email-health surface every caller reads.
13
+ *
14
+ * DIRECTIONAL, like everything else in the deliverability cluster: it produces a
15
+ * recommendation and a launch-preview warning. It never clamps a cap, never
16
+ * pauses a mailbox, and never changes what a sequence sends.
17
+ */
18
+ /** Below this warm-up day a mailbox should carry only the starter cold volume. */
19
+ export const WARMUP_EARLY_DAY_LIMIT = 14;
20
+ /** Below this warm-up day a mailbox should stay at the reduced cold volume. */
21
+ export const WARMUP_ESTABLISHED_DAY_LIMIT = 28;
22
+ /** Recommended cold sends/day before day 14 (and for an un-warmed young mailbox). */
23
+ export const WARMUP_EARLY_DAILY_CAP = 5;
24
+ /** Recommended cold sends/day between day 14 and day 28. */
25
+ export const WARMUP_ESTABLISHED_DAILY_CAP = 10;
26
+ /** A warm-up health score under this is treated as "not ready for volume". */
27
+ export const WARMUP_HEALTH_SCORE_FLOOR = 60;
28
+ /** Hard-bounce rate above which a fleet is burning its domains, not just its list. */
29
+ export const HARD_BOUNCE_RATE_CEILING = 0.03;
30
+ /** Below this send volume a bounce rate is noise, not a signal. */
31
+ export const HARD_BOUNCE_RATE_MIN_SENDS = 20;
32
+ /** Warm-up states in which a vendor is actively conditioning the mailbox. */
33
+ const ACTIVE_WARMUP_STATES = new Set(["warming", "active"]);
34
+ function finiteOrNull(value) {
35
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
36
+ }
37
+ /**
38
+ * The DIRECTIONAL per-mailbox daily cold-send ceiling warm-up readiness supports,
39
+ * or null when nothing in the evidence argues for holding volume back.
40
+ *
41
+ * Pure — the caller resolves `mailboxAgeDays` from created_at, so this stays
42
+ * clock-free and identical on every surface.
43
+ *
44
+ * - warm-up day < 14 -> 5/day
45
+ * - mailbox younger than 28 days, with either no active
46
+ * warm-up or no warm-up day reported at all -> 5/day
47
+ * - warm-up day < 28 -> 10/day
48
+ * - warm-up health score < 60 -> at most 5/day
49
+ * - otherwise -> null (no advice)
50
+ *
51
+ * The second rule is the one that matters for a freshly provisioned fleet: a
52
+ * mailbox whose warm-up never started (state "unknown") reports no day at all,
53
+ * and a rule keyed only on the day number would have said nothing about the exact
54
+ * mailboxes most likely to get blocked.
55
+ */
56
+ export function recommendedDailyCapForWarmup(input) {
57
+ const day = finiteOrNull(input.warmupDay);
58
+ const ageDays = finiteOrNull(input.mailboxAgeDays);
59
+ const healthScore = finiteOrNull(input.warmupHealthScore);
60
+ const warmingNow = ACTIVE_WARMUP_STATES.has((input.warmupState ?? "").trim().toLowerCase());
61
+ let cap = null;
62
+ if (day !== null && day < WARMUP_EARLY_DAY_LIMIT) {
63
+ cap = WARMUP_EARLY_DAILY_CAP;
64
+ }
65
+ else if (ageDays !== null &&
66
+ ageDays < WARMUP_ESTABLISHED_DAY_LIMIT &&
67
+ (day === null || !warmingNow)) {
68
+ // A rail that reports "warming" but no day number proves nothing about how
69
+ // far the ramp got, so age still governs. Gating this on !warmingNow alone
70
+ // left exactly that mailbox — enrolled, young, no telemetry — with no advice.
71
+ cap = WARMUP_EARLY_DAILY_CAP;
72
+ }
73
+ else if (day !== null && day < WARMUP_ESTABLISHED_DAY_LIMIT) {
74
+ cap = WARMUP_ESTABLISHED_DAILY_CAP;
75
+ }
76
+ if (healthScore !== null && healthScore < WARMUP_HEALTH_SCORE_FLOOR) {
77
+ cap = cap === null ? WARMUP_EARLY_DAILY_CAP : Math.min(cap, WARMUP_EARLY_DAILY_CAP);
78
+ }
79
+ return cap;
80
+ }
81
+ /**
82
+ * True when a hard-bounce count is high enough, over enough sends, to be a real
83
+ * reputation problem rather than list noise.
84
+ */
85
+ export function hardBounceRateIsHigh(input) {
86
+ if (!Number.isFinite(input.coldSends) || input.coldSends < HARD_BOUNCE_RATE_MIN_SENDS) {
87
+ return false;
88
+ }
89
+ return input.hardBounces / input.coldSends > HARD_BOUNCE_RATE_CEILING;
90
+ }
@@ -30,12 +30,17 @@ export * from "./copilot-journeys.js";
30
30
  export * from "./copilot-plan.js";
31
31
  export * from "./credit-guidance.js";
32
32
  export * from "./directory.js";
33
+ export * from "./email-dsn.js";
34
+ export * from "./email-warmup-readiness.js";
33
35
  export * from "./email-tracking-token.js";
34
36
  export * from "./email-unsubscribe-token.js";
35
37
  export * from "./error-redaction.js";
36
38
  export * from "./future-signup-events.js";
37
39
  export * from "./future-signup-lifecycle-projection.js";
38
40
  export * from "./feature-gates.js";
41
+ export * from "./product-analytics-core.js";
42
+ export * from "./product-analytics-environment.js";
43
+ export * from "./product-analytics-events.js";
39
44
  export * from "./hosted-ai.js";
40
45
  export * from "./identifiers.js";
41
46
  export * from "./knowledge-constants.js";
@@ -44,6 +49,7 @@ export * from "./knowledge-links.js";
44
49
  export * from "./knowledge-markdown.js";
45
50
  export * from "./knowledge-seed-content.js";
46
51
  export * from "./langfuse.js";
52
+ export * from "./llm-usage.js";
47
53
  export * from "./linkedin-mentions.js";
48
54
  export * from "./linkedin-post-url.js";
49
55
  export * from "./linkedin-quota-denial.js";
@@ -30,12 +30,17 @@ export * from "./copilot-journeys.js";
30
30
  export * from "./copilot-plan.js";
31
31
  export * from "./credit-guidance.js";
32
32
  export * from "./directory.js";
33
+ export * from "./email-dsn.js";
34
+ export * from "./email-warmup-readiness.js";
33
35
  export * from "./email-tracking-token.js";
34
36
  export * from "./email-unsubscribe-token.js";
35
37
  export * from "./error-redaction.js";
36
38
  export * from "./future-signup-events.js";
37
39
  export * from "./future-signup-lifecycle-projection.js";
38
40
  export * from "./feature-gates.js";
41
+ export * from "./product-analytics-core.js";
42
+ export * from "./product-analytics-environment.js";
43
+ export * from "./product-analytics-events.js";
39
44
  export * from "./hosted-ai.js";
40
45
  export * from "./identifiers.js";
41
46
  export * from "./knowledge-constants.js";
@@ -44,6 +49,7 @@ export * from "./knowledge-links.js";
44
49
  export * from "./knowledge-markdown.js";
45
50
  export * from "./knowledge-seed-content.js";
46
51
  export * from "./langfuse.js";
52
+ export * from "./llm-usage.js";
47
53
  export * from "./linkedin-mentions.js";
48
54
  export * from "./linkedin-post-url.js";
49
55
  export * from "./linkedin-quota-denial.js";
@@ -2,28 +2,35 @@
2
2
  * Knowledge bootstrap — the once-per-workspace, automatic company research pass.
3
3
  *
4
4
  * WHAT IT IS. Exactly once in a workspace's life, OXYGEN researches the customer's
5
- * OWN company from the domain we already resolved at org creation
6
- * (control-DB `organizations.iconDomain`, `iconStatus = 'ok'`), then fills the typed
7
- * company profile (`ox_context.company_profile`) and writes one cited wiki page.
8
- * A founder who signs up should not face an empty Knowledge Graph and have to type
9
- * their own positioning back at us; every grounded AI action downstream (message
10
- * drafts, AI columns, agent runs) reads that profile, so an empty one degrades the
11
- * whole product's first hour.
5
+ * OWN company from the domain the creator typed when the workspace was created
6
+ * (control-DB `organizations.iconDomain`), then fills the typed company profile
7
+ * (`ox_context.company_profile`) and writes one cited wiki page. A founder who
8
+ * signs up should not face an empty Knowledge Graph and have to type their own
9
+ * positioning back at us; every grounded AI action downstream (message drafts, AI
10
+ * columns, agent runs) reads that profile, so an empty one degrades the whole
11
+ * product's first hour.
12
12
  *
13
13
  * WHY THIS IS NOT A VIOLATION OF THE PAID-ACTIONS RULE. `CLAUDE.md` says never run
14
14
  * paid provider actions unless the user explicitly asks. This slice is a deliberate,
15
- * founder-approved exception: signup IS the standing authorization, the same way an
16
- * armed table-webhook auto-run configuration is scoped standing permission for the
17
- * columns it queues. The exception is defensible ONLY because every one of the
18
- * following properties holds. They are load-bearing — do not drop one for
15
+ * founder-approved exception: naming the company at workspace creation IS the
16
+ * standing authorization, the same way an armed table-webhook auto-run
17
+ * configuration is scoped standing permission for the columns it queues — and
18
+ * since 2026-09-11 the pass is OXYGEN-funded, so it never draws down the
19
+ * customer's balance at all. The exception is defensible ONLY because every one of
20
+ * the following properties holds. They are load-bearing — do not drop one for
19
21
  * convenience, and if you remove one, the exception no longer stands:
20
22
  *
21
23
  * 1. CAPPED — `KNOWLEDGE_BOOTSTRAP_MAX_CREDITS` is a hard ceiling on the whole
22
24
  * pass, with `KNOWLEDGE_BOOTSTRAP_ENRICHMENT_MAX_CREDITS` a tighter
23
- * sub-ceiling on the provider (external-money) half.
24
- * 2. CONSENTED — it runs only for a workspace whose own domain we already derived
25
- * at signup; a personal-email signup is `skipped_personal_email`
26
- * upstream and never reaches here, so we never research a stranger.
25
+ * sub-ceiling on the provider (external-money) half. The cap bounds
26
+ * OXYGEN's own money now, and it is the same number.
27
+ * 2. AUTHORIZED — it runs only for a workspace whose creator typed the company
28
+ * website into the REQUIRED field at workspace creation; that
29
+ * entry is recorded as `knowledge_bootstrap_consent` (source
30
+ * `workspace_creation`) and read FAIL-CLOSED, so a workspace that
31
+ * predates the field is never researched and a personal-email
32
+ * signup with no website never reaches the worker. We never
33
+ * research a stranger.
27
34
  * 3. JOURNALLED — the marker below records status, timing, run id, credits and the
28
35
  * domain in `knowledge_state.watermarks`; the URLs read land as
29
36
  * immutable `page_sources` rows plus an `ingest` knowledge_log
@@ -36,6 +43,14 @@
36
43
  * ordinary revisioned wiki writes a human can revert.
37
44
  * 6. EXACTLY-ONCE — the marker is claimed with a conditional UPDATE, so N worker
38
45
  * replicas polling one tenant produce one run, never N runs.
46
+ * 7. OXYGEN-FUNDED — the automatic pass meters managed credits through the ordinary
47
+ * tool and AI-column lanes (so the cap, the marker and the run row
48
+ * all stay honest), and the worker then grants exactly the credits
49
+ * it used back to the workspace as an idempotent `bonus` ledger
50
+ * row keyed by the run (`KNOWLEDGE_BOOTSTRAP_COVERAGE_IDEMPOTENCY_PREFIX`).
51
+ * The customer's balance nets to zero and both rows are visible in
52
+ * their ledger. Only the AUTOMATIC pass is covered: a requested
53
+ * `--live --max-credits` re-run is the customer's own paid action.
39
54
  *
40
55
  * CAP ARITHMETIC (prices re-read from packages/integrations/src/cogs-rates.ts —
41
56
  * confirm them there rather than trusting this comment after a re-pricing):
@@ -52,8 +67,9 @@
52
67
  * overall ceiling.
53
68
  * Hard cap 300 cr — 85 cr nominal plus room for two retried synthesis
54
69
  * passes. 3% of the 10,000-credit signup grant
55
- * (FREE_SIGNUP_GRANT_CREDITS, ./billing.ts), so the
56
- * pass can never eat a founder's trial.
70
+ * (FREE_SIGNUP_GRANT_CREDITS, ./billing.ts), which is
71
+ * the headroom the balance gate needs even though the
72
+ * spend is granted back.
57
73
  *
58
74
  * The obvious implementation — firecrawl.map + 3x firecrawl.scrape — costs
59
75
  * 4 x 40 = 160 cr for the SAME job, because Firecrawl bills per page. exa.contents
@@ -164,9 +180,11 @@ export type KnowledgeBootstrapMarker = {
164
180
  linkedin_url?: string | null;
165
181
  };
166
182
  /**
167
- * Control-DB `organizations.metadata` key carrying the workspace's recorded consent
168
- * to the automatic research pass. Written once at signup by the setup surface; read
169
- * by the worker cycle before anything is spent.
183
+ * Control-DB `organizations.metadata` key carrying the workspace's recorded
184
+ * authorization for the automatic research pass. Written once, when the creator
185
+ * names the company website at workspace creation (`source: "workspace_creation"`;
186
+ * rows written by the retired `/setup` checkbox carry `source: "signup"` and stay
187
+ * valid); read by the worker cycle before anything is spent.
170
188
  *
171
189
  * It lives in shared, not in either caller, so the writer and the reader cannot
172
190
  * drift onto two different key names — a silent drift there would either deny every
@@ -178,7 +196,10 @@ export declare const KNOWLEDGE_BOOTSTRAP_CONSENT_METADATA_KEY = "knowledge_boots
178
196
  export type KnowledgeBootstrapConsent = {
179
197
  /** ISO timestamp consent was recorded. Must parse; a marker that does not is not consent. */
180
198
  granted_at: string;
181
- /** Where it came from, e.g. `signup`. Informational. */
199
+ /**
200
+ * Where it came from: `workspace_creation` (the required website field on the
201
+ * create-workspace screen) or the retired `signup` checkbox. Informational.
202
+ */
182
203
  source: string | null;
183
204
  /** Clerk user who accepted, when the surface knows. Informational. */
184
205
  granted_by_clerk_user_id: string | null;
@@ -227,6 +248,14 @@ export declare const KNOWLEDGE_BOOTSTRAP_LINKEDIN_METADATA_KEY = "company_linked
227
248
  * dead and the workspace is recoverable through the product.
228
249
  */
229
250
  export declare const KNOWLEDGE_BOOTSTRAP_STUCK_AFTER_MS: number;
251
+ /**
252
+ * Idempotency-key prefix for the ledger row that hands an automatic pass's
253
+ * credits back to the workspace (property 7 above). The key is
254
+ * `${prefix}:${run_id}`, so one pass is covered exactly once no matter how many
255
+ * times the worker revisits the outcome, and a `--force` re-run — a different
256
+ * run — is covered only if it was itself automatic.
257
+ */
258
+ export declare const KNOWLEDGE_BOOTSTRAP_COVERAGE_IDEMPOTENCY_PREFIX = "knowledge_bootstrap_coverage";
230
259
  /**
231
260
  * The one wiki page a bootstrap authors. A stable slug is what makes a re-armed pass
232
261
  * REVISE the note rather than litter the wiki with `company-research-2`.