@oxygen-agent/cli 1.987.20 → 1.1010.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 (176) hide show
  1. package/README.md +1 -1
  2. package/dist/admin-primary-providers-render.d.ts +0 -2
  3. package/dist/admin-primary-providers-render.js +1 -1
  4. package/dist/browser-login.js +1 -4
  5. package/dist/column-decision-options.d.ts +20 -0
  6. package/dist/column-decision-options.js +54 -0
  7. package/dist/command-manifest.d.ts +3 -2
  8. package/dist/command-manifest.js +25 -2
  9. package/dist/credentials.d.ts +1 -1
  10. package/dist/functions-commands.js +13 -9
  11. package/dist/help.d.ts +8 -0
  12. package/dist/help.js +46 -0
  13. package/dist/index.js +2751 -242
  14. package/dist/knowledge-mirror.d.ts +2 -2
  15. package/dist/runtime.d.ts +0 -15
  16. package/dist/runtime.js +1 -1
  17. package/dist/search-ai-filter-notice.d.ts +17 -0
  18. package/dist/search-ai-filter-notice.js +38 -0
  19. package/dist/session.d.ts +4 -3
  20. package/dist/skills.d.ts +8 -7
  21. package/dist/skills.js +58 -20
  22. package/dist/transcript.d.ts +2 -1
  23. package/dist/util.d.ts +10 -1
  24. package/dist/util.js +14 -2
  25. package/node_modules/@oxygen/cli-ugc/dist/commands.js +296 -140
  26. package/node_modules/@oxygen/cli-ugc/dist/field-parser.d.ts +9 -0
  27. package/node_modules/@oxygen/cli-ugc/dist/field-parser.js +34 -0
  28. package/node_modules/@oxygen/formula/dist/coerce.d.ts +10 -0
  29. package/node_modules/@oxygen/formula/dist/coerce.js +10 -0
  30. package/node_modules/@oxygen/formula/dist/formula-functions.js +65 -0
  31. package/node_modules/@oxygen/formula/dist/hash.d.ts +19 -0
  32. package/node_modules/@oxygen/formula/dist/hash.js +199 -0
  33. package/node_modules/@oxygen/formula/dist/value-cleaners.d.ts +6 -1
  34. package/node_modules/@oxygen/formula/dist/value-cleaners.js +10 -26
  35. package/node_modules/@oxygen/recipe-sdk/dist/index.d.ts +13 -0
  36. package/node_modules/@oxygen/shared/dist/array-utils.d.ts +5 -0
  37. package/node_modules/@oxygen/shared/dist/array-utils.js +11 -0
  38. package/node_modules/@oxygen/shared/dist/billing.d.ts +99 -22
  39. package/node_modules/@oxygen/shared/dist/billing.js +195 -40
  40. package/node_modules/@oxygen/shared/dist/byok-connect.js +5 -0
  41. package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +27 -0
  42. package/node_modules/@oxygen/shared/dist/capability-discovery.js +311 -28
  43. package/node_modules/@oxygen/shared/dist/cli-http-error.d.ts +8 -0
  44. package/node_modules/@oxygen/shared/dist/cli-http-error.js +8 -0
  45. package/node_modules/@oxygen/shared/dist/cli-result.js +1 -0
  46. package/node_modules/@oxygen/shared/dist/column-autofill.d.ts +52 -0
  47. package/node_modules/@oxygen/shared/dist/column-autofill.js +62 -0
  48. package/node_modules/@oxygen/shared/dist/column-decision.d.ts +50 -0
  49. package/node_modules/@oxygen/shared/dist/column-decision.js +228 -0
  50. package/node_modules/@oxygen/shared/dist/column-output-fields.js +2 -6
  51. package/node_modules/@oxygen/shared/dist/company-enrichment-fields.d.ts +113 -0
  52. package/node_modules/@oxygen/shared/dist/company-enrichment-fields.js +548 -0
  53. package/node_modules/@oxygen/shared/dist/copilot-skills.generated.d.ts +6 -6
  54. package/node_modules/@oxygen/shared/dist/copilot-skills.generated.js +6 -6
  55. package/node_modules/@oxygen/shared/dist/cutover-freeze.d.ts +26 -0
  56. package/node_modules/@oxygen/shared/dist/cutover-freeze.js +52 -0
  57. package/node_modules/@oxygen/shared/dist/data-suppliers.d.ts +57 -0
  58. package/node_modules/@oxygen/shared/dist/data-suppliers.js +59 -0
  59. package/node_modules/@oxygen/shared/dist/deploy-env.d.ts +74 -0
  60. package/node_modules/@oxygen/shared/dist/deploy-env.js +82 -0
  61. package/node_modules/@oxygen/shared/dist/dnc-rules.d.ts +130 -0
  62. package/node_modules/@oxygen/shared/dist/dnc-rules.js +221 -0
  63. package/node_modules/@oxygen/shared/dist/enrichment-intents.d.ts +107 -0
  64. package/node_modules/@oxygen/shared/dist/enrichment-intents.js +809 -0
  65. package/node_modules/@oxygen/shared/dist/error-message.d.ts +1 -0
  66. package/node_modules/@oxygen/shared/dist/error-message.js +3 -0
  67. package/node_modules/@oxygen/shared/dist/error-redaction.js +1 -3
  68. package/node_modules/@oxygen/shared/dist/external-write-policy.d.ts +33 -0
  69. package/node_modules/@oxygen/shared/dist/external-write-policy.js +68 -0
  70. package/node_modules/@oxygen/shared/dist/format-percent.d.ts +8 -0
  71. package/node_modules/@oxygen/shared/dist/format-percent.js +13 -0
  72. package/node_modules/@oxygen/shared/dist/freemail-domains.d.ts +81 -0
  73. package/node_modules/@oxygen/shared/dist/freemail-domains.js +157 -0
  74. package/node_modules/@oxygen/shared/dist/future-signup-lifecycle-projection.d.ts +1 -0
  75. package/node_modules/@oxygen/shared/dist/future-signup-lifecycle-projection.js +1 -1
  76. package/node_modules/@oxygen/shared/dist/hosted-ai.d.ts +60 -4
  77. package/node_modules/@oxygen/shared/dist/hosted-ai.js +125 -10
  78. package/node_modules/@oxygen/shared/dist/index.d.ts +15 -0
  79. package/node_modules/@oxygen/shared/dist/index.js +15 -0
  80. package/node_modules/@oxygen/shared/dist/json-path.js +1 -3
  81. package/node_modules/@oxygen/shared/dist/knowledge-bases.js +1 -3
  82. package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.d.ts +2 -2
  83. package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.js +2 -2
  84. package/node_modules/@oxygen/shared/dist/langfuse.d.ts +44 -1
  85. package/node_modules/@oxygen/shared/dist/langfuse.js +416 -14
  86. package/node_modules/@oxygen/shared/dist/linkedin-countries.d.ts +33 -0
  87. package/node_modules/@oxygen/shared/dist/linkedin-countries.js +361 -0
  88. package/node_modules/@oxygen/shared/dist/linkedin-country-timezones.d.ts +24 -0
  89. package/node_modules/@oxygen/shared/dist/linkedin-country-timezones.js +276 -0
  90. package/node_modules/@oxygen/shared/dist/linkedin-message-deletion.d.ts +2 -0
  91. package/node_modules/@oxygen/shared/dist/linkedin-message-deletion.js +5 -0
  92. package/node_modules/@oxygen/shared/dist/linkedin-post-keywords.d.ts +44 -0
  93. package/node_modules/@oxygen/shared/dist/linkedin-post-keywords.js +116 -0
  94. package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +96 -0
  95. package/node_modules/@oxygen/shared/dist/linkedin-sequences.js +123 -0
  96. package/node_modules/@oxygen/shared/dist/llm-durable-capture.d.ts +24 -0
  97. package/node_modules/@oxygen/shared/dist/llm-durable-capture.js +89 -0
  98. package/node_modules/@oxygen/shared/dist/llm-prompts.d.ts +75 -0
  99. package/node_modules/@oxygen/shared/dist/llm-prompts.js +161 -0
  100. package/node_modules/@oxygen/shared/dist/log-sink-selector.d.ts +39 -0
  101. package/node_modules/@oxygen/shared/dist/log-sink-selector.js +56 -0
  102. package/node_modules/@oxygen/shared/dist/log.d.ts +1 -0
  103. package/node_modules/@oxygen/shared/dist/log.js +6 -1
  104. package/node_modules/@oxygen/shared/dist/mailbox-egress-ownership.d.ts +90 -0
  105. package/node_modules/@oxygen/shared/dist/mailbox-egress-ownership.js +130 -0
  106. package/node_modules/@oxygen/shared/dist/object-storage.d.ts +17 -0
  107. package/node_modules/@oxygen/shared/dist/object-storage.js +21 -0
  108. package/node_modules/@oxygen/shared/dist/operational-telemetry.d.ts +24 -0
  109. package/node_modules/@oxygen/shared/dist/operational-telemetry.js +73 -0
  110. package/node_modules/@oxygen/shared/dist/otlp-log-sink.d.ts +79 -0
  111. package/node_modules/@oxygen/shared/dist/otlp-log-sink.js +366 -0
  112. package/node_modules/@oxygen/shared/dist/plan-capabilities.js +1 -0
  113. package/node_modules/@oxygen/shared/dist/plan-limits.d.ts +23 -22
  114. package/node_modules/@oxygen/shared/dist/plan-limits.js +45 -18
  115. package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +48 -41
  116. package/node_modules/@oxygen/shared/dist/pricing-sheet.js +36 -25
  117. package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.d.ts +22 -22
  118. package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.js +40 -34
  119. package/node_modules/@oxygen/shared/dist/product-analytics-environment.js +9 -0
  120. package/node_modules/@oxygen/shared/dist/product-analytics-events.d.ts +36 -2
  121. package/node_modules/@oxygen/shared/dist/product-analytics-events.js +36 -1
  122. package/node_modules/@oxygen/shared/dist/rate-window.d.ts +5 -0
  123. package/node_modules/@oxygen/shared/dist/rate-window.js +8 -0
  124. package/node_modules/@oxygen/shared/dist/redaction.js +4 -1
  125. package/node_modules/@oxygen/shared/dist/research-output-contract.js +1 -3
  126. package/node_modules/@oxygen/shared/dist/scraper-lane-credential.d.ts +18 -0
  127. package/node_modules/@oxygen/shared/dist/scraper-lane-credential.js +23 -0
  128. package/node_modules/@oxygen/shared/dist/search-vocab.js +4 -5
  129. package/node_modules/@oxygen/shared/dist/select-options.js +6 -1
  130. package/node_modules/@oxygen/shared/dist/sequence-failures.js +1 -5
  131. package/node_modules/@oxygen/shared/dist/sequences.d.ts +23 -0
  132. package/node_modules/@oxygen/shared/dist/sequences.js +115 -5
  133. package/node_modules/@oxygen/shared/dist/signup-lead-payload.d.ts +80 -0
  134. package/node_modules/@oxygen/shared/dist/signup-lead-payload.js +198 -0
  135. package/node_modules/@oxygen/shared/dist/social-capabilities.d.ts +6 -0
  136. package/node_modules/@oxygen/shared/dist/social-capabilities.js +25 -16
  137. package/node_modules/@oxygen/shared/dist/social-post-metrics-core.d.ts +32 -0
  138. package/node_modules/@oxygen/shared/dist/social-post-metrics-core.js +32 -0
  139. package/node_modules/@oxygen/shared/dist/social-post-metrics-linkedin.d.ts +31 -0
  140. package/node_modules/@oxygen/shared/dist/social-post-metrics-linkedin.js +103 -0
  141. package/node_modules/@oxygen/shared/dist/social-post-metrics-series.d.ts +96 -0
  142. package/node_modules/@oxygen/shared/dist/social-post-metrics-series.js +213 -0
  143. package/node_modules/@oxygen/shared/dist/social-post-metrics-x.d.ts +13 -0
  144. package/node_modules/@oxygen/shared/dist/social-post-metrics-x.js +78 -0
  145. package/node_modules/@oxygen/shared/dist/social-post-metrics.d.ts +36 -0
  146. package/node_modules/@oxygen/shared/dist/social-post-metrics.js +51 -0
  147. package/node_modules/@oxygen/shared/dist/spend-safety.d.ts +22 -10
  148. package/node_modules/@oxygen/shared/dist/spend-safety.js +15 -21
  149. package/node_modules/@oxygen/shared/dist/sql-rows.d.ts +1 -0
  150. package/node_modules/@oxygen/shared/dist/sql-rows.js +3 -0
  151. package/node_modules/@oxygen/shared/dist/stripe-price-catalog.d.ts +36 -0
  152. package/node_modules/@oxygen/shared/dist/stripe-price-catalog.js +184 -0
  153. package/node_modules/@oxygen/shared/dist/stripe-subscription-kind.d.ts +41 -0
  154. package/node_modules/@oxygen/shared/dist/stripe-subscription-kind.js +44 -0
  155. package/node_modules/@oxygen/shared/dist/table-limits.d.ts +3 -0
  156. package/node_modules/@oxygen/shared/dist/table-limits.js +3 -0
  157. package/node_modules/@oxygen/shared/dist/telemetry-export-observer.d.ts +94 -0
  158. package/node_modules/@oxygen/shared/dist/telemetry-export-observer.js +298 -0
  159. package/node_modules/@oxygen/shared/dist/telemetry.d.ts +11 -0
  160. package/node_modules/@oxygen/shared/dist/telemetry.js +28 -2
  161. package/node_modules/@oxygen/shared/dist/type-guards.d.ts +22 -0
  162. package/node_modules/@oxygen/shared/dist/type-guards.js +35 -0
  163. package/node_modules/@oxygen/shared/dist/ugc.d.ts +22 -11
  164. package/node_modules/@oxygen/shared/dist/ugc.js +10 -0
  165. package/node_modules/@oxygen/shared/dist/value-readers.d.ts +21 -0
  166. package/node_modules/@oxygen/shared/dist/value-readers.js +59 -0
  167. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -0
  168. package/node_modules/@oxygen/shared/dist/version.generated.js +2 -0
  169. package/node_modules/@oxygen/shared/dist/version.js +8 -1
  170. package/node_modules/@oxygen/shared/dist/workspace-event-catalog.js +0 -23
  171. package/node_modules/@oxygen/shared/dist/workspace-file-storage.d.ts +29 -0
  172. package/node_modules/@oxygen/shared/dist/workspace-file-storage.js +31 -0
  173. package/node_modules/@oxygen/shared/package.json +59 -0
  174. package/node_modules/@oxygen/workflows/dist/graph/expression.js +2 -5
  175. package/node_modules/@oxygen/workflows/dist/graph/params.js +1 -1
  176. package/package.json +3 -2
@@ -0,0 +1,198 @@
1
+ import { isPersonalEmailDomain } from "./freemail-domains.js";
2
+ import { readCoercedString as normalizeString } from "./type-guards.js";
3
+ /**
4
+ * Classifies an email address into a (likely) company work-email domain.
5
+ * Consumer/generic mailboxes return `{ domain: null, isGenericMailbox: true }`.
6
+ */
7
+ export function classifyWorkEmail(email) {
8
+ if (!email)
9
+ return { domain: null, isGenericMailbox: false };
10
+ const at = email.lastIndexOf("@");
11
+ if (at < 0 || at >= email.length - 1)
12
+ return { domain: null, isGenericMailbox: false };
13
+ const domain = email.slice(at + 1).trim().toLowerCase();
14
+ if (!domain || domain.indexOf(".") < 0)
15
+ return { domain: null, isGenericMailbox: false };
16
+ if (isPersonalEmailDomain(domain)) {
17
+ return { domain: null, isGenericMailbox: true };
18
+ }
19
+ return { domain, isGenericMailbox: false };
20
+ }
21
+ export function isGenericMailboxDomain(domain) {
22
+ if (!domain)
23
+ return false;
24
+ return isPersonalEmailDomain(domain);
25
+ }
26
+ export function buildSignupLeadWebhookPayload(input) {
27
+ const type = normalizeString(input.type) ?? "signup_lead.event";
28
+ const occurredAt = normalizeDate(input.occurredAt) ?? new Date().toISOString();
29
+ const eventId = normalizeString(input.eventId) ?? buildFallbackEventId(input, occurredAt);
30
+ const providerEventId = normalizeString(input.providerEventId) ?? eventId;
31
+ const eventType = input.lifecycleEvent ?? type;
32
+ const clerkUserId = normalizeString(input.user?.clerkUserId);
33
+ const email = normalizeString(input.user?.email)?.toLowerCase() ?? null;
34
+ const firstName = normalizeString(input.user?.firstName);
35
+ const lastName = normalizeString(input.user?.lastName);
36
+ const fullName = normalizeString([firstName, lastName].filter(Boolean).join(" "));
37
+ const clerkOrgId = normalizeString(input.organization?.clerkOrgId);
38
+ const companyName = normalizeString(input.organization?.name);
39
+ // Prefer the explicit Clerk org domain, but most product signups arrive with
40
+ // no org domain set. Fall back to the work-email domain so downstream
41
+ // company enrichment (domain -> LinkedIn -> profile) and the Attio company
42
+ // upsert have a key to work with. Generic mailboxes (gmail, …) stay null.
43
+ const companyDomain = normalizeString(input.organization?.domain)?.toLowerCase() ??
44
+ classifyWorkEmail(email).domain ??
45
+ null;
46
+ const role = normalizeString(input.membership?.role);
47
+ const organizationCreatedBy = normalizeString(input.organization?.createdBy);
48
+ const attribution = buildSignupAttribution(input.metadata);
49
+ const user = compactRecord({
50
+ clerk_user_id: clerkUserId,
51
+ email,
52
+ first_name: firstName,
53
+ last_name: lastName,
54
+ full_name: fullName,
55
+ created_at: normalizeDate(input.user?.createdAt),
56
+ linkedin_url: normalizeString(input.user?.linkedinUrl) ?? undefined,
57
+ });
58
+ const organization = compactRecord({
59
+ clerk_org_id: clerkOrgId,
60
+ name: companyName,
61
+ slug: normalizeString(input.organization?.slug),
62
+ domain: companyDomain,
63
+ created_at: normalizeDate(input.organization?.createdAt),
64
+ created_by: organizationCreatedBy,
65
+ });
66
+ const membership = compactRecord({ role });
67
+ const stripe = input.stripe
68
+ ? compactRecord({
69
+ customer_id: normalizeString(input.stripe.customerId),
70
+ subscription_id: normalizeString(input.stripe.subscriptionId),
71
+ invoice_id: normalizeString(input.stripe.invoiceId),
72
+ price_id: normalizeString(input.stripe.priceId),
73
+ tier: normalizeString(input.stripe.tier),
74
+ status: normalizeString(input.stripe.status),
75
+ trial_start: normalizeDate(input.stripe.trialStart),
76
+ trial_end: normalizeDate(input.stripe.trialEnd),
77
+ period_start: normalizeDate(input.stripe.periodStart),
78
+ period_end: normalizeDate(input.stripe.periodEnd),
79
+ amount_paid: input.stripe.amountPaid ?? undefined,
80
+ currency: normalizeString(input.stripe.currency)?.toLowerCase(),
81
+ billing_reason: normalizeString(input.stripe.billingReason),
82
+ })
83
+ : undefined;
84
+ const payload = compactRecord({
85
+ event_id: eventId,
86
+ event_type: eventType,
87
+ provider_event_id: providerEventId,
88
+ provider_event_type: type,
89
+ event: type,
90
+ type,
91
+ occurred_at: occurredAt,
92
+ source: input.source,
93
+ user,
94
+ person: user,
95
+ organization,
96
+ membership,
97
+ stripe,
98
+ metadata: input.metadata,
99
+ attribution,
100
+ qualification_evidence: input.qualificationEvidence,
101
+ research: input.research,
102
+ raw_event: input.rawEvent,
103
+ });
104
+ return compactRecord({
105
+ id: eventId,
106
+ event_id: eventId,
107
+ event_type: eventType,
108
+ provider_event_id: providerEventId,
109
+ provider_event_type: type,
110
+ event: type,
111
+ type,
112
+ occurred_at: occurredAt,
113
+ timestamp: occurredAt,
114
+ source: input.source,
115
+ provider: input.source,
116
+ clerk_user_id: clerkUserId,
117
+ clerk_org_id: clerkOrgId,
118
+ email,
119
+ first_name: firstName,
120
+ last_name: lastName,
121
+ full_name: fullName,
122
+ company_name: companyName,
123
+ company_domain: companyDomain,
124
+ organization_created_by: organizationCreatedBy,
125
+ person_linkedin_url: normalizeString(input.user?.linkedinUrl) ?? undefined,
126
+ role,
127
+ stripe_customer_id: normalizeString(input.stripe?.customerId),
128
+ stripe_subscription_id: normalizeString(input.stripe?.subscriptionId),
129
+ stripe_invoice_id: normalizeString(input.stripe?.invoiceId),
130
+ user,
131
+ person: user,
132
+ organization,
133
+ membership,
134
+ stripe,
135
+ metadata: input.metadata,
136
+ attribution,
137
+ qualification_evidence: input.qualificationEvidence,
138
+ research: input.research,
139
+ raw_event: input.rawEvent,
140
+ payload,
141
+ });
142
+ }
143
+ function buildSignupAttribution(metadata) {
144
+ if (!metadata)
145
+ return undefined;
146
+ const publicMetadata = recordValue(metadata.clerk_public_metadata);
147
+ const unsafeMetadata = recordValue(metadata.clerk_unsafe_metadata);
148
+ const combined = { ...unsafeMetadata, ...publicMetadata, ...metadata };
149
+ const selfReported = Array.isArray(combined.attribution_sources)
150
+ ? combined.attribution_sources.filter((value) => typeof value === "string" && value.trim().length > 0)
151
+ : [];
152
+ const result = compactRecord({
153
+ self_reported_sources: selfReported.length > 0 ? selfReported : undefined,
154
+ self_reported_other: normalizeString(combined.attribution_other) ?? undefined,
155
+ linkedin_url: firstStringValue(combined.linkedin_url, combined.linkedin_profile_url) ?? undefined,
156
+ landing_page: firstStringValue(combined.landing_page, combined.landing_page_url) ?? undefined,
157
+ referrer: firstStringValue(combined.referrer, combined.referrer_url) ?? undefined,
158
+ utm_source: normalizeString(combined.utm_source) ?? undefined,
159
+ utm_medium: normalizeString(combined.utm_medium) ?? undefined,
160
+ utm_campaign: normalizeString(combined.utm_campaign) ?? undefined,
161
+ utm_content: normalizeString(combined.utm_content) ?? undefined,
162
+ utm_term: normalizeString(combined.utm_term) ?? undefined,
163
+ });
164
+ return Object.keys(result).length > 0 ? result : undefined;
165
+ }
166
+ function buildFallbackEventId(input, occurredAt) {
167
+ const identity = normalizeString(input.user?.clerkUserId) ??
168
+ normalizeString(input.organization?.clerkOrgId) ??
169
+ normalizeString(input.user?.email) ??
170
+ normalizeString(input.organization?.domain) ??
171
+ "unknown";
172
+ return `signup_lead:${input.source}:${input.type}:${identity}:${occurredAt}`;
173
+ }
174
+ function compactRecord(record) {
175
+ return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined));
176
+ }
177
+ function normalizeDate(value) {
178
+ if (value === null || value === undefined)
179
+ return null;
180
+ const date = value instanceof Date ? value : new Date(value);
181
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
182
+ }
183
+ function stringValue(value) {
184
+ return normalizeString(value);
185
+ }
186
+ function firstStringValue(...values) {
187
+ for (const value of values) {
188
+ const normalized = stringValue(value);
189
+ if (normalized)
190
+ return normalized;
191
+ }
192
+ return null;
193
+ }
194
+ function recordValue(value) {
195
+ return value && typeof value === "object" && !Array.isArray(value)
196
+ ? value
197
+ : {};
198
+ }
@@ -26,6 +26,12 @@ export type SocialMetricCapability = {
26
26
  status: SocialCapabilityStatus;
27
27
  provider_field: string | null;
28
28
  reason: string | null;
29
+ /**
30
+ * `author_private` metrics are shown by the network only to the post's author
31
+ * (LinkedIn impressions). They never leave the author's own workspace — a UGC
32
+ * program master sees public telemetry only.
33
+ */
34
+ visibility: "public" | "author_private";
29
35
  };
30
36
  export type SocialCapabilityProfile = {
31
37
  version: 1;
@@ -39,9 +39,11 @@ const LINKEDIN_UNIPILE_CAPABILITIES = {
39
39
  read_post_metrics: {
40
40
  status: "limited",
41
41
  source: "provider",
42
- provider_operation: "linkedin.posts_get",
42
+ // Owned-post discovery pages carry every post's counters; `linkedin.posts_get`
43
+ // is the per-post fallback for posts discovery has not seen recently.
44
+ provider_operation: "linkedin.users_posts",
43
45
  external_write: false,
44
- limitation: "coarse_public_counters_only",
46
+ limitation: "owned_posts_author_analytics",
45
47
  },
46
48
  list_comments: {
47
49
  status: "available",
@@ -59,14 +61,19 @@ const LINKEDIN_UNIPILE_CAPABILITIES = {
59
61
  },
60
62
  },
61
63
  metrics: {
62
- impressions: { status: "unavailable", provider_field: null, reason: NOT_EXPOSED_BY_UNIPILE },
63
- reactions: { status: "available", provider_field: "reaction_counter", reason: null },
64
- comments: { status: "available", provider_field: "comment_counter", reason: null },
65
- reposts: { status: "available", provider_field: "repost_counter", reason: null },
66
- saves: { status: "unavailable", provider_field: null, reason: NOT_EXPOSED_BY_UNIPILE },
67
- sends: { status: "unavailable", provider_field: null, reason: NOT_EXPOSED_BY_UNIPILE },
68
- clicks: { status: "unavailable", provider_field: null, reason: NOT_EXPOSED_BY_UNIPILE },
69
- video_views: { status: "unavailable", provider_field: null, reason: NOT_EXPOSED_BY_UNIPILE },
64
+ impressions: {
65
+ status: "limited",
66
+ provider_field: "analytics.impressions_counter",
67
+ reason: "author_analytics_when_returned",
68
+ visibility: "author_private",
69
+ },
70
+ reactions: { status: "available", provider_field: "reactions_counter[].count", reason: null, visibility: "public" },
71
+ comments: { status: "available", provider_field: "comments_counter", reason: null, visibility: "public" },
72
+ reposts: { status: "available", provider_field: "reposts_counter", reason: null, visibility: "public" },
73
+ saves: { status: "unavailable", provider_field: null, reason: NOT_EXPOSED_BY_UNIPILE, visibility: "public" },
74
+ sends: { status: "unavailable", provider_field: null, reason: NOT_EXPOSED_BY_UNIPILE, visibility: "public" },
75
+ clicks: { status: "unavailable", provider_field: null, reason: NOT_EXPOSED_BY_UNIPILE, visibility: "author_private" },
76
+ video_views: { status: "unavailable", provider_field: null, reason: NOT_EXPOSED_BY_UNIPILE, visibility: "public" },
70
77
  },
71
78
  ingestion: {
72
79
  polling: true,
@@ -108,21 +115,23 @@ const X_COMPOSIO_CAPABILITIES = {
108
115
  },
109
116
  },
110
117
  metrics: {
111
- impressions: { status: "available", provider_field: "public_metrics.impression_count", reason: null },
112
- reactions: { status: "available", provider_field: "public_metrics.like_count", reason: null },
113
- comments: { status: "available", provider_field: "public_metrics.reply_count", reason: null },
114
- reposts: { status: "available", provider_field: "public_metrics.retweet_count", reason: null },
115
- saves: { status: "available", provider_field: "public_metrics.bookmark_count", reason: null },
116
- sends: { status: "unavailable", provider_field: null, reason: "not_exposed_by_x_post_lookup" },
118
+ impressions: { status: "available", provider_field: "public_metrics.impression_count", reason: null, visibility: "public" },
119
+ reactions: { status: "available", provider_field: "public_metrics.like_count", reason: null, visibility: "public" },
120
+ comments: { status: "available", provider_field: "public_metrics.reply_count", reason: null, visibility: "public" },
121
+ reposts: { status: "available", provider_field: "public_metrics.retweet_count", reason: null, visibility: "public" },
122
+ saves: { status: "available", provider_field: "public_metrics.bookmark_count", reason: null, visibility: "public" },
123
+ sends: { status: "unavailable", provider_field: null, reason: "not_exposed_by_x_post_lookup", visibility: "public" },
117
124
  clicks: {
118
125
  status: "limited",
119
126
  provider_field: "non_public_metrics.url_link_clicks",
120
127
  reason: "owned_posts_user_context_30_day_window",
128
+ visibility: "author_private",
121
129
  },
122
130
  video_views: {
123
131
  status: "limited",
124
132
  provider_field: "includes.media[].public_metrics.view_count",
125
133
  reason: "video_media_only",
134
+ visibility: "public",
126
135
  },
127
136
  },
128
137
  ingestion: {
@@ -0,0 +1,32 @@
1
+ import type { SocialPostMetric } from "./social-capabilities.js";
2
+ /**
3
+ * One provider read of a post's engagement, parsed into the registry vocabulary.
4
+ *
5
+ * Every value is a cumulative counter as of the read. An ABSENT key means the
6
+ * provider did not return that metric on this read; it is never a zero. A zero is
7
+ * only ever the provider saying "nobody did this".
8
+ */
9
+ export type ParsedSocialPostMetrics = {
10
+ metrics: Partial<Record<SocialPostMetric, number>>;
11
+ /** Channel-specific counters outside the canonical registry (same absent-key rule). */
12
+ extended: Record<string, number>;
13
+ /** Which provider payload shape was recognised, e.g. `unipile_v2`. */
14
+ shape: string;
15
+ /**
16
+ * False when the counters describe someone else's post — a LinkedIn repost
17
+ * carries the ORIGINAL post's reactions and comments. Such a read must never be
18
+ * attributed to the reposting account's performance.
19
+ */
20
+ attributable: boolean;
21
+ /** Metrics the provider returned but a plausibility guard refused. */
22
+ rejected: SocialPostMetric[];
23
+ };
24
+ /**
25
+ * A count the provider actually returned. Absent / null / non-numeric / negative
26
+ * all collapse to null — never to 0.
27
+ */
28
+ export declare function readMetricCount(value: unknown): number | null;
29
+ /** First key that carries a usable count; null when none did. */
30
+ export declare function pickMetricCount(record: Record<string, unknown>, keys: readonly string[]): number | null;
31
+ /** Drop nulls so an absent metric stays an absent key. */
32
+ export declare function presentMetrics<K extends string>(values: Record<K, number | null>): Partial<Record<K, number>>;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * A count the provider actually returned. Absent / null / non-numeric / negative
3
+ * all collapse to null — never to 0.
4
+ */
5
+ export function readMetricCount(value) {
6
+ if (typeof value === "number") {
7
+ return Number.isFinite(value) && value >= 0 ? Math.trunc(value) : null;
8
+ }
9
+ if (typeof value === "string" && value.trim() !== "") {
10
+ const parsed = Number(value);
11
+ return Number.isFinite(parsed) && parsed >= 0 ? Math.trunc(parsed) : null;
12
+ }
13
+ return null;
14
+ }
15
+ /** First key that carries a usable count; null when none did. */
16
+ export function pickMetricCount(record, keys) {
17
+ for (const key of keys) {
18
+ const value = readMetricCount(record[key]);
19
+ if (value !== null)
20
+ return value;
21
+ }
22
+ return null;
23
+ }
24
+ /** Drop nulls so an absent metric stays an absent key. */
25
+ export function presentMetrics(values) {
26
+ const present = {};
27
+ for (const [key, value] of Object.entries(values)) {
28
+ if (value !== null)
29
+ present[key] = value;
30
+ }
31
+ return present;
32
+ }
@@ -0,0 +1,31 @@
1
+ import { type ParsedSocialPostMetrics } from "./social-post-metrics-core.js";
2
+ /**
3
+ * Bump when the parse of a stored LinkedIn payload changes meaning, so stored raw
4
+ * payloads can be re-derived.
5
+ */
6
+ export declare const LINKEDIN_UNIPILE_POST_METRICS_PARSER_VERSION = 2;
7
+ /**
8
+ * Parse a LinkedIn post object returned by Unipile (`users_posts` list items and
9
+ * `posts_get` share the shape).
10
+ *
11
+ * Unipile's v1 DOCUMENTATION names the counters `reaction_counter`,
12
+ * `comment_counter` and `repost_counter`. The v2 API, which the v2 adapter passes
13
+ * through un-normalized, actually returns:
14
+ *
15
+ * reactions_counter [{ reaction, count }] one entry per reaction type
16
+ * comments_counter number
17
+ * reposts_counter number
18
+ * analytics { impressions_counter, page_viewers_from_this_post_counter,
19
+ * followers_gained_from_this_post_counter } — author-only
20
+ *
21
+ * Parsing the documented names against the real payload stored 5,172 production
22
+ * snapshots with every metric NULL (2026-09-23). Parsers here are pinned to
23
+ * redacted REAL payloads, never to vendor documentation.
24
+ *
25
+ * Impressions are read ONLY from the `analytics` object, which LinkedIn returns to
26
+ * the post's author. The v1 documentation's top-level `impressions_counter` is a
27
+ * placeholder zero and stays ignored. A repost's counters belong to the original
28
+ * post, so a repost is parsed but marked non-attributable.
29
+ */
30
+ export declare function parseLinkedInUnipilePostMetrics(payload: Record<string, unknown>): ParsedSocialPostMetrics;
31
+ export declare function linkedInUnipileMetricEvidence(payload: Record<string, unknown>): Record<string, unknown>;
@@ -0,0 +1,103 @@
1
+ import { pickMetricCount, presentMetrics, readMetricCount, } from "./social-post-metrics-core.js";
2
+ import { isRecord } from "./type-guards.js";
3
+ /**
4
+ * Bump when the parse of a stored LinkedIn payload changes meaning, so stored raw
5
+ * payloads can be re-derived.
6
+ */
7
+ export const LINKEDIN_UNIPILE_POST_METRICS_PARSER_VERSION = 2;
8
+ /**
9
+ * Parse a LinkedIn post object returned by Unipile (`users_posts` list items and
10
+ * `posts_get` share the shape).
11
+ *
12
+ * Unipile's v1 DOCUMENTATION names the counters `reaction_counter`,
13
+ * `comment_counter` and `repost_counter`. The v2 API, which the v2 adapter passes
14
+ * through un-normalized, actually returns:
15
+ *
16
+ * reactions_counter [{ reaction, count }] one entry per reaction type
17
+ * comments_counter number
18
+ * reposts_counter number
19
+ * analytics { impressions_counter, page_viewers_from_this_post_counter,
20
+ * followers_gained_from_this_post_counter } — author-only
21
+ *
22
+ * Parsing the documented names against the real payload stored 5,172 production
23
+ * snapshots with every metric NULL (2026-09-23). Parsers here are pinned to
24
+ * redacted REAL payloads, never to vendor documentation.
25
+ *
26
+ * Impressions are read ONLY from the `analytics` object, which LinkedIn returns to
27
+ * the post's author. The v1 documentation's top-level `impressions_counter` is a
28
+ * placeholder zero and stays ignored. A repost's counters belong to the original
29
+ * post, so a repost is parsed but marked non-attributable.
30
+ */
31
+ export function parseLinkedInUnipilePostMetrics(payload) {
32
+ const analytics = isRecord(payload.analytics) ? payload.analytics : null;
33
+ const shape = "reactions_counter" in payload || "comments_counter" in payload || "reposts_counter" in payload
34
+ ? "unipile_v2"
35
+ : "reaction_counter" in payload || "comment_counter" in payload || "repost_counter" in payload
36
+ ? "unipile_v1"
37
+ : "unknown";
38
+ const reactions = reactionTotal(payload.reactions_counter)
39
+ ?? pickMetricCount(payload, ["reaction_counter", "reaction_count", "reactions_count", "likes_count"]);
40
+ const comments = pickMetricCount(payload, ["comments_counter", "comment_counter", "comment_count", "comments_count"]);
41
+ const reposts = pickMetricCount(payload, ["reposts_counter", "repost_counter", "repost_count", "share_count", "shares_count"]);
42
+ let impressions = analytics ? pickMetricCount(analytics, ["impressions_counter", "impressions"]) : null;
43
+ const rejected = [];
44
+ const engagements = (reactions ?? 0) + (comments ?? 0) + (reposts ?? 0);
45
+ // Every reaction, comment and repost came from a member who saw the post, so an
46
+ // impression count below them is not a measurement we can stand behind.
47
+ if (impressions !== null && impressions < engagements) {
48
+ impressions = null;
49
+ rejected.push("impressions");
50
+ }
51
+ return {
52
+ metrics: presentMetrics({ reactions, comments, reposts, impressions }),
53
+ extended: presentMetrics({
54
+ followers_gained: analytics ? readMetricCount(analytics.followers_gained_from_this_post_counter) : null,
55
+ page_viewers: analytics ? readMetricCount(analytics.page_viewers_from_this_post_counter) : null,
56
+ }),
57
+ shape,
58
+ attributable: payload.is_repost !== true,
59
+ rejected,
60
+ };
61
+ }
62
+ /**
63
+ * Every field the LinkedIn parse reads, and nothing else: enough to re-derive the
64
+ * metrics after a parser fix, without storing the post's text, author or media.
65
+ */
66
+ const LINKEDIN_METRIC_EVIDENCE_KEYS = [
67
+ "id", "social_id", "object", "is_repost", "created_at", "date",
68
+ "reactions_counter", "comments_counter", "reposts_counter", "analytics",
69
+ "reaction_counter", "comment_counter", "repost_counter",
70
+ "reaction_count", "reactions_count", "likes_count", "comment_count", "comments_count",
71
+ "repost_count", "share_count", "shares_count",
72
+ ];
73
+ export function linkedInUnipileMetricEvidence(payload) {
74
+ const evidence = {};
75
+ for (const key of LINKEDIN_METRIC_EVIDENCE_KEYS) {
76
+ if (key in payload)
77
+ evidence[key] = payload[key];
78
+ }
79
+ return evidence;
80
+ }
81
+ /** v2 carries one `{reaction, count}` entry per reaction type; v1 a single number. */
82
+ function reactionTotal(value) {
83
+ const direct = readMetricCount(value);
84
+ if (direct !== null)
85
+ return direct;
86
+ if (!Array.isArray(value))
87
+ return null;
88
+ // An empty list is the provider stating "no reactions of any type".
89
+ if (value.length === 0)
90
+ return 0;
91
+ let total = 0;
92
+ let found = false;
93
+ for (const item of value) {
94
+ if (!isRecord(item))
95
+ continue;
96
+ const count = readMetricCount(item.count);
97
+ if (count === null)
98
+ continue;
99
+ total += count;
100
+ found = true;
101
+ }
102
+ return found ? total : null;
103
+ }
@@ -0,0 +1,96 @@
1
+ import type { SocialPostMetric } from "./social-capabilities.js";
2
+ /**
3
+ * Engagement EARNED per day, derived from cumulative provider counters.
4
+ *
5
+ * A provider only ever reports a post's running total. What a founder asks —
6
+ * "how many people did my posts reach this week, and is that growing?" — is the
7
+ * DIFFERENCE between reads, attributed to the days it happened in. The rules:
8
+ *
9
+ * - Every counter is 0 at publish, so the publish instant is the first point.
10
+ * - An observation is flat from `observedAt` through `confirmedAt`; only the gap
11
+ * from one observation's `confirmedAt` to the next one's `observedAt` is unknown.
12
+ * - A delta whose unknown gap is at most `maxProrateMs` is spread across the days
13
+ * of that gap in proportion to time and flagged `estimated` when it spans more
14
+ * than one day. A longer gap (a post discovered weeks after publishing, a sync
15
+ * outage) is NOT smeared across days: its share inside the window is reported as
16
+ * `unattributed`, because pretending to know which day it happened on would be a
17
+ * confident-looking lie.
18
+ * - A negative delta (a deleted comment) is kept, so totals stay net-correct, and
19
+ * counted in `decreases`.
20
+ * - A day no post was tracked on is a gap (`null`), never a zero.
21
+ */
22
+ export type MetricObservationPoint = {
23
+ observedAt: Date;
24
+ confirmedAt: Date;
25
+ metrics: Partial<Record<SocialPostMetric, number>>;
26
+ };
27
+ export type PostMetricTimeline = {
28
+ publishedAt: Date | null;
29
+ /** Ascending by observedAt. */
30
+ observations: MetricObservationPoint[];
31
+ };
32
+ export type EarnedDay = {
33
+ /** Local calendar date in the requested time zone. */
34
+ date: string;
35
+ value: number | null;
36
+ estimated: boolean;
37
+ postsTracked: number;
38
+ };
39
+ export type EarnedWindow = {
40
+ days: EarnedDay[];
41
+ /** Sum of the day values plus `unattributed`; null when nothing was tracked. */
42
+ total: number | null;
43
+ /** Earned inside the window across gaps too long to place on a day. */
44
+ unattributed: number;
45
+ decreases: number;
46
+ postsReporting: number;
47
+ };
48
+ export declare const EARNED_SERIES_MAX_PRORATE_MS: number;
49
+ /**
50
+ * Earned per day for one metric, or the sum of several (engagements), across a
51
+ * set of posts. `dayStarts` are the instants each local day begins; `windowEnd`
52
+ * closes the last day.
53
+ */
54
+ export declare function computeEarnedWindow(input: {
55
+ timelines: readonly PostMetricTimeline[];
56
+ metrics: readonly SocialPostMetric[];
57
+ dayStarts: readonly {
58
+ date: string;
59
+ start: Date;
60
+ }[];
61
+ windowEnd: Date;
62
+ maxProrateMs?: number;
63
+ }): EarnedWindow;
64
+ export type ValueAtAge = {
65
+ value: number;
66
+ interpolated: boolean;
67
+ };
68
+ /**
69
+ * A post's cumulative metric (or sum of metrics) exactly `ageMs` after publishing,
70
+ * so posts can be compared at the same age ("impressions after 24 hours"). Exact
71
+ * inside a known-flat interval; interpolated linearly across a gap no longer than
72
+ * `maxProrateMs`; null when the age falls in a longer gap, before the first read,
73
+ * after the last confirmed read, or when the post has no publish time.
74
+ */
75
+ export declare function valueAtAge(input: {
76
+ timeline: PostMetricTimeline;
77
+ metrics: readonly SocialPostMetric[];
78
+ ageMs: number;
79
+ maxProrateMs?: number;
80
+ }): ValueAtAge | null;
81
+ /** Median of the values, or null for none. */
82
+ export declare function medianOf(values: readonly number[]): number | null;
83
+ /**
84
+ * The instant each calendar day in [fromDate, toDate] (inclusive, YYYY-MM-DD)
85
+ * starts in `timeZone`, plus the instant after the last day. DST-safe: each start
86
+ * is resolved against that day's own offset.
87
+ */
88
+ export declare function zonedDayStarts(fromDate: string, toDate: string, timeZone: string): {
89
+ days: {
90
+ date: string;
91
+ start: Date;
92
+ }[];
93
+ end: Date;
94
+ };
95
+ /** An IANA zone Intl accepts, or null. Validated here so Postgres never sees a bad one. */
96
+ export declare function validTimeZone(value: string | null | undefined): string | null;